diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..24a6a24 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,16 @@ +name: "Donut Browser CodeQL configuration" + +queries: + - uses: security-extended + +# Test and tooling code is not shipped. Its literals are test vectors and +# fixtures, and the E2E harness downloads its own driver and browser bundle, +# which the scanner reads as production secrets and untrusted writes. +paths-ignore: + - e2e + - src-tauri/tests + - "**/*_tests.rs" + - "**/*.test.mjs" + - "**/*.test.ts" + - "**/*.test.tsx" + - "**/*.spec.ts" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 992a38c..2699076 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -50,7 +50,7 @@ jobs: - name: Initialize CodeQL uses: github/codeql-action/init@b1e4dc3db58c9601794e22a9f6d28d45461b9dbf #v3.29.0 with: - queries: security-extended + config-file: ./.github/codeql/codeql-config.yml languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} diff --git a/AGENTS.md b/AGENTS.md index ee8e14e..d02db98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,7 @@ donutbrowser/ ├── src/ # Next.js frontend │ ├── app/ # App router (page.tsx, layout.tsx) │ ├── components/ # 50+ React components (dialogs, tables, UI) +│ │ └── tips/ # Feature tips: SVG scene primitives and one looping scene per tip │ ├── hooks/ # Event-driven React hooks │ ├── i18n/locales/ # Translations (en, es, fr, ja, ko, pt, ru, tr, vi, zh) │ ├── generated/ # Build-generated third-party license inventory @@ -60,7 +61,8 @@ donutbrowser/ │ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management │ │ ├── downloader.rs # Browser binary downloader │ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi) -│ │ ├── settings_manager.rs # App settings persistence +│ │ ├── settings_manager.rs # App settings persistence (atomic writes), tips + paid-welcome state +│ │ ├── vault.rs # Per-install key that seals local secrets; opens legacy build-password seals once │ │ ├── data_root.rs # Moving the data directory (copy, verify, then delete) + the pointer read at startup │ │ ├── cookie_manager.rs # Cookie import/export │ │ ├── profile_importer.rs # Bulk profile import (Chromium-family detection, ZIP, batch) @@ -270,6 +272,42 @@ When a tabbed sub-page dialog needs to be opened to a specific tab by an externa Reference implementations: `proxy-management-dialog.tsx`, `extension-management-dialog.tsx`, `integrations-dialog.tsx`. The owning page in `src/app/page.tsx` keeps one piece of `useState` per dialog (`proxyManagementInitialTab`, `extensionManagementInitialTab`, `integrationsInitialTab`) and flips it on repeated shortcut presses. +## Feature tips and the paid welcome + +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`, +`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 }`. +- 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 + `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 + their resting frame under reduced motion and never hide the copy. + +## Timelines (`OperationFlow`) + +`src/components/ui/operation-flow.tsx` draws any measured operation as a row +of stations: settled stations wear a check, the current one is a ring (a +cross when `failed`), later ones wait as dots, wires fill as stations settle, +and `busy` sends a pulse along the wire into the station being worked on. +Pass `active` as the station the operation is AT, and `failed` when it +stopped there: a proxy check that cannot connect is `active={1}` (the proxy), +not the device. Reaching the last station with nothing failed settles the row. + ## Keyboard shortcuts All app-wide shortcuts live in `src/lib/shortcuts.ts`: diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index 117d02a..65c09ca 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -28,6 +28,10 @@ export const commandCoverage = { "window_decorations::get_window_decoration_layout", "get_onboarding_completed", "complete_onboarding", + "get_tips_state", + "mark_tip_seen", + "set_tips_auto_show", + "observe_cloud_plan", "data_root::get_data_root_info", "data_root::move_data_root", "data_root::clear_data_root_choice", diff --git a/e2e/lib/app.mjs b/e2e/lib/app.mjs index 7072d74..f631bb3 100644 --- a/e2e/lib/app.mjs +++ b/e2e/lib/app.mjs @@ -71,6 +71,7 @@ export class AppSession { seedDownloadedBrowser = false, onboardingCompleted = true, wayfernTermsAccepted = true, + settings = {}, }) { this.name = name; this.root = root; @@ -84,6 +85,8 @@ export class AppSession { this.seedDownloadedBrowser = seedDownloadedBrowser; this.onboardingCompleted = onboardingCompleted; this.wayfernTermsAccepted = wayfernTermsAccepted; + // Extra keys for the seeded app_settings.json, on top of the defaults. + this.settings = settings; this.session = null; } @@ -138,6 +141,10 @@ export class AppSession { commercial_trial_acknowledged: true, window_resize_warning_dismissed: true, disable_auto_updates: true, + // A tip opening by itself mid-test is a modal nobody asked for; + // the tips suite turns it back on for the one session that wants it. + tips_auto_show: false, + ...this.settings, }, null, 2, @@ -591,6 +598,7 @@ export function appFromEnvironment(name, options = {}) { seedDownloadedBrowser: options.seedDownloadedBrowser, onboardingCompleted: options.onboardingCompleted, wayfernTermsAccepted: options.wayfernTermsAccepted, + settings: options.settings, }); } diff --git a/e2e/tests/smoke.test.mjs b/e2e/tests/smoke.test.mjs index d1db279..4b8e494 100644 --- a/e2e/tests/smoke.test.mjs +++ b/e2e/tests/smoke.test.mjs @@ -71,11 +71,89 @@ 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. + const tips = await app.invoke("get_tips_state"); + assert.equal(tips.auto_show, true); + assert.deepEqual(tips.seen, []); + assert.equal(tips.auto_due, true, "a fresh install owes its first tip"); + const marked = await app.invoke("mark_tip_seen", { + tipId: "dnsBlocklist", + auto: true, + }); + 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 browsed = await app.invoke("mark_tip_seen", { + tipId: "proxyCheck", + auto: false, + }); + assert.deepEqual(browsed.seen, ["dnsBlocklist", "proxyCheck"]); + assert.equal( + browsed.last_auto_shown_at, + marked.last_auto_shown_at, + "a browsed tip must not restart the pacing", + ); + const quiet = await app.invoke("set_tips_auto_show", { enabled: false }); + assert.equal(quiet.auto_show, false); + assert.equal(quiet.auto_due, false); + assert.equal( + await app.invoke("observe_cloud_plan", { + userId: "acct-free", + paid: false, + freshLogin: true, + }), + false, + "a free account is never greeted", + ); + assert.equal( + await app.invoke("observe_cloud_plan", { + userId: "acct-free", + paid: true, + freshLogin: false, + }), + true, + "free to paid is the upgrade the welcome exists for", + ); + assert.equal( + await app.invoke("observe_cloud_plan", { + userId: "acct-free", + paid: true, + freshLogin: true, + }), + false, + "and it is greeted once", + ); + assert.equal( + await app.invoke("observe_cloud_plan", { + userId: "acct-web", + paid: true, + freshLogin: true, + }), + true, + "a paid account first seen right after signing in came from checkout", + ); + assert.equal( + await app.invoke("observe_cloud_plan", { + userId: "acct-old", + paid: true, + freshLogin: false, + }), + false, + "a paid account in an old session is not new to its plan", + ); + await app.restart(); const afterRestart = await app.invoke("get_app_settings"); assert.equal(afterRestart.theme, "dark"); assert.equal(afterRestart.language, "en"); assert.equal(afterRestart.onboarding_completed, true); + assert.deepEqual(afterRestart.tips_seen, ["dnsBlocklist", "proxyCheck"]); + assert.equal(afterRestart.tips_auto_show, false); + assert.deepEqual((await app.invoke("get_tips_state")).seen, [ + "dnsBlocklist", + "proxyCheck", + ]); const settingsFile = path.join( app.dataRoot, diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index 08c50dc..9ca5ffb 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -2495,3 +2495,310 @@ test("the synchroniser panel lists a live session and its controls act on the re } }); }); + +const TIPS_DIALOG = '[data-slot="tips-dialog"]'; + +async function openTipsFromRail(app) { + await app.clickSelector('[aria-label="More"]'); + await app.waitFor( + () => + app.execute(`return Boolean(document.querySelector("[role='menu']"));`), + { description: "More menu" }, + ); + await app.clickSelector('[data-slot="rail-open-tips"]'); + await app.waitFor( + () => + app.execute( + `return document.querySelector(arguments[0])?.dataset.mode === "browse";`, + [TIPS_DIALOG], + ), + { description: "the tips catalog" }, + ); +} + +test("tips open from the rail, walk the catalog, and deep-link into the feature", async () => { + await withApp("ui-tips-browse", async (app) => { + await openTipsFromRail(app); + assert.ok(await app.visibleTextIncludes(en.tips.items.dnsBlocklist.title)); + + // Every essential is listed; a plan tip needs a plan, and there is none. + const listed = await app.execute( + `return [...document.querySelectorAll('[data-slot="tips-list-item"]')].map((node) => node.dataset.tipId);`, + ); + assert.ok(listed.includes("dnsBlocklist")); + assert.ok(listed.includes("trash")); + assert.ok(!listed.includes("cookieBot")); + assert.ok(!listed.includes("team")); + + // The drawing is live SVG in the scene panel, not a picture. + assert.equal( + await app.execute( + `return document.querySelectorAll('[data-slot="tip-scene-panel"] svg[data-slot="tip-scene"]').length;`, + ), + 1, + ); + + await app.clickSelector('[data-slot="tip-next"]'); + await app.waitFor( + () => + app.execute( + `return document.querySelector('[data-slot="tip-detail"]')?.dataset.tipId === "proxyCheck";`, + ), + { description: "the second tip" }, + ); + assert.ok(await app.visibleTextIncludes(en.tips.items.proxyCheck.title)); + + // Picking from the catalog moves the sliding indicator onto that entry. + await app.clickSelector( + '[data-slot="tips-list-item"][data-tip-id="trash"]', + ); + await app.waitFor( + () => + app.execute( + `return Boolean(document.querySelector('[data-slot="tips-list-item"][data-tip-id="trash"] [data-slot="tips-list-indicator"]'));`, + ), + { description: "the indicator on the chosen tip" }, + ); + assert.ok(await app.visibleTextIncludes(en.tips.items.trash.title)); + assert.equal( + await app.execute( + `return document.querySelectorAll('[data-slot="tips-list-indicator"]').length;`, + ), + 1, + "exactly one entry is marked current", + ); + await app.capture("tips-browse"); + + // Seen tips are remembered, so the automatic flow never repeats them. + await app.waitFor( + async () => { + const state = await app.invoke("get_tips_state"); + return ["dnsBlocklist", "proxyCheck", "trash"].every((id) => + state.seen.includes(id), + ); + }, + { description: "seen tips persisted" }, + ); + + // The action lands inside the feature: the DNS tip opens settings on + // its DNS section, and the dialog is gone by then. + await app.clickSelector( + '[data-slot="tips-list-item"][data-tip-id="dnsBlocklist"]', + ); + await app.clickSelector('[data-slot="tip-action"]'); + await app.waitFor( + () => + app.execute( + `return document.activeElement?.dataset?.settingsSection === "dns";`, + ), + { description: "the DNS settings section focused" }, + ); + assert.equal( + await app.execute( + `return Boolean(document.querySelector(arguments[0]));`, + [TIPS_DIALOG], + ), + false, + ); + + // The chord opens the catalog too, and the switch turns the automatic + // flow off and persists that. + await app.pressShortcut({ + ...(process.platform === "darwin" ? { meta: true } : { ctrl: true }), + shift: true, + key: "h", + }); + await app.waitFor( + () => + app.execute( + `return document.querySelector(arguments[0])?.dataset.mode === "browse";`, + [TIPS_DIALOG], + ), + { description: "the tips catalog from the keyboard" }, + ); + assert.equal( + await app.execute( + `return document.querySelector('[data-slot="tips-auto-show"]').getAttribute("data-state");`, + ), + "unchecked", + "the harness seeds the automatic flow off", + ); + await app.clickSelector('[data-slot="tips-auto-show"]'); + await app.waitFor( + async () => (await app.invoke("get_tips_state")).auto_show === true, + { description: "the preference persisted" }, + ); + await dismissSurface(app); + await app.waitFor( + () => + app.execute(`return !document.querySelector(arguments[0]);`, [ + TIPS_DIALOG, + ]), + { description: "the dialog closed" }, + ); + }); +}); + +test("a tip opens by itself once the app settles, then waits a day", async () => { + await withApp( + "ui-tips-auto", + async (app) => { + await app.waitFor( + () => + app.execute( + `return document.querySelector(arguments[0])?.dataset.mode === "single";`, + [TIPS_DIALOG], + ), + { description: "the automatic tip", timeoutMs: 30_000 }, + ); + assert.ok( + await app.visibleTextIncludes(en.tips.items.dnsBlocklist.title), + ); + assert.equal( + await app.execute( + `return document.querySelectorAll('[data-slot="tips-list-item"]').length;`, + ), + 0, + "the single card carries no catalog", + ); + await app.capture("tips-auto"); + await app.waitFor( + async () => { + const state = await app.invoke("get_tips_state"); + return ( + state.seen.includes("dnsBlocklist") && state.auto_due === false + ); + }, + { description: "the automatic tip recorded" }, + ); + + await dismissSurface(app); + await app.waitFor( + () => + app.execute(`return !document.querySelector(arguments[0]);`, [ + TIPS_DIALOG, + ]), + { description: "the dialog closed" }, + ); + + // A restart within the day shows nothing: one tip a day. + await app.restart(); + await app.waitForText("No profiles yet"); + await new Promise((resolve) => setTimeout(resolve, 4_500)); + assert.equal( + await app.execute( + `return Boolean(document.querySelector(arguments[0]));`, + [TIPS_DIALOG], + ), + false, + ); + }, + { settings: { tips_auto_show: true } }, + ); +}); + +test("a freshly paid account is welcomed once and walked to its plan tips", async () => { + await withApp("ui-paid-welcome", async (app) => { + await app.waitForText("No profiles yet"); + try { + // A pro account that signed in a moment ago, as the desktop would hold + // it after a device-code login. Only the IPC read of the cached user is + // stubbed; the plan observation and the tips state run for real. + await stubCommand(app, "cloud_get_user", { + logged_in_at: new Date().toISOString(), + user: { + id: "ui-paid-welcome", + email: "paid@example.test", + plan: "pro", + planPeriod: "monthly", + subscriptionStatus: "active", + profileLimit: 50, + cloudProfilesUsed: 0, + proxyBandwidthLimitMb: 0, + proxyBandwidthUsedMb: 0, + proxyBandwidthExtraMb: 0, + isPrimaryDevice: true, + }, + }); + await app.invoke("plugin:event|emit", { + event: "cloud-auth-changed", + payload: null, + }); + await app.waitFor( + () => + app.execute( + `return Boolean(document.querySelector('[data-slot="paid-welcome"]'));`, + ), + { description: "the paid welcome" }, + ); + assert.ok( + await app.visibleTextIncludes( + en.paidWelcome.title.replace("{{plan}}", "Pro"), + ), + ); + assert.deepEqual( + await app.execute( + `return [...document.querySelectorAll('[data-slot="paid-welcome-item"]')].map((node) => node.dataset.tipId);`, + ), + ["cloudBackup", "cookieBot", "crossOs", "automation", "agent"], + "every capability the plan grants, in catalog order, and nothing it lacks", + ); + await app.capture("paid-welcome"); + + // A row opens the catalog on that tip, with the plan tips now listed. + await app.clickSelector( + '[data-slot="paid-welcome-item"][data-tip-id="cookieBot"]', + ); + await app.waitFor( + () => + app.execute( + `return document.querySelector('[data-slot="tip-detail"]')?.dataset.tipId === "cookieBot";`, + ), + { description: "the Cookie Bot tip" }, + ); + assert.equal( + await app.execute( + `return Boolean(document.querySelector('[data-slot="paid-welcome"]'));`, + ), + false, + ); + const listed = await app.execute( + `return [...document.querySelectorAll('[data-slot="tips-list-item"]')].map((node) => node.dataset.tipId);`, + ); + assert.ok(listed.includes("cookieBot") && listed.includes("agent")); + assert.ok(listed.includes("dnsBlocklist")); + await app.capture("tips-plan-catalog"); + await dismissSurface(app); + await app.waitFor( + () => + app.execute(`return !document.querySelector(arguments[0]);`, [ + TIPS_DIALOG, + ]), + { description: "the catalog closed" }, + ); + + // Greeted once: the same account signing in again is not welcomed twice. + await app.invoke("plugin:event|emit", { + event: "cloud-auth-changed", + payload: null, + }); + await new Promise((resolve) => setTimeout(resolve, 1_500)); + assert.equal( + await app.execute( + `return Boolean(document.querySelector('[data-slot="paid-welcome"]'));`, + ), + false, + ); + assert.equal( + await app.invoke("observe_cloud_plan", { + userId: "ui-paid-welcome", + paid: true, + freshLogin: true, + }), + false, + ); + } finally { + await restoreStubs(app); + } + }); +}); diff --git a/package.json b/package.json index bb76f49..a225665 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,9 @@ "prebuild": "pnpm licenses:generate", "build": "next build", "start": "next start", - "test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:agent && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e", + "test": "pnpm test:themes && pnpm test:tips && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:agent && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e", "test:themes": "node --test src/lib/themes.test.mjs", + "test:tips": "node --test src/lib/tips.test.mjs", "test:window-decorations": "node --test src/lib/window-decorations.test.mjs", "test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs src/lib/schedule-layout.test.mjs", "test:cookie-bot-outcomes": "node --test src/lib/cookie-bot-outcomes.test.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d45087..0df48a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,10 +15,11 @@ overrides: js-cookie@<3.0.7: '>=3.0.7' nanoid@<3.3.17: '>=3.3.17 <4' fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5' - multer@>=2.0.0 <2.2.0: '>=2.2.0' + multer@>=2.0.0 <2.3.0: '>=2.3.0' form-data@>=4.0.0 <4.0.6: '>=4.0.6' - js-yaml@<3.15.1: '>=3.15.1 <4' - js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5' + js-yaml@<3.15.2: '>=3.15.2 <4' + js-yaml@>=4.0.0 <4.3.2: '>=4.3.2 <5' + browserslist@<4.28.7: '>=4.28.7' '@babel/core@<7.29.6': '>=7.29.6 <8' brace-expansion@<5.0.9: 5.0.9 sharp@<0.35.0: '>=0.35.0 <0.36' @@ -2761,11 +2762,6 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.19: - resolution: {integrity: sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==} - engines: {node: '>=6.0.0'} - hasBin: true - baseline-browser-mapping@2.11.21: resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} engines: {node: '>=6.0.0'} @@ -2785,8 +2781,8 @@ packages: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} - browserslist@4.28.4: - resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -3132,8 +3128,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.376: - resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} + electron-to-chromium@1.5.423: + resolution: {integrity: sha512-rRZfTSY8ptHYMQxa+uIycJMFKmY1T0GIApNMXJYGehguTZa56TEEl19pKPCoBqk5Gpf7QizZn/jt7xur+DYxag==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -3672,12 +3668,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.15.1: - resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} + js-yaml@3.15.2: + resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} hasBin: true - js-yaml@4.3.1: - resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true jsesc@3.1.0: @@ -3969,8 +3965,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - multer@2.2.0: - resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + multer@2.3.0: + resolution: {integrity: sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==} engines: {node: '>= 10.16.0'} mute-stream@2.0.0: @@ -4033,8 +4029,8 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.48: - resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} normalize-path@3.0.0: @@ -4805,11 +4801,11 @@ packages: unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: - browserslist: '>= 4.21.0' + browserslist: '>=4.28.7' uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -5254,7 +5250,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.4 + browserslist: 4.28.9 lru-cache: 5.1.1 semver: 6.3.1 @@ -5262,7 +5258,7 @@ snapshots: dependencies: '@babel/compat-data': 8.0.0 '@babel/helper-validator-option': 8.0.0 - browserslist: 4.28.4 + browserslist: 4.28.9 lru-cache: 11.5.2 semver: 7.8.5 optional: true @@ -5804,7 +5800,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.15.1 + js-yaml: 3.15.2 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.6': {} @@ -6100,7 +6096,7 @@ snapshots: '@nestjs/core': 11.2.2(@nestjs/common@11.2.2(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.2.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1(supports-color@8.1.1) - multer: 2.2.0 + multer: 2.3.0 path-to-regexp: 8.4.2 tslib: 2.8.1 transitivePeerDependencies: @@ -7662,8 +7658,6 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.19: {} - baseline-browser-mapping@2.11.21: {} bl@4.1.0: @@ -7692,13 +7686,13 @@ snapshots: dependencies: balanced-match: 4.0.4 - browserslist@4.28.4: + browserslist@4.28.9: dependencies: - baseline-browser-mapping: 2.11.19 + baseline-browser-mapping: 2.11.21 caniuse-lite: 1.0.30001810 - electron-to-chromium: 1.5.376 - node-releases: 2.0.48 - update-browserslist-db: 1.2.3(browserslist@4.28.4) + electron-to-chromium: 1.5.423 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.9) bs-logger@0.2.6: dependencies: @@ -7873,7 +7867,7 @@ snapshots: cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.3.1 + js-yaml: 4.3.2 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -7984,7 +7978,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.376: {} + electron-to-chromium@1.5.423: {} emittery@0.13.1: {} @@ -8728,12 +8722,12 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.15.1: + js-yaml@3.15.2: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.3.1: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -8969,7 +8963,7 @@ snapshots: ms@2.1.3: {} - multer@2.2.0: + multer@2.3.0: dependencies: append-field: 1.0.0 busboy: 1.6.0 @@ -9028,7 +9022,7 @@ snapshots: node-int64@0.4.0: {} - node-releases@2.0.48: {} + node-releases@2.0.54: {} normalize-path@3.0.0: {} @@ -9830,9 +9824,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 - update-browserslist-db@1.2.3(browserslist@4.28.4): + update-browserslist-db@1.3.2(browserslist@4.28.9): dependencies: - browserslist: 4.28.4 + browserslist: 4.28.9 escalade: 3.2.0 picocolors: 1.1.1 @@ -9914,7 +9908,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.18.0 acorn-import-phases: 1.0.4(acorn@8.18.0) - browserslist: 4.28.4 + browserslist: 4.28.9 chrome-trace-event: 1.0.4 enhanced-resolve: 5.24.5 es-module-lexer: 2.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 09ad066..e21b1df 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -30,10 +30,11 @@ overrides: js-cookie@<3.0.7: '>=3.0.7' nanoid@<3.3.17: '>=3.3.17 <4' fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5' - multer@>=2.0.0 <2.2.0: '>=2.2.0' + multer@>=2.0.0 <2.3.0: '>=2.3.0' form-data@>=4.0.0 <4.0.6: '>=4.0.6' - js-yaml@<3.15.1: '>=3.15.1 <4' - js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5' + js-yaml@<3.15.2: '>=3.15.2 <4' + js-yaml@>=4.0.0 <4.3.2: '>=4.3.2 <5' + browserslist@<4.28.7: '>=4.28.7' '@babel/core@<7.29.6': '>=7.29.6 <8' brace-expansion@<5.0.9: 5.0.9 sharp@<0.35.0: '>=0.35.0 <0.36' diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 3cc8c11..01c9137 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -37,13 +37,20 @@ fn main() { println!("cargo:rustc-env=BUILD_VERSION=dev-{version}"); } - // Inject vault password at build time - if let Ok(vault_password) = std::env::var("DONUT_BROWSER_VAULT_PASSWORD") { - println!("cargo:rustc-env=DONUT_BROWSER_VAULT_PASSWORD={vault_password}"); - } else { - // Use default password if environment variable is not set - println!("cargo:rustc-env=DONUT_BROWSER_VAULT_PASSWORD=donutbrowser-api-vault-password"); - } + // The sealing password of every build before the per-install vault key. + // Still compiled in so an update can open the files those builds sealed + // and re-seal them under the installation's own key (see `src/vault.rs`). + // It reaches the crate through a file in OUT_DIR rather than a rustc-env + // line, so the build log never carries it. + let legacy_vault_password = std::env::var("DONUT_BROWSER_VAULT_PASSWORD") + .unwrap_or_else(|_| "donutbrowser-api-vault-password".to_string()); + let out_dir = std::env::var("OUT_DIR").expect("cargo sets OUT_DIR for build scripts"); + std::fs::write( + std::path::Path::new(&out_dir).join("legacy_vault_password.txt"), + legacy_vault_password, + ) + .expect("write the legacy vault password for include_str!"); + println!("cargo:rerun-if-env-changed=DONUT_BROWSER_VAULT_PASSWORD"); // Tell Cargo to rebuild if the proxy binary source changes println!("cargo:rerun-if-changed=src/bin/proxy_server.rs"); diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 9d9b7d7..c648e7f 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -1,6 +1,7 @@ use crate::browser::ProxySettings; use crate::events; use crate::group_manager::GROUP_MANAGER; +use crate::log_redaction::ShortId; use crate::profile::manager::ProfileManager; use crate::proxy_manager::PROXY_MANAGER; use crate::tag_manager::TAG_MANAGER; @@ -2542,7 +2543,8 @@ fn resolve_extension_source( Ok(Some(ExtensionSource::Upload { file_name, data })) } (None, Some(path)) => Ok(Some(ExtensionSource::LocalPath { - path: std::path::PathBuf::from(path), + path: crate::extension_manager::client_named_path(&path) + .map_err(|_| extension_request_error("EXTENSION_PATH_INVALID"))?, link, })), (None, None) => Ok(None), @@ -3472,7 +3474,10 @@ async fn pump_cdp(session_id: String, client: WebSocket, upstream: crate::cdp_ta () = to_relay => {} () = to_client => {} } - log::info!("CDP proxy for remote session {session_id} closed"); + log::info!( + "CDP proxy for remote session {} closed", + ShortId(&session_id) + ); } // API Handler - Every remote session this account currently owns @@ -4159,7 +4164,7 @@ async fn batch_run_profiles( .list_profiles() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let mut results = Vec::with_capacity(request.profile_ids.len()); + let mut results = Vec::new(); for profile_id in &request.profile_ids { let fail = |error: &str| BatchRunResult { profile_id: profile_id.clone(), @@ -4285,7 +4290,7 @@ async fn batch_stop_profiles( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let browser_runner = crate::browser_runner::BrowserRunner::instance(); - let mut results = Vec::with_capacity(request.profile_ids.len()); + let mut results = Vec::new(); for profile_id in &request.profile_ids { let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else { results.push(BatchStopResult { diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 3a59029..941f99f 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -2,6 +2,7 @@ use crate::browser::ProxySettings; use crate::cloud_auth::CLOUD_AUTH; use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry; use crate::events; +use crate::log_redaction::ShortId; use crate::profile::{BrowserProfile, ProfileManager}; use crate::proxy_manager::PROXY_MANAGER; use crate::wayfern_manager::{WayfernConfig, WayfernManager}; @@ -1076,7 +1077,8 @@ impl BrowserRunner { }; log::info!( - "Stopping remote session {session_id} for profile {} ({profile_id})", + "Stopping remote session {} for profile {} ({profile_id})", + ShortId(&session_id), profile.name ); crate::remote_session::end_remote_session(&session_id) @@ -1085,7 +1087,10 @@ impl BrowserRunner { // Surfaced rather than swallowed. A failure here means the browser is // STILL RUNNING; reporting success would tell the user their profile is // free when a remote host is still writing to it. - log::warn!("Failed to stop remote session {session_id}: {e}"); + log::warn!( + "Failed to stop remote session {}: {e}", + ShortId(&session_id) + ); e.to_error_json().into() })?; diff --git a/src-tauri/src/cdp_target.rs b/src-tauri/src/cdp_target.rs index 7888ba2..347cbb2 100644 --- a/src-tauri/src/cdp_target.rs +++ b/src-tauri/src/cdp_target.rs @@ -16,6 +16,7 @@ //! never holds any credential or hostname belonging to the machine the browser //! runs on. That boundary is why this is a relay and not a direct connection. +use crate::log_redaction::ShortId; use crate::profile::types::BrowserProfile; use serde_json::Value; use std::time::Duration; @@ -88,7 +89,7 @@ impl CdpTarget { pub fn describe(&self) -> String { match self { Self::Local { .. } => "local browser".to_string(), - Self::Remote { session_id, .. } => format!("remote session {session_id}"), + Self::Remote { session_id, .. } => format!("remote session {}", ShortId(session_id)), } } } @@ -212,7 +213,7 @@ pub async fn resolve(profile: &BrowserProfile) -> Result { let mut connection = dial_relay(ws_url, bearer).await?; if let Err(e) = connection.attach_to_page().await { - log::warn!("Could not attach to a page in remote session {session_id}: {e}"); + log::warn!( + "Could not attach to a page in remote session {}: {e}", + ShortId(session_id) + ); return Err(e); } Ok(connection) diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index 496f222..45c9ee7 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -1,15 +1,10 @@ -use aes_gcm::{ - aead::{Aead, KeyInit}, - Aes256Gcm, Key, Nonce, -}; use chrono::Utc; use lazy_static::lazy_static; -use rand::RngExt; use reqwest::Client; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tokio::sync::Mutex; use crate::browser::ProxySettings; @@ -378,114 +373,21 @@ impl CloudAuthManager { SettingsManager::instance().get_settings_dir() } - fn get_vault_password() -> String { - env!("DONUT_BROWSER_VAULT_PASSWORD").to_string() + // --- Encrypted file storage (shared with settings_manager.rs via crate::vault) --- + + fn magic(header: &[u8; 5]) -> [u8; 6] { + let mut magic = [0u8; 6]; + magic[..5].copy_from_slice(header); + magic[5] = 2; + magic } - // --- Encrypted file storage (same pattern as settings_manager.rs) --- - - fn encrypt_and_store(file_path: &PathBuf, header: &[u8; 5], data: &str) -> Result<(), String> { - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?; - } - - let vault_password = Self::get_vault_password(); - let salt_bytes: [u8; 16] = rand::rng().random(); - let salt = crate::sync::encryption::encode_salt(&salt_bytes); - let key_bytes = - crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?; - let key = Key::::from(key_bytes); - let cipher = Aes256Gcm::new(&key); - let nonce_bytes: [u8; 12] = rand::rng().random(); - let nonce = Nonce::from(nonce_bytes); - let ciphertext = cipher - .encrypt(&nonce, data.as_bytes()) - .map_err(|e| format!("Encryption failed: {e}"))?; - - let mut file_data = Vec::new(); - file_data.extend_from_slice(header); - file_data.push(2u8); - let salt_str = salt.as_str(); - file_data.push(salt_str.len() as u8); - file_data.extend_from_slice(salt_str.as_bytes()); - file_data.extend_from_slice(&nonce); - file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes()); - file_data.extend_from_slice(&ciphertext); - - fs::write(file_path, file_data).map_err(|e| format!("Failed to write file: {e}"))?; - crate::app_dirs::restrict_to_owner(file_path); - Ok(()) + fn encrypt_and_store(file_path: &Path, header: &[u8; 5], data: &str) -> Result<(), String> { + crate::vault::seal(file_path, &Self::magic(header), data) } - fn decrypt_from_file(file_path: &PathBuf, header: &[u8; 5]) -> Result, String> { - if !file_path.exists() { - return Ok(None); - } - - let file_data = fs::read(file_path).map_err(|e| format!("Failed to read file: {e}"))?; - - if file_data.len() < 6 || &file_data[0..5] != header { - return Ok(None); - } - - let version = file_data[5]; - if version != 2 { - return Ok(None); - } - - let mut offset = 6; - if offset >= file_data.len() { - return Ok(None); - } - let salt_len = file_data[offset] as usize; - offset += 1; - - if offset + salt_len > file_data.len() { - return Ok(None); - } - let salt_bytes = &file_data[offset..offset + salt_len]; - let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?; - let salt_bytes = crate::sync::encryption::decode_salt(salt_str)?; - offset += salt_len; - - if offset + 12 > file_data.len() { - return Ok(None); - } - let nonce_bytes: [u8; 12] = file_data[offset..offset + 12] - .try_into() - .map_err(|_| "Invalid nonce length".to_string())?; - let nonce = Nonce::from(nonce_bytes); - offset += 12; - - if offset + 4 > file_data.len() { - return Ok(None); - } - let ciphertext_len = u32::from_le_bytes([ - file_data[offset], - file_data[offset + 1], - file_data[offset + 2], - file_data[offset + 3], - ]) as usize; - offset += 4; - - if offset + ciphertext_len > file_data.len() { - return Ok(None); - } - let ciphertext = &file_data[offset..offset + ciphertext_len]; - - let vault_password = Self::get_vault_password(); - let key_bytes = - crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?; - let key = Key::::from(key_bytes); - let cipher = Aes256Gcm::new(&key); - let plaintext = cipher - .decrypt(&nonce, ciphertext) - .map_err(|_| "Decryption failed".to_string())?; - - match String::from_utf8(plaintext) { - Ok(token) => Ok(Some(token)), - Err(_) => Ok(None), - } + fn decrypt_from_file(file_path: &Path, header: &[u8; 5]) -> Result, String> { + crate::vault::open(file_path, &Self::magic(header)) } // --- Token storage methods --- diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index 574519c..8030feb 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -9,6 +9,7 @@ use crate::api_client::ApiClient; use crate::browser::{create_browser, BrowserType}; use crate::browser_version_manager::DownloadInfo; use crate::events; +use crate::log_redaction::Plain; // Maximum time to wait for the next chunk of a streaming download before treating // the connection as stalled. Converts an indefinite hang into a terminal error so @@ -705,7 +706,11 @@ impl Downloader { return Ok(version); } else { // Registry says it's downloaded but files don't exist - clean up registry - log::info!("Registry indicates {browser_str} {version} is downloaded, but files are missing. Cleaning up registry entry."); + log::info!( + "Registry indicates {} {} is downloaded, but files are missing. Cleaning up registry entry.", + Plain(&browser_str), + Plain(&version) + ); self.registry.remove_browser(&browser_str, &version); self .registry @@ -811,7 +816,11 @@ impl Downloader { // Do not remove the archive here. We keep it until verification succeeds. } Err(e) => { - log::error!("Extraction failed for {browser_str} {version}: {e}"); + log::error!( + "Extraction failed for {} {}: {e}", + Plain(&browser_str), + Plain(&version) + ); // Delete the corrupt/invalid archive so a fresh download happens next time if download_path.exists() { @@ -857,7 +866,11 @@ impl Downloader { let _ = events::emit("download-progress", &progress); // Verify the browser was downloaded correctly - log::info!("Verifying download for browser: {browser_str}, version: {version}"); + log::info!( + "Verifying download for browser: {}, version: {}", + Plain(&browser_str), + Plain(&version) + ); // Use the browser's own verification method if !browser.is_version_downloaded(&version, &binaries_dir) { @@ -912,7 +925,11 @@ impl Downloader { .registry .mark_download_completed(&browser_str, &version, browser_dir.clone()) { - log::warn!("Warning: Could not mark {browser_str} {version} as completed in registry: {e}"); + log::warn!( + "Warning: Could not mark {} {} as completed in registry: {e}", + Plain(&browser_str), + Plain(&version) + ); } self .registry diff --git a/src-tauri/src/extension_manager.rs b/src-tauri/src/extension_manager.rs index 46df64a..4a76004 100644 --- a/src-tauri/src/extension_manager.rs +++ b/src-tauri/src/extension_manager.rs @@ -405,6 +405,19 @@ fn err_code(code: &str) -> Box { serde_json::json!({ "code": code }).to_string().into() } +/// A filesystem path named by an automation client (REST or MCP). +/// +/// Automation loads an extension from whatever folder or archive the caller +/// names, so the location is the caller's to choose. What a request may not +/// do is climb: a `..` component is refused before the path is touched, and +/// the path is then used exactly as given. +pub fn client_named_path(raw: &str) -> Result> { + if raw.contains("..") { + return Err(err_code("EXTENSION_PATH_INVALID")); + } + Ok(PathBuf::from(raw)) +} + /// Validate that `dir` is a loadable unpacked extension and return its parsed /// manifest. fn validate_unpacked_dir(dir: &Path) -> Result> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 061866c..add04c3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -121,6 +121,7 @@ mod mcp_remote; mod mcp_server; mod tag_manager; mod team_lock; +mod vault; mod version_updater; pub mod vpn; mod vpn_extension_detect; @@ -167,8 +168,9 @@ use downloader::{cancel_download, download_browser}; use settings_manager::{ complete_onboarding, dismiss_window_resize_warning, get_app_settings, get_onboarding_completed, get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings, - get_window_resize_warning_dismissed, open_log_directory, read_log_files, save_app_settings, - save_sync_settings, save_table_sorting_settings, + get_tips_state, get_window_resize_warning_dismissed, mark_tip_seen, observe_cloud_plan, + open_log_directory, read_log_files, save_app_settings, save_sync_settings, + save_table_sorting_settings, set_tips_auto_show, }; use sync::{ @@ -3480,6 +3482,10 @@ pub fn run_with_builder( get_window_resize_warning_dismissed, get_onboarding_completed, complete_onboarding, + get_tips_state, + mark_tip_seen, + set_tips_auto_show, + observe_cloud_plan, data_root::get_data_root_info, data_root::move_data_root, data_root::clear_data_root_choice, diff --git a/src-tauri/src/log_redaction.rs b/src-tauri/src/log_redaction.rs index 8a0b5f4..0deaadf 100644 --- a/src-tauri/src/log_redaction.rs +++ b/src-tauri/src/log_redaction.rs @@ -39,6 +39,43 @@ static UUID_RE: LazyLock = LazyLock::new(|| { .expect("valid UUID regex") }); +/// A caller-supplied string as it may appear in a log line: control +/// characters, a newline above all, are shown escaped, so no request can +/// forge a second log entry or hide the end of the real one. +pub struct Plain<'a>(pub &'a str); + +impl std::fmt::Display for Plain<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use std::fmt::Write; + for c in self.0.chars() { + if c.is_control() { + for escaped in c.escape_default() { + f.write_char(escaped)?; + } + } else { + f.write_char(c)?; + } + } + Ok(()) + } +} + +/// The first characters of an identifier: enough to match log lines up by +/// eye, and not the whole value, which for a session is a bearer of sorts. +pub struct ShortId<'a>(pub &'a str); + +impl std::fmt::Display for ShortId<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + const SHOWN: usize = 8; + let shown: String = Plain(self.0).to_string().chars().take(SHOWN).collect(); + f.write_str(&shown)?; + if self.0.chars().count() > SHOWN { + f.write_str("\u{2026}")?; + } + Ok(()) + } +} + pub fn url_label(value: &str) -> String { url::Url::parse(value) .map(|parsed| format!("{}://", parsed.scheme())) @@ -66,6 +103,22 @@ pub fn text(value: &str) -> String { mod tests { use super::*; + #[test] + fn plain_escapes_every_control_character() { + assert_eq!(Plain("wayfern").to_string(), "wayfern"); + assert_eq!( + Plain("1.0\nINFO forged line\r\t").to_string(), + "1.0\\nINFO forged line\\r\\t" + ); + } + + #[test] + fn short_id_keeps_a_prefix_and_marks_the_cut() { + assert_eq!(ShortId("abcdef").to_string(), "abcdef"); + assert_eq!(ShortId("0123456789abcdef").to_string(), "01234567\u{2026}"); + assert_eq!(ShortId("ab\ncd").to_string(), "ab\\ncd"); + } + #[test] fn redacts_sensitive_log_content() { let input = format!( diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index bbbbb7a..7bd8b15 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -23,6 +23,7 @@ use crate::browser::ProxySettings; use crate::cdp_target::{CdpError, CdpTarget}; use crate::cloud_auth::CLOUD_AUTH; use crate::group_manager::GROUP_MANAGER; +use crate::log_redaction::ShortId; use crate::profile::{BrowserProfile, ProfileManager}; use crate::proxy_manager::PROXY_MANAGER; use crate::settings_manager::SettingsManager; @@ -2479,7 +2480,7 @@ impl McpServer { let mut inner = self.inner.lock().await; match inner.sessions.remove(session_id) { Some(session) => { - log::info!("[mcp] Session terminated: {session_id}"); + log::info!("[mcp] Session terminated: {}", ShortId(session_id)); session.cached_pages } None => return, @@ -2501,8 +2502,9 @@ impl McpServer { .is_err() { log::debug!( - "[mcp] Session {session_id} ended before its element caches could be cleared; the \ - page-side slot cap will reclaim them" + "[mcp] Session {} ended before its element caches could be cleared; the \ + page-side slot cap will reclaim them", + ShortId(session_id) ); } } @@ -4606,7 +4608,7 @@ impl McpServer { "instructions": "Donut Browser MCP server. Use tools/list to discover available browser automation tools." }); - log::info!("[mcp] New session initialized: {}", session_id); + log::info!("[mcp] New session initialized: {}", ShortId(&session_id)); Ok((session_id, (id, result))) } @@ -7605,9 +7607,14 @@ impl McpServer { .get("link") .and_then(|v| v.as_bool()) .unwrap_or(false); + let path = crate::extension_manager::client_named_path(path).map_err(|e| McpError { + code: -32602, + message: format!("Invalid path: {e}"), + data: None, + })?; let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); let extension = mgr - .add_extension_from_path(name, std::path::Path::new(path), link) + .add_extension_from_path(name, &path, link) .map_err(|e| McpError { code: -32000, message: format!("Failed to add extension: {e}"), @@ -7651,11 +7658,18 @@ impl McpServer { .get("link") .and_then(|v| v.as_bool()) .unwrap_or(false); + let path = path + .map(|path| { + crate::extension_manager::client_named_path(path).map_err(|e| McpError { + code: -32602, + message: format!("Invalid path: {e}"), + data: None, + }) + }) + .transpose()?; let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); let extension = match path { - Some(path) => { - mgr.update_extension_from_path(extension_id, name, std::path::Path::new(path), link) - } + Some(path) => mgr.update_extension_from_path(extension_id, name, &path, link), None => mgr.update_extension(extension_id, name, None, None), } .map_err(|e| McpError { diff --git a/src-tauri/src/profile/encryption.rs b/src-tauri/src/profile/encryption.rs index 7ea36e4..4a39f17 100644 --- a/src-tauri/src/profile/encryption.rs +++ b/src-tauri/src/profile/encryption.rs @@ -407,296 +407,5 @@ pub fn fresh_salt() -> String { } #[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn make_key() -> [u8; 32] { - derive_profile_key("hunter2", &generate_salt()).unwrap() - } - - #[test] - fn test_hmac_filename_deterministic() { - let key = [7u8; 32]; - let a = hmac_filename(&key, "Default/Cookies"); - let b = hmac_filename(&key, "Default/Cookies"); - assert_eq!(a, b); - assert_eq!(a.len(), HMAC_FILENAME_LEN); - } - - #[test] - fn test_hmac_filename_different_keys() { - let a = hmac_filename(&[1u8; 32], "Default/Cookies"); - let b = hmac_filename(&[2u8; 32], "Default/Cookies"); - assert_ne!(a, b); - } - - #[test] - fn test_hmac_filename_different_paths() { - let key = [1u8; 32]; - let a = hmac_filename(&key, "Default/Cookies"); - let b = hmac_filename(&key, "Default/Login Data"); - assert_ne!(a, b); - } - - #[test] - fn test_file_roundtrip() { - let key = make_key(); - let original = b"hello world".to_vec(); - let encrypted = encrypt_profile_file(&key, "Default/Cookies", &original).unwrap(); - let (path, content) = decrypt_profile_file(&key, &encrypted).unwrap(); - assert_eq!(path, "Default/Cookies"); - assert_eq!(content, original); - } - - #[test] - fn test_file_wrong_key_fails() { - let key1 = make_key(); - let key2 = make_key(); - let encrypted = encrypt_profile_file(&key1, "Cookies", b"data").unwrap(); - assert!(matches!( - decrypt_profile_file(&key2, &encrypted), - Err(PasswordError::WrongPassword) - )); - } - - #[test] - fn test_file_truncated_ciphertext() { - let key = make_key(); - let encrypted = encrypt_profile_file(&key, "x", b"y").unwrap(); - // Drop the auth tag - let truncated = &encrypted[..encrypted.len() - 1]; - assert!(decrypt_profile_file(&key, truncated).is_err()); - } - - #[test] - fn test_dir_roundtrip() { - let key = make_key(); - let work = TempDir::new().unwrap(); - let plain = work.path().join("plain"); - let enc = work.path().join("enc"); - std::fs::create_dir_all(plain.join("Default")).unwrap(); - std::fs::write(plain.join("Default/Cookies"), b"sqlite-data").unwrap(); - std::fs::write(plain.join("Default/Bookmarks"), b"{\"x\":1}").unwrap(); - std::fs::write(plain.join("Local State"), b"state").unwrap(); - - encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap(); - - // No plaintext filenames on disk - let names: Vec = std::fs::read_dir(&enc) - .unwrap() - .filter_map(|e| e.ok()) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .collect(); - for n in &names { - assert!(!n.contains("Cookies"), "plaintext leaked: {n}"); - assert!(!n.contains("Bookmarks")); - assert!(!n.contains("Local State")); - } - - // Verify file present - assert!(enc.join(VERIFY_FILE_NAME).exists()); - - let restored = work.path().join("restored"); - let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap(); - assert_eq!(mtimes.len(), 3); - - assert_eq!( - std::fs::read(restored.join("Default/Cookies")).unwrap(), - b"sqlite-data" - ); - assert_eq!( - std::fs::read(restored.join("Default/Bookmarks")).unwrap(), - b"{\"x\":1}" - ); - assert_eq!( - std::fs::read(restored.join("Local State")).unwrap(), - b"state" - ); - } - - #[test] - fn test_dir_excludes() { - let key = make_key(); - let work = TempDir::new().unwrap(); - let plain = work.path().join("plain"); - let enc = work.path().join("enc"); - std::fs::create_dir_all(plain.join("Default/Cache")).unwrap(); - std::fs::write(plain.join("Default/Cookies"), b"keep").unwrap(); - std::fs::write(plain.join("Default/Cache/data"), b"drop").unwrap(); - - encrypt_profile_dir(&key, &plain, &enc, &["**/Cache/**"]).unwrap(); - - let restored = work.path().join("restored"); - let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap(); - - // Only Cookies (1 file) should be present, not Cache contents - assert_eq!(mtimes.len(), 1); - assert!(mtimes.contains_key("Default/Cookies")); - assert!(restored.join("Default/Cookies").exists()); - assert!(!restored.join("Default/Cache/data").exists()); - } - - #[test] - fn test_verify_against_wrong_key() { - let key1 = make_key(); - let key2 = make_key(); - let work = TempDir::new().unwrap(); - let plain = work.path().join("plain"); - let enc = work.path().join("enc"); - std::fs::create_dir_all(&plain).unwrap(); - std::fs::write(plain.join("file"), b"data").unwrap(); - encrypt_profile_dir(&key1, &plain, &enc, &[]).unwrap(); - assert!(verify_key_against_dir(&key1, &enc).is_ok()); - assert!(matches!( - verify_key_against_dir(&key2, &enc), - Err(PasswordError::WrongPassword) - )); - } - - #[test] - fn test_reencrypt_skips_unchanged() { - let key = make_key(); - let work = TempDir::new().unwrap(); - let plain = work.path().join("plain"); - let enc = work.path().join("enc"); - std::fs::create_dir_all(&plain).unwrap(); - std::fs::write(plain.join("a"), b"AAA").unwrap(); - std::fs::write(plain.join("b"), b"BBB").unwrap(); - encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap(); - - let restored = work.path().join("restored"); - let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap(); - - // Capture pre-rewrite ciphertext bytes - let name_a = hmac_filename(&key, "a"); - let name_b = hmac_filename(&key, "b"); - let cipher_a_before = std::fs::read(enc.join(&name_a)).unwrap(); - let cipher_b_before = std::fs::read(enc.join(&name_b)).unwrap(); - - // Modify only "a" in the restored tree - std::thread::sleep(std::time::Duration::from_millis(1100)); - std::fs::write(restored.join("a"), b"AAA-CHANGED").unwrap(); - - let rewrote = reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap(); - assert_eq!(rewrote, 1); - - let cipher_a_after = std::fs::read(enc.join(&name_a)).unwrap(); - let cipher_b_after = std::fs::read(enc.join(&name_b)).unwrap(); - assert_ne!( - cipher_a_before, cipher_a_after, - "changed file should have new ciphertext" - ); - assert_eq!( - cipher_b_before, cipher_b_after, - "unchanged file should have stable ciphertext" - ); - } - - #[test] - fn test_reencrypt_handles_added_and_removed() { - let key = make_key(); - let work = TempDir::new().unwrap(); - let plain = work.path().join("plain"); - let enc = work.path().join("enc"); - std::fs::create_dir_all(&plain).unwrap(); - std::fs::write(plain.join("keep"), b"k").unwrap(); - std::fs::write(plain.join("delete"), b"d").unwrap(); - encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap(); - - let restored = work.path().join("restored"); - let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap(); - - std::fs::remove_file(restored.join("delete")).unwrap(); - std::fs::write(restored.join("new"), b"n").unwrap(); - - reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap(); - - let names: HashSet = std::fs::read_dir(&enc) - .unwrap() - .filter_map(|e| e.ok()) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .collect(); - - assert!(names.contains(&hmac_filename(&key, "keep"))); - assert!(names.contains(&hmac_filename(&key, "new"))); - assert!(!names.contains(&hmac_filename(&key, "delete"))); - assert!(names.contains(VERIFY_FILE_NAME)); - } - - #[test] - fn test_rekey_changes_filenames_and_content() { - let old = make_key(); - let new = make_key(); - let work = TempDir::new().unwrap(); - let plain = work.path().join("plain"); - let enc = work.path().join("enc"); - std::fs::create_dir_all(&plain).unwrap(); - std::fs::write(plain.join("x"), b"data").unwrap(); - encrypt_profile_dir(&old, &plain, &enc, &[]).unwrap(); - - let old_name = hmac_filename(&old, "x"); - let new_name = hmac_filename(&new, "x"); - assert_ne!(old_name, new_name); - - rekey_profile_dir(&old, &new, &enc).unwrap(); - - assert!(!enc.join(&old_name).exists()); - assert!(enc.join(&new_name).exists()); - verify_key_against_dir(&new, &enc).unwrap(); - assert!(matches!( - verify_key_against_dir(&old, &enc), - Err(PasswordError::WrongPassword) - )); - - let restored = work.path().join("restored"); - decrypt_profile_dir(&new, &enc, &restored).unwrap(); - assert_eq!(std::fs::read(restored.join("x")).unwrap(), b"data"); - } - - #[test] - fn test_atomic_write_leaves_original_intact_if_tmp_lingers() { - let work = TempDir::new().unwrap(); - let target = work.path().join("file"); - std::fs::write(&target, b"original").unwrap(); - - // Simulate a stale tmp from a crashed write - std::fs::write(target.with_extension("donut-tmp"), b"partial").unwrap(); - - // A successful write should overwrite the original even when stale tmp exists - atomic_write(&target, b"new").unwrap(); - assert_eq!(std::fs::read(&target).unwrap(), b"new"); - } - - #[test] - fn test_key_cache_lifecycle() { - let id = uuid::Uuid::new_v4(); - assert!(!has_cached_key(&id)); - cache_key(id, [9u8; 32]); - assert!(has_cached_key(&id)); - assert_eq!(get_cached_key(&id), Some([9u8; 32])); - drop_cached_key(&id); - assert!(!has_cached_key(&id)); - } - - #[test] - fn test_unlock_helper() { - let work = TempDir::new().unwrap(); - let plain = work.path().join("plain"); - let enc = work.path().join("enc"); - std::fs::create_dir_all(&plain).unwrap(); - std::fs::write(plain.join("x"), b"data").unwrap(); - - let salt = generate_salt(); - let key = derive_profile_key("correct horse", &salt).unwrap(); - encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap(); - - let id = uuid::Uuid::new_v4(); - drop_cached_key(&id); - assert!(unlock(id, "wrong", &salt, &enc).is_err()); - assert!(!has_cached_key(&id)); - assert!(unlock(id, "correct horse", &salt, &enc).is_ok()); - assert!(has_cached_key(&id)); - drop_cached_key(&id); - } -} +#[path = "encryption_tests.rs"] +mod tests; diff --git a/src-tauri/src/profile/encryption_tests.rs b/src-tauri/src/profile/encryption_tests.rs new file mode 100644 index 0000000..20b5c9b --- /dev/null +++ b/src-tauri/src/profile/encryption_tests.rs @@ -0,0 +1,291 @@ +use super::*; +use tempfile::TempDir; + +fn make_key() -> [u8; 32] { + derive_profile_key("hunter2", &generate_salt()).unwrap() +} + +#[test] +fn test_hmac_filename_deterministic() { + let key = [7u8; 32]; + let a = hmac_filename(&key, "Default/Cookies"); + let b = hmac_filename(&key, "Default/Cookies"); + assert_eq!(a, b); + assert_eq!(a.len(), HMAC_FILENAME_LEN); +} + +#[test] +fn test_hmac_filename_different_keys() { + let a = hmac_filename(&[1u8; 32], "Default/Cookies"); + let b = hmac_filename(&[2u8; 32], "Default/Cookies"); + assert_ne!(a, b); +} + +#[test] +fn test_hmac_filename_different_paths() { + let key = [1u8; 32]; + let a = hmac_filename(&key, "Default/Cookies"); + let b = hmac_filename(&key, "Default/Login Data"); + assert_ne!(a, b); +} + +#[test] +fn test_file_roundtrip() { + let key = make_key(); + let original = b"hello world".to_vec(); + let encrypted = encrypt_profile_file(&key, "Default/Cookies", &original).unwrap(); + let (path, content) = decrypt_profile_file(&key, &encrypted).unwrap(); + assert_eq!(path, "Default/Cookies"); + assert_eq!(content, original); +} + +#[test] +fn test_file_wrong_key_fails() { + let key1 = make_key(); + let key2 = make_key(); + let encrypted = encrypt_profile_file(&key1, "Cookies", b"data").unwrap(); + assert!(matches!( + decrypt_profile_file(&key2, &encrypted), + Err(PasswordError::WrongPassword) + )); +} + +#[test] +fn test_file_truncated_ciphertext() { + let key = make_key(); + let encrypted = encrypt_profile_file(&key, "x", b"y").unwrap(); + // Drop the auth tag + let truncated = &encrypted[..encrypted.len() - 1]; + assert!(decrypt_profile_file(&key, truncated).is_err()); +} + +#[test] +fn test_dir_roundtrip() { + let key = make_key(); + let work = TempDir::new().unwrap(); + let plain = work.path().join("plain"); + let enc = work.path().join("enc"); + std::fs::create_dir_all(plain.join("Default")).unwrap(); + std::fs::write(plain.join("Default/Cookies"), b"sqlite-data").unwrap(); + std::fs::write(plain.join("Default/Bookmarks"), b"{\"x\":1}").unwrap(); + std::fs::write(plain.join("Local State"), b"state").unwrap(); + + encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap(); + + // No plaintext filenames on disk + let names: Vec = std::fs::read_dir(&enc) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + for n in &names { + assert!(!n.contains("Cookies"), "plaintext leaked: {n}"); + assert!(!n.contains("Bookmarks")); + assert!(!n.contains("Local State")); + } + + // Verify file present + assert!(enc.join(VERIFY_FILE_NAME).exists()); + + let restored = work.path().join("restored"); + let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap(); + assert_eq!(mtimes.len(), 3); + + assert_eq!( + std::fs::read(restored.join("Default/Cookies")).unwrap(), + b"sqlite-data" + ); + assert_eq!( + std::fs::read(restored.join("Default/Bookmarks")).unwrap(), + b"{\"x\":1}" + ); + assert_eq!( + std::fs::read(restored.join("Local State")).unwrap(), + b"state" + ); +} + +#[test] +fn test_dir_excludes() { + let key = make_key(); + let work = TempDir::new().unwrap(); + let plain = work.path().join("plain"); + let enc = work.path().join("enc"); + std::fs::create_dir_all(plain.join("Default/Cache")).unwrap(); + std::fs::write(plain.join("Default/Cookies"), b"keep").unwrap(); + std::fs::write(plain.join("Default/Cache/data"), b"drop").unwrap(); + + encrypt_profile_dir(&key, &plain, &enc, &["**/Cache/**"]).unwrap(); + + let restored = work.path().join("restored"); + let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap(); + + // Only Cookies (1 file) should be present, not Cache contents + assert_eq!(mtimes.len(), 1); + assert!(mtimes.contains_key("Default/Cookies")); + assert!(restored.join("Default/Cookies").exists()); + assert!(!restored.join("Default/Cache/data").exists()); +} + +#[test] +fn test_verify_against_wrong_key() { + let key1 = make_key(); + let key2 = make_key(); + let work = TempDir::new().unwrap(); + let plain = work.path().join("plain"); + let enc = work.path().join("enc"); + std::fs::create_dir_all(&plain).unwrap(); + std::fs::write(plain.join("file"), b"data").unwrap(); + encrypt_profile_dir(&key1, &plain, &enc, &[]).unwrap(); + assert!(verify_key_against_dir(&key1, &enc).is_ok()); + assert!(matches!( + verify_key_against_dir(&key2, &enc), + Err(PasswordError::WrongPassword) + )); +} + +#[test] +fn test_reencrypt_skips_unchanged() { + let key = make_key(); + let work = TempDir::new().unwrap(); + let plain = work.path().join("plain"); + let enc = work.path().join("enc"); + std::fs::create_dir_all(&plain).unwrap(); + std::fs::write(plain.join("a"), b"AAA").unwrap(); + std::fs::write(plain.join("b"), b"BBB").unwrap(); + encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap(); + + let restored = work.path().join("restored"); + let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap(); + + // Capture pre-rewrite ciphertext bytes + let name_a = hmac_filename(&key, "a"); + let name_b = hmac_filename(&key, "b"); + let cipher_a_before = std::fs::read(enc.join(&name_a)).unwrap(); + let cipher_b_before = std::fs::read(enc.join(&name_b)).unwrap(); + + // Modify only "a" in the restored tree + std::thread::sleep(std::time::Duration::from_millis(1100)); + std::fs::write(restored.join("a"), b"AAA-CHANGED").unwrap(); + + let rewrote = reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap(); + assert_eq!(rewrote, 1); + + let cipher_a_after = std::fs::read(enc.join(&name_a)).unwrap(); + let cipher_b_after = std::fs::read(enc.join(&name_b)).unwrap(); + assert_ne!( + cipher_a_before, cipher_a_after, + "changed file should have new ciphertext" + ); + assert_eq!( + cipher_b_before, cipher_b_after, + "unchanged file should have stable ciphertext" + ); +} + +#[test] +fn test_reencrypt_handles_added_and_removed() { + let key = make_key(); + let work = TempDir::new().unwrap(); + let plain = work.path().join("plain"); + let enc = work.path().join("enc"); + std::fs::create_dir_all(&plain).unwrap(); + std::fs::write(plain.join("keep"), b"k").unwrap(); + std::fs::write(plain.join("delete"), b"d").unwrap(); + encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap(); + + let restored = work.path().join("restored"); + let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap(); + + std::fs::remove_file(restored.join("delete")).unwrap(); + std::fs::write(restored.join("new"), b"n").unwrap(); + + reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap(); + + let names: HashSet = std::fs::read_dir(&enc) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + + assert!(names.contains(&hmac_filename(&key, "keep"))); + assert!(names.contains(&hmac_filename(&key, "new"))); + assert!(!names.contains(&hmac_filename(&key, "delete"))); + assert!(names.contains(VERIFY_FILE_NAME)); +} + +#[test] +fn test_rekey_changes_filenames_and_content() { + let old = make_key(); + let new = make_key(); + let work = TempDir::new().unwrap(); + let plain = work.path().join("plain"); + let enc = work.path().join("enc"); + std::fs::create_dir_all(&plain).unwrap(); + std::fs::write(plain.join("x"), b"data").unwrap(); + encrypt_profile_dir(&old, &plain, &enc, &[]).unwrap(); + + let old_name = hmac_filename(&old, "x"); + let new_name = hmac_filename(&new, "x"); + assert_ne!(old_name, new_name); + + rekey_profile_dir(&old, &new, &enc).unwrap(); + + assert!(!enc.join(&old_name).exists()); + assert!(enc.join(&new_name).exists()); + verify_key_against_dir(&new, &enc).unwrap(); + assert!(matches!( + verify_key_against_dir(&old, &enc), + Err(PasswordError::WrongPassword) + )); + + let restored = work.path().join("restored"); + decrypt_profile_dir(&new, &enc, &restored).unwrap(); + assert_eq!(std::fs::read(restored.join("x")).unwrap(), b"data"); +} + +#[test] +fn test_atomic_write_leaves_original_intact_if_tmp_lingers() { + let work = TempDir::new().unwrap(); + let target = work.path().join("file"); + std::fs::write(&target, b"original").unwrap(); + + // Simulate a stale tmp from a crashed write + std::fs::write(target.with_extension("donut-tmp"), b"partial").unwrap(); + + // A successful write should overwrite the original even when stale tmp exists + atomic_write(&target, b"new").unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"new"); +} + +#[test] +fn test_key_cache_lifecycle() { + let id = uuid::Uuid::new_v4(); + assert!(!has_cached_key(&id)); + cache_key(id, [9u8; 32]); + assert!(has_cached_key(&id)); + assert_eq!(get_cached_key(&id), Some([9u8; 32])); + drop_cached_key(&id); + assert!(!has_cached_key(&id)); +} + +#[test] +fn test_unlock_helper() { + let work = TempDir::new().unwrap(); + let plain = work.path().join("plain"); + let enc = work.path().join("enc"); + std::fs::create_dir_all(&plain).unwrap(); + std::fs::write(plain.join("x"), b"data").unwrap(); + + let salt = generate_salt(); + let key = derive_profile_key("correct horse", &salt).unwrap(); + encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap(); + + let id = uuid::Uuid::new_v4(); + drop_cached_key(&id); + assert!(unlock(id, "wrong", &salt, &enc).is_err()); + assert!(!has_cached_key(&id)); + assert!(unlock(id, "correct horse", &salt, &enc).is_ok()); + assert!(has_cached_key(&id)); + drop_cached_key(&id); +} diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 6877906..a9b8ed2 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -1016,7 +1016,7 @@ impl ProfileManager { .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; let mut seen = std::collections::HashSet::new(); - let mut deduped: Vec = Vec::with_capacity(tags.len()); + let mut deduped: Vec = Vec::new(); for t in tags.into_iter() { if seen.insert(t.clone()) { deduped.push(t); diff --git a/src-tauri/src/profile/password.rs b/src-tauri/src/profile/password.rs index 4d39b56..8f02ee0 100644 --- a/src-tauri/src/profile/password.rs +++ b/src-tauri/src/profile/password.rs @@ -745,629 +745,5 @@ pub async fn complete_after_quit_and_wait( } #[cfg(test)] -mod tests { - use super::*; - use crate::profile::BrowserProfile; - use tempfile::TempDir; - - fn make_profile(name: &str) -> BrowserProfile { - BrowserProfile { - id: uuid::Uuid::new_v4(), - name: name.to_string(), - browser: "wayfern".to_string(), - version: "1.0".to_string(), - release_type: "stable".to_string(), - ..Default::default() - } - } - - fn populate_plaintext_dir(dir: &Path) { - std::fs::create_dir_all(dir.join("Default")).unwrap(); - std::fs::write(dir.join("Default/Cookies"), b"sqlite-data").unwrap(); - std::fs::write(dir.join("Default/Bookmarks"), b"{\"x\":1}").unwrap(); - std::fs::write(dir.join("Local State"), b"local-state").unwrap(); - // Cache files should be excluded: - std::fs::create_dir_all(dir.join("Default/Cache")).unwrap(); - std::fs::write(dir.join("Default/Cache/data_0"), b"cache-blob").unwrap(); - } - - fn parse_err_code(err: &str) -> Option<&'static str> { - let v: serde_json::Value = serde_json::from_str(err).ok()?; - let code = v.get("code")?.as_str()?; - Some(match code { - "INCORRECT_PASSWORD" => "INCORRECT_PASSWORD", - "LOCKED_OUT" => "LOCKED_OUT", - "PROFILE_NOT_FOUND" => "PROFILE_NOT_FOUND", - "PROFILE_NOT_PROTECTED" => "PROFILE_NOT_PROTECTED", - "PROFILE_ALREADY_PROTECTED" => "PROFILE_ALREADY_PROTECTED", - "PROFILE_RUNNING" => "PROFILE_RUNNING", - "PROFILE_MISSING_SALT" => "PROFILE_MISSING_SALT", - "PROFILE_LOCKED" => "PROFILE_LOCKED", - "INVALID_PROFILE_ID" => "INVALID_PROFILE_ID", - "PASSWORD_TOO_SHORT" => "PASSWORD_TOO_SHORT", - "INTERNAL_ERROR" => "INTERNAL_ERROR", - _ => return None, - }) - } - - fn parse_err_param(err: &str, key: &str) -> Option { - let v: serde_json::Value = serde_json::from_str(err).ok()?; - Some(v.get("params")?.get(key)?.as_str()?.to_string()) - } - - fn fresh_test_state(id: &uuid::Uuid) { - drop_cached_key(id); - let _ = LAUNCH_SNAPSHOTS.lock().map(|mut g| g.remove(id)); - let _ = POPULATED_EPHEMERAL.lock().map(|mut g| g.remove(id)); - crate::ephemeral_dirs::remove_ephemeral_dir(&id.to_string()); - } - - fn profile_full_path(profile: &BrowserProfile, profiles_dir: &Path) -> PathBuf { - profiles_dir.join(profile.id.to_string()).join("profile") - } - - #[test] - #[serial_test::serial] - fn integration_set_password_encrypts_dir() { - let temp = TempDir::new().unwrap(); - let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); - - let mut profile = make_profile("test-set"); - let profiles_dir = ProfileManager::instance().get_profiles_dir(); - let plain_dir = profile_full_path(&profile, &profiles_dir); - populate_plaintext_dir(&plain_dir); - ProfileManager::instance().save_profile(&profile).unwrap(); - - fresh_test_state(&profile.id); - - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(set_profile_password( - profile.id.to_string(), - "hunter2!".into(), - )) - .unwrap(); - - profile = ProfileManager::instance() - .list_profiles() - .unwrap() - .into_iter() - .find(|p| p.id == profile.id) - .unwrap(); - assert!(profile.password_protected); - assert!(profile.encryption_salt.is_some()); - - // No plaintext filenames should remain on disk - let names: Vec = std::fs::read_dir(&plain_dir) - .unwrap() - .filter_map(|e| e.ok()) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .collect(); - for n in &names { - assert!(!n.contains("Cookies"), "plaintext name leaked: {n}"); - assert!(!n.contains("Bookmarks")); - assert!(!n.contains("Local State")); - } - - fresh_test_state(&profile.id); - } - - #[test] - #[serial_test::serial] - fn integration_full_lifecycle_persists_data() { - let temp = TempDir::new().unwrap(); - let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); - - let profile = make_profile("test-lifecycle"); - let profiles_dir = ProfileManager::instance().get_profiles_dir(); - let plain_dir = profile_full_path(&profile, &profiles_dir); - populate_plaintext_dir(&plain_dir); - ProfileManager::instance().save_profile(&profile).unwrap(); - - fresh_test_state(&profile.id); - - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(set_profile_password( - profile.id.to_string(), - "hunter2!".into(), - )) - .unwrap(); - - let mut profile = ProfileManager::instance() - .list_profiles() - .unwrap() - .into_iter() - .find(|p| p.id == profile.id) - .unwrap(); - - // Simulate launch: prepare_for_launch decrypts to ephemeral - let ephemeral = prepare_for_launch(&profile).unwrap(); - assert_eq!( - std::fs::read(ephemeral.join("Default/Cookies")).unwrap(), - b"sqlite-data" - ); - - // Simulate user activity: modify Cookies, leave Bookmarks alone - std::thread::sleep(std::time::Duration::from_millis(1100)); - std::fs::write(ephemeral.join("Default/Cookies"), b"sqlite-modified").unwrap(); - - // Capture pre-quit ciphertext for the unchanged Bookmarks file - let key = get_cached_key(&profile.id).unwrap(); - let bookmarks_name = crate::profile::encryption::hmac_filename(&key, "Default/Bookmarks"); - let bookmarks_cipher_before = std::fs::read(plain_dir.join(&bookmarks_name)).unwrap(); - - // Simulate quit (purge=true): re-encrypts and clears cached key + ephemeral - let n = complete_after_quit_blocking(&profile, false); - assert!(n.is_some(), "should have re-encrypted at least one file"); - assert!( - get_cached_key(&profile.id).is_none(), - "key should be dropped" - ); - assert!( - crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_none(), - "ephemeral should be purged" - ); - - // Unchanged file's ciphertext should be byte-identical - let bookmarks_cipher_after = std::fs::read(plain_dir.join(&bookmarks_name)).unwrap(); - assert_eq!( - bookmarks_cipher_before, bookmarks_cipher_after, - "unchanged file's ciphertext should be stable across quit" - ); - - // Wrong password rejected - let r = rt.block_on(unlock_profile(profile.id.to_string(), "wrong".into())); - assert!(r.is_err()); - - // Correct password unlocks - rt.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) - .unwrap(); - - // Re-launch and verify the modification persisted - profile = ProfileManager::instance() - .list_profiles() - .unwrap() - .into_iter() - .find(|p| p.id == profile.id) - .unwrap(); - let ephemeral2 = prepare_for_launch(&profile).unwrap(); - assert_eq!( - std::fs::read(ephemeral2.join("Default/Cookies")).unwrap(), - b"sqlite-modified", - "modification should persist across the encrypt/decrypt cycle" - ); - assert_eq!( - std::fs::read(ephemeral2.join("Default/Bookmarks")).unwrap(), - b"{\"x\":1}", - "unchanged file should still be present" - ); - - fresh_test_state(&profile.id); - } - - #[test] - #[serial_test::serial] - fn integration_keep_decrypted_keeps_ephemeral_but_still_re_encrypts() { - let temp = TempDir::new().unwrap(); - let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); - - let profile = make_profile("test-keep"); - let profiles_dir = ProfileManager::instance().get_profiles_dir(); - let plain_dir = profile_full_path(&profile, &profiles_dir); - populate_plaintext_dir(&plain_dir); - ProfileManager::instance().save_profile(&profile).unwrap(); - - fresh_test_state(&profile.id); - - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(set_profile_password( - profile.id.to_string(), - "hunter2!".into(), - )) - .unwrap(); - - let profile = ProfileManager::instance() - .list_profiles() - .unwrap() - .into_iter() - .find(|p| p.id == profile.id) - .unwrap(); - let ephemeral = prepare_for_launch(&profile).unwrap(); - std::thread::sleep(std::time::Duration::from_millis(1100)); - std::fs::write(ephemeral.join("Default/Cookies"), b"new-bytes").unwrap(); - - // keep_decrypted=true: ephemeral stays, key stays cached - let n = complete_after_quit_blocking(&profile, true); - assert!(n.is_some()); - assert!( - get_cached_key(&profile.id).is_some(), - "key should still be cached" - ); - assert!( - crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_some(), - "ephemeral should be preserved" - ); - - // The on-disk encrypted dir was still updated - let key = get_cached_key(&profile.id).unwrap(); - let cookies_name = crate::profile::encryption::hmac_filename(&key, "Default/Cookies"); - let cipher = std::fs::read(plain_dir.join(&cookies_name)).unwrap(); - let (path, content) = crate::profile::encryption::decrypt_profile_file(&key, &cipher).unwrap(); - assert_eq!(path, "Default/Cookies"); - assert_eq!(content, b"new-bytes"); - - fresh_test_state(&profile.id); - } - - #[test] - #[serial_test::serial] - fn integration_change_and_remove_password() { - let temp = TempDir::new().unwrap(); - let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); - - let profile = make_profile("test-change"); - let profiles_dir = ProfileManager::instance().get_profiles_dir(); - let plain_dir = profile_full_path(&profile, &profiles_dir); - populate_plaintext_dir(&plain_dir); - ProfileManager::instance().save_profile(&profile).unwrap(); - - fresh_test_state(&profile.id); - let rt = tokio::runtime::Runtime::new().unwrap(); - - rt.block_on(set_profile_password( - profile.id.to_string(), - "hunter2!".into(), - )) - .unwrap(); - let salt_v1 = ProfileManager::instance() - .list_profiles() - .unwrap() - .into_iter() - .find(|p| p.id == profile.id) - .unwrap() - .encryption_salt - .clone() - .unwrap(); - - // Wrong old password should fail - let r = rt.block_on(change_profile_password( - profile.id.to_string(), - "wrong".into(), - "newpassword!".into(), - )); - assert!(r.is_err()); - - // Correct old password works, salt should change - rt.block_on(change_profile_password( - profile.id.to_string(), - "hunter2!".into(), - "newpassword!".into(), - )) - .unwrap(); - let salt_v2 = ProfileManager::instance() - .list_profiles() - .unwrap() - .into_iter() - .find(|p| p.id == profile.id) - .unwrap() - .encryption_salt - .clone() - .unwrap(); - assert_ne!(salt_v1, salt_v2, "salt should rotate on password change"); - - // Old password rejected, new accepted - assert!(rt - .block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) - .is_err()); - rt.block_on(unlock_profile( - profile.id.to_string(), - "newpassword!".into(), - )) - .unwrap(); - - // Remove password: data should be plaintext again - rt.block_on(remove_profile_password( - profile.id.to_string(), - "newpassword!".into(), - )) - .unwrap(); - - let final_profile = ProfileManager::instance() - .list_profiles() - .unwrap() - .into_iter() - .find(|p| p.id == profile.id) - .unwrap(); - assert!(!final_profile.password_protected); - assert!(final_profile.encryption_salt.is_none()); - assert_eq!( - std::fs::read(plain_dir.join("Default/Cookies")).unwrap(), - b"sqlite-data" - ); - - fresh_test_state(&profile.id); - } - - #[test] - #[serial_test::serial] - fn integration_empty_profile_session_survives_restart() { - let temp = TempDir::new().unwrap(); - let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); - - // Mimic a freshly created profile with no browser data yet - let profile = make_profile("test-empty"); - let profiles_dir = ProfileManager::instance().get_profiles_dir(); - let plain_dir = profile_full_path(&profile, &profiles_dir); - std::fs::create_dir_all(&plain_dir).unwrap(); - ProfileManager::instance().save_profile(&profile).unwrap(); - fresh_test_state(&profile.id); - - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(set_profile_password( - profile.id.to_string(), - "hunter2!".into(), - )) - .unwrap(); - - // After encrypting an empty profile, only the verifier file lives on disk - let on_disk_count = std::fs::read_dir(&plain_dir).unwrap().count(); - assert_eq!( - on_disk_count, 1, - "fresh encrypted profile should have only the verifier file" - ); - - let profile = ProfileManager::instance() - .list_profiles() - .unwrap() - .into_iter() - .find(|p| p.id == profile.id) - .unwrap(); - - // Launch — ephemeral starts empty (only the verifier in encrypted, which is skipped) - let ephemeral = prepare_for_launch(&profile).unwrap(); - assert!( - std::fs::read_dir(&ephemeral).unwrap().next().is_none(), - "ephemeral should start empty for a fresh encrypted profile" - ); - - // Simulate the browser writing a session - std::fs::create_dir_all(ephemeral.join("Default")).unwrap(); - std::fs::write(ephemeral.join("Default/Cookies"), b"session-cookies").unwrap(); - std::fs::write(ephemeral.join("Default/places.sqlite"), b"places-data").unwrap(); - std::fs::write(ephemeral.join("prefs.js"), b"user_pref(\"x\", 1);").unwrap(); - - // Browser exits — re-encrypt back to disk - let n = complete_after_quit_blocking(&profile, false); - assert!( - matches!(n, Some(rewrote) if rewrote >= 3), - "expected at least 3 files re-encrypted, got {n:?}" - ); - - // Encrypted dir should now have verifier + 3 user files - let on_disk_count = std::fs::read_dir(&plain_dir).unwrap().count(); - assert!( - on_disk_count >= 4, - "encrypted dir should contain session data + verifier, got {on_disk_count} files" - ); - - // Simulate full app restart: drop key, drop ephemeral tracking, remove ephemeral - fresh_test_state(&profile.id); - - // Unlock with same password - rt.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) - .unwrap(); - - // Re-launch — session must come back - let ephemeral2 = prepare_for_launch(&profile).unwrap(); - assert_eq!( - std::fs::read(ephemeral2.join("Default/Cookies")).unwrap(), - b"session-cookies", - "Cookies should survive across encrypt/quit/restart/unlock cycle" - ); - assert_eq!( - std::fs::read(ephemeral2.join("Default/places.sqlite")).unwrap(), - b"places-data" - ); - assert_eq!( - std::fs::read(ephemeral2.join("prefs.js")).unwrap(), - b"user_pref(\"x\", 1);" - ); - - fresh_test_state(&profile.id); - } - - #[test] - #[serial_test::serial] - fn integration_progressive_backoff_on_wrong_password() { - let temp = TempDir::new().unwrap(); - let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); - - let profile = make_profile("test-backoff"); - let profiles_dir = ProfileManager::instance().get_profiles_dir(); - let plain_dir = profile_full_path(&profile, &profiles_dir); - populate_plaintext_dir(&plain_dir); - ProfileManager::instance().save_profile(&profile).unwrap(); - fresh_test_state(&profile.id); - clear_failed_attempts(&profile.id); - - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(set_profile_password( - profile.id.to_string(), - "hunter2!".into(), - )) - .unwrap(); - drop_cached_key(&profile.id); - - // First 4 wrong attempts produce the INCORRECT_PASSWORD code - for _ in 0..4 { - let err = rt - .block_on(unlock_profile(profile.id.to_string(), "wrong".into())) - .unwrap_err(); - assert_eq!(parse_err_code(&err), Some("INCORRECT_PASSWORD")); - } - - // 5th wrong attempt also returns the code, but the next one will be locked out - let err = rt - .block_on(unlock_profile(profile.id.to_string(), "wrong".into())) - .unwrap_err(); - assert_eq!(parse_err_code(&err), Some("INCORRECT_PASSWORD")); - - // 6th attempt is rate-limited regardless of password correctness - let err = rt - .block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) - .unwrap_err(); - assert_eq!(parse_err_code(&err), Some("LOCKED_OUT")); - let secs = parse_err_param(&err, "seconds") - .and_then(|v| v.parse::().ok()) - .unwrap(); - assert!(secs > 0 && secs <= 60, "expected 1m countdown, got {secs}s"); - - // Bypass the timer by manually expiring last_failed_at past the lockout - if let Ok(mut guard) = FAILED_ATTEMPTS.lock() { - if let Some(record) = guard.get_mut(&profile.id) { - record.last_failed_at_secs = now_epoch_secs().saturating_sub(120); - } - } - if let Some(record) = FAILED_ATTEMPTS - .lock() - .ok() - .and_then(|g| g.get(&profile.id).copied()) - { - persist_record(&profile.id, &record); - } - - // Correct password now succeeds, clearing the failure history - rt.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) - .unwrap(); - let post = FAILED_ATTEMPTS - .lock() - .map(|g| g.contains_key(&profile.id)) - .unwrap_or(true); - assert!(!post, "successful unlock should clear failure record"); - - fresh_test_state(&profile.id); - clear_failed_attempts(&profile.id); - } - - #[test] - #[serial_test::serial] - fn integration_lockout_survives_restart() { - let temp = TempDir::new().unwrap(); - let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); - - let profile = make_profile("test-restart"); - let profiles_dir = ProfileManager::instance().get_profiles_dir(); - let plain_dir = profile_full_path(&profile, &profiles_dir); - populate_plaintext_dir(&plain_dir); - ProfileManager::instance().save_profile(&profile).unwrap(); - fresh_test_state(&profile.id); - clear_failed_attempts(&profile.id); - - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(set_profile_password( - profile.id.to_string(), - "hunter2!".into(), - )) - .unwrap(); - drop_cached_key(&profile.id); - - // 5 wrong attempts to trigger lockout - for _ in 0..5 { - let _ = rt.block_on(unlock_profile(profile.id.to_string(), "wrong".into())); - } - - // Sidecar file should now exist - let sidecar = lockout_sidecar_path(&profile.id); - assert!(sidecar.exists(), "sidecar should be persisted to disk"); - - // Simulate app restart by clearing the in-memory cache (but NOT the sidecar) - if let Ok(mut g) = FAILED_ATTEMPTS.lock() { - g.clear(); - } - - // Lockout should still apply because state was loaded from disk - let err = rt - .block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) - .unwrap_err(); - assert_eq!( - parse_err_code(&err), - Some("LOCKED_OUT"), - "expected lockout to persist across restart, got: {err}" - ); - - fresh_test_state(&profile.id); - clear_failed_attempts(&profile.id); - } - - #[tokio::test] - async fn attempt_lock_serializes_one_profile_without_blocking_others() { - let a = uuid::Uuid::new_v4(); - let b = uuid::Uuid::new_v4(); - - // One lock per profile is what turns check-lockout -> verify -> record - // into a critical section instead of a check-then-act race. - assert!(Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&a))); - assert!(!Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&b))); - - let held = attempt_lock(&a); - let guard = held.lock().await; - assert!( - attempt_lock(&a).try_lock().is_err(), - "a concurrent attempt on the same profile must wait for the window" - ); - assert!( - attempt_lock(&b).try_lock().is_ok(), - "a different profile must not be serialized behind it" - ); - drop(guard); - assert!(attempt_lock(&a).try_lock().is_ok()); - } - - #[test] - fn lockout_schedule_progression() { - use std::time::Duration; - assert_eq!(lockout_for_count(0), None); - assert_eq!(lockout_for_count(4), None); - assert_eq!(lockout_for_count(5), Some(Duration::from_secs(60))); - assert_eq!(lockout_for_count(6), Some(Duration::from_secs(5 * 60))); - assert_eq!(lockout_for_count(7), Some(Duration::from_secs(15 * 60))); - assert_eq!(lockout_for_count(8), Some(Duration::from_secs(60 * 60))); - assert_eq!(lockout_for_count(9), Some(Duration::from_secs(2 * 3600))); - assert_eq!(lockout_for_count(10), Some(Duration::from_secs(4 * 3600))); - assert_eq!(lockout_for_count(11), Some(Duration::from_secs(8 * 3600))); - assert_eq!(lockout_for_count(12), Some(Duration::from_secs(24 * 3600))); - assert_eq!(lockout_for_count(50), Some(Duration::from_secs(24 * 3600))); - } - - #[test] - #[serial_test::serial] - fn integration_lock_drops_key() { - let temp = TempDir::new().unwrap(); - let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); - - let profile = make_profile("test-lock"); - let profiles_dir = ProfileManager::instance().get_profiles_dir(); - let plain_dir = profile_full_path(&profile, &profiles_dir); - populate_plaintext_dir(&plain_dir); - ProfileManager::instance().save_profile(&profile).unwrap(); - fresh_test_state(&profile.id); - - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(set_profile_password( - profile.id.to_string(), - "hunter2!".into(), - )) - .unwrap(); - assert!(get_cached_key(&profile.id).is_some()); - assert!(!rt - .block_on(is_profile_locked(profile.id.to_string())) - .unwrap()); - - rt.block_on(lock_profile(profile.id.to_string())).unwrap(); - assert!(get_cached_key(&profile.id).is_none()); - assert!(rt - .block_on(is_profile_locked(profile.id.to_string())) - .unwrap()); - - fresh_test_state(&profile.id); - } -} +#[path = "password_tests.rs"] +mod tests; diff --git a/src-tauri/src/profile/password_tests.rs b/src-tauri/src/profile/password_tests.rs new file mode 100644 index 0000000..bc336a3 --- /dev/null +++ b/src-tauri/src/profile/password_tests.rs @@ -0,0 +1,624 @@ +use super::*; +use crate::profile::BrowserProfile; +use tempfile::TempDir; + +fn make_profile(name: &str) -> BrowserProfile { + BrowserProfile { + id: uuid::Uuid::new_v4(), + name: name.to_string(), + browser: "wayfern".to_string(), + version: "1.0".to_string(), + release_type: "stable".to_string(), + ..Default::default() + } +} + +fn populate_plaintext_dir(dir: &Path) { + std::fs::create_dir_all(dir.join("Default")).unwrap(); + std::fs::write(dir.join("Default/Cookies"), b"sqlite-data").unwrap(); + std::fs::write(dir.join("Default/Bookmarks"), b"{\"x\":1}").unwrap(); + std::fs::write(dir.join("Local State"), b"local-state").unwrap(); + // Cache files should be excluded: + std::fs::create_dir_all(dir.join("Default/Cache")).unwrap(); + std::fs::write(dir.join("Default/Cache/data_0"), b"cache-blob").unwrap(); +} + +fn parse_err_code(err: &str) -> Option<&'static str> { + let v: serde_json::Value = serde_json::from_str(err).ok()?; + let code = v.get("code")?.as_str()?; + Some(match code { + "INCORRECT_PASSWORD" => "INCORRECT_PASSWORD", + "LOCKED_OUT" => "LOCKED_OUT", + "PROFILE_NOT_FOUND" => "PROFILE_NOT_FOUND", + "PROFILE_NOT_PROTECTED" => "PROFILE_NOT_PROTECTED", + "PROFILE_ALREADY_PROTECTED" => "PROFILE_ALREADY_PROTECTED", + "PROFILE_RUNNING" => "PROFILE_RUNNING", + "PROFILE_MISSING_SALT" => "PROFILE_MISSING_SALT", + "PROFILE_LOCKED" => "PROFILE_LOCKED", + "INVALID_PROFILE_ID" => "INVALID_PROFILE_ID", + "PASSWORD_TOO_SHORT" => "PASSWORD_TOO_SHORT", + "INTERNAL_ERROR" => "INTERNAL_ERROR", + _ => return None, + }) +} + +fn parse_err_param(err: &str, key: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(err).ok()?; + Some(v.get("params")?.get(key)?.as_str()?.to_string()) +} + +fn fresh_test_state(id: &uuid::Uuid) { + drop_cached_key(id); + let _ = LAUNCH_SNAPSHOTS.lock().map(|mut g| g.remove(id)); + let _ = POPULATED_EPHEMERAL.lock().map(|mut g| g.remove(id)); + crate::ephemeral_dirs::remove_ephemeral_dir(&id.to_string()); +} + +fn profile_full_path(profile: &BrowserProfile, profiles_dir: &Path) -> PathBuf { + profiles_dir.join(profile.id.to_string()).join("profile") +} + +#[test] +#[serial_test::serial] +fn integration_set_password_encrypts_dir() { + let temp = TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + + let mut profile = make_profile("test-set"); + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + let plain_dir = profile_full_path(&profile, &profiles_dir); + populate_plaintext_dir(&plain_dir); + ProfileManager::instance().save_profile(&profile).unwrap(); + + fresh_test_state(&profile.id); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(set_profile_password( + profile.id.to_string(), + "hunter2!".into(), + )) + .unwrap(); + + profile = ProfileManager::instance() + .list_profiles() + .unwrap() + .into_iter() + .find(|p| p.id == profile.id) + .unwrap(); + assert!(profile.password_protected); + assert!(profile.encryption_salt.is_some()); + + // No plaintext filenames should remain on disk + let names: Vec = std::fs::read_dir(&plain_dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + for n in &names { + assert!(!n.contains("Cookies"), "plaintext name leaked: {n}"); + assert!(!n.contains("Bookmarks")); + assert!(!n.contains("Local State")); + } + + fresh_test_state(&profile.id); +} + +#[test] +#[serial_test::serial] +fn integration_full_lifecycle_persists_data() { + let temp = TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + + let profile = make_profile("test-lifecycle"); + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + let plain_dir = profile_full_path(&profile, &profiles_dir); + populate_plaintext_dir(&plain_dir); + ProfileManager::instance().save_profile(&profile).unwrap(); + + fresh_test_state(&profile.id); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(set_profile_password( + profile.id.to_string(), + "hunter2!".into(), + )) + .unwrap(); + + let mut profile = ProfileManager::instance() + .list_profiles() + .unwrap() + .into_iter() + .find(|p| p.id == profile.id) + .unwrap(); + + // Simulate launch: prepare_for_launch decrypts to ephemeral + let ephemeral = prepare_for_launch(&profile).unwrap(); + assert_eq!( + std::fs::read(ephemeral.join("Default/Cookies")).unwrap(), + b"sqlite-data" + ); + + // Simulate user activity: modify Cookies, leave Bookmarks alone + std::thread::sleep(std::time::Duration::from_millis(1100)); + std::fs::write(ephemeral.join("Default/Cookies"), b"sqlite-modified").unwrap(); + + // Capture pre-quit ciphertext for the unchanged Bookmarks file + let key = get_cached_key(&profile.id).unwrap(); + let bookmarks_name = crate::profile::encryption::hmac_filename(&key, "Default/Bookmarks"); + let bookmarks_cipher_before = std::fs::read(plain_dir.join(&bookmarks_name)).unwrap(); + + // Simulate quit (purge=true): re-encrypts and clears cached key + ephemeral + let n = complete_after_quit_blocking(&profile, false); + assert!(n.is_some(), "should have re-encrypted at least one file"); + assert!( + get_cached_key(&profile.id).is_none(), + "key should be dropped" + ); + assert!( + crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_none(), + "ephemeral should be purged" + ); + + // Unchanged file's ciphertext should be byte-identical + let bookmarks_cipher_after = std::fs::read(plain_dir.join(&bookmarks_name)).unwrap(); + assert_eq!( + bookmarks_cipher_before, bookmarks_cipher_after, + "unchanged file's ciphertext should be stable across quit" + ); + + // Wrong password rejected + let r = rt.block_on(unlock_profile(profile.id.to_string(), "wrong".into())); + assert!(r.is_err()); + + // Correct password unlocks + rt.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) + .unwrap(); + + // Re-launch and verify the modification persisted + profile = ProfileManager::instance() + .list_profiles() + .unwrap() + .into_iter() + .find(|p| p.id == profile.id) + .unwrap(); + let ephemeral2 = prepare_for_launch(&profile).unwrap(); + assert_eq!( + std::fs::read(ephemeral2.join("Default/Cookies")).unwrap(), + b"sqlite-modified", + "modification should persist across the encrypt/decrypt cycle" + ); + assert_eq!( + std::fs::read(ephemeral2.join("Default/Bookmarks")).unwrap(), + b"{\"x\":1}", + "unchanged file should still be present" + ); + + fresh_test_state(&profile.id); +} + +#[test] +#[serial_test::serial] +fn integration_keep_decrypted_keeps_ephemeral_but_still_re_encrypts() { + let temp = TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + + let profile = make_profile("test-keep"); + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + let plain_dir = profile_full_path(&profile, &profiles_dir); + populate_plaintext_dir(&plain_dir); + ProfileManager::instance().save_profile(&profile).unwrap(); + + fresh_test_state(&profile.id); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(set_profile_password( + profile.id.to_string(), + "hunter2!".into(), + )) + .unwrap(); + + let profile = ProfileManager::instance() + .list_profiles() + .unwrap() + .into_iter() + .find(|p| p.id == profile.id) + .unwrap(); + let ephemeral = prepare_for_launch(&profile).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(1100)); + std::fs::write(ephemeral.join("Default/Cookies"), b"new-bytes").unwrap(); + + // keep_decrypted=true: ephemeral stays, key stays cached + let n = complete_after_quit_blocking(&profile, true); + assert!(n.is_some()); + assert!( + get_cached_key(&profile.id).is_some(), + "key should still be cached" + ); + assert!( + crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_some(), + "ephemeral should be preserved" + ); + + // The on-disk encrypted dir was still updated + let key = get_cached_key(&profile.id).unwrap(); + let cookies_name = crate::profile::encryption::hmac_filename(&key, "Default/Cookies"); + let cipher = std::fs::read(plain_dir.join(&cookies_name)).unwrap(); + let (path, content) = crate::profile::encryption::decrypt_profile_file(&key, &cipher).unwrap(); + assert_eq!(path, "Default/Cookies"); + assert_eq!(content, b"new-bytes"); + + fresh_test_state(&profile.id); +} + +#[test] +#[serial_test::serial] +fn integration_change_and_remove_password() { + let temp = TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + + let profile = make_profile("test-change"); + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + let plain_dir = profile_full_path(&profile, &profiles_dir); + populate_plaintext_dir(&plain_dir); + ProfileManager::instance().save_profile(&profile).unwrap(); + + fresh_test_state(&profile.id); + let rt = tokio::runtime::Runtime::new().unwrap(); + + rt.block_on(set_profile_password( + profile.id.to_string(), + "hunter2!".into(), + )) + .unwrap(); + let salt_v1 = ProfileManager::instance() + .list_profiles() + .unwrap() + .into_iter() + .find(|p| p.id == profile.id) + .unwrap() + .encryption_salt + .clone() + .unwrap(); + + // Wrong old password should fail + let r = rt.block_on(change_profile_password( + profile.id.to_string(), + "wrong".into(), + "newpassword!".into(), + )); + assert!(r.is_err()); + + // Correct old password works, salt should change + rt.block_on(change_profile_password( + profile.id.to_string(), + "hunter2!".into(), + "newpassword!".into(), + )) + .unwrap(); + let salt_v2 = ProfileManager::instance() + .list_profiles() + .unwrap() + .into_iter() + .find(|p| p.id == profile.id) + .unwrap() + .encryption_salt + .clone() + .unwrap(); + assert_ne!(salt_v1, salt_v2, "salt should rotate on password change"); + + // Old password rejected, new accepted + assert!(rt + .block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) + .is_err()); + rt.block_on(unlock_profile( + profile.id.to_string(), + "newpassword!".into(), + )) + .unwrap(); + + // Remove password: data should be plaintext again + rt.block_on(remove_profile_password( + profile.id.to_string(), + "newpassword!".into(), + )) + .unwrap(); + + let final_profile = ProfileManager::instance() + .list_profiles() + .unwrap() + .into_iter() + .find(|p| p.id == profile.id) + .unwrap(); + assert!(!final_profile.password_protected); + assert!(final_profile.encryption_salt.is_none()); + assert_eq!( + std::fs::read(plain_dir.join("Default/Cookies")).unwrap(), + b"sqlite-data" + ); + + fresh_test_state(&profile.id); +} + +#[test] +#[serial_test::serial] +fn integration_empty_profile_session_survives_restart() { + let temp = TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + + // Mimic a freshly created profile with no browser data yet + let profile = make_profile("test-empty"); + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + let plain_dir = profile_full_path(&profile, &profiles_dir); + std::fs::create_dir_all(&plain_dir).unwrap(); + ProfileManager::instance().save_profile(&profile).unwrap(); + fresh_test_state(&profile.id); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(set_profile_password( + profile.id.to_string(), + "hunter2!".into(), + )) + .unwrap(); + + // After encrypting an empty profile, only the verifier file lives on disk + let on_disk_count = std::fs::read_dir(&plain_dir).unwrap().count(); + assert_eq!( + on_disk_count, 1, + "fresh encrypted profile should have only the verifier file" + ); + + let profile = ProfileManager::instance() + .list_profiles() + .unwrap() + .into_iter() + .find(|p| p.id == profile.id) + .unwrap(); + + // Launch — ephemeral starts empty (only the verifier in encrypted, which is skipped) + let ephemeral = prepare_for_launch(&profile).unwrap(); + assert!( + std::fs::read_dir(&ephemeral).unwrap().next().is_none(), + "ephemeral should start empty for a fresh encrypted profile" + ); + + // Simulate the browser writing a session + std::fs::create_dir_all(ephemeral.join("Default")).unwrap(); + std::fs::write(ephemeral.join("Default/Cookies"), b"session-cookies").unwrap(); + std::fs::write(ephemeral.join("Default/places.sqlite"), b"places-data").unwrap(); + std::fs::write(ephemeral.join("prefs.js"), b"user_pref(\"x\", 1);").unwrap(); + + // Browser exits — re-encrypt back to disk + let n = complete_after_quit_blocking(&profile, false); + assert!( + matches!(n, Some(rewrote) if rewrote >= 3), + "expected at least 3 files re-encrypted, got {n:?}" + ); + + // Encrypted dir should now have verifier + 3 user files + let on_disk_count = std::fs::read_dir(&plain_dir).unwrap().count(); + assert!( + on_disk_count >= 4, + "encrypted dir should contain session data + verifier, got {on_disk_count} files" + ); + + // Simulate full app restart: drop key, drop ephemeral tracking, remove ephemeral + fresh_test_state(&profile.id); + + // Unlock with same password + rt.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) + .unwrap(); + + // Re-launch — session must come back + let ephemeral2 = prepare_for_launch(&profile).unwrap(); + assert_eq!( + std::fs::read(ephemeral2.join("Default/Cookies")).unwrap(), + b"session-cookies", + "Cookies should survive across encrypt/quit/restart/unlock cycle" + ); + assert_eq!( + std::fs::read(ephemeral2.join("Default/places.sqlite")).unwrap(), + b"places-data" + ); + assert_eq!( + std::fs::read(ephemeral2.join("prefs.js")).unwrap(), + b"user_pref(\"x\", 1);" + ); + + fresh_test_state(&profile.id); +} + +#[test] +#[serial_test::serial] +fn integration_progressive_backoff_on_wrong_password() { + let temp = TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + + let profile = make_profile("test-backoff"); + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + let plain_dir = profile_full_path(&profile, &profiles_dir); + populate_plaintext_dir(&plain_dir); + ProfileManager::instance().save_profile(&profile).unwrap(); + fresh_test_state(&profile.id); + clear_failed_attempts(&profile.id); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(set_profile_password( + profile.id.to_string(), + "hunter2!".into(), + )) + .unwrap(); + drop_cached_key(&profile.id); + + // First 4 wrong attempts produce the INCORRECT_PASSWORD code + for _ in 0..4 { + let err = rt + .block_on(unlock_profile(profile.id.to_string(), "wrong".into())) + .unwrap_err(); + assert_eq!(parse_err_code(&err), Some("INCORRECT_PASSWORD")); + } + + // 5th wrong attempt also returns the code, but the next one will be locked out + let err = rt + .block_on(unlock_profile(profile.id.to_string(), "wrong".into())) + .unwrap_err(); + assert_eq!(parse_err_code(&err), Some("INCORRECT_PASSWORD")); + + // 6th attempt is rate-limited regardless of password correctness + let err = rt + .block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) + .unwrap_err(); + assert_eq!(parse_err_code(&err), Some("LOCKED_OUT")); + let secs = parse_err_param(&err, "seconds") + .and_then(|v| v.parse::().ok()) + .unwrap(); + assert!(secs > 0 && secs <= 60, "expected 1m countdown, got {secs}s"); + + // Bypass the timer by manually expiring last_failed_at past the lockout + if let Ok(mut guard) = FAILED_ATTEMPTS.lock() { + if let Some(record) = guard.get_mut(&profile.id) { + record.last_failed_at_secs = now_epoch_secs().saturating_sub(120); + } + } + if let Some(record) = FAILED_ATTEMPTS + .lock() + .ok() + .and_then(|g| g.get(&profile.id).copied()) + { + persist_record(&profile.id, &record); + } + + // Correct password now succeeds, clearing the failure history + rt.block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) + .unwrap(); + let post = FAILED_ATTEMPTS + .lock() + .map(|g| g.contains_key(&profile.id)) + .unwrap_or(true); + assert!(!post, "successful unlock should clear failure record"); + + fresh_test_state(&profile.id); + clear_failed_attempts(&profile.id); +} + +#[test] +#[serial_test::serial] +fn integration_lockout_survives_restart() { + let temp = TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + + let profile = make_profile("test-restart"); + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + let plain_dir = profile_full_path(&profile, &profiles_dir); + populate_plaintext_dir(&plain_dir); + ProfileManager::instance().save_profile(&profile).unwrap(); + fresh_test_state(&profile.id); + clear_failed_attempts(&profile.id); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(set_profile_password( + profile.id.to_string(), + "hunter2!".into(), + )) + .unwrap(); + drop_cached_key(&profile.id); + + // 5 wrong attempts to trigger lockout + for _ in 0..5 { + let _ = rt.block_on(unlock_profile(profile.id.to_string(), "wrong".into())); + } + + // Sidecar file should now exist + let sidecar = lockout_sidecar_path(&profile.id); + assert!(sidecar.exists(), "sidecar should be persisted to disk"); + + // Simulate app restart by clearing the in-memory cache (but NOT the sidecar) + if let Ok(mut g) = FAILED_ATTEMPTS.lock() { + g.clear(); + } + + // Lockout should still apply because state was loaded from disk + let err = rt + .block_on(unlock_profile(profile.id.to_string(), "hunter2!".into())) + .unwrap_err(); + assert_eq!( + parse_err_code(&err), + Some("LOCKED_OUT"), + "expected lockout to persist across restart, got: {err}" + ); + + fresh_test_state(&profile.id); + clear_failed_attempts(&profile.id); +} + +#[tokio::test] +async fn attempt_lock_serializes_one_profile_without_blocking_others() { + let a = uuid::Uuid::new_v4(); + let b = uuid::Uuid::new_v4(); + + // One lock per profile is what turns check-lockout -> verify -> record + // into a critical section instead of a check-then-act race. + assert!(Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&a))); + assert!(!Arc::ptr_eq(&attempt_lock(&a), &attempt_lock(&b))); + + let held = attempt_lock(&a); + let guard = held.lock().await; + assert!( + attempt_lock(&a).try_lock().is_err(), + "a concurrent attempt on the same profile must wait for the window" + ); + assert!( + attempt_lock(&b).try_lock().is_ok(), + "a different profile must not be serialized behind it" + ); + drop(guard); + assert!(attempt_lock(&a).try_lock().is_ok()); +} + +#[test] +fn lockout_schedule_progression() { + use std::time::Duration; + assert_eq!(lockout_for_count(0), None); + assert_eq!(lockout_for_count(4), None); + assert_eq!(lockout_for_count(5), Some(Duration::from_secs(60))); + assert_eq!(lockout_for_count(6), Some(Duration::from_secs(5 * 60))); + assert_eq!(lockout_for_count(7), Some(Duration::from_secs(15 * 60))); + assert_eq!(lockout_for_count(8), Some(Duration::from_secs(60 * 60))); + assert_eq!(lockout_for_count(9), Some(Duration::from_secs(2 * 3600))); + assert_eq!(lockout_for_count(10), Some(Duration::from_secs(4 * 3600))); + assert_eq!(lockout_for_count(11), Some(Duration::from_secs(8 * 3600))); + assert_eq!(lockout_for_count(12), Some(Duration::from_secs(24 * 3600))); + assert_eq!(lockout_for_count(50), Some(Duration::from_secs(24 * 3600))); +} + +#[test] +#[serial_test::serial] +fn integration_lock_drops_key() { + let temp = TempDir::new().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf()); + + let profile = make_profile("test-lock"); + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + let plain_dir = profile_full_path(&profile, &profiles_dir); + populate_plaintext_dir(&plain_dir); + ProfileManager::instance().save_profile(&profile).unwrap(); + fresh_test_state(&profile.id); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(set_profile_password( + profile.id.to_string(), + "hunter2!".into(), + )) + .unwrap(); + assert!(get_cached_key(&profile.id).is_some()); + assert!(!rt + .block_on(is_profile_locked(profile.id.to_string())) + .unwrap()); + + rt.block_on(lock_profile(profile.id.to_string())).unwrap(); + assert!(get_cached_key(&profile.id).is_none()); + assert!(rt + .block_on(is_profile_locked(profile.id.to_string())) + .unwrap()); + + fresh_test_state(&profile.id); +} diff --git a/src-tauri/src/profile_import/os_crypt.rs b/src-tauri/src/profile_import/os_crypt.rs index a137cd0..bbaf094 100644 --- a/src-tauri/src/profile_import/os_crypt.rs +++ b/src-tauri/src/profile_import/os_crypt.rs @@ -377,191 +377,5 @@ impl SourceKeyring { } #[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn empty_password_key_matches_chromium_constant() { - // Locks the constant against the value Chromium hardcodes in encryptor.cc. - assert_eq!(derive_key(b"", POSIX_ITERATIONS), EMPTY_PASSWORD_KEY); - } - - #[test] - fn peanuts_key_matches_known_vector() { - // PBKDF2-HMAC-SHA1("peanuts", "saltysalt", 1, 16). Any drift here silently - // breaks every Linux `--password-store=basic` import. - assert_eq!( - derive_key(POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS), - [ - 0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27, - 0x78 - ] - ); - } - - #[test] - fn cbc_round_trip() { - let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS)); - let sealed = key.encrypt(b"session-token").expect("encrypt"); - assert_eq!(key.decrypt(&sealed).expect("decrypt"), b"session-token"); - } - - #[test] - fn cbc_round_trip_empty_plaintext() { - let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS)); - let sealed = key.encrypt(b"").expect("encrypt"); - // PKCS7 always emits a full padding block, so this must not be empty. - assert_eq!(sealed.len(), 16); - assert!(key.decrypt(&sealed).expect("decrypt").is_empty()); - } - - #[test] - fn gcm_round_trip_with_fresh_nonce_each_time() { - let key = CryptoKey::Aes256Gcm([7u8; 32]); - let a = key.encrypt(b"session-token").expect("encrypt"); - let b = key.encrypt(b"session-token").expect("encrypt"); - assert_ne!(a, b, "nonce must be random per call"); - assert_eq!(key.decrypt(&a).expect("decrypt"), b"session-token"); - assert_eq!(key.decrypt(&b).expect("decrypt"), b"session-token"); - } - - #[test] - fn gcm_rejects_tampered_ciphertext() { - let key = CryptoKey::Aes256Gcm([7u8; 32]); - let mut sealed = key.encrypt(b"session-token").expect("encrypt"); - let last = sealed.len() - 1; - sealed[last] ^= 0xff; - assert!(key.decrypt(&sealed).is_none()); - } - - #[test] - fn target_key_is_stable_across_calls() { - let dir = TempDir::new().unwrap(); - let first = TargetKey::ensure(dir.path()).expect("mint"); - let sealed = first.encrypt(b"value").expect("encrypt"); - - let second = TargetKey::ensure(dir.path()).expect("reuse"); - // Re-running import over the same directory must not orphan what the - // previous run wrote. - let key_file = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap(); - let reloaded = TargetKey::from_file_contents(&key_file).expect("reload"); - assert_eq!( - reloaded.encrypt(b"probe").map(|v| v[..3].to_vec()), - second.encrypt(b"probe").map(|v| v[..3].to_vec()) - ); - - let mut keyring = SourceKeyring::default(); - let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap(); - install_host_key(&mut keyring, &contents); - match keyring.decrypt(&sealed) { - Decrypted::Value(v) => assert_eq!(v, b"value"), - _ => panic!("target key must round-trip through the source keyring"), - } - } - - #[test] - fn minted_key_matches_wayfern_file_format() { - let dir = TempDir::new().unwrap(); - TargetKey::ensure(dir.path()).expect("mint"); - let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap(); - - #[cfg(target_os = "windows")] - assert_eq!( - contents.len(), - 32, - "DPAPIKeyProvider only adopts a 32-byte portable key" - ); - - #[cfg(not(target_os = "windows"))] - { - // The non-Windows key file is base64(16 random bytes) = 24 ASCII chars. - assert_eq!(contents.len(), 24); - let text = String::from_utf8(contents).expect("ascii"); - assert!( - base64::engine::general_purpose::STANDARD - .decode(&text) - .map(|b| b.len()) - == Ok(16), - "expected base64 of 16 bytes, got {text}" - ); - } - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(dir.path().join(KEY_FILE_NAME)) - .unwrap() - .permissions() - .mode(); - assert_eq!(mode & 0o777, 0o600); - } - } - - #[test] - fn unknown_tag_is_treated_as_plaintext_not_as_loss() { - let keyring = SourceKeyring::default(); - assert!(matches!( - keyring.decrypt(b"plain cookie value"), - Decrypted::NotEncrypted - )); - } - - #[test] - fn app_bound_records_are_flagged_unrecoverable() { - let keyring = SourceKeyring::default(); - let mut sealed = b"v20".to_vec(); - sealed.extend_from_slice(&[0u8; 40]); - assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable)); - assert!( - keyring.saw_app_bound.get(), - "v20 must be reported to the user, not silently dropped" - ); - } - - #[test] - fn missing_key_for_known_tag_is_unrecoverable() { - let keyring = SourceKeyring::default(); - let mut sealed = b"v10".to_vec(); - sealed.extend_from_slice(&[0u8; 32]); - assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable)); - } - - #[test] - fn empty_password_fallback_recovers_the_record() { - // A record sealed with the empty-password key must still open when the - // keyring holds a different primary key, mirroring Chromium. - let sealed_body = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY) - .encrypt(b"legacy") - .unwrap(); - let mut stored = b"v10".to_vec(); - stored.extend_from_slice(&sealed_body); - - let keyring = SourceKeyring { - v10: Some(CryptoKey::Aes128Cbc(derive_key(b"a different key", 1003))), - ..Default::default() - }; - match keyring.decrypt(&stored) { - Decrypted::Value(v) => assert_eq!(v, b"legacy"), - _ => panic!("empty-password fallback must be attempted"), - } - } - - /// Load the host-format key into a keyring under the host tag, for tests - /// that need to verify what we wrote is what Wayfern will read. - fn install_host_key(keyring: &mut SourceKeyring, contents: &[u8]) { - #[cfg(target_os = "windows")] - { - let bytes: [u8; 32] = contents.try_into().unwrap(); - keyring.v10 = Some(CryptoKey::Aes256Gcm(bytes)); - } - #[cfg(target_os = "macos")] - { - keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS))); - } - #[cfg(target_os = "linux")] - { - keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS))); - } - } -} +#[path = "os_crypt_tests.rs"] +mod tests; diff --git a/src-tauri/src/profile_import/os_crypt_tests.rs b/src-tauri/src/profile_import/os_crypt_tests.rs new file mode 100644 index 0000000..222883e --- /dev/null +++ b/src-tauri/src/profile_import/os_crypt_tests.rs @@ -0,0 +1,186 @@ +use super::*; +use tempfile::TempDir; + +#[test] +fn empty_password_key_matches_chromium_constant() { + // Locks the constant against the value Chromium hardcodes in encryptor.cc. + assert_eq!(derive_key(b"", POSIX_ITERATIONS), EMPTY_PASSWORD_KEY); +} + +#[test] +fn peanuts_key_matches_known_vector() { + // PBKDF2-HMAC-SHA1("peanuts", "saltysalt", 1, 16). Any drift here silently + // breaks every Linux `--password-store=basic` import. + assert_eq!( + derive_key(POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS), + [ + 0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27, + 0x78 + ] + ); +} + +#[test] +fn cbc_round_trip() { + let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS)); + let sealed = key.encrypt(b"session-token").expect("encrypt"); + assert_eq!(key.decrypt(&sealed).expect("decrypt"), b"session-token"); +} + +#[test] +fn cbc_round_trip_empty_plaintext() { + let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS)); + let sealed = key.encrypt(b"").expect("encrypt"); + // PKCS7 always emits a full padding block, so this must not be empty. + assert_eq!(sealed.len(), 16); + assert!(key.decrypt(&sealed).expect("decrypt").is_empty()); +} + +#[test] +fn gcm_round_trip_with_fresh_nonce_each_time() { + let key = CryptoKey::Aes256Gcm([7u8; 32]); + let a = key.encrypt(b"session-token").expect("encrypt"); + let b = key.encrypt(b"session-token").expect("encrypt"); + assert_ne!(a, b, "nonce must be random per call"); + assert_eq!(key.decrypt(&a).expect("decrypt"), b"session-token"); + assert_eq!(key.decrypt(&b).expect("decrypt"), b"session-token"); +} + +#[test] +fn gcm_rejects_tampered_ciphertext() { + let key = CryptoKey::Aes256Gcm([7u8; 32]); + let mut sealed = key.encrypt(b"session-token").expect("encrypt"); + let last = sealed.len() - 1; + sealed[last] ^= 0xff; + assert!(key.decrypt(&sealed).is_none()); +} + +#[test] +fn target_key_is_stable_across_calls() { + let dir = TempDir::new().unwrap(); + let first = TargetKey::ensure(dir.path()).expect("mint"); + let sealed = first.encrypt(b"value").expect("encrypt"); + + let second = TargetKey::ensure(dir.path()).expect("reuse"); + // Re-running import over the same directory must not orphan what the + // previous run wrote. + let key_file = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap(); + let reloaded = TargetKey::from_file_contents(&key_file).expect("reload"); + assert_eq!( + reloaded.encrypt(b"probe").map(|v| v[..3].to_vec()), + second.encrypt(b"probe").map(|v| v[..3].to_vec()) + ); + + let mut keyring = SourceKeyring::default(); + let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap(); + install_host_key(&mut keyring, &contents); + match keyring.decrypt(&sealed) { + Decrypted::Value(v) => assert_eq!(v, b"value"), + _ => panic!("target key must round-trip through the source keyring"), + } +} + +#[test] +fn minted_key_matches_wayfern_file_format() { + let dir = TempDir::new().unwrap(); + TargetKey::ensure(dir.path()).expect("mint"); + let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap(); + + #[cfg(target_os = "windows")] + assert_eq!( + contents.len(), + 32, + "DPAPIKeyProvider only adopts a 32-byte portable key" + ); + + #[cfg(not(target_os = "windows"))] + { + // The non-Windows key file is base64(16 random bytes) = 24 ASCII chars. + assert_eq!(contents.len(), 24); + let text = String::from_utf8(contents).expect("ascii"); + assert!( + base64::engine::general_purpose::STANDARD + .decode(&text) + .map(|b| b.len()) + == Ok(16), + "expected base64 of 16 bytes, got {text}" + ); + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.path().join(KEY_FILE_NAME)) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } +} + +#[test] +fn unknown_tag_is_treated_as_plaintext_not_as_loss() { + let keyring = SourceKeyring::default(); + assert!(matches!( + keyring.decrypt(b"plain cookie value"), + Decrypted::NotEncrypted + )); +} + +#[test] +fn app_bound_records_are_flagged_unrecoverable() { + let keyring = SourceKeyring::default(); + let mut sealed = b"v20".to_vec(); + sealed.extend_from_slice(&[0u8; 40]); + assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable)); + assert!( + keyring.saw_app_bound.get(), + "v20 must be reported to the user, not silently dropped" + ); +} + +#[test] +fn missing_key_for_known_tag_is_unrecoverable() { + let keyring = SourceKeyring::default(); + let mut sealed = b"v10".to_vec(); + sealed.extend_from_slice(&[0u8; 32]); + assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable)); +} + +#[test] +fn empty_password_fallback_recovers_the_record() { + // A record sealed with the empty-password key must still open when the + // keyring holds a different primary key, mirroring Chromium. + let sealed_body = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY) + .encrypt(b"legacy") + .unwrap(); + let mut stored = b"v10".to_vec(); + stored.extend_from_slice(&sealed_body); + + let keyring = SourceKeyring { + v10: Some(CryptoKey::Aes128Cbc(derive_key(b"a different key", 1003))), + ..Default::default() + }; + match keyring.decrypt(&stored) { + Decrypted::Value(v) => assert_eq!(v, b"legacy"), + _ => panic!("empty-password fallback must be attempted"), + } +} + +/// Load the host-format key into a keyring under the host tag, for tests +/// that need to verify what we wrote is what Wayfern will read. +fn install_host_key(keyring: &mut SourceKeyring, contents: &[u8]) { + #[cfg(target_os = "windows")] + { + let bytes: [u8; 32] = contents.try_into().unwrap(); + keyring.v10 = Some(CryptoKey::Aes256Gcm(bytes)); + } + #[cfg(target_os = "macos")] + { + keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS))); + } + #[cfg(target_os = "linux")] + { + keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS))); + } +} diff --git a/src-tauri/src/profile_import/report.rs b/src-tauri/src/profile_import/report.rs index a429a99..b3cffd3 100644 --- a/src-tauri/src/profile_import/report.rs +++ b/src-tauri/src/profile_import/report.rs @@ -40,8 +40,12 @@ pub struct ProfileImportReport { pub cookies_migrated: usize, /// Cookies carried over as rows but whose value could not be recovered. pub cookies_unrecoverable: usize, - pub passwords_migrated: usize, - pub passwords_unrecoverable: usize, + /// Saved logins whose secret is readable in the new profile. `passwords_migrated` on the wire. + #[serde(rename = "passwords_migrated")] + pub logins_migrated: usize, + /// Saved logins carried as rows whose secret could not be recovered. `passwords_unrecoverable` on the wire. + #[serde(rename = "passwords_unrecoverable")] + pub logins_unrecoverable: usize, /// Saved cards / IBANs / autofill secrets re-encrypted. pub payment_methods_migrated: usize, pub payment_methods_unrecoverable: usize, @@ -66,7 +70,7 @@ impl ProfileImportReport { /// should present the import as a success or as a warning. pub fn is_empty_import(&self) -> bool { self.cookies_migrated == 0 - && self.passwords_migrated == 0 + && self.logins_migrated == 0 && self.history_entries == 0 && self.bookmarks == 0 && self.local_storage_origins == 0 diff --git a/src-tauri/src/profile_import/rewrite.rs b/src-tauri/src/profile_import/rewrite.rs index bb17893..244219a 100644 --- a/src-tauri/src/profile_import/rewrite.rs +++ b/src-tauri/src/profile_import/rewrite.rs @@ -306,8 +306,8 @@ pub fn reencrypt_profile( if let Some(conn) = open_rw(&default_dir.join("Login Data")) { for (table, column) in LOGIN_COLUMNS { let counts = reencrypt_column(&conn, table, column, source, target); - report.passwords_migrated += counts.migrated; - report.passwords_unrecoverable += counts.unrecoverable; + report.logins_migrated += counts.migrated; + report.logins_unrecoverable += counts.unrecoverable; } } @@ -515,586 +515,5 @@ pub fn finalize_profile( } #[cfg(test)] -mod tests { - use super::*; - use crate::profile_import::os_crypt::{derive_key, CryptoKey}; - use tempfile::TempDir; - - fn source_keyring_with(password: &[u8]) -> SourceKeyring { - // Match the host's CBC iteration count so tests exercise the real path. - #[cfg(target_os = "linux")] - let key = CryptoKey::Aes128Cbc(derive_key( - password, - super::super::os_crypt::POSIX_ITERATIONS, - )); - #[cfg(not(target_os = "linux"))] - let key = CryptoKey::Aes128Cbc(derive_key(password, super::super::os_crypt::MAC_ITERATIONS)); - - #[cfg(target_os = "linux")] - return SourceKeyring { - v11: Some(key), - ..Default::default() - }; - #[cfg(not(target_os = "linux"))] - SourceKeyring { - v10: Some(key), - ..Default::default() - } - } - - fn seal_as_source(keyring: &SourceKeyring, plaintext: &[u8]) -> Vec { - let (tag, key) = if let Some(k) = keyring.v10.as_ref() { - (b"v10", k) - } else { - (b"v11", keyring.v11.as_ref().unwrap()) - }; - let mut out = tag.to_vec(); - out.extend_from_slice(&key.encrypt(plaintext).unwrap()); - out - } - - fn make_cookie_db(path: &Path, version: i64) -> Connection { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - let conn = Connection::open(path).unwrap(); - conn - .execute_batch( - "CREATE TABLE cookies( - creation_utc INTEGER NOT NULL, - host_key TEXT NOT NULL, - top_frame_site_key TEXT NOT NULL DEFAULT '', - name TEXT NOT NULL, - value TEXT NOT NULL DEFAULT '', - encrypted_value BLOB NOT NULL DEFAULT '', - path TEXT NOT NULL DEFAULT '/' - ); - CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);", - ) - .unwrap(); - conn - .execute( - "INSERT INTO meta VALUES('version', ?1)", - [version.to_string()], - ) - .unwrap(); - conn - .execute( - "INSERT INTO meta VALUES('last_compatible_version', ?1)", - [version.to_string()], - ) - .unwrap(); - conn - } - - #[test] - fn v24_cookie_is_reframed_for_the_target_key() { - let dir = TempDir::new().unwrap(); - let default_dir = dir.path().join("Default"); - let cookie_path = layout::host_cookie_path(&default_dir); - - let source = source_keyring_with(b"source-password"); - let mut framed = Sha256::digest(b"example.com").to_vec(); - framed.extend_from_slice(b"tasty"); - let sealed = seal_as_source(&source, &framed); - - let conn = make_cookie_db(&cookie_path, 24); - conn - .execute( - "INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path) - VALUES(0, 'example.com', '', 'sid', '', ?1, '/')", - rusqlite::params![sealed], - ) - .unwrap(); - drop(conn); - - let target = TargetKey::ensure(dir.path()).unwrap(); - let mut report = ProfileImportReport::default(); - reencrypt_cookies(&default_dir, &source, &target, &mut report); - - assert_eq!(report.cookies_migrated, 1); - assert_eq!(report.cookies_unrecoverable, 0); - - // Read it back exactly the way Wayfern will. - let conn = Connection::open(&cookie_path).unwrap(); - let (value, encrypted): (String, Vec) = conn - .query_row("SELECT value, encrypted_value FROM cookies", [], |r| { - Ok((r.get(0)?, r.get(1)?)) - }) - .unwrap(); - assert!( - value.is_empty(), - "a row with both value and encrypted_value set is dropped at load" - ); - - let target_keyring = target_as_keyring(dir.path()); - let Decrypted::Value(plain) = target_keyring.decrypt(&encrypted) else { - panic!("target must be able to open what it sealed"); - }; - assert_eq!(&plain[..32], &Sha256::digest(b"example.com")[..]); - assert_eq!(&plain[32..], b"tasty"); - } - - #[test] - fn cookie_sealed_as_sqlite_text_is_still_recovered() { - // Chromium's own v23->v24 migration binds `encrypted_value` with - // BindString, so an established profile's cookies carry storage class TEXT - // in a column declared BLOB. Reading them as a strict blob returns empty, - // which used to blank every cookie and report it as migrated. - let dir = TempDir::new().unwrap(); - let default_dir = dir.path().join("Default"); - let cookie_path = layout::host_cookie_path(&default_dir); - - let source = source_keyring_with(b"source-password"); - let mut framed = Sha256::digest(b"example.com").to_vec(); - framed.extend_from_slice(b"tasty"); - let sealed = seal_as_source(&source, &framed); - - let conn = make_cookie_db(&cookie_path, 24); - conn - .execute( - "INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path) - VALUES(0, 'example.com', '', 'sid', '', CAST(?1 AS TEXT), '/')", - rusqlite::params![sealed], - ) - .unwrap(); - let stored_type: String = conn - .query_row("SELECT typeof(encrypted_value) FROM cookies", [], |r| { - r.get(0) - }) - .unwrap(); - assert_eq!( - stored_type, "text", - "fixture must reproduce Chromium's binding" - ); - drop(conn); - - let target = TargetKey::ensure(dir.path()).unwrap(); - let mut report = ProfileImportReport::default(); - reencrypt_cookies(&default_dir, &source, &target, &mut report); - - assert_eq!(report.cookies_migrated, 1); - let conn = Connection::open(&cookie_path).unwrap(); - let encrypted: Vec = conn - .query_row("SELECT encrypted_value FROM cookies", [], |r| r.get(0)) - .unwrap(); - let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else { - panic!("expected a readable cookie"); - }; - assert_eq!(&plain[32..], b"tasty", "the cookie value must survive"); - } - - #[test] - fn password_note_sealed_as_sqlite_text_is_still_recovered() { - // `password_notes.value` is written with BindString on every platform, so - // this is not an edge case — it is how the column always looks. - let dir = TempDir::new().unwrap(); - let default_dir = dir.path().join("Default"); - std::fs::create_dir_all(&default_dir).unwrap(); - - let source = source_keyring_with(b"source-password"); - let sealed = seal_as_source(&source, b"a private note"); - - let conn = Connection::open(default_dir.join("Login Data")).unwrap(); - conn - .execute_batch( - "CREATE TABLE logins(password_value BLOB); - CREATE TABLE password_notes(id INTEGER PRIMARY KEY, value BLOB);", - ) - .unwrap(); - conn - .execute( - "INSERT INTO password_notes(value) VALUES(CAST(?1 AS TEXT))", - rusqlite::params![sealed], - ) - .unwrap(); - drop(conn); - - let target = TargetKey::ensure(dir.path()).unwrap(); - let mut report = ProfileImportReport::default(); - reencrypt_profile(&default_dir, &source, &target, &mut report); - - assert_eq!(report.passwords_migrated, 1); - let conn = Connection::open(default_dir.join("Login Data")).unwrap(); - let stored: Vec = conn - .query_row("SELECT value FROM password_notes", [], |r| r.get(0)) - .unwrap(); - let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&stored) else { - panic!("note must be readable with the target key"); - }; - assert_eq!(plain, b"a private note"); - } - - #[test] - fn windows_extension_paths_are_recognised_as_absolute_on_every_host() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("Secure Preferences"); - std::fs::write( - &path, - serde_json::json!({ - "extensions": { "settings": { - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { "path": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/1.0_0" }, - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { "path": "C:\\Program Files\\Google\\Chrome\\Application\\151.0.0\\resources\\pdf" }, - "cccccccccccccccccccccccccccccccc": { "path": "//host/share/ext" } - }} - }) - .to_string(), - ) - .unwrap(); - - let mut report = ProfileImportReport::default(); - sanitize_secure_preferences(&path, &mut report); - - let value: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - let settings = value["extensions"]["settings"].as_object().unwrap(); - assert_eq!( - settings.len(), - 1, - "a Windows-syntax path is still absolute when imported onto macOS" - ); - assert!(settings.contains_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); - assert_eq!(report.extensions_migrated, 1); - } - - #[test] - fn plaintext_cookie_is_sealed_and_value_cleared() { - let dir = TempDir::new().unwrap(); - let default_dir = dir.path().join("Default"); - let cookie_path = layout::host_cookie_path(&default_dir); - - let conn = make_cookie_db(&cookie_path, 24); - conn - .execute( - "INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path) - VALUES(0, 'example.com', '', 'sid', 'plain', X'', '/')", - [], - ) - .unwrap(); - drop(conn); - - let target = TargetKey::ensure(dir.path()).unwrap(); - let source = source_keyring_with(b"unused"); - let mut report = ProfileImportReport::default(); - reencrypt_cookies(&default_dir, &source, &target, &mut report); - - assert_eq!(report.cookies_migrated, 1); - let conn = Connection::open(&cookie_path).unwrap(); - let (value, encrypted): (String, Vec) = conn - .query_row("SELECT value, encrypted_value FROM cookies", [], |r| { - Ok((r.get(0)?, r.get(1)?)) - }) - .unwrap(); - assert!(value.is_empty()); - let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else { - panic!("expected a readable cookie"); - }; - assert_eq!(&plain[32..], b"plain"); - } - - #[test] - fn v23_cookie_has_no_prefix_to_strip_and_is_upgraded_to_v24() { - let dir = TempDir::new().unwrap(); - let default_dir = dir.path().join("Default"); - let cookie_path = layout::host_cookie_path(&default_dir); - - let source = source_keyring_with(b"source-password"); - // v23 stores the bare value, with no SHA256(host) prefix. - let sealed = seal_as_source(&source, b"tasty"); - - let conn = make_cookie_db(&cookie_path, 23); - conn - .execute( - "INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path) - VALUES(0, 'example.com', '', 'sid', '', ?1, '/')", - rusqlite::params![sealed], - ) - .unwrap(); - drop(conn); - - let target = TargetKey::ensure(dir.path()).unwrap(); - let mut report = ProfileImportReport::default(); - reencrypt_cookies(&default_dir, &source, &target, &mut report); - - assert_eq!(report.cookies_migrated, 1); - let conn = Connection::open(&cookie_path).unwrap(); - let version: String = conn - .query_row("SELECT value FROM meta WHERE key='version'", [], |r| { - r.get(0) - }) - .unwrap(); - assert_eq!( - version, "24", - "we wrote v24 framing, so the store must declare v24 or Chromium re-prefixes it" - ); - - let encrypted: Vec = conn - .query_row("SELECT encrypted_value FROM cookies", [], |r| r.get(0)) - .unwrap(); - let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else { - panic!("expected a readable cookie"); - }; - assert_eq!(&plain[32..], b"tasty"); - } - - #[test] - fn unrecoverable_cookie_row_is_deleted_and_counted() { - let dir = TempDir::new().unwrap(); - let default_dir = dir.path().join("Default"); - let cookie_path = layout::host_cookie_path(&default_dir); - - let conn = make_cookie_db(&cookie_path, 24); - let mut app_bound = b"v20".to_vec(); - app_bound.extend_from_slice(&[0u8; 48]); - conn - .execute( - "INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path) - VALUES(0, 'example.com', '', 'sid', '', ?1, '/')", - rusqlite::params![app_bound], - ) - .unwrap(); - drop(conn); - - let target = TargetKey::ensure(dir.path()).unwrap(); - let source = source_keyring_with(b"source-password"); - let mut report = ProfileImportReport::default(); - reencrypt_cookies(&default_dir, &source, &target, &mut report); - - assert_eq!(report.cookies_unrecoverable, 1); - assert_eq!(report.cookies_migrated, 0); - let conn = Connection::open(&cookie_path).unwrap(); - let remaining: i64 = conn - .query_row("SELECT count(*) FROM cookies", [], |r| r.get(0)) - .unwrap(); - assert_eq!(remaining, 0, "a row no key can open is dead weight"); - } - - #[test] - fn cookie_store_older_than_chromium_migrates_is_removed_with_a_warning() { - let dir = TempDir::new().unwrap(); - let default_dir = dir.path().join("Default"); - let cookie_path = layout::host_cookie_path(&default_dir); - make_cookie_db(&cookie_path, 22); - - let target = TargetKey::ensure(dir.path()).unwrap(); - let source = source_keyring_with(b"x"); - let mut report = ProfileImportReport::default(); - reencrypt_cookies(&default_dir, &source, &target, &mut report); - - assert!(report - .warnings - .contains(&warning::STORE_TOO_OLD.to_string())); - assert!(!cookie_path.exists()); - } - - #[test] - fn passwords_are_reencrypted() { - let dir = TempDir::new().unwrap(); - let default_dir = dir.path().join("Default"); - std::fs::create_dir_all(&default_dir).unwrap(); - - let source = source_keyring_with(b"source-password"); - let sealed = seal_as_source(&source, b"hunter2"); - - let conn = Connection::open(default_dir.join("Login Data")).unwrap(); - conn - .execute_batch("CREATE TABLE logins(origin_url VARCHAR, password_value BLOB);") - .unwrap(); - conn - .execute( - "INSERT INTO logins VALUES('https://example.com', ?1)", - rusqlite::params![sealed], - ) - .unwrap(); - drop(conn); - - let target = TargetKey::ensure(dir.path()).unwrap(); - let mut report = ProfileImportReport::default(); - reencrypt_profile(&default_dir, &source, &target, &mut report); - - assert_eq!(report.passwords_migrated, 1); - let conn = Connection::open(default_dir.join("Login Data")).unwrap(); - let stored: Vec = conn - .query_row("SELECT password_value FROM logins", [], |r| r.get(0)) - .unwrap(); - let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&stored) else { - panic!("password must be readable with the target key"); - }; - assert_eq!(plain, b"hunter2"); - } - - #[test] - fn missing_optional_tables_are_not_an_error() { - // `password_notes` and most payment tables only exist on some schemas. - let dir = TempDir::new().unwrap(); - let default_dir = dir.path().join("Default"); - std::fs::create_dir_all(&default_dir).unwrap(); - let conn = Connection::open(default_dir.join("Login Data")).unwrap(); - conn - .execute_batch("CREATE TABLE logins(password_value BLOB);") - .unwrap(); - drop(conn); - - let target = TargetKey::ensure(dir.path()).unwrap(); - let source = source_keyring_with(b"x"); - let mut report = ProfileImportReport::default(); - reencrypt_profile(&default_dir, &source, &target, &mut report); - assert_eq!(report.passwords_migrated, 0); - } - - #[test] - fn secure_preferences_keeps_extensions_and_drops_protection() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("Secure Preferences"); - std::fs::write( - &path, - serde_json::json!({ - "protection": { "macs": { "extensions": { "settings": "deadbeef" } }, "super_mac": "x" }, - "extensions": { "settings": { - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { "path": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/1.0_0" }, - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { "path": "/Applications/Chromium.app/Contents/Resources/x" } - }} - }) - .to_string(), - ) - .unwrap(); - - let mut report = ProfileImportReport::default(); - sanitize_secure_preferences(&path, &mut report); - - let value: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - assert!(value.get("protection").is_none()); - let settings = value["extensions"]["settings"].as_object().unwrap(); - assert!( - settings.contains_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), - "a relative path is the user's real extension and must survive" - ); - assert!( - !settings.contains_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), - "an absolute path points into the source browser's bundle" - ); - assert_eq!(report.extensions_migrated, 1); - assert!(report - .warnings - .contains(&warning::SECURE_PREFERENCES_RESET.to_string())); - } - - #[test] - fn preferences_lose_machine_paths_and_crash_state() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("Preferences"); - std::fs::write( - &path, - serde_json::json!({ - "download": { "default_directory": "/Users/someone-else/Downloads" }, - "profile": { "exit_type": "Crashed", "exited_cleanly": false, "name": "Person 1" }, - "intl": { "accept_languages": "de,de-DE" } - }) - .to_string(), - ) - .unwrap(); - - let mut report = ProfileImportReport::default(); - sanitize_preferences(&path, &mut report); - - let value: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - assert!(value["download"].get("default_directory").is_none()); - assert_eq!(value["profile"]["exit_type"], "Normal"); - assert_eq!(value["profile"]["exited_cleanly"], true); - assert!(value["intl"].get("accept_languages").is_none()); - assert_eq!( - value["profile"]["name"], "Person 1", - "unrelated preferences must be preserved" - ); - } - - #[test] - fn plaintext_cookies_still_migrate_when_no_source_key_is_available() { - // A declined Keychain prompt loses the encrypted rows, but a profile whose - // cookies were stored in plaintext has nothing to lose. Reporting zero for - // it would be the same silent-empty-import failure this work exists to fix. - let dir = TempDir::new().unwrap(); - let default_dir = dir.path().join("Default"); - let cookie_path = layout::host_cookie_path(&default_dir); - - let conn = make_cookie_db(&cookie_path, 24); - conn - .execute( - "INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path) - VALUES(0, 'example.com', '', 'sid', 'plain', X'', '/')", - [], - ) - .unwrap(); - let mut sealed_elsewhere = b"v10".to_vec(); - sealed_elsewhere.extend_from_slice(&[9u8; 32]); - conn - .execute( - "INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path) - VALUES(1, 'other.example', '', 'sid', '', ?1, '/')", - rusqlite::params![sealed_elsewhere], - ) - .unwrap(); - drop(conn); - - let target = TargetKey::ensure(dir.path()).unwrap(); - let empty = SourceKeyring::default(); - let mut report = ProfileImportReport::default(); - finalize_profile(&default_dir, &empty, &target, &mut report); - - assert_eq!( - report.cookies_migrated, 1, - "the plaintext row is recoverable" - ); - assert_eq!(report.cookies_unrecoverable, 1, "the sealed row is not"); - assert!(report - .warnings - .contains(&warning::SECRETS_NOT_MIGRATED.to_string())); - } - - #[test] - fn bookmarks_are_counted_recursively() { - let roots = serde_json::json!({ - "bookmark_bar": { "type": "folder", "children": [ - { "type": "url", "url": "https://a.example" }, - { "type": "folder", "children": [{ "type": "url", "url": "https://b.example" }] } - ]}, - "other": { "type": "folder", "children": [] } - }); - assert_eq!(count_bookmarks(Some(&roots)), 2); - } - - /// Load the freshly minted `os_crypt_key` back as a keyring, so tests assert - /// against what Wayfern will actually do rather than against our own writer. - fn target_as_keyring(user_data_dir: &Path) -> SourceKeyring { - let contents = - std::fs::read(user_data_dir.join(crate::profile_import::os_crypt::KEY_FILE_NAME)).unwrap(); - #[cfg(target_os = "windows")] - { - let bytes: [u8; 32] = contents.as_slice().try_into().unwrap(); - SourceKeyring { - v10: Some(CryptoKey::Aes256Gcm(bytes)), - ..Default::default() - } - } - #[cfg(target_os = "macos")] - { - SourceKeyring { - v10: Some(CryptoKey::Aes128Cbc(derive_key( - &contents, - super::super::os_crypt::MAC_ITERATIONS, - ))), - ..Default::default() - } - } - #[cfg(target_os = "linux")] - { - SourceKeyring { - v11: Some(CryptoKey::Aes128Cbc(derive_key( - &contents, - super::super::os_crypt::POSIX_ITERATIONS, - ))), - ..Default::default() - } - } - } -} +#[path = "rewrite_tests.rs"] +mod tests; diff --git a/src-tauri/src/profile_import/rewrite_tests.rs b/src-tauri/src/profile_import/rewrite_tests.rs new file mode 100644 index 0000000..edeb807 --- /dev/null +++ b/src-tauri/src/profile_import/rewrite_tests.rs @@ -0,0 +1,581 @@ +use super::*; +use crate::profile_import::os_crypt::{derive_key, CryptoKey}; +use tempfile::TempDir; + +fn source_keyring_with(password: &[u8]) -> SourceKeyring { + // Match the host's CBC iteration count so tests exercise the real path. + #[cfg(target_os = "linux")] + let key = CryptoKey::Aes128Cbc(derive_key( + password, + super::super::os_crypt::POSIX_ITERATIONS, + )); + #[cfg(not(target_os = "linux"))] + let key = CryptoKey::Aes128Cbc(derive_key(password, super::super::os_crypt::MAC_ITERATIONS)); + + #[cfg(target_os = "linux")] + return SourceKeyring { + v11: Some(key), + ..Default::default() + }; + #[cfg(not(target_os = "linux"))] + SourceKeyring { + v10: Some(key), + ..Default::default() + } +} + +fn seal_as_source(keyring: &SourceKeyring, plaintext: &[u8]) -> Vec { + let (tag, key) = if let Some(k) = keyring.v10.as_ref() { + (b"v10", k) + } else { + (b"v11", keyring.v11.as_ref().unwrap()) + }; + let mut out = tag.to_vec(); + out.extend_from_slice(&key.encrypt(plaintext).unwrap()); + out +} + +fn make_cookie_db(path: &Path, version: i64) -> Connection { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let conn = Connection::open(path).unwrap(); + conn + .execute_batch( + "CREATE TABLE cookies( + creation_utc INTEGER NOT NULL, + host_key TEXT NOT NULL, + top_frame_site_key TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + value TEXT NOT NULL DEFAULT '', + encrypted_value BLOB NOT NULL DEFAULT '', + path TEXT NOT NULL DEFAULT '/' + ); + CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);", + ) + .unwrap(); + conn + .execute( + "INSERT INTO meta VALUES('version', ?1)", + [version.to_string()], + ) + .unwrap(); + conn + .execute( + "INSERT INTO meta VALUES('last_compatible_version', ?1)", + [version.to_string()], + ) + .unwrap(); + conn +} + +#[test] +fn v24_cookie_is_reframed_for_the_target_key() { + let dir = TempDir::new().unwrap(); + let default_dir = dir.path().join("Default"); + let cookie_path = layout::host_cookie_path(&default_dir); + + let source = source_keyring_with(b"source-password"); + let mut framed = Sha256::digest(b"example.com").to_vec(); + framed.extend_from_slice(b"tasty"); + let sealed = seal_as_source(&source, &framed); + + let conn = make_cookie_db(&cookie_path, 24); + conn + .execute( + "INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path) + VALUES(0, 'example.com', '', 'sid', '', ?1, '/')", + rusqlite::params![sealed], + ) + .unwrap(); + drop(conn); + + let target = TargetKey::ensure(dir.path()).unwrap(); + let mut report = ProfileImportReport::default(); + reencrypt_cookies(&default_dir, &source, &target, &mut report); + + assert_eq!(report.cookies_migrated, 1); + assert_eq!(report.cookies_unrecoverable, 0); + + // Read it back exactly the way Wayfern will. + let conn = Connection::open(&cookie_path).unwrap(); + let (value, encrypted): (String, Vec) = conn + .query_row("SELECT value, encrypted_value FROM cookies", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .unwrap(); + assert!( + value.is_empty(), + "a row with both value and encrypted_value set is dropped at load" + ); + + let target_keyring = target_as_keyring(dir.path()); + let Decrypted::Value(plain) = target_keyring.decrypt(&encrypted) else { + panic!("target must be able to open what it sealed"); + }; + assert_eq!(&plain[..32], &Sha256::digest(b"example.com")[..]); + assert_eq!(&plain[32..], b"tasty"); +} + +#[test] +fn cookie_sealed_as_sqlite_text_is_still_recovered() { + // Chromium's own v23->v24 migration binds `encrypted_value` with + // BindString, so an established profile's cookies carry storage class TEXT + // in a column declared BLOB. Reading them as a strict blob returns empty, + // which used to blank every cookie and report it as migrated. + let dir = TempDir::new().unwrap(); + let default_dir = dir.path().join("Default"); + let cookie_path = layout::host_cookie_path(&default_dir); + + let source = source_keyring_with(b"source-password"); + let mut framed = Sha256::digest(b"example.com").to_vec(); + framed.extend_from_slice(b"tasty"); + let sealed = seal_as_source(&source, &framed); + + let conn = make_cookie_db(&cookie_path, 24); + conn + .execute( + "INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path) + VALUES(0, 'example.com', '', 'sid', '', CAST(?1 AS TEXT), '/')", + rusqlite::params![sealed], + ) + .unwrap(); + let stored_type: String = conn + .query_row("SELECT typeof(encrypted_value) FROM cookies", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!( + stored_type, "text", + "fixture must reproduce Chromium's binding" + ); + drop(conn); + + let target = TargetKey::ensure(dir.path()).unwrap(); + let mut report = ProfileImportReport::default(); + reencrypt_cookies(&default_dir, &source, &target, &mut report); + + assert_eq!(report.cookies_migrated, 1); + let conn = Connection::open(&cookie_path).unwrap(); + let encrypted: Vec = conn + .query_row("SELECT encrypted_value FROM cookies", [], |r| r.get(0)) + .unwrap(); + let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else { + panic!("expected a readable cookie"); + }; + assert_eq!(&plain[32..], b"tasty", "the cookie value must survive"); +} + +#[test] +fn password_note_sealed_as_sqlite_text_is_still_recovered() { + // `password_notes.value` is written with BindString on every platform, so + // this is not an edge case — it is how the column always looks. + let dir = TempDir::new().unwrap(); + let default_dir = dir.path().join("Default"); + std::fs::create_dir_all(&default_dir).unwrap(); + + let source = source_keyring_with(b"source-password"); + let sealed = seal_as_source(&source, b"a private note"); + + let conn = Connection::open(default_dir.join("Login Data")).unwrap(); + conn + .execute_batch( + "CREATE TABLE logins(password_value BLOB); + CREATE TABLE password_notes(id INTEGER PRIMARY KEY, value BLOB);", + ) + .unwrap(); + conn + .execute( + "INSERT INTO password_notes(value) VALUES(CAST(?1 AS TEXT))", + rusqlite::params![sealed], + ) + .unwrap(); + drop(conn); + + let target = TargetKey::ensure(dir.path()).unwrap(); + let mut report = ProfileImportReport::default(); + reencrypt_profile(&default_dir, &source, &target, &mut report); + + assert_eq!(report.logins_migrated, 1); + let conn = Connection::open(default_dir.join("Login Data")).unwrap(); + let stored: Vec = conn + .query_row("SELECT value FROM password_notes", [], |r| r.get(0)) + .unwrap(); + let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&stored) else { + panic!("note must be readable with the target key"); + }; + assert_eq!(plain, b"a private note"); +} + +#[test] +fn windows_extension_paths_are_recognised_as_absolute_on_every_host() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("Secure Preferences"); + std::fs::write( + &path, + serde_json::json!({ + "extensions": { "settings": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { "path": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/1.0_0" }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { "path": "C:\\Program Files\\Google\\Chrome\\Application\\151.0.0\\resources\\pdf" }, + "cccccccccccccccccccccccccccccccc": { "path": "//host/share/ext" } + }} + }) + .to_string(), + ) + .unwrap(); + + let mut report = ProfileImportReport::default(); + sanitize_secure_preferences(&path, &mut report); + + let value: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let settings = value["extensions"]["settings"].as_object().unwrap(); + assert_eq!( + settings.len(), + 1, + "a Windows-syntax path is still absolute when imported onto macOS" + ); + assert!(settings.contains_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); + assert_eq!(report.extensions_migrated, 1); +} + +#[test] +fn plaintext_cookie_is_sealed_and_value_cleared() { + let dir = TempDir::new().unwrap(); + let default_dir = dir.path().join("Default"); + let cookie_path = layout::host_cookie_path(&default_dir); + + let conn = make_cookie_db(&cookie_path, 24); + conn + .execute( + "INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path) + VALUES(0, 'example.com', '', 'sid', 'plain', X'', '/')", + [], + ) + .unwrap(); + drop(conn); + + let target = TargetKey::ensure(dir.path()).unwrap(); + let source = source_keyring_with(b"unused"); + let mut report = ProfileImportReport::default(); + reencrypt_cookies(&default_dir, &source, &target, &mut report); + + assert_eq!(report.cookies_migrated, 1); + let conn = Connection::open(&cookie_path).unwrap(); + let (value, encrypted): (String, Vec) = conn + .query_row("SELECT value, encrypted_value FROM cookies", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .unwrap(); + assert!(value.is_empty()); + let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else { + panic!("expected a readable cookie"); + }; + assert_eq!(&plain[32..], b"plain"); +} + +#[test] +fn v23_cookie_has_no_prefix_to_strip_and_is_upgraded_to_v24() { + let dir = TempDir::new().unwrap(); + let default_dir = dir.path().join("Default"); + let cookie_path = layout::host_cookie_path(&default_dir); + + let source = source_keyring_with(b"source-password"); + // v23 stores the bare value, with no SHA256(host) prefix. + let sealed = seal_as_source(&source, b"tasty"); + + let conn = make_cookie_db(&cookie_path, 23); + conn + .execute( + "INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path) + VALUES(0, 'example.com', '', 'sid', '', ?1, '/')", + rusqlite::params![sealed], + ) + .unwrap(); + drop(conn); + + let target = TargetKey::ensure(dir.path()).unwrap(); + let mut report = ProfileImportReport::default(); + reencrypt_cookies(&default_dir, &source, &target, &mut report); + + assert_eq!(report.cookies_migrated, 1); + let conn = Connection::open(&cookie_path).unwrap(); + let version: String = conn + .query_row("SELECT value FROM meta WHERE key='version'", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!( + version, "24", + "we wrote v24 framing, so the store must declare v24 or Chromium re-prefixes it" + ); + + let encrypted: Vec = conn + .query_row("SELECT encrypted_value FROM cookies", [], |r| r.get(0)) + .unwrap(); + let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&encrypted) else { + panic!("expected a readable cookie"); + }; + assert_eq!(&plain[32..], b"tasty"); +} + +#[test] +fn unrecoverable_cookie_row_is_deleted_and_counted() { + let dir = TempDir::new().unwrap(); + let default_dir = dir.path().join("Default"); + let cookie_path = layout::host_cookie_path(&default_dir); + + let conn = make_cookie_db(&cookie_path, 24); + let mut app_bound = b"v20".to_vec(); + app_bound.extend_from_slice(&[0u8; 48]); + conn + .execute( + "INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path) + VALUES(0, 'example.com', '', 'sid', '', ?1, '/')", + rusqlite::params![app_bound], + ) + .unwrap(); + drop(conn); + + let target = TargetKey::ensure(dir.path()).unwrap(); + let source = source_keyring_with(b"source-password"); + let mut report = ProfileImportReport::default(); + reencrypt_cookies(&default_dir, &source, &target, &mut report); + + assert_eq!(report.cookies_unrecoverable, 1); + assert_eq!(report.cookies_migrated, 0); + let conn = Connection::open(&cookie_path).unwrap(); + let remaining: i64 = conn + .query_row("SELECT count(*) FROM cookies", [], |r| r.get(0)) + .unwrap(); + assert_eq!(remaining, 0, "a row no key can open is dead weight"); +} + +#[test] +fn cookie_store_older_than_chromium_migrates_is_removed_with_a_warning() { + let dir = TempDir::new().unwrap(); + let default_dir = dir.path().join("Default"); + let cookie_path = layout::host_cookie_path(&default_dir); + make_cookie_db(&cookie_path, 22); + + let target = TargetKey::ensure(dir.path()).unwrap(); + let source = source_keyring_with(b"x"); + let mut report = ProfileImportReport::default(); + reencrypt_cookies(&default_dir, &source, &target, &mut report); + + assert!(report + .warnings + .contains(&warning::STORE_TOO_OLD.to_string())); + assert!(!cookie_path.exists()); +} + +#[test] +fn passwords_are_reencrypted() { + let dir = TempDir::new().unwrap(); + let default_dir = dir.path().join("Default"); + std::fs::create_dir_all(&default_dir).unwrap(); + + let source = source_keyring_with(b"source-password"); + let sealed = seal_as_source(&source, b"hunter2"); + + let conn = Connection::open(default_dir.join("Login Data")).unwrap(); + conn + .execute_batch("CREATE TABLE logins(origin_url VARCHAR, password_value BLOB);") + .unwrap(); + conn + .execute( + "INSERT INTO logins VALUES('https://example.com', ?1)", + rusqlite::params![sealed], + ) + .unwrap(); + drop(conn); + + let target = TargetKey::ensure(dir.path()).unwrap(); + let mut report = ProfileImportReport::default(); + reencrypt_profile(&default_dir, &source, &target, &mut report); + + assert_eq!(report.logins_migrated, 1); + let conn = Connection::open(default_dir.join("Login Data")).unwrap(); + let stored: Vec = conn + .query_row("SELECT password_value FROM logins", [], |r| r.get(0)) + .unwrap(); + let Decrypted::Value(plain) = target_as_keyring(dir.path()).decrypt(&stored) else { + panic!("password must be readable with the target key"); + }; + assert_eq!(plain, b"hunter2"); +} + +#[test] +fn missing_optional_tables_are_not_an_error() { + // `password_notes` and most payment tables only exist on some schemas. + let dir = TempDir::new().unwrap(); + let default_dir = dir.path().join("Default"); + std::fs::create_dir_all(&default_dir).unwrap(); + let conn = Connection::open(default_dir.join("Login Data")).unwrap(); + conn + .execute_batch("CREATE TABLE logins(password_value BLOB);") + .unwrap(); + drop(conn); + + let target = TargetKey::ensure(dir.path()).unwrap(); + let source = source_keyring_with(b"x"); + let mut report = ProfileImportReport::default(); + reencrypt_profile(&default_dir, &source, &target, &mut report); + assert_eq!(report.logins_migrated, 0); +} + +#[test] +fn secure_preferences_keeps_extensions_and_drops_protection() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("Secure Preferences"); + std::fs::write( + &path, + serde_json::json!({ + "protection": { "macs": { "extensions": { "settings": "deadbeef" } }, "super_mac": "x" }, + "extensions": { "settings": { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { "path": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/1.0_0" }, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": { "path": "/Applications/Chromium.app/Contents/Resources/x" } + }} + }) + .to_string(), + ) + .unwrap(); + + let mut report = ProfileImportReport::default(); + sanitize_secure_preferences(&path, &mut report); + + let value: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert!(value.get("protection").is_none()); + let settings = value["extensions"]["settings"].as_object().unwrap(); + assert!( + settings.contains_key("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + "a relative path is the user's real extension and must survive" + ); + assert!( + !settings.contains_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + "an absolute path points into the source browser's bundle" + ); + assert_eq!(report.extensions_migrated, 1); + assert!(report + .warnings + .contains(&warning::SECURE_PREFERENCES_RESET.to_string())); +} + +#[test] +fn preferences_lose_machine_paths_and_crash_state() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("Preferences"); + std::fs::write( + &path, + serde_json::json!({ + "download": { "default_directory": "/Users/someone-else/Downloads" }, + "profile": { "exit_type": "Crashed", "exited_cleanly": false, "name": "Person 1" }, + "intl": { "accept_languages": "de,de-DE" } + }) + .to_string(), + ) + .unwrap(); + + let mut report = ProfileImportReport::default(); + sanitize_preferences(&path, &mut report); + + let value: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert!(value["download"].get("default_directory").is_none()); + assert_eq!(value["profile"]["exit_type"], "Normal"); + assert_eq!(value["profile"]["exited_cleanly"], true); + assert!(value["intl"].get("accept_languages").is_none()); + assert_eq!( + value["profile"]["name"], "Person 1", + "unrelated preferences must be preserved" + ); +} + +#[test] +fn plaintext_cookies_still_migrate_when_no_source_key_is_available() { + // A declined Keychain prompt loses the encrypted rows, but a profile whose + // cookies were stored in plaintext has nothing to lose. Reporting zero for + // it would be the same silent-empty-import failure this work exists to fix. + let dir = TempDir::new().unwrap(); + let default_dir = dir.path().join("Default"); + let cookie_path = layout::host_cookie_path(&default_dir); + + let conn = make_cookie_db(&cookie_path, 24); + conn + .execute( + "INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path) + VALUES(0, 'example.com', '', 'sid', 'plain', X'', '/')", + [], + ) + .unwrap(); + let mut sealed_elsewhere = b"v10".to_vec(); + sealed_elsewhere.extend_from_slice(&[9u8; 32]); + conn + .execute( + "INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path) + VALUES(1, 'other.example', '', 'sid', '', ?1, '/')", + rusqlite::params![sealed_elsewhere], + ) + .unwrap(); + drop(conn); + + let target = TargetKey::ensure(dir.path()).unwrap(); + let empty = SourceKeyring::default(); + let mut report = ProfileImportReport::default(); + finalize_profile(&default_dir, &empty, &target, &mut report); + + assert_eq!( + report.cookies_migrated, 1, + "the plaintext row is recoverable" + ); + assert_eq!(report.cookies_unrecoverable, 1, "the sealed row is not"); + assert!(report + .warnings + .contains(&warning::SECRETS_NOT_MIGRATED.to_string())); +} + +#[test] +fn bookmarks_are_counted_recursively() { + let roots = serde_json::json!({ + "bookmark_bar": { "type": "folder", "children": [ + { "type": "url", "url": "https://a.example" }, + { "type": "folder", "children": [{ "type": "url", "url": "https://b.example" }] } + ]}, + "other": { "type": "folder", "children": [] } + }); + assert_eq!(count_bookmarks(Some(&roots)), 2); +} + +/// Load the freshly minted `os_crypt_key` back as a keyring, so tests assert +/// against what Wayfern will actually do rather than against our own writer. +fn target_as_keyring(user_data_dir: &Path) -> SourceKeyring { + let contents = + std::fs::read(user_data_dir.join(crate::profile_import::os_crypt::KEY_FILE_NAME)).unwrap(); + #[cfg(target_os = "windows")] + { + let bytes: [u8; 32] = contents.as_slice().try_into().unwrap(); + SourceKeyring { + v10: Some(CryptoKey::Aes256Gcm(bytes)), + ..Default::default() + } + } + #[cfg(target_os = "macos")] + { + SourceKeyring { + v10: Some(CryptoKey::Aes128Cbc(derive_key( + &contents, + super::super::os_crypt::MAC_ITERATIONS, + ))), + ..Default::default() + } + } + #[cfg(target_os = "linux")] + { + SourceKeyring { + v11: Some(CryptoKey::Aes128Cbc(derive_key( + &contents, + super::super::os_crypt::POSIX_ITERATIONS, + ))), + ..Default::default() + } + } +} diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index 842c624..7437e00 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -711,7 +711,7 @@ impl ProfileImporter { .collect(); let total = items.len(); - let mut results = Vec::with_capacity(total); + let mut results = Vec::new(); let mut imported_count = 0usize; let mut skipped_count = 0usize; let mut failed_count = 0usize; @@ -857,10 +857,10 @@ impl ProfileImporter { let profile_id = uuid::Uuid::new_v4(); let profiles_dir = self.profile_manager.get_profiles_dir(); - let new_profile_uuid_dir = profiles_dir.join(profile_id.to_string()); - let new_profile_data_dir = new_profile_uuid_dir.join("profile"); + let new_profile_dir = profiles_dir.join(profile_id.to_string()); + let new_profile_data_dir = new_profile_dir.join("profile"); - create_dir_all(&new_profile_uuid_dir)?; + create_dir_all(&new_profile_dir)?; create_dir_all(&new_profile_data_dir)?; // Profile dirs can be multiple GB and the migration hits SQLite and the @@ -884,7 +884,7 @@ impl ProfileImporter { // every other error path here, or the half-copied — possibly multi-GB // — directory is orphaned with no metadata pointing at it, so nothing // ever reclaims it. - let _ = fs::remove_dir_all(&new_profile_uuid_dir); + let _ = fs::remove_dir_all(&new_profile_dir); return Err( serde_json::json!({ "code": "INTERNAL_ERROR", @@ -898,7 +898,7 @@ impl ProfileImporter { let report = match migrate_result { Ok(report) => report, Err(e) => { - let _ = fs::remove_dir_all(&new_profile_uuid_dir); + let _ = fs::remove_dir_all(&new_profile_dir); // Structured codes (an unimportable source, a running browser) pass // through so the frontend can translate them; anything else is // internal. @@ -915,7 +915,7 @@ impl ProfileImporter { let version = match self.get_default_version_for_browser(mapped) { Ok(version) => version, Err(e) => { - let _ = fs::remove_dir_all(&new_profile_uuid_dir); + let _ = fs::remove_dir_all(&new_profile_dir); return Err(e); } }; @@ -1013,7 +1013,7 @@ impl ProfileImporter { }; } Err(e) => { - let _ = fs::remove_dir_all(&new_profile_uuid_dir); + let _ = fs::remove_dir_all(&new_profile_dir); return Err( serde_json::json!({ "code": "INTERNAL_ERROR", @@ -1099,9 +1099,9 @@ impl ProfileImporter { new_profile_name, source_path.display(), report.cookies_migrated, - report.passwords_migrated, + report.logins_migrated, report.history_entries, - report.cookies_unrecoverable + report.passwords_unrecoverable, + report.cookies_unrecoverable + report.logins_unrecoverable, report.warnings ); } diff --git a/src-tauri/src/proxy_server.rs b/src-tauri/src/proxy_server.rs index d005dfb..fefeadf 100644 --- a/src-tauri/src/proxy_server.rs +++ b/src-tauri/src/proxy_server.rs @@ -3120,7 +3120,7 @@ this line has no colon\r\n\ ] { assert!( !as_text.contains(secret), - "{secret:?} reached the wire in the clear on an httpstls upstream" + "a CONNECT detail or credential reached the wire in the clear on an httpstls upstream" ); } } diff --git a/src-tauri/src/proxy_udp.rs b/src-tauri/src/proxy_udp.rs index 78a29b9..867dbae 100644 --- a/src-tauri/src/proxy_udp.rs +++ b/src-tauri/src/proxy_udp.rs @@ -111,7 +111,7 @@ async fn socks5_udp_associate(settings: &ProxySettings) -> std::io::Result = match credentials { Some(_) => vec![SOCKS5, 2, AUTH_NONE, AUTH_USERPASS], diff --git a/src-tauri/src/remote_handoff.rs b/src-tauri/src/remote_handoff.rs index b0d8411..7ed3ed9 100644 --- a/src-tauri/src/remote_handoff.rs +++ b/src-tauri/src/remote_handoff.rs @@ -28,6 +28,7 @@ //! and the work is sitting in cloud storage. This is the window that used to //! be wide open. +use crate::log_redaction::ShortId; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::RwLock; @@ -287,8 +288,9 @@ pub fn reconcile(live_session_ids: &std::collections::HashSet) -> Vec, + /// Unix seconds of the last tip that opened by itself. Paces the automatic + /// flow to one tip a day at most. + #[serde(default)] + pub tips_last_auto_shown_at: Option, + /// Cloud user ids that have had the paid-plan welcome. + #[serde(default)] + pub paid_welcome_seen_for: Vec, + /// The plan status last observed per cloud user id, `"free"` or `"paid"`. + /// A change from free to paid is what earns the paid-plan welcome. + #[serde(default)] + pub cloud_plan_memory: std::collections::HashMap, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] @@ -118,6 +132,18 @@ fn default_trash_retention_days() -> u32 { crate::profile::trash::DEFAULT_RETENTION_DAYS } +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 plan status remembered per cloud user. +const PLAN_STATUS_PAID: &str = "paid"; +const PLAN_STATUS_FREE: &str = "free"; + impl Default for AppSettings { fn default() -> Self { Self { @@ -144,6 +170,11 @@ impl Default for AppSettings { disable_auto_updates: false, keep_decrypted_profiles_in_ram: false, trash_retention_days: crate::profile::trash::DEFAULT_RETENTION_DAYS, + tips_auto_show: true, + tips_seen: Vec::new(), + tips_last_auto_shown_at: None, + paid_welcome_seen_for: Vec::new(), + cloud_plan_memory: std::collections::HashMap::new(), } } } @@ -159,6 +190,20 @@ pub struct StoredMcpRemoteKey { pub struct SettingsManager; +/// Write `content` to `path` in one step: to a sibling first, then renamed +/// into place. A reader that opens the file mid-write, and there are several +/// at startup, sees the old settings or the new ones, never an empty file +/// that parses as the defaults. +fn write_whole(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> { + let staging = path.with_extension("json.tmp"); + fs::write(&staging, content)?; + if let Err(e) = fs::rename(&staging, path) { + let _ = fs::remove_file(&staging); + return Err(e); + } + Ok(()) +} + impl SettingsManager { pub(crate) fn new() -> Self { Self @@ -213,7 +258,7 @@ impl SettingsManager { let settings_file = self.get_settings_file(); let json = serde_json::to_string_pretty(&on_disk)?; - fs::write(settings_file, json)?; + write_whole(&settings_file, json.as_bytes())?; Ok(()) } @@ -240,55 +285,23 @@ impl SettingsManager { let sorting_file = self.get_table_sorting_file(); let json = serde_json::to_string_pretty(sorting)?; - fs::write(sorting_file, json)?; + write_whole(&sorting_file, json.as_bytes())?; Ok(()) } - fn get_vault_password() -> String { - env!("DONUT_BROWSER_VAULT_PASSWORD").to_string() - } - - /// Encrypt `secret` into `file` under the vault password. + /// Seal `secret` into `file`. /// - /// One implementation for every secret this manager keeps on disk. The API, - /// MCP and sync tokens each carried their own copy of this routine, and the - /// remote MCP credential would have been the fourth; the file layout is the - /// same for all of them and only the five-byte header tells them apart. + /// One implementation for every secret this manager keeps on disk, in + /// `crate::vault`: the API, MCP and sync tokens and the remote MCP + /// credential share the layout, and only the five-byte header tells them + /// apart. fn encrypt_to_file( file: &std::path::Path, header: &[u8; 5], secret: &str, ) -> Result<(), Box> { - if let Some(parent) = file.parent() { - std::fs::create_dir_all(parent)?; - } - - let vault_password = Self::get_vault_password(); - let salt_bytes: [u8; 16] = rand::rng().random(); - let salt = crate::sync::encryption::encode_salt(&salt_bytes); - let key_bytes = - crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?; - let key = Key::::from(key_bytes); - let cipher = Aes256Gcm::new(&key); - let nonce_bytes: [u8; 12] = rand::rng().random(); - let nonce = Nonce::from(nonce_bytes); - let ciphertext = cipher - .encrypt(&nonce, secret.as_bytes()) - .map_err(|e| format!("Encryption failed: {e}"))?; - - let mut file_data = Vec::new(); - file_data.extend_from_slice(header); - file_data.push(2u8); // Version 2 (Argon2 + AES-GCM) - let salt_str = salt.as_str(); - file_data.push(salt_str.len() as u8); - file_data.extend_from_slice(salt_str.as_bytes()); - file_data.extend_from_slice(&nonce); - file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes()); - file_data.extend_from_slice(&ciphertext); - - std::fs::write(file, file_data)?; - crate::app_dirs::restrict_to_owner(file); + crate::vault::seal(file, &Self::magic(header), secret)?; Ok(()) } @@ -301,74 +314,15 @@ impl SettingsManager { file: &std::path::Path, header: &[u8; 5], ) -> Result, Box> { - if !file.exists() { - return Ok(None); - } + Ok(crate::vault::open(file, &Self::magic(header))?) + } - let file_data = std::fs::read(file)?; - - if file_data.len() < 6 || &file_data[0..5] != header { - return Ok(None); - } - - let version = file_data[5]; - if version != 2 { - return Ok(None); - } - - let mut offset = 6; - if offset >= file_data.len() { - return Ok(None); - } - let salt_len = file_data[offset] as usize; - offset += 1; - - if offset + salt_len > file_data.len() { - return Ok(None); - } - let salt_bytes = &file_data[offset..offset + salt_len]; - let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?; - let salt_bytes = crate::sync::encryption::decode_salt(salt_str)?; - offset += salt_len; - - if offset + 12 > file_data.len() { - return Ok(None); - } - let nonce_bytes: [u8; 12] = file_data[offset..offset + 12] - .try_into() - .map_err(|_| "Invalid nonce length")?; - let nonce = Nonce::from(nonce_bytes); - offset += 12; - - if offset + 4 > file_data.len() { - return Ok(None); - } - let ciphertext_len = u32::from_le_bytes([ - file_data[offset], - file_data[offset + 1], - file_data[offset + 2], - file_data[offset + 3], - ]) as usize; - offset += 4; - - if offset + ciphertext_len > file_data.len() { - return Ok(None); - } - let ciphertext = &file_data[offset..offset + ciphertext_len]; - - let vault_password = Self::get_vault_password(); - let key_bytes = - crate::sync::encryption::derive_vault_key(vault_password.as_bytes(), &salt_bytes)?; - let key = Key::::from(key_bytes); - let cipher = Aes256Gcm::new(&key); - let plaintext = cipher - .decrypt(&nonce, ciphertext) - .map_err(|_| "Decryption failed")?; - - match String::from_utf8(plaintext) { - Ok(token) => Ok(Some(token)), - Err(_) => Ok(None), - } + /// The header plus the layout version every file of this manager carries. + fn magic(header: &[u8; 5]) -> [u8; 6] { + let mut magic = [0u8; 6]; + magic[..5].copy_from_slice(header); + magic[5] = 2; + magic } fn remove_secret_file(file: &std::path::Path) -> Result<(), Box> { @@ -959,6 +913,158 @@ pub async fn complete_onboarding() -> Result<(), String> { .map_err(|e| format!("Failed to save settings: {e}")) } +/// What the tips dialog needs to decide what to open and what to skip. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct TipsState { + pub auto_show: bool, + pub seen: Vec, + pub last_auto_shown_at: Option, + /// Whether the automatic flow may open a tip right now: it is switched on + /// and the last automatic tip is old enough. + pub auto_due: bool, +} + +impl TipsState { + fn of(settings: &AppSettings, now: u64) -> Self { + 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), + } + } +} + +/// 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. +static TIPS_WRITE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// 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) { + 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); + } +} + +/// Records the plan status seen for a cloud account and answers whether the +/// paid-plan welcome is due for it. +/// +/// The welcome is for an account that just became paid: one this desktop last +/// saw as free, or one it sees for the first time right after the user signed +/// in (they bought a plan on the website and came back). An account that was +/// already paid the last time anybody looked, or that turns up paid in an old +/// session after an app update, is not new to its plan and is recorded as +/// greeted without a dialog. +fn paid_welcome_due( + settings: &mut AppSettings, + user_id: &str, + paid: bool, + fresh_login: bool, +) -> bool { + let status = if paid { + PLAN_STATUS_PAID + } else { + PLAN_STATUS_FREE + }; + let previous = settings + .cloud_plan_memory + .insert(user_id.to_string(), status.to_string()); + if !paid { + return false; + } + if settings + .paid_welcome_seen_for + .iter() + .any(|id| id == user_id) + { + return false; + } + let due = match previous.as_deref() { + Some(PLAN_STATUS_FREE) => true, + Some(_) => false, + None => fresh_login, + }; + settings.paid_welcome_seen_for.push(user_id.to_string()); + due +} + +#[tauri::command] +pub async fn get_tips_state() -> Result { + let manager = SettingsManager::instance(); + let settings = manager + .load_settings() + .map_err(|e| format!("Failed to load settings: {e}"))?; + Ok(TipsState::of(&settings, unix_now())) +} + +#[tauri::command] +pub async fn mark_tip_seen(tip_id: String, auto: bool) -> Result { + let _serial = TIPS_WRITE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let manager = SettingsManager::instance(); + let mut settings = manager + .load_settings() + .map_err(|e| format!("Failed to load settings: {e}"))?; + let now = unix_now(); + record_tip_seen(&mut settings, &tip_id, auto, now); + manager + .save_settings(&settings) + .map_err(|e| format!("Failed to save settings: {e}"))?; + Ok(TipsState::of(&settings, now)) +} + +#[tauri::command] +pub async fn set_tips_auto_show(enabled: bool) -> Result { + let _serial = TIPS_WRITE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let manager = SettingsManager::instance(); + let mut settings = manager + .load_settings() + .map_err(|e| format!("Failed to load settings: {e}"))?; + settings.tips_auto_show = enabled; + manager + .save_settings(&settings) + .map_err(|e| format!("Failed to save settings: {e}"))?; + Ok(TipsState::of(&settings, unix_now())) +} + +#[tauri::command] +pub async fn observe_cloud_plan( + user_id: String, + paid: bool, + fresh_login: bool, +) -> Result { + let _serial = TIPS_WRITE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let manager = SettingsManager::instance(); + let mut settings = manager + .load_settings() + .map_err(|e| format!("Failed to load settings: {e}"))?; + let due = paid_welcome_due(&mut settings, &user_id, paid, fresh_login); + manager + .save_settings(&settings) + .map_err(|e| format!("Failed to save settings: {e}"))?; + Ok(due) +} + #[tauri::command] pub fn get_system_language() -> String { sys_locale::get_locale() @@ -1031,6 +1137,83 @@ mod tests { let (_manager, _temp_dir, _guard) = create_test_settings_manager(); } + #[test] + fn tips_state_defaults_to_automatic_and_due() { + let settings = AppSettings::default(); + let state = TipsState::of(&settings, 1_000_000); + assert!(state.auto_show); + assert!(state.seen.is_empty()); + assert_eq!(state.last_auto_shown_at, None); + assert!(state.auto_due, "a fresh install owes its first tip"); + } + + #[test] + fn tips_seen_dedupes_and_paces_the_automatic_flow() { + let mut settings = AppSettings::default(); + record_tip_seen(&mut settings, "dns", false, 100); + record_tip_seen(&mut settings, "dns", false, 200); + 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" + ); + + record_tip_seen(&mut settings, "proxy", true, 1_000); + 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" + ); + 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, + "switched off means never due" + ); + } + + #[test] + fn paid_welcome_is_due_once_when_an_account_turns_paid() { + let mut settings = AppSettings::default(); + assert!(!paid_welcome_due(&mut settings, "u1", false, true)); + assert!( + settings.paid_welcome_seen_for.is_empty(), + "a free account is not greeted, so nothing is recorded" + ); + assert!( + paid_welcome_due(&mut settings, "u1", true, false), + "free to paid is the upgrade the welcome exists for" + ); + assert!(!paid_welcome_due(&mut settings, "u1", true, true), "once"); + assert_eq!(settings.paid_welcome_seen_for, vec!["u1".to_string()]); + } + + #[test] + fn paid_welcome_greets_a_fresh_sign_in_but_not_an_old_paid_session() { + let mut settings = AppSettings::default(); + assert!( + paid_welcome_due(&mut settings, "bought-on-web", true, true), + "first sight right after signing in: they came back from checkout" + ); + + assert!( + !paid_welcome_due(&mut settings, "long-paid", true, false), + "an app update on a machine that was already paid is not a new plan" + ); + assert!( + !paid_welcome_due(&mut settings, "long-paid", true, true), + "and it is recorded as greeted, so it never fires later" + ); + assert_eq!( + settings + .cloud_plan_memory + .get("long-paid") + .map(String::as_str), + Some(PLAN_STATUS_PAID) + ); + } + #[test] fn test_default_app_settings() { let default_settings = AppSettings::default(); @@ -1105,6 +1288,11 @@ mod tests { disable_auto_updates: false, keep_decrypted_profiles_in_ram: false, trash_retention_days: 14, + tips_auto_show: true, + tips_seen: Vec::new(), + tips_last_auto_shown_at: None, + paid_welcome_seen_for: Vec::new(), + cloud_plan_memory: std::collections::HashMap::new(), }; let save_result = manager.save_settings(&test_settings); diff --git a/src-tauri/src/sync/encryption.rs b/src-tauri/src/sync/encryption.rs index e2e7dc0..99a3678 100644 --- a/src-tauri/src/sync/encryption.rs +++ b/src-tauri/src/sync/encryption.rs @@ -19,7 +19,9 @@ use base64::{ /// silently lock every user out of their encrypted data, so the defaults are /// pinned by the test below rather than trusted. pub fn derive_vault_key(password: &[u8], salt: &[u8]) -> Result<[u8; 32], String> { - let mut key = [0u8; 32]; + // Filled by the KDF. It starts as noise rather than zeros so that no + // failure path can ever hand back an all-zero key. + let mut key: [u8; 32] = rand::rng().random(); Argon2::default() .hash_password_into(password, salt, &mut key) .map_err(|e| format!("Argon2 key derivation failed: {e}"))?; @@ -42,9 +44,6 @@ use rand::RngExt; use std::collections::HashMap; use std::sync::Mutex; -const E2E_FILE_HEADER: &[u8] = b"DBE2E"; -const E2E_FILE_VERSION: u8 = 1; - /// Argon2id is intentionally expensive (~80–150 ms per call). During an /// encryption rollover, every synced entity (proxy, group, vpn, extension, /// extension group, profile metadata) goes through `derive_profile_key`, @@ -61,10 +60,7 @@ fn password_fingerprint(pwd: &str) -> [u8; 32] { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(pwd.as_bytes()); - let result = hasher.finalize(); - let mut out = [0u8; 32]; - out.copy_from_slice(&result); - out + hasher.finalize().into() } fn invalidate_key_cache() { @@ -77,122 +73,16 @@ fn get_e2e_password_path() -> std::path::PathBuf { crate::app_dirs::settings_dir().join("e2e_password.dat") } -fn get_vault_password() -> String { - env!("DONUT_BROWSER_VAULT_PASSWORD").to_string() -} +/// Header plus layout version of the sync password file. +const E2E_MAGIC: [u8; 6] = *b"DBE2E\x01"; pub fn store_e2e_password(password: &str) -> Result<(), String> { invalidate_key_cache(); - let file_path = get_e2e_password_path(); - - if let Some(parent) = file_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?; - } - - let vault_password = get_vault_password(); - let salt_bytes: [u8; 16] = rand::rng().random(); - let salt = encode_salt(&salt_bytes); - let key_bytes = derive_vault_key(vault_password.as_bytes(), &salt_bytes)?; - let key = Key::::from(key_bytes); - let cipher = Aes256Gcm::new(&key); - let nonce_bytes: [u8; 12] = rand::rng().random(); - let nonce = aes_gcm::Nonce::from(nonce_bytes); - - let ciphertext = cipher - .encrypt(&nonce, password.as_bytes()) - .map_err(|e| format!("Encryption failed: {e}"))?; - - let mut file_data = Vec::new(); - file_data.extend_from_slice(E2E_FILE_HEADER); - file_data.push(E2E_FILE_VERSION); - - let salt_str = salt.as_str(); - file_data.push(salt_str.len() as u8); - file_data.extend_from_slice(salt_str.as_bytes()); - file_data.extend_from_slice(&nonce); - file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes()); - file_data.extend_from_slice(&ciphertext); - - std::fs::write(&file_path, file_data) - .map_err(|e| format!("Failed to write e2e password file: {e}"))?; - crate::app_dirs::restrict_to_owner(std::path::Path::new(&file_path)); - - Ok(()) + crate::vault::seal(&get_e2e_password_path(), &E2E_MAGIC, password) } pub fn load_e2e_password() -> Result, String> { - let file_path = get_e2e_password_path(); - if !file_path.exists() { - return Ok(None); - } - - let file_data = - std::fs::read(&file_path).map_err(|e| format!("Failed to read e2e password file: {e}"))?; - - if file_data.len() < E2E_FILE_HEADER.len() + 1 { - return Ok(None); - } - - if &file_data[..E2E_FILE_HEADER.len()] != E2E_FILE_HEADER { - return Ok(None); - } - - let version = file_data[E2E_FILE_HEADER.len()]; - if version != E2E_FILE_VERSION { - return Ok(None); - } - - let mut offset = E2E_FILE_HEADER.len() + 1; - - if offset >= file_data.len() { - return Ok(None); - } - let salt_len = file_data[offset] as usize; - offset += 1; - - if offset + salt_len > file_data.len() { - return Ok(None); - } - let salt_str = std::str::from_utf8(&file_data[offset..offset + salt_len]) - .map_err(|_| "Invalid salt encoding")?; - offset += salt_len; - - let salt_bytes = decode_salt(salt_str)?; - - if offset + 12 > file_data.len() { - return Ok(None); - } - let nonce_bytes: [u8; 12] = file_data[offset..offset + 12] - .try_into() - .map_err(|_| "Invalid nonce")?; - let nonce = aes_gcm::Nonce::from(nonce_bytes); - offset += 12; - - if offset + 4 > file_data.len() { - return Ok(None); - } - let ciphertext_len = - u32::from_le_bytes(file_data[offset..offset + 4].try_into().unwrap()) as usize; - offset += 4; - - if offset + ciphertext_len > file_data.len() { - return Ok(None); - } - let ciphertext = &file_data[offset..offset + ciphertext_len]; - - let vault_password = get_vault_password(); - let key_bytes = derive_vault_key(vault_password.as_bytes(), &salt_bytes)?; - let key = Key::::from(key_bytes); - let cipher = Aes256Gcm::new(&key); - - let plaintext = cipher - .decrypt(&nonce, ciphertext) - .map_err(|e| format!("Decryption failed: {e}"))?; - - let password = - String::from_utf8(plaintext).map_err(|e| format!("Invalid UTF-8 in password: {e}"))?; - - Ok(Some(password)) + crate::vault::open(&get_e2e_password_path(), &E2E_MAGIC) } pub fn has_e2e_password() -> bool { @@ -381,138 +271,9 @@ async fn enforce_team_owner_for_encryption_change() -> Result<(), String> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_encrypt_decrypt_roundtrip() { - let key = [42u8; 32]; - let plaintext = b"Hello, World!"; - let encrypted = encrypt_bytes(&key, plaintext).unwrap(); - let decrypted = decrypt_bytes(&key, &encrypted).unwrap(); - assert_eq!(decrypted, plaintext); - } - - #[test] - fn test_encrypt_decrypt_empty_data() { - let key = [1u8; 32]; - let plaintext = b""; - let encrypted = encrypt_bytes(&key, plaintext).unwrap(); - let decrypted = decrypt_bytes(&key, &encrypted).unwrap(); - assert_eq!(decrypted, plaintext.to_vec()); - } - - #[test] - fn test_encrypt_decrypt_large_data() { - let key = [7u8; 32]; - let plaintext = vec![0xABu8; 1_048_576]; // 1MB - let encrypted = encrypt_bytes(&key, &plaintext).unwrap(); - let decrypted = decrypt_bytes(&key, &encrypted).unwrap(); - assert_eq!(decrypted, plaintext); - } - - #[test] - fn test_different_keys_different_ciphertext() { - let key1 = [1u8; 32]; - let key2 = [2u8; 32]; - let plaintext = b"same data"; - let encrypted1 = encrypt_bytes(&key1, plaintext).unwrap(); - let encrypted2 = encrypt_bytes(&key2, plaintext).unwrap(); - // Nonces are random so ciphertexts will differ regardless, - // but decrypting with wrong key should fail - assert!(decrypt_bytes(&key2, &encrypted1).is_err()); - assert!(decrypt_bytes(&key1, &encrypted2).is_err()); - } - - #[test] - fn test_nonce_uniqueness() { - let key = [5u8; 32]; - let plaintext = b"same data encrypted twice"; - let encrypted1 = encrypt_bytes(&key, plaintext).unwrap(); - let encrypted2 = encrypt_bytes(&key, plaintext).unwrap(); - // Different nonces should produce different ciphertext - assert_ne!(encrypted1, encrypted2); - // But both should decrypt to the same plaintext - assert_eq!( - decrypt_bytes(&key, &encrypted1).unwrap(), - decrypt_bytes(&key, &encrypted2).unwrap() - ); - } - - #[test] - fn test_wrong_key_fails() { - let key = [10u8; 32]; - let wrong_key = [20u8; 32]; - let plaintext = b"secret data"; - let encrypted = encrypt_bytes(&key, plaintext).unwrap(); - assert!(decrypt_bytes(&wrong_key, &encrypted).is_err()); - } - - #[test] - fn test_key_derivation_deterministic() { - let salt = generate_salt(); - let key1 = derive_profile_key("my_password", &salt).unwrap(); - let key2 = derive_profile_key("my_password", &salt).unwrap(); - assert_eq!(key1, key2); - } - - #[test] - fn test_key_derivation_different_salts() { - let salt1 = generate_salt(); - let salt2 = generate_salt(); - let key1 = derive_profile_key("my_password", &salt1).unwrap(); - let key2 = derive_profile_key("my_password", &salt2).unwrap(); - assert_ne!(key1, key2); - } - - #[test] - fn test_salt_generation_unique() { - let salt1 = generate_salt(); - let salt2 = generate_salt(); - assert_ne!(salt1, salt2); - } - - #[test] - fn test_password_storage_roundtrip() { - let password = "test_password_12345"; - store_e2e_password(password).unwrap(); - assert!(has_e2e_password()); - let loaded = load_e2e_password().unwrap(); - assert_eq!(loaded, Some(password.to_string())); - remove_e2e_password().unwrap(); - assert!(!has_e2e_password()); - } - - #[test] - fn test_decrypt_too_short_data() { - let key = [1u8; 32]; - assert!(decrypt_bytes(&key, &[0u8; 5]).is_err()); - } -} +#[path = "encryption_tests.rs"] +mod tests; #[cfg(test)] -mod vault_key_tests { - use super::{decode_salt, derive_vault_key, encode_salt}; - - /// A stored vault is only readable while this vector holds. It pins the - /// Argon2id parameters and the salt encoding together: a dependency bump - /// that changed either would fail here instead of at the user's data. - #[test] - fn vault_key_derivation_is_pinned() { - let key = derive_vault_key(b"correct horse battery staple", &[7u8; 16]).unwrap(); - let hex: String = key.iter().map(|b| format!("{b:02x}")).collect(); - assert_eq!( - hex, - "799f12b9e17710824482d829835acb69f5a9355bf774c4f07342823b11b90928" - ); - } - - #[test] - fn salt_encoding_round_trips_without_padding() { - let salt = [0u8, 1, 2, 3, 250, 251, 252, 253, 254, 255, 9, 8, 7, 6, 5, 4]; - let encoded = encode_salt(&salt); - assert!(!encoded.contains('='), "PHC B64 carries no padding"); - assert_eq!(decode_salt(&encoded).unwrap(), salt); - assert!(decode_salt("not*valid").is_err()); - } -} +#[path = "encryption_vault_key_tests.rs"] +mod vault_key_tests; diff --git a/src-tauri/src/sync/encryption_tests.rs b/src-tauri/src/sync/encryption_tests.rs new file mode 100644 index 0000000..6c71b68 --- /dev/null +++ b/src-tauri/src/sync/encryption_tests.rs @@ -0,0 +1,106 @@ +use super::*; + +#[test] +fn test_encrypt_decrypt_roundtrip() { + let key = [42u8; 32]; + let plaintext = b"Hello, World!"; + let encrypted = encrypt_bytes(&key, plaintext).unwrap(); + let decrypted = decrypt_bytes(&key, &encrypted).unwrap(); + assert_eq!(decrypted, plaintext); +} + +#[test] +fn test_encrypt_decrypt_empty_data() { + let key = [1u8; 32]; + let plaintext = b""; + let encrypted = encrypt_bytes(&key, plaintext).unwrap(); + let decrypted = decrypt_bytes(&key, &encrypted).unwrap(); + assert_eq!(decrypted, plaintext.to_vec()); +} + +#[test] +fn test_encrypt_decrypt_large_data() { + let key = [7u8; 32]; + let plaintext = vec![0xABu8; 1_048_576]; // 1MB + let encrypted = encrypt_bytes(&key, &plaintext).unwrap(); + let decrypted = decrypt_bytes(&key, &encrypted).unwrap(); + assert_eq!(decrypted, plaintext); +} + +#[test] +fn test_different_keys_different_ciphertext() { + let key1 = [1u8; 32]; + let key2 = [2u8; 32]; + let plaintext = b"same data"; + let encrypted1 = encrypt_bytes(&key1, plaintext).unwrap(); + let encrypted2 = encrypt_bytes(&key2, plaintext).unwrap(); + // Nonces are random so ciphertexts will differ regardless, + // but decrypting with wrong key should fail + assert!(decrypt_bytes(&key2, &encrypted1).is_err()); + assert!(decrypt_bytes(&key1, &encrypted2).is_err()); +} + +#[test] +fn test_nonce_uniqueness() { + let key = [5u8; 32]; + let plaintext = b"same data encrypted twice"; + let encrypted1 = encrypt_bytes(&key, plaintext).unwrap(); + let encrypted2 = encrypt_bytes(&key, plaintext).unwrap(); + // Different nonces should produce different ciphertext + assert_ne!(encrypted1, encrypted2); + // But both should decrypt to the same plaintext + assert_eq!( + decrypt_bytes(&key, &encrypted1).unwrap(), + decrypt_bytes(&key, &encrypted2).unwrap() + ); +} + +#[test] +fn test_wrong_key_fails() { + let key = [10u8; 32]; + let wrong_key = [20u8; 32]; + let plaintext = b"secret data"; + let encrypted = encrypt_bytes(&key, plaintext).unwrap(); + assert!(decrypt_bytes(&wrong_key, &encrypted).is_err()); +} + +#[test] +fn test_key_derivation_deterministic() { + let salt = generate_salt(); + let key1 = derive_profile_key("my_password", &salt).unwrap(); + let key2 = derive_profile_key("my_password", &salt).unwrap(); + assert_eq!(key1, key2); +} + +#[test] +fn test_key_derivation_different_salts() { + let salt1 = generate_salt(); + let salt2 = generate_salt(); + let key1 = derive_profile_key("my_password", &salt1).unwrap(); + let key2 = derive_profile_key("my_password", &salt2).unwrap(); + assert_ne!(key1, key2); +} + +#[test] +fn test_salt_generation_unique() { + let salt1 = generate_salt(); + let salt2 = generate_salt(); + assert_ne!(salt1, salt2); +} + +#[test] +fn test_password_storage_roundtrip() { + let password = "test_password_12345"; + store_e2e_password(password).unwrap(); + assert!(has_e2e_password()); + let loaded = load_e2e_password().unwrap(); + assert_eq!(loaded, Some(password.to_string())); + remove_e2e_password().unwrap(); + assert!(!has_e2e_password()); +} + +#[test] +fn test_decrypt_too_short_data() { + let key = [1u8; 32]; + assert!(decrypt_bytes(&key, &[0u8; 5]).is_err()); +} diff --git a/src-tauri/src/sync/encryption_vault_key_tests.rs b/src-tauri/src/sync/encryption_vault_key_tests.rs new file mode 100644 index 0000000..73c25f8 --- /dev/null +++ b/src-tauri/src/sync/encryption_vault_key_tests.rs @@ -0,0 +1,23 @@ +use super::{decode_salt, derive_vault_key, encode_salt}; + +/// A stored vault is only readable while this vector holds. It pins the +/// Argon2id parameters and the salt encoding together: a dependency bump +/// that changed either would fail here instead of at the user's data. +#[test] +fn vault_key_derivation_is_pinned() { + let key = derive_vault_key(b"correct horse battery staple", &[7u8; 16]).unwrap(); + let hex: String = key.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!( + hex, + "799f12b9e17710824482d829835acb69f5a9355bf774c4f07342823b11b90928" + ); +} + +#[test] +fn salt_encoding_round_trips_without_padding() { + let salt = [0u8, 1, 2, 3, 250, 251, 252, 253, 254, 255, 9, 8, 7, 6, 5, 4]; + let encoded = encode_salt(&salt); + assert!(!encoded.contains('='), "PHC B64 carries no padding"); + assert_eq!(decode_salt(&encoded).unwrap(), salt); + assert!(decode_salt("not*valid").is_err()); +} diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 269fb67..624c616 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -5,6 +5,7 @@ use super::manifest::{ }; use super::types::*; use crate::events; +use crate::log_redaction::Plain; use crate::profile::types::{BrowserProfile, SyncMode}; use crate::profile::ProfileManager; use crate::settings_manager::SettingsManager; @@ -3555,7 +3556,7 @@ pub async fn set_profile_sync_mode( let _ = engine.client.delete(&manifest_key, None).await; log::info!( "Deleted remote manifest for profile {} due to sync mode change ({:?} -> {:?})", - profile_id, + Plain(&profile_id), old_mode, new_mode ); @@ -3658,9 +3659,13 @@ pub async fn set_profile_sync_mode( match SyncEngine::create_from_settings(&app_handle).await { Ok(engine) => { if let Err(e) = engine.delete_profile(&profile_id).await { - log::warn!("Failed to delete profile {} from sync: {}", profile_id, e); + log::warn!( + "Failed to delete profile {} from sync: {}", + Plain(&profile_id), + e + ); } else { - log::info!("Profile {} deleted from sync service", profile_id); + log::info!("Profile {} deleted from sync service", Plain(&profile_id)); } } Err(e) => { diff --git a/src-tauri/src/sync/preflight.rs b/src-tauri/src/sync/preflight.rs index 8abd5a7..38698f7 100644 --- a/src-tauri/src/sync/preflight.rs +++ b/src-tauri/src/sync/preflight.rs @@ -282,7 +282,7 @@ mod tests { // Exercises the real probe against a host that cannot resolve, which is // what a container-only endpoint looks like from the desktop. let client = probe_client(); - let error = probe_storage_endpoint(&client, "http://minio.invalid:9000") + let error = probe_storage_endpoint(&client, "https://minio.invalid:9000") .await .expect_err("an unresolvable host must not report as reachable"); assert!( @@ -313,7 +313,7 @@ mod tests { // names and a bare "connection failed", and could not tell that the host // their server had signed into every URL was one only the server could // resolve. - let url = "http://minio.invalid:9000/donut/profiles/p1/Cookies?X-Amz-Signature=abc"; + let url = "https://minio.invalid:9000/donut/profiles/p1/Cookies?X-Amz-Signature=abc"; let error = probe_client() .put(url) .body(b"payload".to_vec()) diff --git a/src-tauri/src/sync/scheduler.rs b/src-tauri/src/sync/scheduler.rs index fb4afa3..d7e3884 100644 --- a/src-tauri/src/sync/scheduler.rs +++ b/src-tauri/src/sync/scheduler.rs @@ -1,6 +1,7 @@ use super::engine::SyncEngine; use super::subscription::SyncWorkItem; use crate::events; +use crate::log_redaction::Plain; use crate::profile::ProfileManager; use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -241,7 +242,7 @@ impl SyncScheduler { ); log::debug!( "Profile {} is running, queued sync for after stop", - profile_id + Plain(&profile_id) ); } else { // Profile is not running - sync immediately (set stopped_at to past) @@ -252,7 +253,7 @@ impl SyncScheduler { queued: true, }, ); - log::debug!("Profile {} queued for immediate sync", profile_id); + log::debug!("Profile {} queued for immediate sync", Plain(&profile_id)); } } diff --git a/src-tauri/src/synchronizer.rs b/src-tauri/src/synchronizer.rs index a85bbbe..44f2aa3 100644 --- a/src-tauri/src/synchronizer.rs +++ b/src-tauri/src/synchronizer.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use tauri::Emitter; use tokio::sync::Mutex as AsyncMutex; +use crate::log_redaction::ShortId; use crate::profile::manager::ProfileManager; use crate::profile::types::BrowserProfile; @@ -777,7 +778,7 @@ impl SynchronizerManager { tokio::select! { _ = cancel_rx.changed() => { if *cancel_rx.borrow() { - log::info!("Synchronizer session {session_id}: cancelled"); + log::info!("Synchronizer session {}: cancelled", ShortId(&session_id)); break; } } @@ -827,7 +828,10 @@ impl SynchronizerManager { } // Leader closed or session cancelled — kill all followers - log::info!("Synchronizer session {session_id}: stopping all followers"); + log::info!( + "Synchronizer session {}: stopping all followers", + ShortId(&session_id) + ); let follower_ids: Vec = { let inner = manager.lock().await; if let Some(session) = inner.sessions.get(&session_id) { @@ -1145,7 +1149,8 @@ impl SynchronizerManager { let info = session.info(); let _ = app_handle.emit("sync-session-changed", &info); log::info!( - "Synchronizer session {session_id}: mirroring {}", + "Synchronizer session {}: mirroring {}", + ShortId(session_id), if paused { "paused" } else { "resumed" } ); Ok(info) @@ -1239,7 +1244,8 @@ impl SynchronizerManager { return Err(serde_json::json!({ "code": "SYNC_ARRANGE_FAILED" }).to_string()); } log::info!( - "Synchronizer session {session_id}: placed {placed} of {} windows", + "Synchronizer session {}: placed {placed} of {} windows", + ShortId(session_id), follower_ids.len() ); Ok(info) diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs new file mode 100644 index 0000000..b92cd2a --- /dev/null +++ b/src-tauri/src/vault.rs @@ -0,0 +1,304 @@ +//! Sealing of the secrets Donut keeps on this machine. +//! +//! The API and MCP tokens, the cloud session, the sync token and the sync +//! encryption password each live in a small file under the settings folder. +//! They are sealed with AES-256-GCM under a key derived (Argon2id, per-file +//! salt) from this installation's own vault key: 32 random bytes minted on +//! first use and kept in `vault.key`, readable by the owner only. +//! +//! Every build before the per-install key sealed those files under one +//! password compiled into the binary, the same for every install whose build +//! did not set `DONUT_BROWSER_VAULT_PASSWORD`. A file that still carries that +//! seal is opened with the legacy password and re-sealed under the +//! installation key on the spot, so an update keeps every login and token. +//! +//! File layout, unchanged from the earlier per-module copies: +//! `magic (5-byte header + 1 version byte) | salt length | PHC base64 salt | +//! 12-byte nonce | 4-byte little-endian ciphertext length | ciphertext`. + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Key, Nonce}; +use rand::RngExt; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use crate::sync::encryption::{decode_salt, derive_vault_key, encode_salt}; + +pub const VAULT_KEY_FILE: &str = "vault.key"; +const KEY_LEN: usize = 32; +const NONCE_LEN: usize = 12; + +/// The sealing password of every build before the per-install key. Written +/// by `build.rs` from the build environment, with the historical default when +/// nothing was set. Only ever used to open a file sealed by such a build. +const LEGACY_PASSWORD: &str = include_str!(concat!(env!("OUT_DIR"), "/legacy_vault_password.txt")); + +/// The installation key, cached with the file it came from so a test that +/// moves the settings folder never reads a key from the previous one. +static INSTALL_KEY: Mutex> = Mutex::new(None); + +fn key_file() -> PathBuf { + crate::app_dirs::settings_dir().join(VAULT_KEY_FILE) +} + +/// This installation's vault key, minted the first time anything needs it. +/// +/// A key file of the wrong size is refused rather than replaced: minting a +/// new key over it would silently orphan every file sealed under the old one. +pub fn install_key() -> Result<[u8; KEY_LEN], String> { + let path = key_file(); + if let Ok(cached) = INSTALL_KEY.lock() { + if let Some((cached_path, key)) = cached.as_ref() { + if *cached_path == path { + return Ok(*key); + } + } + } + let key = match std::fs::read(&path) { + Ok(bytes) if bytes.len() == KEY_LEN => <[u8; KEY_LEN]>::try_from(bytes.as_slice()) + .map_err(|_| "The vault key file could not be read whole".to_string())?, + Ok(bytes) => { + return Err(format!( + "The vault key file {} holds {} bytes instead of {KEY_LEN}", + path.display(), + bytes.len() + )); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => mint_key(&path)?, + Err(e) => return Err(format!("Could not read the vault key: {e}")), + }; + if let Ok(mut cached) = INSTALL_KEY.lock() { + *cached = Some((path, key)); + } + Ok(key) +} + +/// Write a fresh key next to the sealed files. Written to a sibling first and +/// renamed into place, so a crash mid-write never leaves a short key behind. +fn mint_key(path: &Path) -> Result<[u8; KEY_LEN], String> { + let key: [u8; KEY_LEN] = rand::rng().random(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Could not create the settings folder: {e}"))?; + } + let staging = path.with_extension("key.tmp"); + std::fs::write(&staging, key).map_err(|e| format!("Could not write the vault key: {e}"))?; + crate::app_dirs::restrict_to_owner(&staging); + if let Err(e) = std::fs::rename(&staging, path) { + let _ = std::fs::remove_file(&staging); + // Another process minted the key first; theirs is the one to keep. + if path.exists() { + return install_key(); + } + return Err(format!("Could not place the vault key: {e}")); + } + crate::app_dirs::restrict_to_owner(path); + Ok(key) +} + +/// Seal `secret` into `file` under this installation's key. +pub fn seal(file: &Path, magic: &[u8; 6], secret: &str) -> Result<(), String> { + let key = install_key()?; + seal_with(file, magic, secret, &key) +} + +fn seal_with(file: &Path, magic: &[u8; 6], secret: &str, material: &[u8]) -> Result<(), String> { + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?; + } + let salt_bytes: [u8; 16] = rand::rng().random(); + let salt = encode_salt(&salt_bytes); + let key = Key::::from(derive_vault_key(material, &salt_bytes)?); + let cipher = Aes256Gcm::new(&key); + let nonce_bytes: [u8; NONCE_LEN] = rand::rng().random(); + let nonce = Nonce::from(nonce_bytes); + let ciphertext = cipher + .encrypt(&nonce, secret.as_bytes()) + .map_err(|e| format!("Encryption failed: {e}"))?; + + let mut data = Vec::new(); + data.extend_from_slice(magic); + let salt_str = salt.as_str(); + data.push(salt_str.len() as u8); + data.extend_from_slice(salt_str.as_bytes()); + data.extend_from_slice(&nonce); + data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes()); + data.extend_from_slice(&ciphertext); + + std::fs::write(file, data).map_err(|e| format!("Failed to write file: {e}"))?; + crate::app_dirs::restrict_to_owner(file); + Ok(()) +} + +/// The parts of a sealed file, once the layout has been checked. +struct Sealed<'a> { + salt: Vec, + nonce: [u8; NONCE_LEN], + ciphertext: &'a [u8], +} + +/// Take a sealed file apart. A foreign magic or a layout this version does not +/// know reads as "no secret", never as an error. +fn parse<'a>(data: &'a [u8], magic: &[u8; 6]) -> Result>, String> { + if data.len() < magic.len() + 1 || &data[..magic.len()] != magic { + return Ok(None); + } + let mut offset = magic.len(); + let salt_len = data[offset] as usize; + offset += 1; + if offset + salt_len > data.len() { + return Ok(None); + } + let salt_str = + std::str::from_utf8(&data[offset..offset + salt_len]).map_err(|_| "Invalid salt encoding")?; + let salt = decode_salt(salt_str)?; + offset += salt_len; + if offset + NONCE_LEN > data.len() { + return Ok(None); + } + let nonce: [u8; NONCE_LEN] = data[offset..offset + NONCE_LEN] + .try_into() + .map_err(|_| "Invalid nonce length".to_string())?; + offset += NONCE_LEN; + if offset + 4 > data.len() { + return Ok(None); + } + let ciphertext_len = u32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]) as usize; + offset += 4; + if offset + ciphertext_len > data.len() { + return Ok(None); + } + Ok(Some(Sealed { + salt, + nonce, + ciphertext: &data[offset..offset + ciphertext_len], + })) +} + +fn unseal(sealed: &Sealed<'_>, material: &[u8]) -> Result, String> { + let key = Key::::from(derive_vault_key(material, &sealed.salt)?); + let cipher = Aes256Gcm::new(&key); + let Ok(plaintext) = cipher.decrypt(&Nonce::from(sealed.nonce), sealed.ciphertext) else { + return Ok(None); + }; + Ok(String::from_utf8(plaintext).ok()) +} + +/// Read back a secret written by `seal`. +/// +/// A missing file, a foreign magic or a damaged layout all read as "no +/// secret" so a stale file never blocks the feature it belongs to. A file +/// that opens only under the legacy build password is re-sealed under this +/// installation's key before the secret is returned. A seal that neither key +/// opens is an error: the file is real, and the caller must not mint over it +/// as if it were absent. +pub fn open(file: &Path, magic: &[u8; 6]) -> Result, String> { + if !file.exists() { + return Ok(None); + } + let data = std::fs::read(file).map_err(|e| format!("Failed to read file: {e}"))?; + let Some(sealed) = parse(&data, magic)? else { + return Ok(None); + }; + let key = install_key()?; + if let Some(secret) = unseal(&sealed, &key)? { + return Ok(Some(secret)); + } + match unseal(&sealed, LEGACY_PASSWORD.trim().as_bytes())? { + Some(secret) => { + if let Err(e) = seal_with(file, magic, &secret, &key) { + log::warn!( + "Could not re-seal {} under the vault key: {e}", + file.display() + ); + } + Ok(Some(secret)) + } + None => Err("Decryption failed".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn isolated() -> (TempDir, crate::app_dirs::TestDirGuard) { + let dir = TempDir::new().unwrap(); + let guard = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf()); + (dir, guard) + } + + #[test] + fn the_key_is_minted_once_and_reused() { + let (_dir, _guard) = isolated(); + let first = install_key().unwrap(); + let second = install_key().unwrap(); + assert_eq!(first, second); + assert_eq!(std::fs::read(key_file()).unwrap().len(), KEY_LEN); + } + + #[test] + fn a_seal_round_trips_and_a_foreign_magic_reads_as_nothing() { + let (dir, _guard) = isolated(); + let file = dir.path().join("secret.dat"); + seal(&file, b"DBTST\x02", "hunter's token").unwrap(); + assert_eq!( + open(&file, b"DBTST\x02").unwrap().as_deref(), + Some("hunter's token") + ); + assert_eq!(open(&file, b"DBOTH\x02").unwrap(), None); + assert_eq!( + open(&dir.path().join("missing.dat"), b"DBTST\x02").unwrap(), + None + ); + } + + #[test] + fn a_legacy_seal_opens_once_and_comes_back_under_the_install_key() { + let (dir, _guard) = isolated(); + let file = dir.path().join("legacy.dat"); + seal_with( + &file, + b"DBTST\x02", + "kept", + LEGACY_PASSWORD.trim().as_bytes(), + ) + .unwrap(); + assert_eq!(open(&file, b"DBTST\x02").unwrap().as_deref(), Some("kept")); + + // Re-sealed: the legacy password no longer opens the file, the key does. + let data = std::fs::read(&file).unwrap(); + let sealed = parse(&data, b"DBTST\x02").unwrap().unwrap(); + assert_eq!( + unseal(&sealed, LEGACY_PASSWORD.trim().as_bytes()).unwrap(), + None + ); + assert_eq!( + unseal(&sealed, &install_key().unwrap()).unwrap().as_deref(), + Some("kept") + ); + } + + #[test] + fn a_seal_under_an_unknown_key_is_an_error_not_an_absence() { + let (dir, _guard) = isolated(); + let file = dir.path().join("foreign.dat"); + let other: [u8; KEY_LEN] = rand::rng().random(); + seal_with(&file, b"DBTST\x02", "elsewhere", &other).unwrap(); + assert!(open(&file, b"DBTST\x02").is_err()); + } + + #[test] + fn a_damaged_key_file_is_refused_rather_than_replaced() { + let (_dir, _guard) = isolated(); + std::fs::create_dir_all(key_file().parent().unwrap()).unwrap(); + std::fs::write(key_file(), b"short").unwrap(); + assert!(install_key().is_err()); + } +} diff --git a/src-tauri/src/vpn/storage.rs b/src-tauri/src/vpn/storage.rs index a9b3fb9..b9ecaa3 100644 --- a/src-tauri/src/vpn/storage.rs +++ b/src-tauri/src/vpn/storage.rs @@ -77,9 +77,7 @@ impl VpnStorage { }; let encryption_key = if key_path.exists() { if let Ok(key_data) = fs::read(&key_path) { - if key_data.len() == 32 { - let mut key = [0u8; 32]; - key.copy_from_slice(&key_data); + if let Ok(key) = <[u8; 32]>::try_from(key_data.as_slice()) { key } else { let key: [u8; 32] = rand::rng().random(); diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index 46f2444..b1857e5 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -367,11 +367,54 @@ fn badge_fonts() -> std::sync::Arc { .get_or_init(|| { let mut db = resvg::usvg::fontdb::Database::new(); db.load_system_fonts(); + if let Some(family) = badge_sans_family(&db) { + db.set_sans_serif_family(family); + } std::sync::Arc::new(db) }) .clone() } +/// The family the badge's `sans-serif` resolves to. +/// +/// The database names Arial for the generic family, which macOS and Windows +/// have and a Linux desktop usually does not: Ubuntu ships Noto, DejaVu, +/// Liberation and Ubuntu instead. An unresolved family draws no initial at +/// all, so the first family that is actually installed is chosen, and failing +/// every known name, any installed font at all. +fn badge_sans_family(db: &resvg::usvg::fontdb::Database) -> Option { + use resvg::usvg::fontdb::{Family, Query, Stretch, Style, Weight}; + const PREFERRED: [&str; 10] = [ + "Arial", + "Helvetica Neue", + "Helvetica", + "Segoe UI", + "Noto Sans", + "DejaVu Sans", + "Liberation Sans", + "Ubuntu", + "Cantarell", + "Roboto", + ]; + let installed = |name: &str| { + db.query(&Query { + families: &[Family::Name(name)], + weight: Weight::NORMAL, + stretch: Stretch::Normal, + style: Style::Normal, + }) + .is_some() + }; + PREFERRED + .iter() + .find(|name| installed(name)) + .map(|name| name.to_string()) + .or_else(|| { + db.faces() + .find_map(|face| face.families.first().map(|(name, _)| name.clone())) + }) +} + /// The first letter (or digit) of a profile name, upper-cased, for its badge. pub fn badge_initial(name: &str) -> String { name diff --git a/src-tauri/src/wayfern_persona.rs b/src-tauri/src/wayfern_persona.rs index a27e9e0..a146350 100644 --- a/src-tauri/src/wayfern_persona.rs +++ b/src-tauri/src/wayfern_persona.rs @@ -40,10 +40,10 @@ pub const FIELD_IDS: [&str; 9] = [ "postal_code", ]; -/// FNV-1a with the salt folded into the initial state, then splitmix64, so -/// neighbouring salts do not produce visibly related values. -fn draw(seed: &str, salt: u64) -> u64 { - let mut hash = 0xcbf2_9ce4_8422_2325u64 ^ salt; +/// FNV-1a with the stream number folded into the initial state, then +/// splitmix64, so neighbouring streams do not produce visibly related values. +fn draw(seed: &str, stream: u64) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325u64 ^ stream; for byte in seed.as_bytes() { hash ^= u64::from(*byte); hash = hash.wrapping_mul(0x0000_0100_0000_01b3); @@ -54,8 +54,8 @@ fn draw(seed: &str, salt: u64) -> u64 { z ^ (z >> 31) } -fn pick<'a>(seed: &str, salt: u64, options: &[&'a str]) -> &'a str { - options[(draw(seed, salt) % options.len() as u64) as usize] +fn pick<'a>(seed: &str, stream: u64, options: &[&'a str]) -> &'a str { + options[(draw(seed, stream) % options.len() as u64) as usize] } const GIVEN_NAMES: [&str; 32] = [ diff --git a/src-tauri/src/xray_worker_runner.rs b/src-tauri/src/xray_worker_runner.rs index 692cd38..5f9c5b6 100644 --- a/src-tauri/src/xray_worker_runner.rs +++ b/src-tauri/src/xray_worker_runner.rs @@ -647,164 +647,5 @@ pub async fn run_xray_worker(config_path: &Path) -> Result<(), Box Child { - Command::new("sh") - .args(["-c", "sleep 0.2"]) - .spawn() - .unwrap() - } - - #[cfg(windows)] - fn short_lived_child() -> Child { - Command::new("cmd") - .args(["/C", "ping -n 2 127.0.0.1 >NUL"]) - .spawn() - .unwrap() - } - - #[test] - fn supervisor_child_is_reaped_after_exit() { - let child = short_lived_child(); - let pid = child.id(); - let start_time = resolve_process_start_time(pid).unwrap(); - spawn_supervisor_reaper(child).join().unwrap(); - assert!(!process_identity_matches(pid, Some(start_time))); - } - - fn readiness_config(port: u16) -> XrayWorkerConfig { - let mut config = XrayWorkerConfig::new( - "readiness".to_string(), - None, - "vless://unused".to_string(), - port, - "local-user".to_string(), - "local-password".to_string(), - ); - let pid = std::process::id(); - config.xray_pid = Some(pid); - config.xray_pid_start_time = process_start_time(pid); - config - } - - #[test] - fn parses_macos_major_versions_for_sidecar_compatibility() { - assert_eq!(parse_macos_major_version("11.7.10\n"), Some(11)); - assert_eq!(parse_macos_major_version("12.0"), Some(12)); - assert_eq!(parse_macos_major_version("15.5.1"), Some(15)); - assert_eq!(parse_macos_major_version("unknown"), None); - } - - #[tokio::test] - async fn readiness_requires_the_expected_authenticated_socks_endpoint() { - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let config = readiness_config(listener.local_addr().unwrap().port()); - let expected_username = config.username.clone(); - let expected_password = config.password.clone(); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let mut greeting = [0_u8; 3]; - stream.read_exact(&mut greeting).await.unwrap(); - assert_eq!(greeting, [5, 1, 2]); - stream.write_all(&[5, 2]).await.unwrap(); - - let mut header = [0_u8; 2]; - stream.read_exact(&mut header).await.unwrap(); - assert_eq!(header[0], 1); - let mut username = vec![0_u8; header[1] as usize]; - stream.read_exact(&mut username).await.unwrap(); - let password_len = stream.read_u8().await.unwrap(); - let mut password = vec![0_u8; password_len as usize]; - stream.read_exact(&mut password).await.unwrap(); - assert_eq!(username, expected_username.as_bytes()); - assert_eq!(password, expected_password.as_bytes()); - stream.write_all(&[1, 0]).await.unwrap(); - }); - - assert!(authenticated_socks_ready(&config).await); - server.await.unwrap(); - } - - #[tokio::test] - async fn readiness_rejects_an_unrelated_listener_on_the_reserved_port() { - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let config = readiness_config(listener.local_addr().unwrap().port()); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let mut greeting = [0_u8; 3]; - stream.read_exact(&mut greeting).await.unwrap(); - stream.write_all(&[5, 0]).await.unwrap(); - }); - - assert!(!authenticated_socks_ready(&config).await); - server.await.unwrap(); - } - - #[test] - fn browser_identity_is_persisted_on_the_exact_worker() { - let temp = tempfile::tempdir().unwrap(); - let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf()); - let id = format!("xray-browser-owner-{}", uuid::Uuid::new_v4()); - let config = XrayWorkerConfig::new( - id.clone(), - Some("profile".to_string()), - "vless://unused".to_string(), - 1080, - "local-user".to_string(), - "local-password".to_string(), - ); - save_xray_worker_config(&config).unwrap(); - - let browser_pid = std::process::id(); - assert!(set_browser_pid(&id, browser_pid)); - let saved = get_xray_worker_config(&id).unwrap(); - assert_eq!(saved.browser_pid, Some(browser_pid)); - assert_eq!( - saved.browser_pid_start_time, - process_start_time(browser_pid) - ); - - assert!(delete_xray_worker_config(&id)); - } - - #[test] - fn reusable_worker_requires_and_persists_the_exact_live_owner() { - let temp = tempfile::tempdir().unwrap(); - let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf()); - let id = format!("xray-worker-lease-{}", uuid::Uuid::new_v4()); - let mut config = XrayWorkerConfig::new( - id.clone(), - Some("profile".to_string()), - "vless://unused".to_string(), - 1080, - "local-user".to_string(), - "local-password".to_string(), - ); - config.browser_pid = Some(u32::MAX); - config.browser_pid_start_time = Some(1); - save_xray_worker_config(&config).unwrap(); - - let owner_pid = std::process::id(); - let owner_start_time = process_start_time(owner_pid).unwrap(); - assert!(!worker_is_leased_to(&config, owner_pid, owner_start_time)); - assert!(persist_browser_identity( - &mut config, - owner_pid, - owner_start_time - )); - assert!(worker_is_leased_to(&config, owner_pid, owner_start_time)); - let saved = get_xray_worker_config(&id).unwrap(); - assert_eq!(saved.browser_pid, Some(owner_pid)); - assert_eq!(saved.browser_pid_start_time, Some(owner_start_time)); - - assert!(delete_xray_worker_config(&id)); - } -} +#[path = "xray_worker_runner_tests.rs"] +mod tests; diff --git a/src-tauri/src/xray_worker_runner_tests.rs b/src-tauri/src/xray_worker_runner_tests.rs new file mode 100644 index 0000000..f4d78bc --- /dev/null +++ b/src-tauri/src/xray_worker_runner_tests.rs @@ -0,0 +1,159 @@ +use super::*; +use crate::proxy_storage::process_start_time; + +#[cfg(unix)] +fn short_lived_child() -> Child { + Command::new("sh") + .args(["-c", "sleep 0.2"]) + .spawn() + .unwrap() +} + +#[cfg(windows)] +fn short_lived_child() -> Child { + Command::new("cmd") + .args(["/C", "ping -n 2 127.0.0.1 >NUL"]) + .spawn() + .unwrap() +} + +#[test] +fn supervisor_child_is_reaped_after_exit() { + let child = short_lived_child(); + let pid = child.id(); + let start_time = resolve_process_start_time(pid).unwrap(); + spawn_supervisor_reaper(child).join().unwrap(); + assert!(!process_identity_matches(pid, Some(start_time))); +} + +fn readiness_config(port: u16) -> XrayWorkerConfig { + let mut config = XrayWorkerConfig::new( + "readiness".to_string(), + None, + "vless://unused".to_string(), + port, + "local-user".to_string(), + "local-password".to_string(), + ); + let pid = std::process::id(); + config.xray_pid = Some(pid); + config.xray_pid_start_time = process_start_time(pid); + config +} + +#[test] +fn parses_macos_major_versions_for_sidecar_compatibility() { + assert_eq!(parse_macos_major_version("11.7.10\n"), Some(11)); + assert_eq!(parse_macos_major_version("12.0"), Some(12)); + assert_eq!(parse_macos_major_version("15.5.1"), Some(15)); + assert_eq!(parse_macos_major_version("unknown"), None); +} + +#[tokio::test] +async fn readiness_requires_the_expected_authenticated_socks_endpoint() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let config = readiness_config(listener.local_addr().unwrap().port()); + let expected_username = config.username.clone(); + let expected_password = config.password.clone(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut greeting = [0_u8; 3]; + stream.read_exact(&mut greeting).await.unwrap(); + assert_eq!(greeting, [5, 1, 2]); + stream.write_all(&[5, 2]).await.unwrap(); + + let mut header = [0_u8; 2]; + stream.read_exact(&mut header).await.unwrap(); + assert_eq!(header[0], 1); + let mut username = vec![0_u8; header[1] as usize]; + stream.read_exact(&mut username).await.unwrap(); + let password_len = stream.read_u8().await.unwrap(); + let mut password = vec![0_u8; password_len as usize]; + stream.read_exact(&mut password).await.unwrap(); + assert_eq!(username, expected_username.as_bytes()); + assert_eq!(password, expected_password.as_bytes()); + stream.write_all(&[1, 0]).await.unwrap(); + }); + + assert!(authenticated_socks_ready(&config).await); + server.await.unwrap(); +} + +#[tokio::test] +async fn readiness_rejects_an_unrelated_listener_on_the_reserved_port() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let config = readiness_config(listener.local_addr().unwrap().port()); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut greeting = [0_u8; 3]; + stream.read_exact(&mut greeting).await.unwrap(); + stream.write_all(&[5, 0]).await.unwrap(); + }); + + assert!(!authenticated_socks_ready(&config).await); + server.await.unwrap(); +} + +#[test] +fn browser_identity_is_persisted_on_the_exact_worker() { + let temp = tempfile::tempdir().unwrap(); + let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf()); + let id = format!("xray-browser-owner-{}", uuid::Uuid::new_v4()); + let config = XrayWorkerConfig::new( + id.clone(), + Some("profile".to_string()), + "vless://unused".to_string(), + 1080, + "local-user".to_string(), + "local-password".to_string(), + ); + save_xray_worker_config(&config).unwrap(); + + let browser_pid = std::process::id(); + assert!(set_browser_pid(&id, browser_pid)); + let saved = get_xray_worker_config(&id).unwrap(); + assert_eq!(saved.browser_pid, Some(browser_pid)); + assert_eq!( + saved.browser_pid_start_time, + process_start_time(browser_pid) + ); + + assert!(delete_xray_worker_config(&id)); +} + +#[test] +fn reusable_worker_requires_and_persists_the_exact_live_owner() { + let temp = tempfile::tempdir().unwrap(); + let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf()); + let id = format!("xray-worker-lease-{}", uuid::Uuid::new_v4()); + let mut config = XrayWorkerConfig::new( + id.clone(), + Some("profile".to_string()), + "vless://unused".to_string(), + 1080, + "local-user".to_string(), + "local-password".to_string(), + ); + config.browser_pid = Some(u32::MAX); + config.browser_pid_start_time = Some(1); + save_xray_worker_config(&config).unwrap(); + + let owner_pid = std::process::id(); + let owner_start_time = process_start_time(owner_pid).unwrap(); + assert!(!worker_is_leased_to(&config, owner_pid, owner_start_time)); + assert!(persist_browser_identity( + &mut config, + owner_pid, + owner_start_time + )); + assert!(worker_is_leased_to(&config, owner_pid, owner_start_time)); + let saved = get_xray_worker_config(&id).unwrap(); + assert_eq!(saved.browser_pid, Some(owner_pid)); + assert_eq!(saved.browser_pid_start_time, Some(owner_start_time)); + + assert!(delete_xray_worker_config(&id)); +} diff --git a/src-tauri/src/xray_worker_storage.rs b/src-tauri/src/xray_worker_storage.rs index 17d3355..0028109 100644 --- a/src-tauri/src/xray_worker_storage.rs +++ b/src-tauri/src/xray_worker_storage.rs @@ -347,182 +347,5 @@ pub fn generate_xray_worker_id() -> String { } #[cfg(test)] -mod tests { - use super::*; - - fn test_config(id: &str) -> XrayWorkerConfig { - XrayWorkerConfig::new( - id.to_string(), - Some("profile".to_string()), - "vless://example".to_string(), - 1080, - "local-user".to_string(), - "local-password".to_string(), - ) - } - - #[test] - fn local_proxy_settings_use_authenticated_loopback_socks() { - let config = test_config("id"); - - let proxy = config.local_proxy_settings(); - assert_eq!(proxy.proxy_type, "socks5"); - assert_eq!(proxy.host, "127.0.0.1"); - assert_eq!(proxy.port, 1080); - assert_eq!(proxy.username.as_deref(), Some("local-user")); - assert_eq!(proxy.password.as_deref(), Some("local-password")); - assert!(proxy.vless_uri.is_none()); - } - - #[test] - fn worker_storage_round_trips_updates_lists_and_securely_cleans_runtime_files() { - let temp = tempfile::tempdir().unwrap(); - let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf()); - let id = format!("xray-storage-test-{}", uuid::Uuid::new_v4()); - let mut config = test_config(&id); - - save_xray_worker_config(&config).unwrap(); - assert_eq!(get_xray_worker_config(&id).unwrap().username, "local-user"); - assert_eq!( - find_xray_worker_by_profile_id("profile").unwrap().id, - config.id - ); - assert!(list_xray_worker_configs() - .iter() - .any(|candidate| candidate.id == id)); - - config.pid = Some(41); - config.xray_pid = Some(42); - config.browser_pid = Some(43); - assert!(update_xray_worker_config(&config)); - let updated = get_xray_worker_config(&id).unwrap(); - assert_eq!(updated.pid, Some(41)); - assert_eq!(updated.xray_pid, Some(42)); - assert_eq!(updated.browser_pid, Some(43)); - - let runtime_path = xray_runtime_config_path(&id); - write_xray_runtime_config(&id, b"{\"runtime\":true}").unwrap(); - let log_path = xray_worker_log_path(&id); - drop(create_xray_worker_log(&id).unwrap()); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - std::fs::metadata(crate::proxy_storage::get_storage_dir()) - .unwrap() - .permissions() - .mode() - & 0o777, - 0o700 - ); - assert_eq!( - std::fs::metadata(xray_worker_config_path(&id)) - .unwrap() - .permissions() - .mode() - & 0o777, - 0o600 - ); - assert_eq!( - std::fs::metadata(&runtime_path) - .unwrap() - .permissions() - .mode() - & 0o777, - 0o600 - ); - assert_eq!( - std::fs::metadata(&log_path).unwrap().permissions().mode() & 0o777, - 0o600 - ); - } - - assert!(delete_xray_worker_config(&id)); - assert!(get_xray_worker_config(&id).is_none()); - assert!(!runtime_path.exists()); - assert!(!log_path.exists()); - assert!(!update_xray_worker_config(&config)); - assert!(write_xray_runtime_config(&id, b"{}").is_err()); - assert!(create_xray_worker_log(&id).is_err()); - } - - #[test] - fn fresh_unstarted_workers_have_a_grace_period_but_legacy_entries_are_stale() { - let fresh = test_config("fresh"); - assert!(!unstarted_worker_is_stale(&fresh)); - - let mut legacy = test_config("legacy"); - legacy.created_at = 0; - assert!(unstarted_worker_is_stale(&legacy)); - - legacy.pid = Some(1); - assert!(!unstarted_worker_is_stale(&legacy)); - } - - #[test] - fn atomic_state_updates_never_expose_partial_json() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("state.json"); - atomic_write_owner_only(&path, br#"{"value":0}"#).unwrap(); - let writer_path = path.clone(); - let writer = std::thread::spawn(move || { - for value in 1..=500 { - let content = serde_json::to_vec(&serde_json::json!({ "value": value })).unwrap(); - atomic_write_owner_only(&writer_path, &content).unwrap(); - } - }); - - // Bound the reader on the writer's own lifetime. A completion flag the - // writer sets last is never set when it panics, which strands this loop - // reading the last good file forever instead of failing. - while !writer.is_finished() { - let content = read_worker_state(&path).expect("state file stays readable while replaced"); - let value: serde_json::Value = serde_json::from_slice(&content).unwrap(); - assert!(value["value"].is_number()); - std::thread::yield_now(); - } - writer.join().unwrap(); - } - - #[cfg(unix)] - #[test] - fn atomic_state_write_replaces_a_symlink_without_touching_its_target() { - use std::os::unix::fs::symlink; - - let temp = tempfile::tempdir().unwrap(); - let victim = temp.path().join("victim"); - let state = temp.path().join("state.json"); - std::fs::write(&victim, "untouched").unwrap(); - symlink(&victim, &state).unwrap(); - - atomic_write_owner_only(&state, br#"{"safe":true}"#).unwrap(); - - assert_eq!(std::fs::read_to_string(victim).unwrap(), "untouched"); - assert_eq!( - serde_json::from_slice::(&std::fs::read(state).unwrap()).unwrap()["safe"], - true - ); - } - - #[test] - fn legacy_worker_config_defaults_missing_browser_pid() { - let value = serde_json::json!({ - "id": "legacy", - "profile_id": "profile", - "vless_uri": "vless://example", - "local_port": 1080, - "username": "user", - "password": "password", - "pid": 1, - "xray_pid": 2 - }); - let config: XrayWorkerConfig = serde_json::from_value(value).unwrap(); - assert_eq!(config.created_at, 0); - assert_eq!(config.pid_start_time, None); - assert_eq!(config.xray_pid_start_time, None); - assert!(!config.ready); - assert_eq!(config.browser_pid, None); - assert_eq!(config.browser_pid_start_time, None); - } -} +#[path = "xray_worker_storage_tests.rs"] +mod tests; diff --git a/src-tauri/src/xray_worker_storage_tests.rs b/src-tauri/src/xray_worker_storage_tests.rs new file mode 100644 index 0000000..ecef065 --- /dev/null +++ b/src-tauri/src/xray_worker_storage_tests.rs @@ -0,0 +1,177 @@ +use super::*; + +fn test_config(id: &str) -> XrayWorkerConfig { + XrayWorkerConfig::new( + id.to_string(), + Some("profile".to_string()), + "vless://example".to_string(), + 1080, + "local-user".to_string(), + "local-password".to_string(), + ) +} + +#[test] +fn local_proxy_settings_use_authenticated_loopback_socks() { + let config = test_config("id"); + + let proxy = config.local_proxy_settings(); + assert_eq!(proxy.proxy_type, "socks5"); + assert_eq!(proxy.host, "127.0.0.1"); + assert_eq!(proxy.port, 1080); + assert_eq!(proxy.username.as_deref(), Some("local-user")); + assert_eq!(proxy.password.as_deref(), Some("local-password")); + assert!(proxy.vless_uri.is_none()); +} + +#[test] +fn worker_storage_round_trips_updates_lists_and_securely_cleans_runtime_files() { + let temp = tempfile::tempdir().unwrap(); + let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf()); + let id = format!("xray-storage-test-{}", uuid::Uuid::new_v4()); + let mut config = test_config(&id); + + save_xray_worker_config(&config).unwrap(); + assert_eq!(get_xray_worker_config(&id).unwrap().username, "local-user"); + assert_eq!( + find_xray_worker_by_profile_id("profile").unwrap().id, + config.id + ); + assert!(list_xray_worker_configs() + .iter() + .any(|candidate| candidate.id == id)); + + config.pid = Some(41); + config.xray_pid = Some(42); + config.browser_pid = Some(43); + assert!(update_xray_worker_config(&config)); + let updated = get_xray_worker_config(&id).unwrap(); + assert_eq!(updated.pid, Some(41)); + assert_eq!(updated.xray_pid, Some(42)); + assert_eq!(updated.browser_pid, Some(43)); + + let runtime_path = xray_runtime_config_path(&id); + write_xray_runtime_config(&id, b"{\"runtime\":true}").unwrap(); + let log_path = xray_worker_log_path(&id); + drop(create_xray_worker_log(&id).unwrap()); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(crate::proxy_storage::get_storage_dir()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert_eq!( + std::fs::metadata(xray_worker_config_path(&id)) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_eq!( + std::fs::metadata(&runtime_path) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_eq!( + std::fs::metadata(&log_path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + assert!(delete_xray_worker_config(&id)); + assert!(get_xray_worker_config(&id).is_none()); + assert!(!runtime_path.exists()); + assert!(!log_path.exists()); + assert!(!update_xray_worker_config(&config)); + assert!(write_xray_runtime_config(&id, b"{}").is_err()); + assert!(create_xray_worker_log(&id).is_err()); +} + +#[test] +fn fresh_unstarted_workers_have_a_grace_period_but_legacy_entries_are_stale() { + let fresh = test_config("fresh"); + assert!(!unstarted_worker_is_stale(&fresh)); + + let mut legacy = test_config("legacy"); + legacy.created_at = 0; + assert!(unstarted_worker_is_stale(&legacy)); + + legacy.pid = Some(1); + assert!(!unstarted_worker_is_stale(&legacy)); +} + +#[test] +fn atomic_state_updates_never_expose_partial_json() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("state.json"); + atomic_write_owner_only(&path, br#"{"value":0}"#).unwrap(); + let writer_path = path.clone(); + let writer = std::thread::spawn(move || { + for value in 1..=500 { + let content = serde_json::to_vec(&serde_json::json!({ "value": value })).unwrap(); + atomic_write_owner_only(&writer_path, &content).unwrap(); + } + }); + + // Bound the reader on the writer's own lifetime. A completion flag the + // writer sets last is never set when it panics, which strands this loop + // reading the last good file forever instead of failing. + while !writer.is_finished() { + let content = read_worker_state(&path).expect("state file stays readable while replaced"); + let value: serde_json::Value = serde_json::from_slice(&content).unwrap(); + assert!(value["value"].is_number()); + std::thread::yield_now(); + } + writer.join().unwrap(); +} + +#[cfg(unix)] +#[test] +fn atomic_state_write_replaces_a_symlink_without_touching_its_target() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let victim = temp.path().join("victim"); + let state = temp.path().join("state.json"); + std::fs::write(&victim, "untouched").unwrap(); + symlink(&victim, &state).unwrap(); + + atomic_write_owner_only(&state, br#"{"safe":true}"#).unwrap(); + + assert_eq!(std::fs::read_to_string(victim).unwrap(), "untouched"); + assert_eq!( + serde_json::from_slice::(&std::fs::read(state).unwrap()).unwrap()["safe"], + true + ); +} + +#[test] +fn legacy_worker_config_defaults_missing_browser_pid() { + let value = serde_json::json!({ + "id": "legacy", + "profile_id": "profile", + "vless_uri": "vless://example", + "local_port": 1080, + "username": "user", + "password": "password", + "pid": 1, + "xray_pid": 2 + }); + let config: XrayWorkerConfig = serde_json::from_value(value).unwrap(); + assert_eq!(config.created_at, 0); + assert_eq!(config.pid_start_time, None); + assert_eq!(config.xray_pid_start_time, None); + assert!(!config.ready); + assert_eq!(config.browser_pid, None); + assert_eq!(config.browser_pid_start_time, None); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 90853eb..ccce752 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -28,6 +28,7 @@ import HomeHeader from "@/components/home-header"; import { ImportProfileDialog } from "@/components/import-profile-dialog"; import { IntegrationsDialog } from "@/components/integrations-dialog"; import { ONBOARDING_TOUR } from "@/components/onboarding-provider"; +import { PaidWelcomeDialog } from "@/components/paid-welcome-dialog"; import { PermissionDialog } from "@/components/permission-dialog"; import { type GateDecision, @@ -53,6 +54,7 @@ import { SyncConfigDialog } from "@/components/sync-config-dialog"; import { SyncFollowerDialog } from "@/components/sync-follower-dialog"; import { SynchronizerPanel } from "@/components/synchronizer-panel"; import { ThankYouDialog } from "@/components/thank-you-dialog"; +import { TipsDialog } from "@/components/tips-dialog"; import { TrashPage } from "@/components/trash-page"; import { WayfernConfigDialog } from "@/components/wayfern-config-dialog"; import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog"; @@ -69,6 +71,7 @@ import { usePermissions } from "@/hooks/use-permissions"; import { useProfileEvents } from "@/hooks/use-profile-events"; import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useSyncSessions } from "@/hooks/use-sync-session"; +import { useTips } from "@/hooks/use-tips"; import { useUpdateNotifications } from "@/hooks/use-update-notifications"; import { useVersionUpdater } from "@/hooks/use-version-updater"; import { useVpnEvents } from "@/hooks/use-vpn-events"; @@ -96,6 +99,7 @@ import { SHORTCUTS, type ShortcutId, } from "@/lib/shortcuts"; +import type { TipAction } from "@/lib/tips"; import { dismissToast, showErrorToast, @@ -340,8 +344,31 @@ export default function Home() { } = useCommercialTrial(); // Cloud auth for cross-OS unlock - const { user: cloudUser } = useCloudAuth(); + const { user: cloudUser, loggedInAt: cloudLoggedInAt } = useCloudAuth(); const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints; + // Shown once when the commercial trial runs out; modal, so it goes first. + const commercialTrialModalOpen = + !termsLoading && + termsAccepted === true && + 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. @@ -396,6 +423,10 @@ export default function Home() { const [agentInitialTab, setAgentInitialTab] = useState("run"); const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false); const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); + // A settings section to land on, set by a tip's action for one opening. + const [settingsInitialSection, setSettingsInitialSection] = useState< + string | null + >(null); const [trashPageOpen, setTrashPageOpen] = useState(false); const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false); const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false); @@ -531,6 +562,7 @@ export default function Home() { setCookieBotDialogOpen(false); setAgentDialogOpen(false); setTrashPageOpen(false); + setSettingsInitialSection(null); setCurrentPage(page); switch (page) { @@ -579,12 +611,36 @@ export default function Home() { } }, []); + const runTipAction = useCallback( + (action: TipAction) => { + closeTips(); + switch (action.kind) { + case "page": + handleRailNavigate(action.page); + break; + case "settings": + // The navigation clears the section; setting it afterwards in the + // same batch is what makes it win. + handleRailNavigate("settings"); + setSettingsInitialSection(action.section); + break; + case "palette": + setCommandPaletteOpen(true); + break; + } + }, + [closeTips, handleRailNavigate], + ); + const runShortcut = useCallback( (id: ShortcutId) => { switch (id) { case "openPalette": setCommandPaletteOpen(true); break; + case "openTips": + openTips(); + break; case "openShortcuts": handleRailNavigate("shortcuts"); break; @@ -675,7 +731,13 @@ export default function Home() { break; } }, - [handleRailNavigate, currentPage, proxyManagementInitialTab, cloudUser], + [ + handleRailNavigate, + currentPage, + proxyManagementInitialTab, + cloudUser, + openTips, + ], ); // Ordered list the digit shortcuts and palette consume. "__all__" is index 1 @@ -2216,6 +2278,7 @@ export default function Home() { onOpenAbout={() => { setAboutDialogOpen(true); }} + onOpenTips={() => openTips()} cookieBotRunning={Object.keys(cookieBotLiveSessions).length > 0} />
@@ -2303,6 +2366,7 @@ export default function Home() { setCurrentPage("integrations"); }} subPage={currentPage === "settings"} + initialSection={settingsInitialSection} /> )} @@ -2516,6 +2580,34 @@ export default function Home() { isOpen={thankYouOpen} onClose={() => setThankYouOpen(false)} /> + { + if (!open) closeTips(); + }} + onTipShown={tipsFlow.markSeen} + onAutoShowChange={(enabled) => void tipsFlow.setAutoShow(enabled)} + onAction={runTipAction} + /> + { + if (!open) tipsFlow.dismissPaidWelcome(); + }} + onOpenTip={(id) => { + tipsFlow.dismissPaidWelcome(); + openTips(id); + }} + /> diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx index c10c1d6..4cb064e 100644 --- a/src/components/command-palette.tsx +++ b/src/components/command-palette.tsx @@ -12,6 +12,7 @@ import { LuCookie, LuInfo, LuKeyboard, + LuLightbulb, LuPlay, LuPlug, LuPlus, @@ -65,6 +66,7 @@ interface CommandPaletteProps { const ICONS: Record> = { openPalette: LuKeyboard, openShortcuts: LuKeyboard, + openTips: LuLightbulb, importProfile: FaDownload, goProfiles: LuUser, goProxies: FiWifi, diff --git a/src/components/import-profile-dialog.tsx b/src/components/import-profile-dialog.tsx index 8952980..d2c5702 100644 --- a/src/components/import-profile-dialog.tsx +++ b/src/components/import-profile-dialog.tsx @@ -192,6 +192,10 @@ export function ImportProfileDialog({ const [isImporting, setIsImporting] = useState(false); const [progress, setProgress] = useState(null); const activeImportItems = useRef([]); + // Each run's summary toast gets its own id. Re-using one id after + // dismissing it merges the new toast into the one still sliding out, and + // a fast retry's summary was never seen. + const resultsToastId = useRef(null); const [sourceProgress, setSourceProgress] = useState< Record >({}); @@ -404,7 +408,7 @@ export function ImportProfileDialog({ setCurrentStep("importing"); setIsImporting(true); setProgress(null); - toast.dismiss("profile-import-results"); + if (resultsToastId.current) toast.dismiss(resultsToastId.current); // A retry covers only the failed subset, so the earlier results are still // the truth for everything else and must not be thrown away. const previous = retryPaths ? result : null; @@ -435,13 +439,14 @@ export function ImportProfileDialog({ ? toast.warning : toast.error : toast.success; + resultsToastId.current = `profile-import-results-${Date.now()}`; notify( t("importProfile.resultsSummary", { imported: combined.imported_count, skipped: combined.skipped_count, failed: combined.failed_count, }), - { id: "profile-import-results" }, + { id: resultsToastId.current }, ); if ( batchResult.imported_count > 0 && @@ -977,6 +982,7 @@ export function ImportProfileDialog({ void; + onOpenTip: (id: TipId) => void; +}) { + const { t } = useTranslation(); + const reduceMotion = useReducedMotion(); + const modality = useInputModality(); + const animate = !reduceMotion && modality === "pointer"; + const mod = isMacOS() ? "⌘" : "Ctrl"; + + useEffect(() => { + if (!open || reduceMotion || document.hidden) return; + const fire = (options: confetti.Options) => { + if (document.hidden) return; + void confetti({ + origin: { y: 0.65 }, + disableForReducedMotion: true, + ...options, + }); + }; + fire({ particleCount: 80, spread: 66, startVelocity: 42 }); + const second = window.setTimeout( + () => fire({ particleCount: 40, spread: 100, decay: 0.92 }), + 220, + ); + return () => window.clearTimeout(second); + }, [open, reduceMotion]); + + return ( + + +
+
+ + + + + {t("paidWelcome.title", { plan: displayPlan(plan) })} + +

+ {t("paidWelcome.body")} +

+
+ + {tips.length > 0 && ( +
    + {tips.map((tip, index) => { + const keys = tipTextKeys(tip.id); + return ( +
  • + onOpenTip(tip.id)} + initial={animate ? { y: 8 } : false} + animate={{ y: 0 }} + transition={{ + delay: animate ? 0.05 * index : 0, + duration: animate ? 0.3 : 0, + ease: MOTION_EASE_OUT, + }} + className="flex w-full min-w-0 cursor-pointer items-center justify-between gap-3 rounded-md px-3 py-2 text-left transition-colors duration-100 hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-ring" + > + + + {t(keys.title, { mod })} + + + {t(keys.body, { mod })} + + + +
  • + ); + })} +
+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/src/components/profile-launch-activity.tsx b/src/components/profile-launch-activity.tsx index 8b792da..9aee7ea 100644 --- a/src/components/profile-launch-activity.tsx +++ b/src/components/profile-launch-activity.tsx @@ -45,6 +45,7 @@ export function ProfileLaunchActivity({ + entry.ok && typeof entry.latency_ms === "number" + ? Math.max(max, entry.latency_ms) + : max, + 0, + ); + if (entries.length < 2 || peak === 0) return null; + return ( +
+
+ {entries.map((entry, index) => { + const latest = index === entries.length - 1; + const ms = + entry.ok && typeof entry.latency_ms === "number" + ? entry.latency_ms + : null; + return ( + + + + {ms === null ? ( + + +

+ {ms === null + ? t("proxyCheck.historyFailed") + : t("proxyCheck.latencyValue", { ms })} + {" · "} + {formatRelativeTime(entry.timestamp)} +

+
+
+ ); + })} +
+

+ {t("proxyCheck.trendPeak", { ms: peak })} +

+
+ ); +} + /** One remembered check, as a single line. */ function HistoryRow({ entry }: { entry: ProxyCheckHistoryEntry }) { const { t } = useTranslation(); @@ -302,7 +380,9 @@ export function ProxyCheckButton({

{proxy.name}

{t("proxyCheck.historyTitle")} + {history && } {history && history.length > 0 ? (
    {history.map((entry, index) => ( diff --git a/src/components/rail-nav.tsx b/src/components/rail-nav.tsx index e102ce3..e47bee1 100644 --- a/src/components/rail-nav.tsx +++ b/src/components/rail-nav.tsx @@ -12,6 +12,7 @@ import { LuCookie, LuInfo, LuKeyboard, + LuLightbulb, LuPlug, LuPuzzle, LuTrash2, @@ -218,6 +219,8 @@ interface RailNavProps { currentPage: AppPage; onNavigate: (page: AppPage) => void; onOpenAbout: () => void; + /** Opens the feature tips catalog. */ + onOpenTips: () => void; /** * A remote session is running right now. The Cookie Bot item carries a dot so * the state is legible from every other page — an overnight job you cannot @@ -291,6 +294,7 @@ export function RailNav({ currentPage, onNavigate, onOpenAbout, + onOpenTips, cookieBotRunning = false, }: RailNavProps) { const { t } = useTranslation(); @@ -496,6 +500,28 @@ export function RailNav({ ))} + - ))} + {sections.map(([id, label]) => { + const active = activeSection === id; + return ( + + ); + })} @@ -946,6 +1033,8 @@ export function SettingsDialog({ side gutters); the width cap lives on the inner column. Fusing them was the dead-wheel-zone bug. */}
    void; + onTipShown: (id: TipId, auto: boolean) => void; + onAutoShowChange: (enabled: boolean) => void; + onAction: (action: TipAction) => void; +} + +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + return ( + target.isContentEditable || + ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) + ); +} + +/** + * 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. + */ +export function TipsDialog({ + open, + mode, + tips, + seen, + initialTipId, + auto, + autoShow, + onOpenChange, + onTipShown, + onAutoShowChange, + onAction, +}: TipsDialogProps) { + const { t } = useTranslation(); + const reduceMotion = useReducedMotion(); + const modality = useInputModality(); + const animate = !reduceMotion && modality === "pointer"; + const listId = useId(); + const autoShowId = useId(); + const total = tips.length; + const initialIndex = Math.max( + 0, + initialTipId ? tips.findIndex((tip) => tip.id === initialTipId) : 0, + ); + const [index, setIndex] = useState(initialIndex); + const [direction, setDirection] = useState<1 | -1>(1); + const tip = tips[Math.min(index, Math.max(0, total - 1))]; + const mod = isMacOS() ? "⌘" : "Ctrl"; + + // Each tip is reported once as it comes on screen, so the automatic flow + // never repeats one and the catalog can tell seen from new. + const reportedRef = useRef(null); + useEffect(() => { + if (!open || !tip || reportedRef.current === tip.id) return; + reportedRef.current = tip.id; + onTipShown(tip.id, auto && tip.id === initialTipId); + }, [open, tip, auto, initialTipId, onTipShown]); + + if (!tip) return null; + + const keys = tipTextKeys(tip.id); + const isLast = index >= total - 1; + const go = (next: number) => { + const clamped = Math.max(0, Math.min(total - 1, next)); + if (clamped === index) return; + setDirection(clamped > index ? 1 : -1); + setIndex(clamped); + }; + + const essentials = tips + .map((item, itemIndex) => ({ item, itemIndex })) + .filter(({ item }) => !isPlanTip(item)); + const planTips = tips + .map((item, itemIndex) => ({ item, itemIndex })) + .filter(({ item }) => isPlanTip(item)); + const sections = [ + { key: "essentials", label: t("tips.essentials"), entries: essentials }, + ...(planTips.length > 0 + ? [{ key: "plan", label: t("tips.planSection"), entries: planTips }] + : []), + ]; + + const detail = ( +
    + + {t(keys.title, { mod })} + + +
    + + + + + +
    + +
    + {isPlanTip(tip) && ( +

    + {t("tips.planSection")} +

    + )} +

    + {t(keys.body, { mod })} +

    +
    + +
    +
    + + + {t("tips.count", { current: index + 1, total })} + + +
    +
    + + +
    +
    + +
    + + +
    +
    + ); + + return ( + + { + if (isTypingTarget(event.target)) return; + if (event.key === "ArrowRight") { + event.preventDefault(); + go(index + 1); + } else if (event.key === "ArrowLeft") { + event.preventDefault(); + go(index - 1); + } + }} + > + {mode === "browse" ? ( +
    + + {detail} +
    + ) : ( +
    {detail}
    + )} +
    +
    + ); +} diff --git a/src/components/tips/scene-for.tsx b/src/components/tips/scene-for.tsx new file mode 100644 index 0000000..f5f91fa --- /dev/null +++ b/src/components/tips/scene-for.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { isMacOS } from "@/lib/platform"; +import type { TipId } from "@/lib/tips"; +import { + ApiScene, + ConsistencyScene, + DnsScene, + ExtensionsScene, + GroupsScene, + ImportScene, + LinkRouteScene, + LockScene, + PaletteScene, + ProxyRouteScene, + SweepScene, + SyncScene, + TrashScene, +} from "./scenes-essentials"; +import { + AgentScene, + CloudSyncScene, + CookieBotScene, + CrossOsScene, + RemoteScene, + TeamScene, +} from "./scenes-plan"; + +/** The drawing for one tip. Every tip id has one; the switch is exhaustive. */ +export function TipScene({ id }: { id: TipId }) { + switch (id) { + case "dnsBlocklist": + return ; + case "proxyCheck": + return ; + case "groups": + return ; + case "commandPalette": + return ; + case "fingerprintGate": + return ; + case "profilePassword": + return ; + case "clearOnClose": + return ; + case "defaultBrowser": + return ; + case "extensionGroups": + return ; + case "selfHostedSync": + return ; + case "trash": + return ; + case "localApi": + return ; + case "importProfiles": + return ; + case "cloudBackup": + return ; + case "cookieBot": + return ; + case "crossOs": + return ; + case "automation": + return ; + case "agent": + return ; + case "team": + return ; + case "remoteControl": + return ; + } +} diff --git a/src/components/tips/scene-primitives.tsx b/src/components/tips/scene-primitives.tsx new file mode 100644 index 0000000..1d2f56f --- /dev/null +++ b/src/components/tips/scene-primitives.tsx @@ -0,0 +1,368 @@ +"use client"; + +import { + motion, + type TargetAndTransition, + type Transition, + useReducedMotion, +} from "motion/react"; +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +export const VIEW_W = 320; +export const VIEW_H = 160; + +/** + * Shared timing for one looping scene. Every element in a scene keys off the + * same duration and its own `times`, so the parts stay in step without any + * orchestration. With reduced motion a scene shows its resting frame: the + * last keyframe of every value, no loop. + */ +export function useScene(duration: number) { + const reduce = useReducedMotion() ?? false; + const kf = (values: T[]): T | T[] => + reduce ? (values[values.length - 1] as T) : values; + const tr = (times: number[], extra?: Transition): Transition => + reduce + ? { duration: 0 } + : { + duration, + times, + repeat: Number.POSITIVE_INFINITY, + ease: "easeInOut", + ...extra, + }; + return { reduce, kf, tr }; +} + +export interface Point { + x: number; + y: number; +} + +/** Points along a cubic bezier, so a dot can travel a drawn wire. */ +export function bezier( + p0: Point, + p1: Point, + p2: Point, + p3: Point, + steps: number, +): Point[] { + const out: Point[] = []; + for (let i = 0; i <= steps; i += 1) { + const t = i / steps; + const mt = 1 - t; + out.push({ + x: + mt ** 3 * p0.x + + 3 * mt ** 2 * t * p1.x + + 3 * mt * t ** 2 * p2.x + + t ** 3 * p3.x, + y: + mt ** 3 * p0.y + + 3 * mt ** 2 * t * p1.y + + 3 * mt * t ** 2 * p2.y + + t ** 3 * p3.y, + }); + } + return out; +} + +/** + * Keyframes that hold at the first point, travel through every point between + * `from` and `to` (as fractions of the loop), then hold at the last point. + */ +export function travel(points: Point[], from: number, to: number) { + const last = points.length - 1; + const times = [ + 0, + ...points.map((_, index) => from + ((to - from) * index) / last), + 1, + ]; + return { + cx: [points[0].x, ...points.map((p) => p.x), points[last].x], + cy: [points[0].y, ...points.map((p) => p.y), points[last].y], + times, + }; +} + +export function Scene({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( + + ); +} + +/** A browser window: a frame, a title bar with one tab, and some text lines. */ +export function Window({ + x, + y, + width, + height, + lines = 3, + className, + children, +}: { + x: number; + y: number; + width: number; + height: number; + lines?: number; + className?: string; + children?: ReactNode; +}) { + const barY = y + 14; + return ( + + + + + {Array.from({ length: lines }, (_, index) => { + const lineY = barY + 14 + index * 12; + const lineWidth = (width - 24) * (index % 2 === 0 ? 0.8 : 0.55); + return lineY < y + height - 8 ? ( + + ) : null; + })} + {children} + + ); +} + +export function Laptop({ x, y }: { x: number; y: number }) { + return ( + + + + + ); +} + +export function Monitor({ x, y }: { x: number; y: number }) { + return ( + + + + + ); +} + +export function Server({ x, y }: { x: number; y: number }) { + return ( + + + + {[9, 28, 47].map((offset) => ( + + ))} + + ); +} + +export function Person({ cx, cy }: { cx: number; cy: number }) { + return ( + + + + + ); +} + +export function Cloud({ cx, cy }: { cx: number; cy: number }) { + return ( + + ); +} + +export function Globe({ cx, cy, r }: { cx: number; cy: number; r: number }) { + return ( + + + + + + + ); +} + +export function Bin({ x, y }: { x: number; y: number }) { + return ( + + ); +} + +/** A padlock; the shackle is its own element so a scene can lift it. */ +export function Padlock({ + x, + y, + shackle, + className, +}: { + x: number; + y: number; + /** Motion props for the shackle group. */ + shackle?: { animate: TargetAndTransition; transition: Transition }; + className?: string; +}) { + return ( + + + + + + ); +} + +/** A puzzle piece with a bump on top and one on the right, for extensions. */ +export function puzzlePath(x: number, y: number, size: number): string { + const r = size * 0.16; + const side = size / 2 - r; + return `M${x} ${y} h${side} a${r} ${r} 0 1 1 ${r * 2} 0 h${side} v${side} a${r} ${r} 0 1 1 0 ${r * 2} v${side} h-${size} z`; +} + +export function Puzzle({ + x, + y, + size, + className, +}: { + x: number; + y: number; + size: number; + className?: string; +}) { + return ; +} + +export function Keycap({ + x, + y, + width, + label, + className, + animate, + transition, +}: { + x: number; + y: number; + width: number; + label: string; + className?: string; + animate?: TargetAndTransition; + transition?: Transition; +}) { + return ( + + + + {label} + + + ); +} + +export function Cursor({ + animate, + transition, + className, +}: { + animate: TargetAndTransition; + transition: Transition; + className?: string; +}) { + return ( + + ); +} + +export function Check({ + x, + y, + size = 12, + className, + animate, + transition, +}: { + x: number; + y: number; + size?: number; + className?: string; + animate: TargetAndTransition; + transition: Transition; +}) { + return ( + + ); +} diff --git a/src/components/tips/scenes-essentials.tsx b/src/components/tips/scenes-essentials.tsx new file mode 100644 index 0000000..7d11fdf --- /dev/null +++ b/src/components/tips/scenes-essentials.tsx @@ -0,0 +1,745 @@ +"use client"; + +import { motion } from "motion/react"; +import { + Bin, + bezier, + Check, + Cursor, + Globe, + Keycap, + Laptop, + Padlock, + Puzzle, + puzzlePath, + Scene, + Server, + travel, + useScene, + Window, +} from "./scene-primitives"; + +/** + * Every scene here is decorative: the dialog text carries the meaning and the + * drawing shows it happening. Scenes loop on their own clock, render their + * resting frame under reduced motion, and never hide anything a reader needs. + */ + +/** Requests leave a profile; the ones bound for ad and tracker hosts stop at the shield. */ +export function DnsScene() { + const { kf, tr } = useScene(3.6); + return ( + + + + {[50, 84, 118].map((y) => ( + + + + + + ))} + + {[84, 118].map((y, index) => { + const hit = 0.34 + index * 0.1; + return ( + + + + + ); + })} + + ); +} + +const ROUTE = travel( + [ + ...bezier( + { x: 82, y: 104 }, + { x: 118, y: 104 }, + { x: 128, y: 44 }, + { x: 160, y: 44 }, + 8, + ), + ...bezier( + { x: 160, y: 44 }, + { x: 192, y: 44 }, + { x: 204, y: 104 }, + { x: 256, y: 104 }, + 8, + ).slice(1), + ], + 0.08, + 0.66, +); + +/** A check travels this device, the proxy, the exit; the exit is confirmed. */ +export function ProxyRouteScene() { + const { kf, tr } = useScene(3.4); + return ( + + + + + + + + + + ); +} + +const COLUMNS = [20, 118, 216]; + +/** Profiles settle into groups, then the group keys walk the columns. */ +export function GroupsScene() { + const { kf, tr } = useScene(4.4); + return ( + + {COLUMNS.map((x, index) => { + const press = 0.5 + index * 0.14; + return ( + + + + + + ); + })} + + {Array.from({ length: 6 }, (_, index) => { + const column = Math.floor(index / 2); + const fromY = 52 + index * 12; + const toY = 52 + (index % 2) * 12; + const start = 0.1 + index * 0.05; + return ( + + ); + })} + + ); +} + +/** The chord opens the palette; two typed letters narrow it to one entry. */ +export function PaletteScene({ modLabel }: { modLabel: string }) { + const { kf, tr } = useScene(4); + const press = (at: number) => ({ + animate: { y: kf([0, 0, 2, 0, 0]) }, + transition: tr([0, at, at + 0.04, at + 0.1, 1]), + }); + return ( + + + + + + + + {[62, 80, 98, 116].map((y, index) => ( + + + + + ))} + + + ); +} + +const CLOCK = { cx: 212, cy: 82, r: 16 }; + +function hand(deg: number, length: number) { + return { + x: CLOCK.cx + Math.sin((deg * Math.PI) / 180) * length, + y: CLOCK.cy - Math.cos((deg * Math.PI) / 180) * length, + }; +} + +/** The profile clock turns to the exit's timezone; the mismatch mark gives way. */ +export function ConsistencyScene() { + const { kf, tr } = useScene(4); + const wrong = hand(-110, 9); + const right = hand(60, 9); + return ( + + + + + + + + + + + + + + + + + + + + ); +} + +const CIPHER_LINES = [64, 78, 92, 106]; + +/** The padlock drops and the profile's text turns to cipher. */ +export function LockScene() { + const { kf, tr } = useScene(4.2); + const swap = tr([0, 0.3, 0.42, 1], { repeatDelay: 0.8 }); + return ( + + + + {CIPHER_LINES.map((y, index) => ( + + ))} + + + {CIPHER_LINES.map((y, index) => ( + + ))} + + + + + ); +} + +const CRUMBS = [ + { x: 100, y: 72, at: 0.4 }, + { x: 132, y: 66, at: 0.46 }, + { x: 166, y: 76, at: 0.52 }, +]; + +/** The window closes and its cookies and storage fall away. */ +export function SweepScene() { + const { kf, tr } = useScene(4); + const fall = (at: number) => tr([0, at, at + 0.22, 1]); + return ( + + + + + {CRUMBS.map((crumb) => ( + + + + + + ))} + {[ + { x: 96, at: 0.5 }, + { x: 150, at: 0.56 }, + ].map((store) => ( + + ))} + + ); +} + +/** A link from another app passes the chooser and opens in the chosen profile. */ +export function LinkRouteScene() { + const { kf, tr } = useScene(4.2); + const pop = tr([0, 0.55, 0.7, 1]); + return ( + + + + + + + + {[52, 76, 100].map((y, index) => ( + + + + + ))} + + + + + + + + + + ); +} + +/** One extension group; each profile that uses it receives the pieces. */ +export function ExtensionsScene() { + const { kf, tr } = useScene(4); + return ( + + + + {[28, 66, 104].map((y, index) => { + const at = 0.25 + index * 0.18; + return ( + + + + + + + ); + })} + + ); +} + +/** A packet is sealed on its way to the server; the server confirms it. */ +export function SyncScene() { + const { kf, tr } = useScene(4.2); + return ( + + + + + + + + + ); +} + +/** A profile goes to the bin, the retention ring drains, and it comes back. */ +export function TrashScene() { + const { kf, tr } = useScene(5); + const move = tr([0, 0.12, 0.34, 0.72, 0.94, 1]); + return ( + + + + + + + + + + ); +} + +/** A command in a terminal opens a real profile; `run` also drives it. */ +export function ApiScene({ variant }: { variant: "api" | "run" }) { + const { kf, tr } = useScene(4.4); + const pop = tr([0, 0.5, 0.64, 1]); + return ( + + + + + + + + + + + {variant === "run" && ( + + )} + + + + + {variant === "run" && ( + + )} + + + + ); +} + +const IMPORT_ARC = bezier( + { x: 126, y: 84 }, + { x: 160, y: 26 }, + { x: 200, y: 26 }, + { x: 263, y: 84 }, + 12, +); + +/** Cookies, logins and extensions cross from another browser into a profile. */ +export function ImportScene() { + const { kf, tr } = useScene(4.6); + return ( + + + + + + + {[0.1, 0.26, 0.42].map((at) => { + const trip = travel(IMPORT_ARC, at, at + 0.3); + return ( + + ); + })} + + + ); +} diff --git a/src/components/tips/scenes-plan.tsx b/src/components/tips/scenes-plan.tsx new file mode 100644 index 0000000..81f824a --- /dev/null +++ b/src/components/tips/scenes-plan.tsx @@ -0,0 +1,332 @@ +"use client"; + +import { motion } from "motion/react"; +import { FaApple, FaLinux, FaWindows } from "react-icons/fa"; +import { + bezier, + Check, + Cloud, + Cursor, + Laptop, + Monitor, + Padlock, + Person, + Scene, + travel, + useScene, + Window, +} from "./scene-primitives"; + +const UP = travel( + bezier( + { x: 82, y: 96 }, + { x: 100, y: 70 }, + { x: 120, y: 56 }, + { x: 140, y: 56 }, + 8, + ), + 0.08, + 0.34, +); +const DOWN = travel( + bezier( + { x: 180, y: 56 }, + { x: 200, y: 56 }, + { x: 220, y: 70 }, + { x: 238, y: 96 }, + 8, + ), + 0.44, + 0.7, +); + +/** A profile goes up to the cloud from one machine and down to another. */ +export function CloudSyncScene() { + const { kf, tr } = useScene(4.4); + return ( + + + + + + + + + + + ); +} + +const SKY = travel( + bezier( + { x: 40, y: 60 }, + { x: 100, y: -4 }, + { x: 220, y: -4 }, + { x: 280, y: 60 }, + 12, + ), + 0.05, + 0.75, +); + +/** The moon crosses the sky while the profile collects cookies and history. */ +export function CookieBotScene() { + const { kf, tr } = useScene(5); + return ( + + + x - 40)), + y: kf(SKY.cy.map((y) => y - 60)), + }} + transition={tr(SKY.times, { ease: "linear" })} + > + + + + {[0, 1, 2, 3, 4].map((index) => { + const at = 0.12 + index * 0.13; + return ( + + ); + })} + + + + ); +} + +const OS_MARKS = [FaApple, FaWindows, FaLinux]; +const OS_TIMES = [0, 0.28, 0.34, 0.61, 0.67, 0.94, 1]; +const OS_VISIBLE = [ + [1, 1, 0, 0, 0, 0, 1], + [0, 0, 1, 1, 0, 0, 0], + [0, 0, 0, 0, 1, 1, 0], +]; + +/** One profile presents as each operating system in turn. */ +export function CrossOsScene() { + const { kf, tr } = useScene(5.4); + return ( + + + + {OS_MARKS.map((Mark, index) => ( + + + + ))} + + + ); +} + +const AGENT_BUTTONS = [ + { x: 36, y: 56 }, + { x: 36, y: 82 }, + { x: 108, y: 108 }, +]; +const AGENT_CLICKS = [0.18, 0.42, 0.66]; + +/** The agent clicks through a page and writes each step into a recipe. */ +export function AgentScene() { + const { kf, tr } = useScene(5.2); + return ( + + + {AGENT_BUTTONS.map((button, index) => { + const at = AGENT_CLICKS[index]; + return ( + + + + + + ); + })} + + + + {AGENT_CLICKS.map((at, index) => ( + + + + + ))} + + + ); +} + +/** One teammate holds the profile lock, releases it, and the other takes it. */ +export function TeamScene() { + const { kf, tr } = useScene(5); + return ( + + + + + + + + + + ); +} + +/** A request from the website crosses the bridge, drives the desktop, and reports back. */ +export function RemoteScene() { + const { kf, tr } = useScene(4.8); + return ( + + + + + + + + + ); +} diff --git a/src/components/trash-page.tsx b/src/components/trash-page.tsx index c5bd607..0bca0c1 100644 --- a/src/components/trash-page.tsx +++ b/src/components/trash-page.tsx @@ -1,6 +1,7 @@ "use client"; import { invoke } from "@tauri-apps/api/core"; +import { motion, useReducedMotion } from "motion/react"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { LuLock, LuRotateCcw, LuTrash2 } from "react-icons/lu"; @@ -27,10 +28,12 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useInputModality } from "@/hooks/use-input-modality"; import { useTrashEvents } from "@/hooks/use-trash-events"; import { translateBackendError } from "@/lib/backend-errors"; import { getBrowserDisplayName } from "@/lib/browser-utils"; import { formatBytes } from "@/lib/format-bytes"; +import { MOTION_EASE_OUT } from "@/lib/motion"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { cn } from "@/lib/utils"; import type { BrowserProfile, TrashedProfileSummary } from "@/types"; @@ -48,6 +51,51 @@ function daysUntil(expiresAt: number, nowSeconds: number): number { return Math.max(0, Math.ceil((expiresAt - nowSeconds) / SECONDS_PER_DAY)); } +/** + * The retention period as a ring that drains from the top: what is left of + * it reads at a glance beside the day count, and an entry about to go is the + * one whose ring is nearly gone. + */ +function RetentionRing({ + deletedAt, + expiresAt, + nowSeconds, + warning, +}: { + deletedAt: number; + expiresAt: number; + nowSeconds: number; + warning: boolean; +}) { + const reduceMotion = useReducedMotion(); + const modality = useInputModality(); + const animate = !reduceMotion && modality === "pointer"; + const total = Math.max(1, expiresAt - deletedAt); + const left = Math.max(0, Math.min(1, (expiresAt - nowSeconds) / total)); + return ( + + ); +} + export function TrashPage({ isOpen, onClose, subPage }: TrashPageProps) { const { t, i18n } = useTranslation(); const { entries, isLoading, error } = useTrashEvents(); @@ -265,9 +313,17 @@ export function TrashPage({ isOpen, onClose, subPage }: TrashPageProps) { : "text-muted-foreground", )} > - {daysLeft === 0 - ? t("trash.expiresToday") - : t("trash.expiresIn", { count: daysLeft })} + + + {daysLeft === 0 + ? t("trash.expiresToday") + : t("trash.expiresIn", { count: daysLeft })} + {formatBytes(entry.size_bytes)} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 77f56c8..5eee088 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -264,7 +264,7 @@ function DialogContent({ // w-[calc(100%-2rem)] (not w-full + max-w) keeps the 1rem window // gutter even when callers override max-w-*: tailwind-merge drops // a base max-w in favor of the caller's, but leaves width alone. - "fixed top-[50%] left-[50%] z-10000 grid max-h-[calc(100dvh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border bg-background p-6", + "fixed top-[50%] left-[50%] z-10000 grid max-h-[calc(100vh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border bg-background p-6", className, )} {...props} diff --git a/src/components/ui/operation-flow.tsx b/src/components/ui/operation-flow.tsx index a3ee7bc..09e892a 100644 --- a/src/components/ui/operation-flow.tsx +++ b/src/components/ui/operation-flow.tsx @@ -12,26 +12,56 @@ export interface OperationStep { detail: ReactNode; } -/** A measured relationship or operation, with labels independent of its marker. */ +type NodeState = "done" | "active" | "failed" | "pending"; + +/** + * A measured relationship or operation as a row of stations. + * + * Every station before the current one is settled and wears a check; the + * current one is a ring, or a cross when the operation failed there; the + * ones after it wait as small dots. The wire between stations fills as they + * settle, and while the operation is busy a pulse travels the wire into the + * station being worked on. Reaching the last station with nothing failed + * settles the whole row. + */ export function OperationFlow({ steps, active, failed = false, + busy = false, label, }: { steps: OperationStep[]; + /** The station the operation is at. */ active: number; + /** The operation stopped at `active`. */ failed?: boolean; + /** The operation is still working towards `active`. */ + busy?: boolean; label: string; }) { const reduced = useReducedMotion(); const modality = useInputModality(); - const current = Math.max(0, Math.min(steps.length - 1, active)); + const animate = !reduced && modality !== "keyboard"; + const last = steps.length - 1; + const current = Math.max(0, Math.min(last, active)); if (steps.length < 2) return null; + const complete = !busy && !failed && current === last; + const stateOf = (index: number): NodeState => { + if (complete || index < current) return "done"; + if (index === current) return failed ? "failed" : "active"; + return "pending"; + }; + const at = (index: number) => `${(index / last) * 100}%`; + const settle = { duration: animate ? 0.35 : 0, ease: MOTION_EASE_OUT }; + return (
      - {steps.map((step, index) => ( -
    1. -

      {step.label}

      -
      - {step.detail} -
      -
    2. - ))} + {steps.map((step, index) => { + const state = stateOf(index); + return ( +
    3. +

      + {step.label} +

      +
      + {step.detail} +
      +
    4. + ); + })}
    ); diff --git a/src/hooks/use-cloud-auth.ts b/src/hooks/use-cloud-auth.ts index 3c93be4..c981ce7 100644 --- a/src/hooks/use-cloud-auth.ts +++ b/src/hooks/use-cloud-auth.ts @@ -5,6 +5,8 @@ import type { CloudAuthState, CloudUser } from "@/types"; interface UseCloudAuthReturn { user: CloudUser | null; + /** When this desktop signed in, as the backend recorded it. */ + loggedInAt: string | null; isLoggedIn: boolean; isLoading: boolean; exchangeDeviceCode: (code: string) => Promise; @@ -77,6 +79,7 @@ export function useCloudAuth(): UseCloudAuthReturn { return { user: authState?.user ?? null, + loggedInAt: authState?.logged_in_at ?? null, isLoggedIn: authState !== null, isLoading, exchangeDeviceCode, diff --git a/src/hooks/use-tips.ts b/src/hooks/use-tips.ts new file mode 100644 index 0000000..ba3feb2 --- /dev/null +++ b/src/hooks/use-tips.ts @@ -0,0 +1,194 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { TipsDialogMode } from "@/components/tips-dialog"; +import { effectivePlanOf, getEntitlements } from "@/lib/entitlements"; +import { + FRESH_LOGIN_WINDOW_MS, + isPlanTip, + pickAutoTip, + TIP_AUTO_DELAY_MS, + type TipDefinition, + type TipId, + tipsFor, +} from "@/lib/tips"; +import type { CloudUser } from "@/types"; + +/** Mirror of `settings_manager::TipsState`. */ +export interface TipsState { + auto_show: boolean; + seen: string[]; + last_auto_shown_at: number | null; + auto_due: boolean; +} + +export interface TipsDialogState { + open: boolean; + mode: TipsDialogMode; + initialTipId: TipId | null; + auto: boolean; + /** Bumped on every open so the dialog remounts with fresh navigation state. */ + session: number; +} + +interface PaidWelcomeState { + plan: string; + status: "pending" | "open" | "done"; +} + +interface UseTipsOptions { + cloudUser: CloudUser | null; + loggedInAt: string | null; + /** + * The app is settled enough to put a dialog in front of the user: not the + * first-run session, terms accepted, nothing else blocking. + */ + ready: boolean; +} + +/** + * 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. + */ +export function useTips({ cloudUser, loggedInAt, ready }: UseTipsOptions) { + const [state, setState] = useState(null); + const [dialog, setDialog] = useState({ + open: false, + mode: "browse", + initialTipId: null, + auto: false, + session: 0, + }); + const [paidWelcome, setPaidWelcome] = useState(null); + + const entitlements = useMemo(() => getEntitlements(cloudUser), [cloudUser]); + const tips = useMemo(() => tipsFor(entitlements), [entitlements]); + const planTips = useMemo(() => tips.filter(isPlanTip), [tips]); + + useEffect(() => { + let cancelled = false; + invoke("get_tips_state") + .then((loaded) => { + if (!cancelled) setState(loaded); + }) + .catch((error: unknown) => { + console.error("Failed to load the tips state:", error); + }); + return () => { + cancelled = true; + }; + }, []); + + const openTips = useCallback( + ( + initialTipId: TipId | null = null, + options: { mode?: TipsDialogMode; auto?: boolean } = {}, + ) => { + setDialog((previous) => ({ + open: true, + mode: options.mode ?? "browse", + initialTipId, + auto: options.auto ?? false, + session: previous.session + 1, + })); + }, + [], + ); + + const closeTips = useCallback(() => { + setDialog((previous) => + previous.open ? { ...previous, open: false } : previous, + ); + }, []); + + // One observation per account and plan status. The backend remembers the + // status and answers whether this is the moment to greet a new paid plan. + const observedRef = useRef(null); + useEffect(() => { + if (!cloudUser) return; + const paid = entitlements.active; + const key = `${cloudUser.id}:${paid ? "paid" : "free"}`; + if (observedRef.current === key) return; + observedRef.current = key; + const freshLogin = + loggedInAt !== null && + Date.now() - Date.parse(loggedInAt) < FRESH_LOGIN_WINDOW_MS; + invoke("observe_cloud_plan", { + userId: cloudUser.id, + paid, + freshLogin, + }) + .then((due) => { + if (!due) return; + setPaidWelcome({ plan: effectivePlanOf(cloudUser), status: "pending" }); + }) + .catch((error: unknown) => { + console.error("Failed to record the cloud plan:", error); + }); + }, [cloudUser, entitlements.active, loggedInAt]); + + // The welcome waits for a quiet moment: the app settled and no tip open. + useEffect(() => { + if (!ready || dialog.open || paidWelcome?.status !== "pending") return; + 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. + const autoHandledRef = useRef(false); + useEffect(() => { + if (!ready || !state || autoHandledRef.current) return; + if (!state.auto_due || dialog.open) return; + if (paidWelcome && paidWelcome.status !== "done") return; + const tip = pickAutoTip(tips, state.seen); + if (!tip) return; + const timer = window.setTimeout(() => { + 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]); + + const markSeen = useCallback(async (id: TipId, auto: boolean) => { + try { + setState(await invoke("mark_tip_seen", { tipId: id, auto })); + } catch (error) { + console.error("Failed to remember the tip as seen:", error); + } + }, []); + + const setAutoShow = useCallback(async (enabled: boolean) => { + try { + setState(await invoke("set_tips_auto_show", { enabled })); + } catch (error) { + console.error("Failed to save the tips preference:", error); + } + }, []); + + const dismissPaidWelcome = useCallback(() => { + setPaidWelcome((previous) => + previous ? { ...previous, status: "done" } : previous, + ); + }, []); + + return { + tips, + planTips, + seen: state?.seen ?? [], + autoShow: state?.auto_show ?? true, + dialog, + openTips, + closeTips, + markSeen, + setAutoShow, + paidWelcome: { + open: paidWelcome?.status === "open", + plan: paidWelcome?.plan ?? "", + }, + dismissPaidWelcome, + }; +} + +export type TipsFlow = ReturnType; +export type { TipDefinition }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d483334..6db1e6f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1982,7 +1982,9 @@ "historyTitle": "Recent checks", "historyEmpty": "No checks recorded yet.", "historyOk": "Passed", - "historyFailed": "Failed" + "historyFailed": "Failed", + "trendLabel": "Latency of the last {{count}} checks, newest on the right", + "trendPeak": "Slowest: {{ms}} ms" }, "vpnCheck": { "valid": "VPN \"{{name}}\" configuration is valid", @@ -2341,7 +2343,8 @@ "syncSessionUnavailable": "The sync session cannot be reached right now.", "syncDisplayUnavailable": "The display size could not be read, so the windows cannot be arranged.", "syncDisplayTooSmall": "The display is too small for {{windows}} windows in that layout.", - "syncArrangeFailed": "No window could be moved." + "syncArrangeFailed": "No window could be moved.", + "extensionPathInvalid": "That path is not allowed: it contains '..'." }, "rail": { "profiles": "Profiles", @@ -2358,7 +2361,9 @@ "about": "About Donut Browser", "aboutHint": "Version and app information", "trash": "Trash", - "trashHint": "Restore deleted profiles" + "trashHint": "Restore deleted profiles", + "tips": "Tips", + "tipsHint": "Feature walkthroughs" }, "network": "Network", "integrations": "Integrations", @@ -2475,7 +2480,8 @@ "goSettings": "Go to Settings", "goCookieBot": "Cookie Bot", "goTrash": "Go to Trash", - "goAgent": "Go to Agent" + "goAgent": "Go to Agent", + "openTips": "Open tips" }, "closeConfirm": { "title": "Close Donut Browser?", @@ -3335,5 +3341,143 @@ "urlPlaceholder": "https://example.com", "folderPlaceholder": "Optional", "saved": "Group bookmarks saved" + }, + "tips": { + "title": "Tips", + "essentials": "Essentials", + "planSection": "Included in your plan", + "count": "{{current}} of {{total}}", + "previous": "Previous tip", + "next": "Next tip", + "done": "Done", + "autoShow": "Show a tip when Donut starts", + "items": { + "dnsBlocklist": { + "label": "DNS blocking", + "title": "Block ads and trackers before they load", + "body": "Every profile can carry its own DNS blocklist. Pick a level in the profile's DNS column; the higher levels also stop tracking and malware domains at the network level.", + "action": "Open DNS settings" + }, + "proxyCheck": { + "label": "Proxy check", + "title": "Check a proxy before you launch", + "body": "The connection check reports the exit IP, country, latency and whether UDP passes. Run it from the Network page or a profile row, and read the trail of past checks to spot a proxy that is going bad.", + "action": "Open Network" + }, + "groups": { + "label": "Groups", + "title": "Switch groups from the keyboard", + "body": "Groups keep related profiles together, and each group gets a number: {{mod}}+1 to {{mod}}+9 switches the list instantly.", + "action": "Open Groups" + }, + "commandPalette": { + "label": "Command palette", + "title": "Every page is one chord away", + "body": "{{mod}}+K opens the command palette. Type a few letters of a page or an action and press Enter.", + "action": "Open the palette" + }, + "fingerprintGate": { + "label": "Fingerprint gate", + "title": "Keep the fingerprint true to the exit", + "body": "Before a launch, Donut compares the proxy exit's timezone and language with the profile's fingerprint and stops a mismatch. Fix the fingerprint, or turn the gate off under Advanced if you know what you are doing.", + "action": "Open Advanced settings" + }, + "profilePassword": { + "label": "Profile password", + "title": "Lock a profile with a password", + "body": "A password-protected profile is encrypted on disk and decrypted only while it runs. Set the password from the profile's menu.", + "action": "Open Profiles" + }, + "clearOnClose": { + "label": "Clear on close", + "title": "Start clean every time", + "body": "With Clear on close, a profile drops its cookies, storage and history when its window closes. Good for one-off sessions and shared machines.", + "action": "Open Profiles" + }, + "defaultBrowser": { + "label": "Default browser", + "title": "Open every link in the right profile", + "body": "Make Donut your default browser and each link from another app asks which profile should open it.", + "action": "Open default browser settings" + }, + "extensionGroups": { + "label": "Extension groups", + "title": "Share one extension set across profiles", + "body": "Put extensions in an extension group and assign the group to profiles. Change the group once and every profile follows at its next launch.", + "action": "Open Extensions" + }, + "selfHostedSync": { + "label": "Self-hosted sync", + "title": "Back up to your own server", + "body": "Point Donut at a self-hosted donut-sync server and profiles, proxies and groups mirror to it. Add an end-to-end password and the server only ever sees ciphertext.", + "action": "Open Account" + }, + "trash": { + "label": "Trash", + "title": "Deleted profiles wait in the trash", + "body": "A deleted profile stays in the trash for 30 days by default and comes back with everything in it. The retention period is under Advanced settings.", + "action": "Open Trash" + }, + "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.", + "action": "Open Integrations" + }, + "importProfiles": { + "label": "Import", + "title": "Bring profiles over from Chrome, Edge or Brave", + "body": "Import copies cookies, logins and extensions from a Chromium profile or an archive into a new Donut profile, ready to launch.", + "action": "Open Import" + }, + "cloudBackup": { + "label": "Cloud sync", + "title": "Your profiles on every device", + "body": "Cloud sync backs up profiles, proxies and groups and restores them on another machine. Turn it on per profile from the sync column.", + "action": "Open Account" + }, + "cookieBot": { + "label": "Cookie Bot", + "title": "Warm profiles overnight", + "body": "Cookie Bot browses real sites on a schedule from a remote host, so a fresh profile builds a natural history before you use it.", + "action": "Open Cookie Bot" + }, + "crossOs": { + "label": "Cross-OS fingerprint", + "title": "Present as any operating system", + "body": "A cross-OS fingerprint lets a profile report macOS, Windows or Linux whatever machine it runs on. Pick the platform when you create or edit the fingerprint.", + "action": "Open Profiles" + }, + "automation": { + "label": "Automation", + "title": "Launch and drive profiles from code", + "body": "The run, open-url and kill endpoints start real profiles from your scripts, and every launched profile exposes a CDP endpoint for Playwright or Puppeteer.", + "action": "Open Integrations" + }, + "agent": { + "label": "Agent", + "title": "Hand the clicking to an agent", + "body": "Describe a task and the agent drives a profile step by step, records what it did and can replay it as a recipe.", + "action": "Open Agent" + }, + "team": { + "label": "Team locks", + "title": "Share profiles without collisions", + "body": "In a team, a running profile is locked for everyone else and released when it closes. The Account page shows who holds what.", + "action": "Open Account" + }, + "remoteControl": { + "label": "Remote control", + "title": "Drive this desktop from donutbrowser.com", + "body": "With remote control on, agents on the website reach this machine's profiles over an outbound bridge. Turn it on under Integrations.", + "action": "Open Integrations" + } + } + }, + "paidWelcome": { + "title": "Welcome to {{plan}}", + "body": "Your plan just unlocked these. Each tip shows one of them working.", + "cta": "Show me", + "later": "Later" } } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 553bae2..5ab244e 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1992,7 +1992,9 @@ "historyTitle": "Comprobaciones recientes", "historyEmpty": "Todavía no hay comprobaciones registradas.", "historyOk": "Correcta", - "historyFailed": "Fallida" + "historyFailed": "Fallida", + "trendLabel": "Latencia de las últimas {{count}} comprobaciones, la más reciente a la derecha", + "trendPeak": "Más lenta: {{ms}} ms" }, "vpnCheck": { "valid": "La configuración de VPN \"{{name}}\" es válida", @@ -2351,7 +2353,8 @@ "syncSessionUnavailable": "No se puede acceder ahora a la sesión de sincronización.", "syncDisplayUnavailable": "No se pudo leer el tamaño de la pantalla, así que las ventanas no se pueden ordenar.", "syncDisplayTooSmall": "La pantalla es demasiado pequeña para {{windows}} ventanas en esa disposición.", - "syncArrangeFailed": "No se pudo mover ninguna ventana." + "syncArrangeFailed": "No se pudo mover ninguna ventana.", + "extensionPathInvalid": "Esa ruta no está permitida: contiene '..'." }, "rail": { "profiles": "Perfiles", @@ -2368,7 +2371,9 @@ "about": "Acerca de Donut Browser", "aboutHint": "Versión e información de la aplicación", "trash": "Papelera", - "trashHint": "Restaurar perfiles eliminados" + "trashHint": "Restaurar perfiles eliminados", + "tips": "Consejos", + "tipsHint": "Recorridos por las funciones" }, "network": "Red", "integrations": "Integraciones", @@ -2485,7 +2490,8 @@ "goSettings": "Ir a Configuración", "goCookieBot": "Cookie Bot", "goTrash": "Ir a la Papelera", - "goAgent": "Ir a Agente" + "goAgent": "Ir a Agente", + "openTips": "Abrir consejos" }, "closeConfirm": { "title": "¿Cerrar Donut Browser?", @@ -3368,5 +3374,143 @@ "urlPlaceholder": "https://ejemplo.com", "folderPlaceholder": "Opcional", "saved": "Marcadores del grupo guardados" + }, + "tips": { + "title": "Consejos", + "essentials": "Básicos", + "planSection": "Incluido en tu plan", + "count": "{{current}} de {{total}}", + "previous": "Consejo anterior", + "next": "Siguiente consejo", + "done": "Listo", + "autoShow": "Mostrar un consejo al iniciar Donut", + "items": { + "dnsBlocklist": { + "label": "Bloqueo DNS", + "title": "Bloquea anuncios y rastreadores antes de que carguen", + "body": "Cada perfil puede llevar su propia lista de bloqueo DNS. Elige un nivel en la columna DNS del perfil; los niveles altos también detienen dominios de rastreo y malware a nivel de red.", + "action": "Abrir ajustes de DNS" + }, + "proxyCheck": { + "label": "Comprobar proxy", + "title": "Comprueba un proxy antes de lanzar", + "body": "La comprobación de conexión informa la IP de salida, el país, la latencia y si pasa UDP. Ejecútala desde la página Red o desde una fila de perfil, y revisa el historial de comprobaciones para detectar un proxy que empieza a fallar.", + "action": "Abrir Red" + }, + "groups": { + "label": "Grupos", + "title": "Cambia de grupo con el teclado", + "body": "Los grupos mantienen juntos los perfiles relacionados, y cada grupo recibe un número: {{mod}}+1 a {{mod}}+9 cambia la lista al instante.", + "action": "Abrir Grupos" + }, + "commandPalette": { + "label": "Paleta de comandos", + "title": "Cada página está a un atajo de distancia", + "body": "{{mod}}+K abre la paleta de comandos. Escribe unas letras de una página o una acción y pulsa Intro.", + "action": "Abrir la paleta" + }, + "fingerprintGate": { + "label": "Control de huella", + "title": "Mantén la huella coherente con la salida", + "body": "Antes de lanzar, Donut compara la zona horaria y el idioma de la salida del proxy con la huella del perfil y detiene cualquier discrepancia. Corrige la huella o desactiva el bloqueo en Avanzado si sabes lo que haces.", + "action": "Abrir ajustes avanzados" + }, + "profilePassword": { + "label": "Contraseña de perfil", + "title": "Protege un perfil con contraseña", + "body": "Un perfil protegido con contraseña se cifra en disco y solo se descifra mientras se ejecuta. Define la contraseña desde el menú del perfil.", + "action": "Abrir Perfiles" + }, + "clearOnClose": { + "label": "Limpiar al cerrar", + "title": "Empieza limpio cada vez", + "body": "Con Limpiar al cerrar, un perfil descarta sus cookies, almacenamiento e historial al cerrar su ventana. Ideal para sesiones puntuales y equipos compartidos.", + "action": "Abrir Perfiles" + }, + "defaultBrowser": { + "label": "Navegador predeterminado", + "title": "Abre cada enlace en el perfil correcto", + "body": "Haz de Donut tu navegador predeterminado y cada enlace de otra aplicación preguntará qué perfil debe abrirlo.", + "action": "Abrir ajustes del navegador predeterminado" + }, + "extensionGroups": { + "label": "Grupos de extensiones", + "title": "Comparte un mismo conjunto de extensiones entre perfiles", + "body": "Pon las extensiones en un grupo de extensiones y asigna el grupo a los perfiles. Cambia el grupo una vez y cada perfil lo sigue en su próximo lanzamiento.", + "action": "Abrir Extensiones" + }, + "selfHostedSync": { + "label": "Sincronización propia", + "title": "Haz copias en tu propio servidor", + "body": "Apunta Donut a un servidor donut-sync autoalojado y los perfiles, proxies y grupos se replicarán en él. Añade una contraseña de extremo a extremo y el servidor solo verá texto cifrado.", + "action": "Abrir Cuenta" + }, + "trash": { + "label": "Papelera", + "title": "Los perfiles eliminados esperan en la papelera", + "body": "Un perfil eliminado permanece en la papelera 30 días de forma predeterminada y vuelve con todo su contenido. El periodo de retención está en los ajustes avanzados.", + "action": "Abrir Papelera" + }, + "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.", + "action": "Abrir Integraciones" + }, + "importProfiles": { + "label": "Importar", + "title": "Trae perfiles desde Chrome, Edge o Brave", + "body": "La importación copia cookies, inicios de sesión y extensiones de un perfil Chromium o de un archivo a un nuevo perfil de Donut, listo para lanzar.", + "action": "Abrir Importar" + }, + "cloudBackup": { + "label": "Sincronización en la nube", + "title": "Tus perfiles en todos tus dispositivos", + "body": "La sincronización en la nube respalda perfiles, proxies y grupos y los restaura en otra máquina. Actívala por perfil desde la columna de sincronización.", + "action": "Abrir Cuenta" + }, + "cookieBot": { + "label": "Cookie Bot", + "title": "Calienta perfiles durante la noche", + "body": "Cookie Bot navega por sitios reales según un horario desde un host remoto, para que un perfil nuevo construya un historial natural antes de que lo uses.", + "action": "Abrir Cookie Bot" + }, + "crossOs": { + "label": "Huella multi-SO", + "title": "Preséntate como cualquier sistema operativo", + "body": "Una huella multi-SO permite que un perfil informe macOS, Windows o Linux sea cual sea la máquina donde se ejecuta. Elige la plataforma al crear o editar la huella.", + "action": "Abrir Perfiles" + }, + "automation": { + "label": "Automatización", + "title": "Lanza y controla perfiles desde código", + "body": "Los endpoints run, open-url y kill inician perfiles reales desde tus scripts, y cada perfil lanzado expone un endpoint CDP para Playwright o Puppeteer.", + "action": "Abrir Integraciones" + }, + "agent": { + "label": "Agente", + "title": "Deja los clics a un agente", + "body": "Describe una tarea y el agente controla un perfil paso a paso, registra lo que hizo y puede repetirlo como una receta.", + "action": "Abrir Agente" + }, + "team": { + "label": "Bloqueos de equipo", + "title": "Comparte perfiles sin choques", + "body": "En un equipo, un perfil en ejecución queda bloqueado para los demás y se libera al cerrarse. La página Cuenta muestra quién tiene cada uno.", + "action": "Abrir Cuenta" + }, + "remoteControl": { + "label": "Control remoto", + "title": "Controla este equipo desde donutbrowser.com", + "body": "Con el control remoto activado, los agentes del sitio web llegan a los perfiles de esta máquina a través de un puente saliente. Actívalo en Integraciones.", + "action": "Abrir Integraciones" + } + } + }, + "paidWelcome": { + "title": "Te damos la bienvenida a {{plan}}", + "body": "Tu plan acaba de desbloquear esto. Cada consejo muestra una función en acción.", + "cta": "Enséñame", + "later": "Más tarde" } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 49e1759..6227cbd 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1992,7 +1992,9 @@ "historyTitle": "Vérifications récentes", "historyEmpty": "Aucune vérification enregistrée pour l'instant.", "historyOk": "Réussie", - "historyFailed": "Échouée" + "historyFailed": "Échouée", + "trendLabel": "Latence des {{count}} derniers tests, le plus récent à droite", + "trendPeak": "Le plus lent : {{ms}} ms" }, "vpnCheck": { "valid": "La configuration VPN « {{name}} » est valide", @@ -2351,7 +2353,8 @@ "syncSessionUnavailable": "La session de synchronisation est inaccessible pour le moment.", "syncDisplayUnavailable": "La taille de l'écran n'a pas pu être lue, les fenêtres ne peuvent donc pas être rangées.", "syncDisplayTooSmall": "L'écran est trop petit pour {{windows}} fenêtres dans cette disposition.", - "syncArrangeFailed": "Aucune fenêtre n'a pu être déplacée." + "syncArrangeFailed": "Aucune fenêtre n'a pu être déplacée.", + "extensionPathInvalid": "Ce chemin n'est pas autorisé : il contient « .. »." }, "rail": { "profiles": "Profils", @@ -2368,7 +2371,9 @@ "about": "À propos de Donut Browser", "aboutHint": "Version et informations sur l'application", "trash": "Corbeille", - "trashHint": "Restaurer des profils supprimés" + "trashHint": "Restaurer des profils supprimés", + "tips": "Astuces", + "tipsHint": "Découverte des fonctions" }, "network": "Réseau", "integrations": "Intégrations", @@ -2485,7 +2490,8 @@ "goSettings": "Aller à Paramètres", "goCookieBot": "Cookie Bot", "goTrash": "Aller à la Corbeille", - "goAgent": "Aller à Agent" + "goAgent": "Aller à Agent", + "openTips": "Ouvrir les astuces" }, "closeConfirm": { "title": "Fermer Donut Browser ?", @@ -3368,5 +3374,143 @@ "urlPlaceholder": "https://exemple.com", "folderPlaceholder": "Facultatif", "saved": "Favoris du groupe enregistrés" + }, + "tips": { + "title": "Astuces", + "essentials": "Essentiels", + "planSection": "Inclus dans votre offre", + "count": "{{current}} sur {{total}}", + "previous": "Astuce précédente", + "next": "Astuce suivante", + "done": "Terminé", + "autoShow": "Afficher une astuce au démarrage de Donut", + "items": { + "dnsBlocklist": { + "label": "Blocage DNS", + "title": "Bloquez les publicités et les traqueurs avant leur chargement", + "body": "Chaque profil peut avoir sa propre liste de blocage DNS. Choisissez un niveau dans la colonne DNS du profil ; les niveaux élevés bloquent aussi les domaines de pistage et de logiciels malveillants au niveau du réseau.", + "action": "Ouvrir les réglages DNS" + }, + "proxyCheck": { + "label": "Test de proxy", + "title": "Vérifiez un proxy avant de lancer", + "body": "Le test de connexion indique l'IP de sortie, le pays, la latence et si l'UDP passe. Lancez-le depuis la page Réseau ou une ligne de profil, et consultez l'historique des tests pour repérer un proxy qui se dégrade.", + "action": "Ouvrir Réseau" + }, + "groups": { + "label": "Groupes", + "title": "Changez de groupe au clavier", + "body": "Les groupes rassemblent les profils liés, et chaque groupe reçoit un numéro : {{mod}}+1 à {{mod}}+9 change la liste instantanément.", + "action": "Ouvrir Groupes" + }, + "commandPalette": { + "label": "Palette de commandes", + "title": "Chaque page est à un raccourci", + "body": "{{mod}}+K ouvre la palette de commandes. Tapez quelques lettres d'une page ou d'une action, puis appuyez sur Entrée.", + "action": "Ouvrir la palette" + }, + "fingerprintGate": { + "label": "Contrôle d'empreinte", + "title": "Gardez l'empreinte cohérente avec la sortie", + "body": "Avant un lancement, Donut compare le fuseau horaire et la langue de la sortie du proxy avec l'empreinte du profil et bloque toute incohérence. Corrigez l'empreinte, ou désactivez le blocage dans Avancé si vous savez ce que vous faites.", + "action": "Ouvrir les réglages avancés" + }, + "profilePassword": { + "label": "Mot de passe de profil", + "title": "Verrouillez un profil avec un mot de passe", + "body": "Un profil protégé par mot de passe est chiffré sur le disque et déchiffré uniquement pendant son exécution. Définissez le mot de passe depuis le menu du profil.", + "action": "Ouvrir Profils" + }, + "clearOnClose": { + "label": "Effacer à la fermeture", + "title": "Repartez de zéro à chaque fois", + "body": "Avec Effacer à la fermeture, un profil abandonne ses cookies, son stockage et son historique quand sa fenêtre se ferme. Idéal pour les sessions ponctuelles et les machines partagées.", + "action": "Ouvrir Profils" + }, + "defaultBrowser": { + "label": "Navigateur par défaut", + "title": "Ouvrez chaque lien dans le bon profil", + "body": "Faites de Donut votre navigateur par défaut et chaque lien venant d'une autre application demandera quel profil doit l'ouvrir.", + "action": "Ouvrir les réglages du navigateur par défaut" + }, + "extensionGroups": { + "label": "Groupes d'extensions", + "title": "Partagez un même jeu d'extensions entre profils", + "body": "Placez les extensions dans un groupe d'extensions et assignez le groupe aux profils. Modifiez le groupe une fois et chaque profil suit à son prochain lancement.", + "action": "Ouvrir Extensions" + }, + "selfHostedSync": { + "label": "Synchro auto-hébergée", + "title": "Sauvegardez sur votre propre serveur", + "body": "Pointez Donut vers un serveur donut-sync auto-hébergé et les profils, proxys et groupes s'y répliquent. Ajoutez un mot de passe de bout en bout et le serveur ne verra jamais que du texte chiffré.", + "action": "Ouvrir Compte" + }, + "trash": { + "label": "Corbeille", + "title": "Les profils supprimés attendent dans la corbeille", + "body": "Un profil supprimé reste 30 jours dans la corbeille par défaut et revient avec tout son contenu. La durée de conservation se règle dans les réglages avancés.", + "action": "Ouvrir Corbeille" + }, + "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.", + "action": "Ouvrir Intégrations" + }, + "importProfiles": { + "label": "Importer", + "title": "Importez des profils depuis Chrome, Edge ou Brave", + "body": "L'import copie les cookies, les identifiants et les extensions d'un profil Chromium ou d'une archive vers un nouveau profil Donut, prêt à lancer.", + "action": "Ouvrir Importer" + }, + "cloudBackup": { + "label": "Synchro cloud", + "title": "Vos profils sur tous vos appareils", + "body": "La synchronisation cloud sauvegarde les profils, proxys et groupes et les restaure sur une autre machine. Activez-la par profil depuis la colonne de synchronisation.", + "action": "Ouvrir Compte" + }, + "cookieBot": { + "label": "Cookie Bot", + "title": "Chauffez vos profils pendant la nuit", + "body": "Cookie Bot navigue sur de vrais sites selon un planning depuis un hôte distant, pour qu'un profil neuf se construise un historique naturel avant que vous l'utilisiez.", + "action": "Ouvrir Cookie Bot" + }, + "crossOs": { + "label": "Empreinte multi-OS", + "title": "Présentez-vous sous n'importe quel système", + "body": "Une empreinte multi-OS permet à un profil d'annoncer macOS, Windows ou Linux quelle que soit la machine. Choisissez la plateforme à la création ou à la modification de l'empreinte.", + "action": "Ouvrir Profils" + }, + "automation": { + "label": "Automatisation", + "title": "Lancez et pilotez des profils depuis du code", + "body": "Les points de terminaison run, open-url et kill démarrent de vrais profils depuis vos scripts, et chaque profil lancé expose un point de terminaison CDP pour Playwright ou Puppeteer.", + "action": "Ouvrir Intégrations" + }, + "agent": { + "label": "Agent", + "title": "Confiez les clics à un agent", + "body": "Décrivez une tâche et l'agent pilote un profil étape par étape, enregistre ce qu'il a fait et peut le rejouer comme une recette.", + "action": "Ouvrir Agent" + }, + "team": { + "label": "Verrous d'équipe", + "title": "Partagez des profils sans collision", + "body": "Dans une équipe, un profil en cours d'exécution est verrouillé pour les autres et libéré à sa fermeture. La page Compte montre qui détient quoi.", + "action": "Ouvrir Compte" + }, + "remoteControl": { + "label": "Contrôle à distance", + "title": "Pilotez ce poste depuis donutbrowser.com", + "body": "Avec le contrôle à distance activé, les agents du site web atteignent les profils de cette machine par un pont sortant. Activez-le dans Intégrations.", + "action": "Ouvrir Intégrations" + } + } + }, + "paidWelcome": { + "title": "Bienvenue dans {{plan}}", + "body": "Votre offre vient de débloquer ceci. Chaque astuce montre une fonction en action.", + "cta": "Montrez-moi", + "later": "Plus tard" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2841492..0890d10 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1982,7 +1982,9 @@ "historyTitle": "最近のチェック", "historyEmpty": "まだチェックの記録がありません。", "historyOk": "成功", - "historyFailed": "失敗" + "historyFailed": "失敗", + "trendLabel": "直近 {{count}} 回のチェックのレイテンシ(右端が最新)", + "trendPeak": "最も遅い: {{ms}} ms" }, "vpnCheck": { "valid": "VPN「{{name}}」の構成は有効です", @@ -2341,7 +2343,8 @@ "syncSessionUnavailable": "いま同期セッションにアクセスできません。", "syncDisplayUnavailable": "画面の大きさを読み取れなかったため、ウィンドウを並べられません。", "syncDisplayTooSmall": "その配置で {{windows}} 個のウィンドウを並べるには画面が小さすぎます。", - "syncArrangeFailed": "動かせたウィンドウはありませんでした。" + "syncArrangeFailed": "動かせたウィンドウはありませんでした。", + "extensionPathInvalid": "そのパスは使用できません。'..' が含まれています。" }, "rail": { "profiles": "プロファイル", @@ -2358,7 +2361,9 @@ "about": "Donut Browser について", "aboutHint": "バージョンとアプリ情報", "trash": "ゴミ箱", - "trashHint": "削除したプロファイルを復元" + "trashHint": "削除したプロファイルを復元", + "tips": "ヒント", + "tipsHint": "機能の使い方" }, "network": "ネットワーク", "integrations": "連携", @@ -2475,7 +2480,8 @@ "goSettings": "設定へ移動", "goCookieBot": "Cookie Bot", "goTrash": "ゴミ箱へ移動", - "goAgent": "エージェントへ移動" + "goAgent": "エージェントへ移動", + "openTips": "ヒントを開く" }, "closeConfirm": { "title": "Donut Browser を閉じますか?", @@ -3335,5 +3341,143 @@ "urlPlaceholder": "https://example.com", "folderPlaceholder": "任意", "saved": "グループのブックマークを保存しました" + }, + "tips": { + "title": "ヒント", + "essentials": "基本", + "planSection": "プランに含まれる機能", + "count": "{{current}} / {{total}}", + "previous": "前のヒント", + "next": "次のヒント", + "done": "完了", + "autoShow": "Donut の起動時にヒントを表示", + "items": { + "dnsBlocklist": { + "label": "DNS ブロック", + "title": "広告とトラッカーを読み込み前にブロック", + "body": "各プロファイルは独自の DNS ブロックリストを持てます。プロファイルの DNS 列でレベルを選んでください。高いレベルではトラッキングやマルウェアのドメインもネットワーク層で止めます。", + "action": "DNS 設定を開く" + }, + "proxyCheck": { + "label": "プロキシ確認", + "title": "起動前にプロキシを確認", + "body": "接続チェックは出口 IP、国、レイテンシ、UDP が通るかを報告します。ネットワークページやプロファイル行から実行し、過去のチェック履歴で劣化しつつあるプロキシを見つけてください。", + "action": "ネットワークを開く" + }, + "groups": { + "label": "グループ", + "title": "キーボードでグループを切り替え", + "body": "グループは関連するプロファイルをまとめ、各グループに番号が付きます。{{mod}}+1 から {{mod}}+9 で一覧を即座に切り替えられます。", + "action": "グループを開く" + }, + "commandPalette": { + "label": "コマンドパレット", + "title": "どのページもショートカット一つで", + "body": "{{mod}}+K でコマンドパレットが開きます。ページや操作の名前を数文字入力して Enter を押してください。", + "action": "パレットを開く" + }, + "fingerprintGate": { + "label": "フィンガープリント検査", + "title": "フィンガープリントを出口と一致させる", + "body": "起動前に Donut はプロキシ出口のタイムゾーンと言語をプロファイルのフィンガープリントと比較し、不一致があれば起動を止めます。フィンガープリントを修正するか、理解した上で「詳細設定」でゲートを無効にしてください。", + "action": "詳細設定を開く" + }, + "profilePassword": { + "label": "プロファイルのパスワード", + "title": "パスワードでプロファイルを保護", + "body": "パスワード保護されたプロファイルはディスク上で暗号化され、実行中だけ復号されます。パスワードはプロファイルのメニューから設定します。", + "action": "プロファイルを開く" + }, + "clearOnClose": { + "label": "閉じるときに消去", + "title": "毎回クリーンな状態で開始", + "body": "「閉じるときに消去」を有効にすると、ウィンドウを閉じた時点でプロファイルの Cookie、ストレージ、履歴が破棄されます。使い捨てのセッションや共有マシンに最適です。", + "action": "プロファイルを開く" + }, + "defaultBrowser": { + "label": "既定のブラウザ", + "title": "すべてのリンクを適切なプロファイルで開く", + "body": "Donut を既定のブラウザにすると、他のアプリからのリンクごとにどのプロファイルで開くかを確認できます。", + "action": "既定のブラウザ設定を開く" + }, + "extensionGroups": { + "label": "拡張機能グループ", + "title": "拡張機能のセットをプロファイル間で共有", + "body": "拡張機能を拡張機能グループに入れ、そのグループをプロファイルに割り当てます。グループを一度変更すれば、各プロファイルは次回起動時に追従します。", + "action": "拡張機能を開く" + }, + "selfHostedSync": { + "label": "セルフホスト同期", + "title": "自分のサーバーにバックアップ", + "body": "セルフホストの donut-sync サーバーを指定すると、プロファイル、プロキシ、グループがそこにミラーされます。エンドツーエンドのパスワードを加えれば、サーバーには暗号文しか届きません。", + "action": "アカウントを開く" + }, + "trash": { + "label": "ゴミ箱", + "title": "削除したプロファイルはゴミ箱で待機", + "body": "削除したプロファイルは既定で 30 日間ゴミ箱に残り、中身ごと復元できます。保持期間は詳細設定にあります。", + "action": "ゴミ箱を開く" + }, + "localApi": { + "label": "API と MCP", + "title": "スクリプトやエージェントから Donut を自動化", + "body": "ローカルの REST API と MCP サーバーで、スクリプトや AI エージェントがプロファイルの一覧取得、作成、設定を行えます。「連携」で有効にしてトークンをコピーしてください。", + "action": "連携を開く" + }, + "importProfiles": { + "label": "インポート", + "title": "Chrome、Edge、Brave からプロファイルを移行", + "body": "インポートは Chromium プロファイルやアーカイブから Cookie、ログイン情報、拡張機能を新しい Donut プロファイルにコピーし、すぐ起動できる状態にします。", + "action": "インポートを開く" + }, + "cloudBackup": { + "label": "クラウド同期", + "title": "あらゆるデバイスにプロファイルを", + "body": "クラウド同期はプロファイル、プロキシ、グループをバックアップし、別のマシンで復元します。同期列からプロファイルごとに有効にしてください。", + "action": "アカウントを開く" + }, + "cookieBot": { + "label": "Cookie Bot", + "title": "夜間にプロファイルを温める", + "body": "Cookie Bot はリモートホストからスケジュールに従って実際のサイトを閲覧し、新しいプロファイルに自然な履歴を作ってから使えるようにします。", + "action": "Cookie Bot を開く" + }, + "crossOs": { + "label": "クロス OS 指紋", + "title": "任意の OS として振る舞う", + "body": "クロス OS フィンガープリントを使うと、どのマシンで実行しても macOS、Windows、Linux として報告できます。フィンガープリントの作成時または編集時にプラットフォームを選んでください。", + "action": "プロファイルを開く" + }, + "automation": { + "label": "自動化", + "title": "コードからプロファイルを起動・操作", + "body": "run、open-url、kill エンドポイントはスクリプトから実際のプロファイルを起動し、起動した各プロファイルは Playwright や Puppeteer 向けの CDP エンドポイントを公開します。", + "action": "連携を開く" + }, + "agent": { + "label": "エージェント", + "title": "クリック作業をエージェントに任せる", + "body": "タスクを説明すると、エージェントがプロファイルを一歩ずつ操作し、実行内容を記録して、レシピとして再実行できます。", + "action": "エージェントを開く" + }, + "team": { + "label": "チームロック", + "title": "衝突なしでプロファイルを共有", + "body": "チームでは、実行中のプロファイルは他のメンバーに対してロックされ、閉じると解放されます。アカウントページで誰が何を使用中か確認できます。", + "action": "アカウントを開く" + }, + "remoteControl": { + "label": "リモート操作", + "title": "donutbrowser.com からこのデスクトップを操作", + "body": "リモートコントロールを有効にすると、ウェブサイト上のエージェントが送信方向のブリッジ経由でこのマシンのプロファイルにアクセスできます。「連携」で有効にしてください。", + "action": "連携を開く" + } + } + }, + "paidWelcome": { + "title": "{{plan}} へようこそ", + "body": "プランで次の機能が使えるようになりました。各ヒントで実際の動きを確認できます。", + "cta": "見てみる", + "later": "あとで" } } diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index ee50f57..ebed1f5 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1982,7 +1982,9 @@ "historyTitle": "최근 검사", "historyEmpty": "아직 기록된 검사가 없습니다.", "historyOk": "성공", - "historyFailed": "실패" + "historyFailed": "실패", + "trendLabel": "최근 {{count}}회 확인의 지연 시간, 오른쪽이 최신", + "trendPeak": "가장 느림: {{ms}} ms" }, "vpnCheck": { "valid": "VPN \"{{name}}\" 구성이 유효합니다", @@ -2341,7 +2343,8 @@ "syncSessionUnavailable": "지금은 동기화 세션에 접근할 수 없습니다.", "syncDisplayUnavailable": "화면 크기를 읽지 못해 창을 정렬할 수 없습니다.", "syncDisplayTooSmall": "그 배치로 창 {{windows}}개를 놓기에는 화면이 너무 작습니다.", - "syncArrangeFailed": "옮겨진 창이 없습니다." + "syncArrangeFailed": "옮겨진 창이 없습니다.", + "extensionPathInvalid": "그 경로는 사용할 수 없습니다. '..'이 포함되어 있습니다." }, "rail": { "profiles": "프로필", @@ -2358,7 +2361,9 @@ "about": "Donut Browser 정보", "aboutHint": "버전 및 앱 정보", "trash": "휴지통", - "trashHint": "삭제한 프로필 복원" + "trashHint": "삭제한 프로필 복원", + "tips": "팁", + "tipsHint": "기능 안내" }, "network": "네트워크", "integrations": "통합", @@ -2475,7 +2480,8 @@ "goSettings": "설정으로 이동", "goCookieBot": "Cookie Bot", "goTrash": "휴지통으로 이동", - "goAgent": "에이전트로 이동" + "goAgent": "에이전트로 이동", + "openTips": "팁 열기" }, "closeConfirm": { "title": "Donut Browser를 닫으시겠습니까?", @@ -3335,5 +3341,143 @@ "urlPlaceholder": "https://example.com", "folderPlaceholder": "선택 사항", "saved": "그룹 북마크를 저장했습니다" + }, + "tips": { + "title": "팁", + "essentials": "기본", + "planSection": "내 플랜에 포함", + "count": "{{current}} / {{total}}", + "previous": "이전 팁", + "next": "다음 팁", + "done": "완료", + "autoShow": "Donut 시작 시 팁 표시", + "items": { + "dnsBlocklist": { + "label": "DNS 차단", + "title": "광고와 추적기를 로드 전에 차단", + "body": "프로필마다 자체 DNS 차단 목록을 가질 수 있습니다. 프로필의 DNS 열에서 수준을 선택하세요. 높은 수준은 추적 및 악성코드 도메인도 네트워크 단계에서 막습니다.", + "action": "DNS 설정 열기" + }, + "proxyCheck": { + "label": "프록시 확인", + "title": "실행 전에 프록시 확인", + "body": "연결 확인은 출구 IP, 국가, 지연 시간, UDP 통과 여부를 알려줍니다. 네트워크 페이지나 프로필 행에서 실행하고, 지난 확인 기록으로 상태가 나빠지는 프록시를 찾아내세요.", + "action": "네트워크 열기" + }, + "groups": { + "label": "그룹", + "title": "키보드로 그룹 전환", + "body": "그룹은 관련 프로필을 한데 모으고, 각 그룹에는 번호가 붙습니다. {{mod}}+1부터 {{mod}}+9까지로 목록을 즉시 전환합니다.", + "action": "그룹 열기" + }, + "commandPalette": { + "label": "명령 팔레트", + "title": "모든 페이지가 단축키 하나 거리", + "body": "{{mod}}+K로 명령 팔레트를 엽니다. 페이지나 작업 이름을 몇 글자 입력하고 Enter를 누르세요.", + "action": "팔레트 열기" + }, + "fingerprintGate": { + "label": "핑거프린트 검사", + "title": "핑거프린트를 출구와 일치시키기", + "body": "실행 전에 Donut은 프록시 출구의 시간대와 언어를 프로필 핑거프린트와 비교하고 불일치가 있으면 실행을 막습니다. 핑거프린트를 고치거나, 잘 알고 있다면 고급 설정에서 게이트를 끄세요.", + "action": "고급 설정 열기" + }, + "profilePassword": { + "label": "프로필 비밀번호", + "title": "비밀번호로 프로필 잠그기", + "body": "비밀번호로 보호된 프로필은 디스크에서 암호화되고 실행 중에만 복호화됩니다. 프로필 메뉴에서 비밀번호를 설정하세요.", + "action": "프로필 열기" + }, + "clearOnClose": { + "label": "닫을 때 지우기", + "title": "매번 깨끗하게 시작", + "body": "닫을 때 지우기를 켜면 창이 닫힐 때 프로필의 쿠키, 저장소, 기록이 삭제됩니다. 일회성 세션과 공용 컴퓨터에 좋습니다.", + "action": "프로필 열기" + }, + "defaultBrowser": { + "label": "기본 브라우저", + "title": "모든 링크를 알맞은 프로필에서 열기", + "body": "Donut을 기본 브라우저로 설정하면 다른 앱에서 온 링크마다 어떤 프로필로 열지 물어봅니다.", + "action": "기본 브라우저 설정 열기" + }, + "extensionGroups": { + "label": "확장 그룹", + "title": "하나의 확장 프로그램 세트를 여러 프로필과 공유", + "body": "확장 프로그램을 확장 그룹에 넣고 그 그룹을 프로필에 할당하세요. 그룹을 한 번 바꾸면 각 프로필이 다음 실행 때 따라갑니다.", + "action": "확장 프로그램 열기" + }, + "selfHostedSync": { + "label": "자체 호스팅 동기화", + "title": "내 서버에 백업", + "body": "자체 호스팅한 donut-sync 서버를 지정하면 프로필, 프록시, 그룹이 그곳에 미러링됩니다. 종단 간 비밀번호를 추가하면 서버는 암호문만 봅니다.", + "action": "계정 열기" + }, + "trash": { + "label": "휴지통", + "title": "삭제된 프로필은 휴지통에서 대기", + "body": "삭제된 프로필은 기본적으로 30일 동안 휴지통에 남고 내용 그대로 복원됩니다. 보관 기간은 고급 설정에 있습니다.", + "action": "휴지통 열기" + }, + "localApi": { + "label": "API와 MCP", + "title": "스크립트와 에이전트로 Donut 자동화", + "body": "로컬 REST API와 MCP 서버로 스크립트와 AI 에이전트가 프로필을 나열, 생성, 설정할 수 있습니다. 통합에서 켜고 토큰을 복사하세요.", + "action": "통합 열기" + }, + "importProfiles": { + "label": "가져오기", + "title": "Chrome, Edge, Brave에서 프로필 가져오기", + "body": "가져오기는 Chromium 프로필이나 아카이브의 쿠키, 로그인 정보, 확장 프로그램을 새 Donut 프로필로 복사해 바로 실행할 수 있게 합니다.", + "action": "가져오기 열기" + }, + "cloudBackup": { + "label": "클라우드 동기화", + "title": "모든 기기에 내 프로필", + "body": "클라우드 동기화는 프로필, 프록시, 그룹을 백업하고 다른 컴퓨터에서 복원합니다. 동기화 열에서 프로필별로 켜세요.", + "action": "계정 열기" + }, + "cookieBot": { + "label": "Cookie Bot", + "title": "밤사이 프로필 워밍", + "body": "Cookie Bot은 원격 호스트에서 일정에 따라 실제 사이트를 탐색해, 새 프로필이 사용 전에 자연스러운 기록을 쌓게 합니다.", + "action": "Cookie Bot 열기" + }, + "crossOs": { + "label": "크로스 OS 핑거프린트", + "title": "어떤 운영체제로든 보이기", + "body": "크로스 OS 핑거프린트를 쓰면 어떤 컴퓨터에서 실행하든 프로필이 macOS, Windows, Linux로 보고할 수 있습니다. 핑거프린트를 만들거나 편집할 때 플랫폼을 고르세요.", + "action": "프로필 열기" + }, + "automation": { + "label": "자동화", + "title": "코드에서 프로필 실행과 제어", + "body": "run, open-url, kill 엔드포인트는 스크립트에서 실제 프로필을 시작하고, 실행된 각 프로필은 Playwright나 Puppeteer용 CDP 엔드포인트를 제공합니다.", + "action": "통합 열기" + }, + "agent": { + "label": "에이전트", + "title": "클릭은 에이전트에게", + "body": "작업을 설명하면 에이전트가 프로필을 단계별로 조작하고, 수행한 내용을 기록하며, 레시피로 다시 실행할 수 있습니다.", + "action": "에이전트 열기" + }, + "team": { + "label": "팀 잠금", + "title": "충돌 없이 프로필 공유", + "body": "팀에서는 실행 중인 프로필이 다른 사람에게 잠기고 닫히면 해제됩니다. 계정 페이지에서 누가 무엇을 쓰는지 볼 수 있습니다.", + "action": "계정 열기" + }, + "remoteControl": { + "label": "원격 제어", + "title": "donutbrowser.com에서 이 데스크톱 제어", + "body": "원격 제어를 켜면 웹사이트의 에이전트가 아웃바운드 브리지를 통해 이 컴퓨터의 프로필에 접근합니다. 통합에서 켜세요.", + "action": "통합 열기" + } + } + }, + "paidWelcome": { + "title": "{{plan}}에 오신 것을 환영합니다", + "body": "플랜으로 다음 기능이 열렸습니다. 각 팁에서 실제 동작을 볼 수 있습니다.", + "cta": "보여주기", + "later": "나중에" } } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index a84bb4b..0e1c2bb 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1992,7 +1992,9 @@ "historyTitle": "Verificações recentes", "historyEmpty": "Nenhuma verificação registrada ainda.", "historyOk": "Aprovada", - "historyFailed": "Falhou" + "historyFailed": "Falhou", + "trendLabel": "Latência das últimas {{count}} verificações, a mais recente à direita", + "trendPeak": "Mais lenta: {{ms}} ms" }, "vpnCheck": { "valid": "Configuração de VPN \"{{name}}\" é válida", @@ -2351,7 +2353,8 @@ "syncSessionUnavailable": "Não é possível aceder agora à sessão de sincronização.", "syncDisplayUnavailable": "Não foi possível ler o tamanho do ecrã, por isso as janelas não podem ser organizadas.", "syncDisplayTooSmall": "O ecrã é demasiado pequeno para {{windows}} janelas nessa disposição.", - "syncArrangeFailed": "Não foi possível mover nenhuma janela." + "syncArrangeFailed": "Não foi possível mover nenhuma janela.", + "extensionPathInvalid": "Esse caminho não é permitido: contém '..'." }, "rail": { "profiles": "Perfis", @@ -2368,7 +2371,9 @@ "about": "Sobre o Donut Browser", "aboutHint": "Versão e informações do aplicativo", "trash": "Lixeira", - "trashHint": "Restaurar perfis excluídos" + "trashHint": "Restaurar perfis excluídos", + "tips": "Dicas", + "tipsHint": "Passo a passo dos recursos" }, "network": "Rede", "integrations": "Integrações", @@ -2485,7 +2490,8 @@ "goSettings": "Ir para Configurações", "goCookieBot": "Cookie Bot", "goTrash": "Ir para a Lixeira", - "goAgent": "Ir para Agente" + "goAgent": "Ir para Agente", + "openTips": "Abrir dicas" }, "closeConfirm": { "title": "Fechar Donut Browser?", @@ -3368,5 +3374,143 @@ "urlPlaceholder": "https://exemplo.com", "folderPlaceholder": "Opcional", "saved": "Marcadores do grupo guardados" + }, + "tips": { + "title": "Dicas", + "essentials": "Essenciais", + "planSection": "Incluído no seu plano", + "count": "{{current}} de {{total}}", + "previous": "Dica anterior", + "next": "Próxima dica", + "done": "Concluído", + "autoShow": "Mostrar uma dica ao iniciar o Donut", + "items": { + "dnsBlocklist": { + "label": "Bloqueio DNS", + "title": "Bloqueie anúncios e rastreadores antes de carregarem", + "body": "Cada perfil pode ter a sua própria lista de bloqueio DNS. Escolha um nível na coluna DNS do perfil; os níveis mais altos também travam domínios de rastreamento e malware na camada de rede.", + "action": "Abrir configurações de DNS" + }, + "proxyCheck": { + "label": "Verificação de proxy", + "title": "Verifique um proxy antes de iniciar", + "body": "A verificação de conexão informa o IP de saída, o país, a latência e se o UDP passa. Execute-a na página Rede ou numa linha de perfil, e consulte o histórico de verificações para detectar um proxy que está piorando.", + "action": "Abrir Rede" + }, + "groups": { + "label": "Grupos", + "title": "Troque de grupo pelo teclado", + "body": "Os grupos mantêm perfis relacionados juntos, e cada grupo recebe um número: {{mod}}+1 a {{mod}}+9 troca a lista na hora.", + "action": "Abrir Grupos" + }, + "commandPalette": { + "label": "Paleta de comandos", + "title": "Toda página a um atalho de distância", + "body": "{{mod}}+K abre a paleta de comandos. Digite algumas letras de uma página ou ação e pressione Enter.", + "action": "Abrir a paleta" + }, + "fingerprintGate": { + "label": "Controle de impressão digital", + "title": "Mantenha a impressão digital fiel à saída", + "body": "Antes de iniciar, o Donut compara o fuso horário e o idioma da saída do proxy com a impressão digital do perfil e bloqueia qualquer divergência. Corrija a impressão digital, ou desative o bloqueio em Avançado se souber o que está fazendo.", + "action": "Abrir configurações avançadas" + }, + "profilePassword": { + "label": "Senha do perfil", + "title": "Proteja um perfil com senha", + "body": "Um perfil protegido por senha é criptografado no disco e descriptografado apenas enquanto está em execução. Defina a senha no menu do perfil.", + "action": "Abrir Perfis" + }, + "clearOnClose": { + "label": "Limpar ao fechar", + "title": "Comece limpo toda vez", + "body": "Com Limpar ao fechar, o perfil descarta cookies, armazenamento e histórico quando a janela fecha. Ótimo para sessões avulsas e máquinas compartilhadas.", + "action": "Abrir Perfis" + }, + "defaultBrowser": { + "label": "Navegador padrão", + "title": "Abra cada link no perfil certo", + "body": "Torne o Donut o seu navegador padrão e cada link vindo de outro aplicativo perguntará qual perfil deve abri-lo.", + "action": "Abrir configurações do navegador padrão" + }, + "extensionGroups": { + "label": "Grupos de extensões", + "title": "Compartilhe um conjunto de extensões entre perfis", + "body": "Coloque as extensões em um grupo de extensões e atribua o grupo aos perfis. Altere o grupo uma vez e cada perfil acompanha na próxima inicialização.", + "action": "Abrir Extensões" + }, + "selfHostedSync": { + "label": "Sincronização própria", + "title": "Faça backup no seu próprio servidor", + "body": "Aponte o Donut para um servidor donut-sync auto-hospedado e perfis, proxies e grupos serão espelhados nele. Adicione uma senha de ponta a ponta e o servidor só verá texto cifrado.", + "action": "Abrir Conta" + }, + "trash": { + "label": "Lixeira", + "title": "Perfis excluídos esperam na lixeira", + "body": "Um perfil excluído fica na lixeira por 30 dias por padrão e volta com tudo o que tinha. O período de retenção está nas configurações avançadas.", + "action": "Abrir Lixeira" + }, + "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.", + "action": "Abrir Integrações" + }, + "importProfiles": { + "label": "Importar", + "title": "Traga perfis do Chrome, Edge ou Brave", + "body": "A importação copia cookies, logins e extensões de um perfil Chromium ou de um arquivo para um novo perfil do Donut, pronto para iniciar.", + "action": "Abrir Importar" + }, + "cloudBackup": { + "label": "Sincronização na nuvem", + "title": "Seus perfis em todos os dispositivos", + "body": "A sincronização na nuvem faz backup de perfis, proxies e grupos e os restaura em outra máquina. Ative-a por perfil na coluna de sincronização.", + "action": "Abrir Conta" + }, + "cookieBot": { + "label": "Cookie Bot", + "title": "Aqueça perfis durante a noite", + "body": "O Cookie Bot navega em sites reais conforme um cronograma a partir de um host remoto, para que um perfil novo construa um histórico natural antes de você usá-lo.", + "action": "Abrir Cookie Bot" + }, + "crossOs": { + "label": "Impressão digital multi-SO", + "title": "Apresente-se como qualquer sistema operacional", + "body": "Uma impressão digital multi-SO permite que um perfil informe macOS, Windows ou Linux em qualquer máquina. Escolha a plataforma ao criar ou editar a impressão digital.", + "action": "Abrir Perfis" + }, + "automation": { + "label": "Automação", + "title": "Inicie e controle perfis por código", + "body": "Os endpoints run, open-url e kill iniciam perfis reais a partir dos seus scripts, e cada perfil iniciado expõe um endpoint CDP para Playwright ou Puppeteer.", + "action": "Abrir Integrações" + }, + "agent": { + "label": "Agente", + "title": "Deixe os cliques com um agente", + "body": "Descreva uma tarefa e o agente controla um perfil passo a passo, registra o que fez e pode repetir tudo como uma receita.", + "action": "Abrir Agente" + }, + "team": { + "label": "Bloqueios de equipe", + "title": "Compartilhe perfis sem colisões", + "body": "Em uma equipe, um perfil em execução fica bloqueado para os demais e é liberado ao fechar. A página Conta mostra quem está com o quê.", + "action": "Abrir Conta" + }, + "remoteControl": { + "label": "Controle remoto", + "title": "Controle este computador pelo donutbrowser.com", + "body": "Com o controle remoto ativado, os agentes do site alcançam os perfis desta máquina por uma ponte de saída. Ative-o em Integrações.", + "action": "Abrir Integrações" + } + } + }, + "paidWelcome": { + "title": "Bem-vindo ao {{plan}}", + "body": "Seu plano acabou de desbloquear isto. Cada dica mostra um recurso em ação.", + "cta": "Mostre-me", + "later": "Mais tarde" } } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e22cce4..5327cb6 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2002,7 +2002,9 @@ "historyTitle": "Последние проверки", "historyEmpty": "Проверок пока нет.", "historyOk": "Успешно", - "historyFailed": "Неудачно" + "historyFailed": "Неудачно", + "trendLabel": "Задержка последних {{count}} проверок, новейшая справа", + "trendPeak": "Самая медленная: {{ms}} мс" }, "vpnCheck": { "valid": "Конфигурация VPN «{{name}}» действительна", @@ -2361,7 +2363,8 @@ "syncSessionUnavailable": "Сейчас к сеансу синхронизации нет доступа.", "syncDisplayUnavailable": "Не удалось узнать размер экрана, поэтому окна не расставить.", "syncDisplayTooSmall": "Экран слишком мал для {{windows}} окон в таком расположении.", - "syncArrangeFailed": "Ни одно окно не удалось переместить." + "syncArrangeFailed": "Ни одно окно не удалось переместить.", + "extensionPathInvalid": "Этот путь недопустим: он содержит «..»." }, "rail": { "profiles": "Профили", @@ -2378,7 +2381,9 @@ "about": "О Donut Browser", "aboutHint": "Версия и сведения о приложении", "trash": "Корзина", - "trashHint": "Восстановить удалённые профили" + "trashHint": "Восстановить удалённые профили", + "tips": "Подсказки", + "tipsHint": "Обзор возможностей" }, "network": "Сеть", "integrations": "Интеграции", @@ -2495,7 +2500,8 @@ "goSettings": "Перейти к Настройкам", "goCookieBot": "Cookie Bot", "goTrash": "Перейти в Корзину", - "goAgent": "Перейти к агенту" + "goAgent": "Перейти к агенту", + "openTips": "Открыть подсказки" }, "closeConfirm": { "title": "Закрыть Donut Browser?", @@ -3401,5 +3407,143 @@ "urlPlaceholder": "https://example.com", "folderPlaceholder": "Необязательно", "saved": "Закладки группы сохранены" + }, + "tips": { + "title": "Подсказки", + "essentials": "Основы", + "planSection": "Входит в ваш тариф", + "count": "{{current}} из {{total}}", + "previous": "Предыдущая подсказка", + "next": "Следующая подсказка", + "done": "Готово", + "autoShow": "Показывать подсказку при запуске Donut", + "items": { + "dnsBlocklist": { + "label": "DNS-блокировка", + "title": "Блокируйте рекламу и трекеры до загрузки", + "body": "У каждого профиля может быть свой DNS-список блокировки. Выберите уровень в столбце DNS профиля; высокие уровни также останавливают домены трекинга и вредоносного ПО на уровне сети.", + "action": "Открыть настройки DNS" + }, + "proxyCheck": { + "label": "Проверка прокси", + "title": "Проверьте прокси перед запуском", + "body": "Проверка соединения показывает IP выхода, страну, задержку и проходит ли UDP. Запускайте её со страницы «Сеть» или из строки профиля и смотрите историю проверок, чтобы заметить прокси, который начинает сбоить.", + "action": "Открыть Сеть" + }, + "groups": { + "label": "Группы", + "title": "Переключайте группы с клавиатуры", + "body": "Группы держат связанные профили вместе, и у каждой группы есть номер: {{mod}}+1 … {{mod}}+9 мгновенно переключают список.", + "action": "Открыть Группы" + }, + "commandPalette": { + "label": "Палитра команд", + "title": "Любая страница в одном сочетании", + "body": "{{mod}}+K открывает палитру команд. Введите несколько букв названия страницы или действия и нажмите Enter.", + "action": "Открыть палитру" + }, + "fingerprintGate": { + "label": "Проверка отпечатка", + "title": "Держите отпечаток согласованным с выходом", + "body": "Перед запуском Donut сравнивает часовой пояс и язык выхода прокси с отпечатком профиля и останавливает несовпадение. Исправьте отпечаток или отключите проверку в разделе «Дополнительно», если понимаете, что делаете.", + "action": "Открыть дополнительные настройки" + }, + "profilePassword": { + "label": "Пароль профиля", + "title": "Защитите профиль паролем", + "body": "Профиль с паролем зашифрован на диске и расшифровывается только на время работы. Пароль задаётся в меню профиля.", + "action": "Открыть Профили" + }, + "clearOnClose": { + "label": "Очистка при закрытии", + "title": "Начинайте с чистого листа", + "body": "С опцией «Очищать при закрытии» профиль сбрасывает cookie, хранилище и историю, когда закрывается его окно. Удобно для разовых сессий и общих компьютеров.", + "action": "Открыть Профили" + }, + "defaultBrowser": { + "label": "Браузер по умолчанию", + "title": "Открывайте каждую ссылку в нужном профиле", + "body": "Сделайте Donut браузером по умолчанию, и каждая ссылка из другого приложения будет спрашивать, в каком профиле её открыть.", + "action": "Открыть настройки браузера по умолчанию" + }, + "extensionGroups": { + "label": "Группы расширений", + "title": "Один набор расширений для многих профилей", + "body": "Соберите расширения в группу расширений и назначьте её профилям. Измените группу один раз, и каждый профиль подхватит изменения при следующем запуске.", + "action": "Открыть Расширения" + }, + "selfHostedSync": { + "label": "Своя синхронизация", + "title": "Резервные копии на своём сервере", + "body": "Укажите Donut собственный сервер donut-sync, и профили, прокси и группы будут зеркалироваться на него. Добавьте сквозной пароль, и сервер увидит только шифртекст.", + "action": "Открыть Аккаунт" + }, + "trash": { + "label": "Корзина", + "title": "Удалённые профили ждут в корзине", + "body": "Удалённый профиль по умолчанию хранится в корзине 30 дней и восстанавливается со всем содержимым. Срок хранения задаётся в дополнительных настройках.", + "action": "Открыть Корзину" + }, + "localApi": { + "label": "API и MCP", + "title": "Автоматизируйте Donut скриптами и агентами", + "body": "Локальный REST API и сервер MCP позволяют скриптам и ИИ-агентам просматривать, создавать и настраивать профили. Включите их в разделе «Интеграции» и скопируйте токен.", + "action": "Открыть Интеграции" + }, + "importProfiles": { + "label": "Импорт", + "title": "Перенесите профили из Chrome, Edge или Brave", + "body": "Импорт копирует cookie, логины и расширения из профиля Chromium или архива в новый профиль Donut, готовый к запуску.", + "action": "Открыть Импорт" + }, + "cloudBackup": { + "label": "Облачная синхронизация", + "title": "Ваши профили на каждом устройстве", + "body": "Облачная синхронизация сохраняет профили, прокси и группы и восстанавливает их на другом компьютере. Включайте её для каждого профиля в столбце синхронизации.", + "action": "Открыть Аккаунт" + }, + "cookieBot": { + "label": "Cookie Bot", + "title": "Прогревайте профили по ночам", + "body": "Cookie Bot по расписанию просматривает настоящие сайты с удалённого хоста, чтобы новый профиль накопил естественную историю до того, как вы им воспользуетесь.", + "action": "Открыть Cookie Bot" + }, + "crossOs": { + "label": "Кросс-ОС отпечаток", + "title": "Выглядите как любая операционная система", + "body": "Кросс-ОС отпечаток позволяет профилю сообщать macOS, Windows или Linux независимо от машины, на которой он запущен. Выберите платформу при создании или редактировании отпечатка.", + "action": "Открыть Профили" + }, + "automation": { + "label": "Автоматизация", + "title": "Запускайте и управляйте профилями из кода", + "body": "Эндпоинты run, open-url и kill запускают настоящие профили из ваших скриптов, а каждый запущенный профиль открывает CDP-эндпоинт для Playwright или Puppeteer.", + "action": "Открыть Интеграции" + }, + "agent": { + "label": "Агент", + "title": "Доверьте клики агенту", + "body": "Опишите задачу, и агент проведёт профиль по шагам, запишет сделанное и сможет повторить это как рецепт.", + "action": "Открыть Агента" + }, + "team": { + "label": "Блокировки команды", + "title": "Делитесь профилями без конфликтов", + "body": "В команде запущенный профиль блокируется для остальных и освобождается при закрытии. На странице «Аккаунт» видно, кто что держит.", + "action": "Открыть Аккаунт" + }, + "remoteControl": { + "label": "Удалённое управление", + "title": "Управляйте этим компьютером с donutbrowser.com", + "body": "При включённом удалённом управлении агенты на сайте получают доступ к профилям этой машины через исходящий мост. Включите его в разделе «Интеграции».", + "action": "Открыть Интеграции" + } + } + }, + "paidWelcome": { + "title": "Добро пожаловать в {{plan}}", + "body": "Ваш тариф только что открыл эти возможности. Каждая подсказка показывает одну из них в действии.", + "cta": "Покажите", + "later": "Позже" } } diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index eb0d92c..2b47fce 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1982,7 +1982,9 @@ "historyTitle": "Son denetimler", "historyEmpty": "Henüz kayıtlı denetim yok.", "historyOk": "Başarılı", - "historyFailed": "Başarısız" + "historyFailed": "Başarısız", + "trendLabel": "Son {{count}} kontrolün gecikmesi, en yenisi sağda", + "trendPeak": "En yavaş: {{ms}} ms" }, "vpnCheck": { "valid": "\"{{name}}\" VPN yapılandırması geçerli", @@ -2341,7 +2343,8 @@ "syncSessionUnavailable": "Eşitleme oturumuna şu anda erişilemiyor.", "syncDisplayUnavailable": "Ekran boyutu okunamadı, bu yüzden pencereler yerleştirilemiyor.", "syncDisplayTooSmall": "Ekran, o düzende {{windows}} pencere için fazla küçük.", - "syncArrangeFailed": "Hiçbir pencere taşınamadı." + "syncArrangeFailed": "Hiçbir pencere taşınamadı.", + "extensionPathInvalid": "Bu yol kullanılamaz: '..' içeriyor." }, "rail": { "profiles": "Profiller", @@ -2358,7 +2361,9 @@ "about": "Donut Browser Hakkında", "aboutHint": "Sürüm ve uygulama bilgileri", "trash": "Çöp Kutusu", - "trashHint": "Silinen profilleri geri yükle" + "trashHint": "Silinen profilleri geri yükle", + "tips": "İpuçları", + "tipsHint": "Özellik turları" }, "network": "Ağ", "integrations": "Entegrasyonlar", @@ -2475,7 +2480,8 @@ "goSettings": "Ayarlar'a git", "goCookieBot": "Cookie Bot", "goTrash": "Çöp Kutusuna git", - "goAgent": "Ajan'a git" + "goAgent": "Ajan'a git", + "openTips": "İpuçlarını aç" }, "closeConfirm": { "title": "Donut Browser kapatılsın mı?", @@ -3335,5 +3341,143 @@ "urlPlaceholder": "https://ornek.com", "folderPlaceholder": "İsteğe bağlı", "saved": "Grup yer imleri kaydedildi" + }, + "tips": { + "title": "İpuçları", + "essentials": "Temeller", + "planSection": "Planınıza dahil", + "count": "{{current}} / {{total}}", + "previous": "Önceki ipucu", + "next": "Sonraki ipucu", + "done": "Bitti", + "autoShow": "Donut açılırken bir ipucu göster", + "items": { + "dnsBlocklist": { + "label": "DNS engelleme", + "title": "Reklamları ve izleyicileri yüklenmeden engelleyin", + "body": "Her profilin kendi DNS engel listesi olabilir. Profilin DNS sütunundan bir düzey seçin; yüksek düzeyler izleme ve kötü amaçlı yazılım alan adlarını da ağ katmanında durdurur.", + "action": "DNS ayarlarını aç" + }, + "proxyCheck": { + "label": "Proxy kontrolü", + "title": "Başlatmadan önce proxy'yi kontrol edin", + "body": "Bağlantı testi çıkış IP'sini, ülkeyi, gecikmeyi ve UDP'nin geçip geçmediğini bildirir. Ağ sayfasından veya bir profil satırından çalıştırın ve bozulmaya başlayan bir proxy'yi geçmiş kontrollerden fark edin.", + "action": "Ağ'ı aç" + }, + "groups": { + "label": "Gruplar", + "title": "Gruplar arasında klavyeyle geçin", + "body": "Gruplar ilişkili profilleri bir arada tutar ve her grubun bir numarası vardır: {{mod}}+1 ile {{mod}}+9 listeyi anında değiştirir.", + "action": "Grupları aç" + }, + "commandPalette": { + "label": "Komut paleti", + "title": "Her sayfa tek bir kısayol uzağınızda", + "body": "{{mod}}+K komut paletini açar. Bir sayfanın veya eylemin birkaç harfini yazıp Enter'a basın.", + "action": "Paleti aç" + }, + "fingerprintGate": { + "label": "Parmak izi denetimi", + "title": "Parmak izini çıkışla tutarlı tutun", + "body": "Başlatmadan önce Donut, proxy çıkışının saat dilimi ve dilini profilin parmak iziyle karşılaştırır ve uyuşmazlığı durdurur. Parmak izini düzeltin ya da ne yaptığınızı biliyorsanız Gelişmiş'ten bu denetimi kapatın.", + "action": "Gelişmiş ayarları aç" + }, + "profilePassword": { + "label": "Profil parolası", + "title": "Bir profili parolayla kilitleyin", + "body": "Parola korumalı bir profil diskte şifrelenir ve yalnızca çalışırken çözülür. Parolayı profil menüsünden belirleyin.", + "action": "Profilleri aç" + }, + "clearOnClose": { + "label": "Kapatınca temizle", + "title": "Her seferinde temiz başlayın", + "body": "Kapatınca temizle açıkken profil, penceresi kapandığında çerezlerini, depolamasını ve geçmişini siler. Tek seferlik oturumlar ve ortak bilgisayarlar için idealdir.", + "action": "Profilleri aç" + }, + "defaultBrowser": { + "label": "Varsayılan tarayıcı", + "title": "Her bağlantıyı doğru profilde açın", + "body": "Donut'u varsayılan tarayıcınız yapın; başka bir uygulamadan gelen her bağlantı hangi profilde açılacağını sorar.", + "action": "Varsayılan tarayıcı ayarlarını aç" + }, + "extensionGroups": { + "label": "Uzantı grupları", + "title": "Tek bir uzantı setini profiller arasında paylaşın", + "body": "Uzantıları bir uzantı grubuna koyun ve grubu profillere atayın. Grubu bir kez değiştirin; her profil bir sonraki başlatmada bunu izler.", + "action": "Uzantıları aç" + }, + "selfHostedSync": { + "label": "Kendi eşitlemeniz", + "title": "Kendi sunucunuza yedekleyin", + "body": "Donut'u kendi barındırdığınız bir donut-sync sunucusuna yönlendirin; profiller, proxy'ler ve gruplar oraya yansıtılır. Uçtan uca bir parola ekleyin, sunucu yalnızca şifreli metin görsün.", + "action": "Hesabı aç" + }, + "trash": { + "label": "Çöp kutusu", + "title": "Silinen profiller çöp kutusunda bekler", + "body": "Silinen bir profil varsayılan olarak 30 gün çöp kutusunda kalır ve içindeki her şeyle geri gelir. Saklama süresi Gelişmiş ayarlardadır.", + "action": "Çöp kutusunu aç" + }, + "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.", + "action": "Entegrasyonları aç" + }, + "importProfiles": { + "label": "İçe aktarma", + "title": "Chrome, Edge veya Brave'den profil getirin", + "body": "İçe aktarma, bir Chromium profilinden veya arşivden çerezleri, oturum bilgilerini ve uzantıları başlatmaya hazır yeni bir Donut profiline kopyalar.", + "action": "İçe aktarmayı aç" + }, + "cloudBackup": { + "label": "Bulut eşitleme", + "title": "Profilleriniz her cihazda", + "body": "Bulut eşitleme profilleri, proxy'leri ve grupları yedekler ve başka bir makinede geri yükler. Eşitleme sütunundan profil başına açın.", + "action": "Hesabı aç" + }, + "cookieBot": { + "label": "Cookie Bot", + "title": "Profilleri gece boyunca ısıtın", + "body": "Cookie Bot uzak bir ana bilgisayardan bir programa göre gerçek siteleri gezer; böylece yeni bir profil siz kullanmadan önce doğal bir geçmiş oluşturur.", + "action": "Cookie Bot'u aç" + }, + "crossOs": { + "label": "Çapraz OS parmak izi", + "title": "Herhangi bir işletim sistemi gibi görünün", + "body": "Çapraz işletim sistemi parmak izi, profilin hangi makinede çalışırsa çalışsın macOS, Windows veya Linux bildirmesini sağlar. Parmak izini oluştururken veya düzenlerken platformu seçin.", + "action": "Profilleri aç" + }, + "automation": { + "label": "Otomasyon", + "title": "Profilleri koddan başlatın ve yönetin", + "body": "run, open-url ve kill uç noktaları betiklerinizden gerçek profiller başlatır; başlatılan her profil Playwright veya Puppeteer için bir CDP uç noktası sunar.", + "action": "Entegrasyonları aç" + }, + "agent": { + "label": "Ajan", + "title": "Tıklamaları bir ajana bırakın", + "body": "Bir görevi tarif edin; ajan profili adım adım yönetir, yaptıklarını kaydeder ve bunu bir tarif olarak yeniden oynatabilir.", + "action": "Ajanı aç" + }, + "team": { + "label": "Ekip kilitleri", + "title": "Profilleri çakışmadan paylaşın", + "body": "Bir ekipte çalışan bir profil diğerleri için kilitlenir ve kapandığında serbest bırakılır. Hesap sayfası kimin neyi tuttuğunu gösterir.", + "action": "Hesabı aç" + }, + "remoteControl": { + "label": "Uzaktan denetim", + "title": "Bu masaüstünü donutbrowser.com'dan yönetin", + "body": "Uzaktan denetim açıkken web sitesindeki ajanlar bu makinenin profillerine giden yönlü bir köprü üzerinden ulaşır. Entegrasyonlar'dan açın.", + "action": "Entegrasyonları aç" + } + } + }, + "paidWelcome": { + "title": "{{plan}} planına hoş geldiniz", + "body": "Planınız bunların kilidini açtı. Her ipucu birini çalışırken gösterir.", + "cta": "Göster", + "later": "Sonra" } } diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index f81d2e1..e96afd8 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -1982,7 +1982,9 @@ "historyTitle": "Các lần kiểm tra gần đây", "historyEmpty": "Chưa có lần kiểm tra nào được ghi lại.", "historyOk": "Đạt", - "historyFailed": "Thất bại" + "historyFailed": "Thất bại", + "trendLabel": "Độ trễ của {{count}} lần kiểm tra gần nhất, mới nhất ở bên phải", + "trendPeak": "Chậm nhất: {{ms}} ms" }, "vpnCheck": { "valid": "Cấu hình VPN \"{{name}}\" hợp lệ", @@ -2341,7 +2343,8 @@ "syncSessionUnavailable": "Hiện không truy cập được phiên đồng bộ.", "syncDisplayUnavailable": "Không đọc được kích thước màn hình nên không xếp được cửa sổ.", "syncDisplayTooSmall": "Màn hình quá nhỏ cho {{windows}} cửa sổ theo cách xếp đó.", - "syncArrangeFailed": "Không cửa sổ nào được di chuyển." + "syncArrangeFailed": "Không cửa sổ nào được di chuyển.", + "extensionPathInvalid": "Đường dẫn đó không được phép: nó chứa '..'." }, "rail": { "profiles": "Profile", @@ -2358,7 +2361,9 @@ "about": "Giới thiệu về Donut Browser", "aboutHint": "Phiên bản và thông tin ứng dụng", "trash": "Thùng rác", - "trashHint": "Khôi phục hồ sơ đã xóa" + "trashHint": "Khôi phục hồ sơ đã xóa", + "tips": "Mẹo", + "tipsHint": "Hướng dẫn tính năng" }, "network": "Mạng", "integrations": "Tích hợp", @@ -2475,7 +2480,8 @@ "goSettings": "Đi đến Cài đặt", "goCookieBot": "Cookie Bot", "goTrash": "Đi tới Thùng rác", - "goAgent": "Đến Tác nhân" + "goAgent": "Đến Tác nhân", + "openTips": "Mở mẹo" }, "closeConfirm": { "title": "Đóng Donut Browser?", @@ -3335,5 +3341,143 @@ "urlPlaceholder": "https://vidu.com", "folderPlaceholder": "Tùy chọn", "saved": "Đã lưu dấu trang của nhóm" + }, + "tips": { + "title": "Mẹo", + "essentials": "Cơ bản", + "planSection": "Có trong gói của bạn", + "count": "{{current}} / {{total}}", + "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", + "items": { + "dnsBlocklist": { + "label": "Chặn DNS", + "title": "Chặn quảng cáo và trình theo dõi trước khi tải", + "body": "Mỗi hồ sơ có thể mang danh sách chặn DNS riêng. Chọn một mức trong cột DNS của hồ sơ; các mức cao hơn còn chặn tên miền theo dõi và mã độc ngay ở tầng mạng.", + "action": "Mở cài đặt DNS" + }, + "proxyCheck": { + "label": "Kiểm tra proxy", + "title": "Kiểm tra proxy trước khi khởi chạy", + "body": "Kiểm tra kết nối cho biết IP đầu ra, quốc gia, độ trễ và UDP có đi qua hay không. Chạy từ trang Mạng hoặc từ một hàng hồ sơ, và xem lịch sử kiểm tra để phát hiện proxy đang xuống cấp.", + "action": "Mở Mạng" + }, + "groups": { + "label": "Nhóm", + "title": "Chuyển nhóm bằng bàn phím", + "body": "Nhóm giữ các hồ sơ liên quan ở cùng nhau, và mỗi nhóm có một số: {{mod}}+1 đến {{mod}}+9 chuyển danh sách ngay lập tức.", + "action": "Mở Nhóm" + }, + "commandPalette": { + "label": "Bảng lệnh", + "title": "Mọi trang chỉ cách một tổ hợp phím", + "body": "{{mod}}+K mở bảng lệnh. Gõ vài chữ cái của một trang hoặc thao tác rồi nhấn Enter.", + "action": "Mở bảng lệnh" + }, + "fingerprintGate": { + "label": "Kiểm tra dấu vân tay", + "title": "Giữ dấu vân tay khớp với điểm ra", + "body": "Trước khi khởi chạy, Donut so sánh múi giờ và ngôn ngữ của điểm ra proxy với dấu vân tay của hồ sơ và chặn khi không khớp. Hãy sửa dấu vân tay, hoặc tắt kiểm tra trong Nâng cao nếu bạn biết mình đang làm gì.", + "action": "Mở cài đặt nâng cao" + }, + "profilePassword": { + "label": "Mật khẩu hồ sơ", + "title": "Khóa hồ sơ bằng mật khẩu", + "body": "Hồ sơ được bảo vệ bằng mật khẩu được mã hóa trên đĩa và chỉ được giải mã khi đang chạy. Đặt mật khẩu từ menu của hồ sơ.", + "action": "Mở Hồ sơ" + }, + "clearOnClose": { + "label": "Xóa khi đóng", + "title": "Bắt đầu sạch sẽ mỗi lần", + "body": "Với Xóa khi đóng, hồ sơ bỏ cookie, bộ nhớ và lịch sử khi cửa sổ của nó đóng lại. Phù hợp cho phiên dùng một lần và máy dùng chung.", + "action": "Mở Hồ sơ" + }, + "defaultBrowser": { + "label": "Trình duyệt mặc định", + "title": "Mở mọi liên kết trong đúng hồ sơ", + "body": "Đặt Donut làm trình duyệt mặc định và mỗi liên kết từ ứng dụng khác sẽ hỏi nên mở bằng hồ sơ nào.", + "action": "Mở cài đặt trình duyệt mặc định" + }, + "extensionGroups": { + "label": "Nhóm tiện ích", + "title": "Dùng chung một bộ tiện ích cho nhiều hồ sơ", + "body": "Đưa tiện ích vào một nhóm tiện ích và gán nhóm đó cho các hồ sơ. Đổi nhóm một lần và mọi hồ sơ sẽ theo ở lần khởi chạy tiếp theo.", + "action": "Mở Tiện ích" + }, + "selfHostedSync": { + "label": "Đồng bộ tự lưu trữ", + "title": "Sao lưu lên máy chủ của riêng bạn", + "body": "Trỏ Donut đến máy chủ donut-sync tự lưu trữ và hồ sơ, proxy, nhóm sẽ được phản chiếu lên đó. Thêm mật khẩu đầu cuối và máy chủ chỉ thấy văn bản đã mã hóa.", + "action": "Mở Tài khoản" + }, + "trash": { + "label": "Thùng rác", + "title": "Hồ sơ đã xóa chờ trong thùng rác", + "body": "Hồ sơ đã xóa nằm trong thùng rác 30 ngày theo mặc định và quay lại với đầy đủ nội dung. Thời gian lưu giữ nằm trong cài đặt nâng cao.", + "action": "Mở Thùng rác" + }, + "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.", + "action": "Mở Tích hợp" + }, + "importProfiles": { + "label": "Nhập", + "title": "Mang hồ sơ từ Chrome, Edge hoặc Brave sang", + "body": "Nhập sao chép cookie, thông tin đăng nhập và tiện ích từ hồ sơ Chromium hoặc tệp nén vào một hồ sơ Donut mới, sẵn sàng khởi chạy.", + "action": "Mở Nhập" + }, + "cloudBackup": { + "label": "Đồng bộ đám mây", + "title": "Hồ sơ của bạn trên mọi thiết bị", + "body": "Đồng bộ đám mây sao lưu hồ sơ, proxy và nhóm rồi khôi phục trên máy khác. Bật cho từng hồ sơ từ cột đồng bộ.", + "action": "Mở Tài khoản" + }, + "cookieBot": { + "label": "Cookie Bot", + "title": "Làm nóng hồ sơ qua đêm", + "body": "Cookie Bot duyệt các trang thật theo lịch từ một máy chủ từ xa, để hồ sơ mới tích lũy lịch sử tự nhiên trước khi bạn dùng.", + "action": "Mở Cookie Bot" + }, + "crossOs": { + "label": "Dấu vân tay đa hệ điều hành", + "title": "Xuất hiện như bất kỳ hệ điều hành nào", + "body": "Dấu vân tay đa hệ điều hành cho phép hồ sơ báo là macOS, Windows hoặc Linux dù chạy trên máy nào. Chọn nền tảng khi tạo hoặc chỉnh sửa dấu vân tay.", + "action": "Mở Hồ sơ" + }, + "automation": { + "label": "Tự động hóa", + "title": "Khởi chạy và điều khiển hồ sơ từ mã", + "body": "Các điểm cuối run, open-url và kill khởi động hồ sơ thật từ script của bạn, và mỗi hồ sơ đã khởi chạy cung cấp một điểm cuối CDP cho Playwright hoặc Puppeteer.", + "action": "Mở Tích hợp" + }, + "agent": { + "label": "Tác nhân", + "title": "Giao việc nhấp chuột cho tác nhân", + "body": "Mô tả một nhiệm vụ và tác nhân sẽ điều khiển hồ sơ từng bước, ghi lại những gì đã làm và có thể phát lại như một công thức.", + "action": "Mở Tác nhân" + }, + "team": { + "label": "Khóa nhóm", + "title": "Chia sẻ hồ sơ mà không xung đột", + "body": "Trong nhóm, hồ sơ đang chạy bị khóa với mọi người khác và được giải phóng khi đóng. Trang Tài khoản cho biết ai đang giữ gì.", + "action": "Mở Tài khoản" + }, + "remoteControl": { + "label": "Điều khiển từ xa", + "title": "Điều khiển máy này từ donutbrowser.com", + "body": "Khi bật điều khiển từ xa, các tác nhân trên trang web tiếp cận hồ sơ của máy này qua một cầu nối đi ra. Bật trong Tích hợp.", + "action": "Mở Tích hợp" + } + } + }, + "paidWelcome": { + "title": "Chào mừng đến với {{plan}}", + "body": "Gói của bạn vừa mở khóa những tính năng này. Mỗi mẹo cho thấy một tính năng đang hoạt động.", + "cta": "Cho tôi xem", + "later": "Để sau" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index f206943..04be8bc 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1982,7 +1982,9 @@ "historyTitle": "最近的检测", "historyEmpty": "尚无检测记录。", "historyOk": "通过", - "historyFailed": "失败" + "historyFailed": "失败", + "trendLabel": "最近 {{count}} 次检查的延迟,最新的在右侧", + "trendPeak": "最慢:{{ms}} ms" }, "vpnCheck": { "valid": "VPN「{{name}}」配置有效", @@ -2341,7 +2343,8 @@ "syncSessionUnavailable": "目前无法访问该同步会话。", "syncDisplayUnavailable": "无法读取显示器尺寸,因此不能排列窗口。", "syncDisplayTooSmall": "显示器太小,无法以该布局排列 {{windows}} 个窗口。", - "syncArrangeFailed": "没有窗口被移动。" + "syncArrangeFailed": "没有窗口被移动。", + "extensionPathInvalid": "该路径不允许使用:它包含“..”。" }, "rail": { "profiles": "配置文件", @@ -2358,7 +2361,9 @@ "about": "关于 Donut Browser", "aboutHint": "版本和应用信息", "trash": "回收站", - "trashHint": "恢复已删除的配置文件" + "trashHint": "恢复已删除的配置文件", + "tips": "小贴士", + "tipsHint": "功能演示" }, "network": "网络", "integrations": "集成", @@ -2475,7 +2480,8 @@ "goSettings": "转到设置", "goCookieBot": "Cookie Bot", "goTrash": "前往回收站", - "goAgent": "前往智能体" + "goAgent": "前往智能体", + "openTips": "打开小贴士" }, "closeConfirm": { "title": "关闭 Donut Browser?", @@ -3335,5 +3341,143 @@ "urlPlaceholder": "https://example.com", "folderPlaceholder": "可选", "saved": "分组书签已保存" + }, + "tips": { + "title": "小贴士", + "essentials": "基础", + "planSection": "您的套餐已包含", + "count": "{{current}} / {{total}}", + "previous": "上一条", + "next": "下一条", + "done": "完成", + "autoShow": "Donut 启动时显示一条小贴士", + "items": { + "dnsBlocklist": { + "label": "DNS 拦截", + "title": "在加载前拦截广告和跟踪器", + "body": "每个配置文件都可以有自己的 DNS 拦截列表。在配置文件的 DNS 列中选择级别;更高的级别还会在网络层拦截跟踪和恶意软件域名。", + "action": "打开 DNS 设置" + }, + "proxyCheck": { + "label": "代理检查", + "title": "启动前先检查代理", + "body": "连接检查会报告出口 IP、国家、延迟以及 UDP 是否可用。可从“网络”页面或配置文件行运行,并通过历史检查记录发现正在变差的代理。", + "action": "打开网络" + }, + "groups": { + "label": "分组", + "title": "用键盘切换分组", + "body": "分组将相关的配置文件放在一起,每个分组都有编号:{{mod}}+1 到 {{mod}}+9 可立即切换列表。", + "action": "打开分组" + }, + "commandPalette": { + "label": "命令面板", + "title": "任何页面只需一个快捷键", + "body": "{{mod}}+K 打开命令面板。输入页面或操作的几个字母,然后按 Enter。", + "action": "打开命令面板" + }, + "fingerprintGate": { + "label": "指纹检查", + "title": "让指纹与出口保持一致", + "body": "启动前,Donut 会将代理出口的时区和语言与配置文件的指纹进行比较,并阻止不一致的启动。请修正指纹,或者在确认无误的情况下于“高级”中关闭此检查。", + "action": "打开高级设置" + }, + "profilePassword": { + "label": "配置文件密码", + "title": "用密码锁定配置文件", + "body": "受密码保护的配置文件在磁盘上加密,仅在运行时解密。可在配置文件菜单中设置密码。", + "action": "打开配置文件" + }, + "clearOnClose": { + "label": "关闭时清除", + "title": "每次都从干净状态开始", + "body": "开启“关闭时清除”后,窗口关闭时配置文件会丢弃 Cookie、存储和历史记录。适合一次性会话和共用电脑。", + "action": "打开配置文件" + }, + "defaultBrowser": { + "label": "默认浏览器", + "title": "让每个链接在正确的配置文件中打开", + "body": "将 Donut 设为默认浏览器后,来自其他应用的每个链接都会询问应由哪个配置文件打开。", + "action": "打开默认浏览器设置" + }, + "extensionGroups": { + "label": "扩展组", + "title": "在多个配置文件间共用一套扩展", + "body": "把扩展放进扩展组,再将该组分配给配置文件。修改一次分组,每个配置文件都会在下次启动时跟随。", + "action": "打开扩展" + }, + "selfHostedSync": { + "label": "自托管同步", + "title": "备份到自己的服务器", + "body": "将 Donut 指向自托管的 donut-sync 服务器,配置文件、代理和分组就会镜像到那里。再加上端到端密码,服务器只会看到密文。", + "action": "打开账户" + }, + "trash": { + "label": "回收站", + "title": "已删除的配置文件在回收站等候", + "body": "已删除的配置文件默认在回收站保留 30 天,并可连同全部内容一起恢复。保留期限在高级设置中。", + "action": "打开回收站" + }, + "localApi": { + "label": "API 与 MCP", + "title": "用脚本和智能体自动化 Donut", + "body": "本地 REST API 和 MCP 服务器可让脚本和 AI 智能体列出、创建和配置配置文件。在“集成”中开启并复制令牌。", + "action": "打开集成" + }, + "importProfiles": { + "label": "导入", + "title": "从 Chrome、Edge 或 Brave 迁移配置文件", + "body": "导入会把 Chromium 配置文件或压缩包中的 Cookie、登录信息和扩展复制到新的 Donut 配置文件,随时可以启动。", + "action": "打开导入" + }, + "cloudBackup": { + "label": "云同步", + "title": "让配置文件出现在每台设备上", + "body": "云同步会备份配置文件、代理和分组,并在另一台电脑上恢复。可在同步列中按配置文件开启。", + "action": "打开账户" + }, + "cookieBot": { + "label": "Cookie Bot", + "title": "夜间养号", + "body": "Cookie Bot 会从远程主机按计划浏览真实网站,让新配置文件在您使用之前积累自然的历史记录。", + "action": "打开 Cookie Bot" + }, + "crossOs": { + "label": "跨系统指纹", + "title": "伪装成任意操作系统", + "body": "跨系统指纹可让配置文件在任何电脑上都报告为 macOS、Windows 或 Linux。在创建或编辑指纹时选择平台。", + "action": "打开配置文件" + }, + "automation": { + "label": "自动化", + "title": "用代码启动和驱动配置文件", + "body": "run、open-url 和 kill 端点可从脚本启动真实的配置文件,每个已启动的配置文件都会提供供 Playwright 或 Puppeteer 使用的 CDP 端点。", + "action": "打开集成" + }, + "agent": { + "label": "智能体", + "title": "把点击交给智能体", + "body": "描述一个任务,智能体就会一步步驱动配置文件,记录它做了什么,并能作为配方重放。", + "action": "打开智能体" + }, + "team": { + "label": "团队锁定", + "title": "共享配置文件而不冲突", + "body": "在团队中,正在运行的配置文件会对其他人锁定,关闭后释放。账户页面会显示谁在使用什么。", + "action": "打开账户" + }, + "remoteControl": { + "label": "远程控制", + "title": "从 donutbrowser.com 控制这台电脑", + "body": "开启远程控制后,网站上的智能体可通过出站桥接访问这台电脑的配置文件。在“集成”中开启。", + "action": "打开集成" + } + } + }, + "paidWelcome": { + "title": "欢迎使用 {{plan}}", + "body": "您的套餐刚刚解锁了这些功能。每条小贴士都会演示其中一项。", + "cta": "带我看看", + "later": "稍后" } } diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index 03484bc..ce8340f 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -39,6 +39,7 @@ export type BackendErrorCode = | "EXTENSION_UNSUPPORTED_FILE_TYPE" | "EXTENSION_DIR_NOT_FOUND" | "EXTENSION_NOT_A_DIRECTORY" + | "EXTENSION_PATH_INVALID" | "EXTENSION_MANIFEST_MISSING" | "EXTENSION_MANIFEST_INVALID" | "EXTENSION_DIR_TOO_LARGE" @@ -395,6 +396,8 @@ export function translateBackendError(t: TFunction, err: unknown): string { return t("backendErrors.extensionDirNotFound"); case "EXTENSION_NOT_A_DIRECTORY": return t("backendErrors.extensionNotADirectory"); + case "EXTENSION_PATH_INVALID": + return t("backendErrors.extensionPathInvalid"); case "EXTENSION_MANIFEST_MISSING": return t("backendErrors.extensionManifestMissing"); case "EXTENSION_MANIFEST_INVALID": diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 28d972a..1414a42 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -31,6 +31,7 @@ export interface ShortcutDef { export type ShortcutId = | "openPalette" | "openShortcuts" + | "openTips" | "importProfile" | "goProfiles" | "goProxies" @@ -59,6 +60,15 @@ export const SHORTCUTS: ShortcutDef[] = [ key: "/", mod: true, }, + { + // Mod+Shift+H, "hints". Plain Mod+H hides the window on macOS. + id: "openTips", + labelKey: "shortcuts.openTips", + group: "actions", + key: "h", + mod: true, + shift: true, + }, { id: "importProfile", labelKey: "shortcuts.importProfile", diff --git a/src/lib/tips.test.mjs b/src/lib/tips.test.mjs new file mode 100644 index 0000000..989b215 --- /dev/null +++ b/src/lib/tips.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +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"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const LOCALES = path.join(HERE, "..", "i18n", "locales"); + +const NONE = { + active: false, + cloudBackup: false, + cookieBot: false, + crossOsFingerprints: false, + browserAutomation: false, + agentAutomation: false, + teamCollaboration: false, + remoteControl: false, +}; + +test("tip ids are unique and every tip has copy in every locale", () => { + const ids = TIPS.map((tip) => tip.id); + assert.equal(new Set(ids).size, ids.length); + const locales = readdirSync(LOCALES).filter((name) => name.endsWith(".json")); + assert.ok(locales.length >= 2, "expected several locale files"); + for (const name of locales) { + const bundle = JSON.parse(readFileSync(path.join(LOCALES, name), "utf8")); + for (const id of ids) { + const item = bundle.tips?.items?.[id]; + assert.ok(item, `${name} is missing tips.items.${id}`); + for (const field of ["label", "title", "body", "action"]) { + assert.equal( + typeof item[field], + "string", + `${name}: tips.items.${id}.${field} must be a string`, + ); + assert.ok( + item[field].trim().length > 0, + `${name}: tips.items.${id}.${field} is empty`, + ); + } + } + } +}); + +test("a free install is offered every essential and no plan tip", () => { + const offered = tipsFor(NONE); + assert.ok(offered.length > 0); + assert.ok(offered.every((tip) => !isPlanTip(tip))); + assert.equal(offered.length, TIPS.filter((tip) => !isPlanTip(tip)).length); +}); + +test("a plan tip needs an active plan with that capability", () => { + const solo = { ...NONE, active: true, cloudBackup: true, cookieBot: true }; + const offered = tipsFor(solo).map((tip) => tip.id); + assert.ok(offered.includes("cloudBackup")); + assert.ok(offered.includes("cookieBot")); + assert.ok(!offered.includes("team"), "solo has no team collaboration"); + assert.ok(!offered.includes("remoteControl")); + + const lapsed = { ...solo, active: false }; + assert.ok( + tipsFor(lapsed).every((tip) => !isPlanTip(tip)), + "a lapsed plan is offered only the essentials", + ); +}); + +test("the automatic flow picks the first unseen tip in catalog order", () => { + 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, + ); +}); diff --git a/src/lib/tips.ts b/src/lib/tips.ts new file mode 100644 index 0000000..bc59c02 --- /dev/null +++ b/src/lib/tips.ts @@ -0,0 +1,161 @@ +import type { AppPage } from "@/components/rail-nav"; +import type { Entitlements } from "@/types"; + +/** + * The plan capabilities a tip can be gated on. A tip that names one of these + * is offered only when the signed-in plan grants it, so nobody is walked + * through a feature they cannot open. + */ +export type TipRequirement = Extract< + keyof Entitlements, + | "cloudBackup" + | "cookieBot" + | "crossOsFingerprints" + | "browserAutomation" + | "agentAutomation" + | "teamCollaboration" + | "remoteControl" +>; + +/** Where a tip's action button takes the user. */ +export type TipAction = + | { kind: "page"; page: AppPage } + | { kind: "settings"; section: string } + | { kind: "palette" }; + +export type TipId = + | "dnsBlocklist" + | "proxyCheck" + | "groups" + | "commandPalette" + | "fingerprintGate" + | "profilePassword" + | "clearOnClose" + | "defaultBrowser" + | "extensionGroups" + | "selfHostedSync" + | "trash" + | "localApi" + | "importProfiles" + | "cloudBackup" + | "cookieBot" + | "crossOs" + | "automation" + | "agent" + | "team" + | "remoteControl"; + +export interface TipDefinition { + id: TipId; + action: TipAction; + requires?: TipRequirement; +} + +/** + * 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. + * + * The copy lives under `tips.items.` 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: "commandPalette", action: { kind: "palette" } }, + { + id: "fingerprintGate", + action: { kind: "settings", section: "advanced" }, + }, + { 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", + }, + { + id: "cookieBot", + action: { kind: "page", page: "cookieBot" }, + requires: "cookieBot", + }, + { + id: "crossOs", + action: { kind: "page", page: "profiles" }, + requires: "crossOsFingerprints", + }, + { + id: "automation", + action: { kind: "page", page: "integrations" }, + requires: "browserAutomation", + }, + { + id: "agent", + action: { kind: "page", page: "agent" }, + requires: "agentAutomation", + }, + { + id: "team", + action: { kind: "page", page: "account" }, + requires: "teamCollaboration", + }, + { + id: "remoteControl", + action: { kind: "page", page: "integrations" }, + requires: "remoteControl", + }, +]; + +/** How long after the app settles the automatic tip waits before opening. */ +export const TIP_AUTO_DELAY_MS = 2500; + +/** + * A sign-in younger than this counts as "just came back from the website", + * which is when a paid account seen for the first time gets its welcome. + */ +export const FRESH_LOGIN_WINDOW_MS = 15 * 60 * 1000; + +export function isPlanTip(tip: TipDefinition): boolean { + return tip.requires !== undefined; +} + +/** The tips this plan may see: every essential, plus the plan tips it unlocks. */ +export function tipsFor( + entitlements: Pick, +): TipDefinition[] { + return TIPS.filter( + (tip) => + tip.requires === undefined || + (entitlements.active && entitlements[tip.requires]), + ); +} + +/** The first tip the user has not seen yet, or null when they have seen them all. */ +export function pickAutoTip( + tips: readonly TipDefinition[], + seen: readonly string[], +): TipDefinition | null { + return tips.find((tip) => !seen.includes(tip.id)) ?? null; +} + +export function tipTextKeys(id: TipId): { + /** The short name the catalog lists the tip under. */ + label: string; + title: string; + body: string; + action: string; +} { + return { + label: `tips.items.${id}.label`, + title: `tips.items.${id}.title`, + body: `tips.items.${id}.body`, + action: `tips.items.${id}.action`, + }; +}