diff --git a/design-qa.md b/design-qa.md index b8847849..ed5294e9 100644 --- a/design-qa.md +++ b/design-qa.md @@ -1,56 +1,101 @@ -# Dream Report Design QA +# Browser Migration Layout Regression QA -**Source visual truth path** +## Source visual truth -`/Users/moonrailgun/Library/Application Support/Open Design/namespaces/release-stable/data/projects/d7969910-805f-49b3-9d34-80d6d21d2420/dream-recap-redesign.html` +- User-reported broken state: `/var/folders/0l/4dc990md3yn_g3b46dtmhp880000gn/T/orca-paste-1786372826229-081b4a4d-c6af-4262-8884-3f09e49acc37.png` +- Source pixels: 1940 x 1610. +- State: dark theme, migration step 2, category selection screen. -**Implementation screenshot path** +## Implementation evidence -Unavailable. The required in-app browser is not available in this session, and the project cannot produce a fresh Dao binary because the shared generated Chromium checkout fails normal import on 15 unrelated Settings patches before compilation begins. +- Browser-rendered screenshot: `/var/folders/0l/4dc990md3yn_g3b46dtmhp880000gn/T/orca-computer-use/1c00cbd7-8d01-4d07-b35d-db9c98d4b199-screenshot.png` +- Before/after comparison: `/tmp/dao-import-layout-before-after.png` +- Window viewport: 1398 x 909 CSS pixels at device scale factor 2. +- Implementation pixels: 2796 x 1818. +- State: dark theme, `dao://import`, Google Chrome selected, migration step 2, all supported categories selected. -**Viewport and normalization** +## Full-view comparison evidence -- Intended comparison viewport: 1440 x 1000 CSS pixels. -- Source: live responsive HTML; no fixed raster pixel dimensions or device scale factor. -- Implementation: no browser-rendered pixels available, so density normalization could not be performed. -- State: history route with the latest completed daily report selected. +The pre-fix capture shows the first completed progress segment stretched vertically through the header, making the header consume most of the shell. The post-fix browser capture shows the intended compact header, four horizontal progress segments, the category list inside the scrollable main region, and the persistent footer at the bottom of the shell. -**Full-view comparison evidence** +The source and implementation captures use different crops, so the comparison image normalizes both to 900 pixels high while preserving aspect ratio. The comparison is used for the layout regression rather than pixel-level typography matching. -Blocked. The source artifact was inspected directly as HTML/CSS/JS and its selected layout tokens were mapped into `dao_dream_app.ts`, but a source screenshot and browser-rendered implementation screenshot could not be placed into a same-viewport comparison. +## Focused region comparison evidence -**Focused region comparison evidence** +The header and progress rail are clearly readable in the full-view comparison, so a separate crop was not needed. The affected progress segment changed from approximately 390 CSS pixels tall to the intended 4 CSS pixels tall. The header no longer inherits completion-view sizing. -Blocked for the same reason. The regions requiring focused comparison are the annual activity heatmap, compact history rows, report header, TL;DR card, rhythm slots, theme cards, statistic strip, memory candidates, and full-report disclosure. +## Required fidelity surfaces -**Primary interactions checked** +- Fonts and typography: unchanged by the fix; hierarchy, weights, wrapping, and localized copy remain consistent with the existing implementation. +- Spacing and layout rhythm: passed; the header, scrollable main region, and footer return to their intended three-row shell layout. +- Colors and visual tokens: unchanged; the existing dark theme and Dao accent tokens remain intact. +- Image quality and asset fidelity: unchanged; existing browser logos remain sharp and correctly scaled. +- Copy and content: unchanged; localized step, category, authorization tip, and action copy remain present. -- The complete WebUI suite passes: 61 test files and 728 tests, including daily and weekly report loading, shared history selection, 53-week heatmap rendering, structured and legacy recaps, measured rhythm buckets, rerun, copy-image states, confirmation-gated habit persistence, non-destructive rejection, source-domain exclusion, debug metadata, loading, empty, and error states. -- Browser interaction and console-error checks are unavailable without a browser-rendered fresh build. +## Primary interactions and console checks -**Findings** +- Selected the Google Chrome source and advanced to step 2 in the rebuilt Dao Debug application. +- Confirmed category toggles, password authorization tip, Back action, and Start action remain visible. +- No import WebUI error appeared in debug stderr. Repeated Touch ID keychain entitlement diagnostics are unrelated to this layout change. -- [P1] Browser-rendered visual fidelity is unverified. - - Location: complete `dao://dream` history route. - - Evidence: no implementation screenshot exists for same-viewport comparison. - - Impact: typography, final computed spacing, dark mode, and Chromium WebUI rendering cannot be accepted visually from source and jsdom tests alone. - - Fix: restore the shared Chromium import baseline, run `npm run rebuild`, launch the debug app, capture `dao://dream/` at 1440 x 1000, and compare it against a same-size capture of the Open Design artifact. +## Findings and comparison history -**Implementation checklist** +- Iteration 1 — P1 fixed: `.done` was shared by completed progress segments and the completion view. The generic completion selector applied `min-height: 390px` and flex-column sizing to the rail segment. +- Fix: renamed the completion-view class to `.completion`, leaving `.rail span.done` as the progress-state selector. +- Post-fix evidence: the focused regression test reports no 390px minimum height on the completed segment, and the rebuilt browser capture shows a compact horizontal rail. -- [x] Match the Open Design two-column desktop structure and responsive stack. -- [x] Use real report history for activity cells and report selection. -- [x] Add structured summary, rhythm, themes, statistics, memory candidates, and folded markdown. -- [x] Preserve rerun, share, exclusions, debug, loading, empty, and error behavior. -- [x] Support legacy markdown-only reports. -- [ ] Complete browser-rendered same-viewport visual comparison and console check. +## Verification -**Comparison history** +- Focused import WebUI tests: 7 passed. +- Full WebUI suite: 64 files and 776 tests passed. +- Lit reactive-field lint: 205 files scanned, 0 violations. +- `npm run rebuild`: succeeded in 16 incremental steps. +- `git diff --check`: passed. -- Iteration 1: source implementation review completed from the Open Design artifact; all 728 WebUI tests passed; visual comparison blocked before the first screenshot pair because no fresh implementation could be rendered. +No actionable P0, P1, or P2 visual findings remain for this regression. -**Follow-up polish** +final result: passed -- Reassess small-screen statistic-label wrapping and 53-week heatmap scroll position after a real browser capture. +--- -final result: blocked +# Browser Migration Failure And Logo QA + +## Source visual truth + +- User-reported completion state: `/var/folders/0l/4dc990md3yn_g3b46dtmhp880000gn/T/orca-paste-1786445140695-69467f1b-dea4-4e72-98ff-929e65e28a19.png`. +- User-reported broken logo state: `/var/folders/0l/4dc990md3yn_g3b46dtmhp880000gn/T/orca-paste-1786445925493-926fcbbe-e275-4e0b-84e5-da3196959779.png`. +- State: light theme, partial migration completion and source selection. + +## Implementation evidence + +- Completion screenshot: `/tmp/dao-import-ui-qa-completion-final.png`. +- Migration screenshot: `/tmp/dao-import-ui-qa-progress-final.png`. +- Before/after completion comparison: `/tmp/dao-import-ui-qa-comparison-final.png`. +- Viewport: 894 x 672 CSS pixels at device scale factor 2. +- Screenshot pixels: 1788 x 1344. + +## Comparison evidence + +The completion state now lists failed categories in a compact danger-tinted summary and repeats the failure state on the affected statistic cards. Successful categories retain the existing neutral card treatment and all imported counts remain visible. The header and migration target use the packaged Dao product logo; the selected source uses its browser-specific logo. + +The runtime image checks reported a 64 x 64 natural size for each Dao logo and a 150 x 150 natural size for the Microsoft Edge logo. No image had a zero natural size or rendered as a broken image. + +## Required fidelity surfaces + +- Typography: passed; existing hierarchy and localized copy are preserved. +- Spacing and layout: passed; the new failure summary fits without overlapping the statistics or footer. +- Colors: passed; failed categories use a restrained danger treatment while successful categories remain neutral. +- Asset fidelity: passed; same-origin WebUI resource mapping serves the packaged Dao logo and source-browser SVGs remain crisp. +- Content clarity: passed; the failed category names and `Migration failed` status are both visible. + +## Verification + +- Focused WebUI and browser-import contract tests: 2 files, 15 tests passed. +- Lit reactive-field lint: 205 files scanned, 0 violations. +- `npm run rebuild`: succeeded in 11 incremental steps after correcting the resource header. +- `git diff --check`: passed. +- Runtime `dao://import` image and failure-state inspection: passed. + +No actionable P0, P1, or P2 visual findings remain for this change. + +final result: passed diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md index f9f15134..1b634130 100644 --- a/docs/feature-checklist.md +++ b/docs/feature-checklist.md @@ -178,6 +178,19 @@ Lightweight popup window form factor. > `_little_dao_external.mm.patch`) — apply order matters. Two patches edit > `startup_browser_creator_impl.cc` (`_impl` then `_little_dao`). +## 6.1 Browser Data Migration + +| ✔ | Feature | Source / patch | Risk | Verify after upgrade | +|---|---------|----------------|------|----------------------| +| ☐ | Source-profile detection | `browser/import/dao_source_detector.*`, Chromium `ImporterList` bridge | 🟡 | Separate detected Chrome, Arc, Edge, Safari, and Firefox profiles remain enabled and expose only categories available on the current platform; browser kinds without a detected profile remain visible as disabled cards; when all kinds are unavailable, the empty explanation and rescan action remain visible and Continue stays disabled | +| ☐ | Asynchronous candidate counts | `browser/import/dao_chromium_profile_adapter.*`, `dao_migration_service.*`, `dao_import_ui.*`, `resources/import/` | 🟡 | Selecting Chrome, Arc, or Edge starts per-category counts without blocking Continue; password counts do not trigger Keychain; tab counts operate on a temporary snapshot; switching sources ignores late results from the previous profile; Safari and Firefox show unavailable; final imported totals remain authoritative | +| ☐ | Stable local snapshots | `browser/import/dao_profile_snapshot.*` | 🟡 | With the source browser open, files and SQLite sidecars retry on metadata changes; session directories copy recursively; cancellation removes the temporary directory; cleanup stays off the UI thread and blocks browser shutdown until sensitive snapshots are removed | +| ☐ | Category-isolated job lifecycle | `browser/import/dao_migration_job.*`, `dao_migration_service.*` | 🟡 | A failed or denied password import does not stop bookmarks/history/tabs/extensions; partial completion lists every failed category and marks its result card while preserving its imported count; reload reconnects without replacing the active source snapshot; retry selects failed categories only; cancel stops at a batch boundary, retains completed counts, and never renders as success | +| ☐ | Merge-only target writes | `browser/import/dao_chromium_migration_target.*`, `dao_legacy_profile_writer.*` | 🔴 | Existing bookmark paths, passwords, open tab URLs, and installed extensions remain unchanged; conflicts are counted instead of overwritten; history/password success is reported only after destination verification | +| ☐ | Imported tab folder | `dao_chromium_migration_target.*`, `dao_sidebar_ui.*`, `dao_sidebar_app.ts` | 🔴 | Imported tabs preserve order, remain cancellable between bounded batches, are background/discarded, appear in one collapsed folder, roll back if folder persistence fails, and notify every same-profile sidebar after success | +| ☐ | Extension reinstall boundary | `dao_chromium_profile_adapter.*`, `dao_chromium_migration_target.*` | 🔴 | Only web-store entries are queued, prompts are sequential, disabled source extensions remain disabled, and extension local data/sign-ins are not copied | +| ☐ | Password authorization notice | `resources/import/dao_import_app.ts`, `dao_strings.grd` | 🟢 | Selecting passwords shows the Keychain tip before Start; denying the prompt marks only passwords as needing attention | + ## 7. `dao://` Scheme & WebUI Routing The compatibility layer that lets both `dao://` (canonical) and `chrome://` (legacy) work. @@ -196,6 +209,7 @@ The compatibility layer that lets both `dao://` (canonical) and `chrome://` (leg | ☐ | Omnibox virtual URL shows `dao://` | `content/browser/renderer_host/navigation_controller_impl.cc.patch` | 🟡 | `chrome://settings` displays as `dao://settings` | | ☐ | Register both factories (PDF/component-ext subresources) | `chrome/browser/chrome_content_browser_client.cc.patch` (**zeroed diff index — apply by context**) | 🔴 | PDF isn't a blank page | | ☐ | Register Dao WebUI controllers | `webui/chrome_web_ui_configs.cc.patch`, `chrome_web_ui_controller_factory.cc.patch` | 🟡 | Dao pages load | +| ☐ | Standalone browser migration WebUI | `dao_import_ui.*`, `resources/import/`, `resources/settings/dao_page/dao_page.{html,ts}.patch`, `ui/chrome_pages.cc.patch`, `webui/chrome_web_ui_configs.cc.patch`, `chrome_paks.gni.patch` | 🟡 | **You and Dao** shows an **Import browser data** row; both that row and the system **Import Bookmarks and Settings…** command open `dao://import`; the browser tab uses the localized **Import browser data** title; source and destination migration nodes use the selected browser and packaged Dao product logos; the page loads without console errors and reload reconnects to an active job | | ☐ | WebUI default CSP allows both schemes | `ui/webui/webui_util.cc.patch` | 🔴 | **Diff upstream's new CSP host list — every new `chrome://` host needs a `dao://` mirror or resources silently break** | | ☐ | `chrome://newtab` → `dao://welcome`; reverse rewriter | `chrome/browser/browser_about_handler.cc.patch` | 🔴 | NTP lands on welcome; omnibox shows `dao://`; reverse rewriter still registered | | ☐ | URL fixer treats chrome/dao as equivalent | `components/url_formatter/url_fixer.cc.patch` | 🟢 | Omnibox fixup accepts both | diff --git a/docs/features.md b/docs/features.md index 9532f079..0c7f18fe 100644 --- a/docs/features.md +++ b/docs/features.md @@ -342,6 +342,64 @@ macOS-style floating control center panel bundling extensions and utilities. - **Menu item + command handling** — User can reopen anytime - **First-run preference tracking** — Auto-opens only on first launch (managed via `dao_pref_names`) +## 6.1 Browser Data Migration + +- **Standalone migration surface** — `dao://import` is a dedicated Lit WebUI, + with a localized **Import browser data** document title for its browser tab, + exposed as an explicit **Import browser data** row on the **You and Dao** + Settings page and from the existing system **Import Bookmarks and Settings…** + command instead of Chromium's modal importer. Its source grid always shows + Chrome, Arc, Edge, Safari, and Firefox: detected profiles remain individually + selectable, while browser kinds without a detected profile appear as disabled + cards. The flow lets the user choose supported data categories, reconnects to + an active profile-scoped job after reload, and reports per-category progress + and retryable partial failures. Stopped jobs retain completed-batch counts and + are presented as cancelled, not completed. Partial completion identifies the + categories that need retrying in a dedicated summary and visually marks their + result cards without hiding any items that were imported before the failure. + The active migration rail uses the detected source browser's product logo and + Dao's packaged product logo instead of generic letter placeholders. +- **Asynchronous candidate counts** — Selecting a Chromium-family profile + starts independent background counts for each supported category without + blocking navigation or migration. Bookmark, history, password, and extension + counts read source metadata directly; password counting never decrypts a + credential or triggers Keychain authorization. Tab sessions are counted only + from a temporary snapshot because Chromium's session reader can rotate files. + These preflight values describe scanned candidates and may differ from final + imported totals if the source changes or Dao skips conflicts. Legacy Safari + and Firefox importers report the count as unavailable until migration. +- **Supported sources** — Chrome, Arc, and Edge profiles use Dao's snapshot + adapters; Safari and Firefox profiles use Chromium's sandboxed platform + importers for the categories those importers support on macOS. +- **Safe source reads** — Chromium-family stores are copied to a temporary + profile snapshot with bounded metadata-stability retries. SQLite sidecars and + session directories are included, so source browsers can normally remain + open. Temporary snapshots are deleted with the category operation. Cleanup + stays off the UI thread and blocks browser shutdown until copied history, + password, and session data has been removed. +- **Merge-only destination writes** — Bookmarks are placed under a localized + imported root, destination password conflicts are preserved, already-open + tab URLs and installed extensions are skipped, and history writes use the + profile History service. History and password counts advance only after the + destination services confirm the persisted records. No category replaces + existing Dao data. +- **Passwords and extensions** — The selection screen warns that password + decryption may trigger a macOS Keychain authorization prompt. A denial fails + only passwords. Compatible web-store extensions are reinstalled in sequence; + extension storage and sign-in state are not copied. +- **Imported tabs** — Source session tabs retain order, become background + discarded tabs, and are collected in a collapsed sidebar folder. Tabs are + created in cancellable batches; a failed folder write rolls back tabs from + that import instead of leaving orphaned browser tabs. Successful folder + persistence invalidates every live same-profile sidebar cache. +- **Privacy boundary** — Migration records, snapshots, and progress stay local; + only official extension reinstallation may use the network. Cookies are not + imported because Chromium does not expose a safe cross-profile cookie import + API and copied encrypted cookie stores are not portable. +- Core owners: `browser/import/dao_migration_service.*`, + `dao_profile_snapshot.*`, source adapters and target writers; + `webui/dao_import_ui.*` and `webui/resources/import/` own the WebUI. + ## 7. Little Dao Window Lightweight window form factor for popups / mini-tools. diff --git a/scripts/commands/__tests__/browser_import_contract.test.ts b/scripts/commands/__tests__/browser_import_contract.test.ts new file mode 100644 index 00000000..384c1367 --- /dev/null +++ b/scripts/commands/__tests__/browser_import_contract.test.ts @@ -0,0 +1,26 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import {describe, expect, it} from 'vitest'; + +const repoRoot = path.resolve(import.meta.dirname, '../../..'); + +function readDaoSource(relativePath: string): string { + return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); +} + +describe('browser import ownership contract', () => { + it('blocks browser shutdown until snapshot cleanup is complete', () => { + const header = readDaoSource( + 'src/dao/browser/import/dao_profile_snapshot.h', + ); + const implementation = readDaoSource( + 'src/dao/browser/import/dao_profile_snapshot.cc', + ); + + expect(header).toContain('base::OnTaskRunnerDeleter'); + expect(implementation).toMatch( + /CreateSequencedTaskRunner\(\s*\{base::MayBlock\(\),[\s\S]*?base::TaskShutdownBehavior::BLOCK_SHUTDOWN\}/, + ); + }); +}); diff --git a/scripts/commands/__tests__/settings_redesign_contract.test.ts b/scripts/commands/__tests__/settings_redesign_contract.test.ts index 4b818925..6810c8ac 100644 --- a/scripts/commands/__tests__/settings_redesign_contract.test.ts +++ b/scripts/commands/__tests__/settings_redesign_contract.test.ts @@ -1249,4 +1249,45 @@ describe("settings continuous overview contract", () => { expect(webUiTestPatch).toContain("clearFailureUsesActionSpecificFeedback"); expect(webUiTestPatch).toContain("resetFailureUsesActionSpecificFeedback"); }); + + it("exposes browser migration from the Dao settings page", () => { + const htmlPatch = readDaoSource( + "src/patches/chrome/browser/resources/settings/dao_page/" + + "dao_page.html.patch", + ); + const tsPatch = readDaoSource( + "src/patches/chrome/browser/resources/settings/dao_page/" + + "dao_page.ts.patch", + ); + const webUiTestPatch = readDaoSource( + "src/patches/chrome/test/data/webui/settings/dao_page_test.ts.patch", + ); + const profileFactoriesPatch = readDaoSource( + "src/patches/chrome/browser/profiles/" + + "chrome_browser_main_extra_parts_profiles.cc.patch", + ); + const importHtml = readDaoSource( + "src/dao/browser/ui/webui/resources/import/import.html", + ); + + expect(htmlPatch).toContain('id="importBrowserData"'); + expect(htmlPatch).toContain('$i18n{importTitle}'); + expect(htmlPatch).toContain('on-click="onImportBrowserDataClick_"'); + expect(tsPatch).toContain( + "OpenWindowProxyImpl.getInstance().openUrl('dao://import/');", + ); + expect(webUiTestPatch).toContain( + "importBrowserDataOpensStandaloneMigration", + ); + expect(profileFactoriesPatch).toContain( + '#include "dao/browser/import/dao_migration_service_factory.h"', + ); + expect(profileFactoriesPatch).toContain( + "dao::import::DaoMigrationServiceFactory::GetInstance();", + ); + expect(importHtml.indexOf('src="strings.m.js"')).toBeGreaterThanOrEqual(0); + expect(importHtml.indexOf('src="strings.m.js"')).toBeLessThan( + importHtml.indexOf('src="import.js"'), + ); + }); }); diff --git a/src/dao/browser/import/dao_chromium_migration_target.cc b/src/dao/browser/import/dao_chromium_migration_target.cc new file mode 100644 index 00000000..6a84fb82 --- /dev/null +++ b/src/dao/browser/import/dao_chromium_migration_target.cc @@ -0,0 +1,576 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/import/dao_chromium_migration_target.h" + +#include +#include + +#include "base/check.h" +#include "base/files/file_util.h" +#include "base/functional/bind.h" +#include "base/json/json_reader.h" +#include "base/json/json_writer.h" +#include "base/strings/utf_string_conversions.h" +#include "base/uuid.h" +#include "chrome/browser/bookmarks/bookmark_model_factory.h" +#include "chrome/browser/extensions/webstore_install_with_prompt.h" +#include "chrome/browser/history/history_service_factory.h" +#include "chrome/browser/password_manager/factories/profile_password_store_factory.h" +#include "chrome/browser/profiles/profile.h" +#include "chrome/browser/resource_coordinator/lifecycle_unit_state.mojom.h" +#include "chrome/browser/resource_coordinator/tab_lifecycle_unit_external.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/browser_finder.h" +#include "chrome/browser/ui/browser_tabstrip.h" +#include "chrome/browser/ui/browser_window/public/browser_window_interface.h" +#include "chrome/browser/ui/browser_window/public/profile_browser_collection.h" +#include "chrome/browser/ui/tabs/tab_strip_model.h" +#include "components/bookmarks/browser/bookmark_model.h" +#include "components/bookmarks/browser/bookmark_node.h" +#include "components/history/core/browser/history_service.h" +#include "components/history/core/browser/history_types.h" +#include "components/password_manager/core/browser/password_form.h" +#include "components/password_manager/core/browser/password_form_digest.h" +#include "components/password_manager/core/browser/password_store/actionable_error.h" +#include "components/password_manager/core/browser/password_store/password_store_interface.h" +#include "components/password_manager/core/common/password_manager_pref_names.h" +#include "components/prefs/pref_service.h" +#include "dao/browser/ui/views/dao_tab_identity.h" +#include "dao/browser/ui/webui/dao_pinned_tab_storage.h" +#include "dao/browser/ui/webui/dao_sidebar_ui.h" +#include "extensions/browser/disable_reason.h" +#include "extensions/browser/extension_registrar.h" +#include "extensions/browser/extension_registry.h" +#include "ui/base/base_window.h" +#include "ui/gfx/native_ui_types.h" + +namespace dao::import { +namespace { + +std::string JoinPath(const std::vector& path) { + std::string key; + for (const std::u16string& component : path) { + key.append(base::UTF16ToUTF8(component)); + key.push_back('\0'); + } + return key; +} + +std::pair PasswordKey(const PasswordEntry& entry) { + return {entry.signon_realm, entry.username}; +} + +} // namespace + +DaoChromiumMigrationTarget::DaoChromiumMigrationTarget(Profile* profile) + : profile_(profile), + bookmark_model_(BookmarkModelFactory::GetForBrowserContext(profile)) {} + +DaoChromiumMigrationTarget::~DaoChromiumMigrationTarget() = default; + +bool DaoChromiumMigrationTarget::HasBookmark( + const BookmarkEntry& entry, + const std::u16string& root_name) const { + if (added_bookmarks_.contains(BookmarkKey(entry))) { + return true; + } + if (!bookmark_model_ || !bookmark_model_->loaded()) { + return false; + } + const bookmarks::BookmarkNode* bar = + bookmark_model_->account_bookmark_bar_node() + ? bookmark_model_->account_bookmark_bar_node() + : bookmark_model_->bookmark_bar_node(); + const bookmarks::BookmarkNode* parent = nullptr; + for (const auto& child : bar->children()) { + if (child->is_folder() && child->GetTitle() == root_name) { + parent = child.get(); + break; + } + } + if (!parent) { + return false; + } + for (const std::u16string& component : entry.path) { + const bookmarks::BookmarkNode* next = nullptr; + for (const auto& child : parent->children()) { + if (child->is_folder() && child->GetTitle() == component) { + next = child.get(); + break; + } + } + if (!next) { + return false; + } + parent = next; + } + for (const auto& child : parent->children()) { + if (child->GetTitle() == entry.title && + child->is_folder() == entry.is_folder && + (entry.is_folder || child->url() == entry.url)) { + return true; + } + } + return false; +} + +bool DaoChromiumMigrationTarget::AddBookmark(const BookmarkEntry& entry, + const std::u16string& root_name) { + if (!bookmark_model_ || !bookmark_model_->loaded()) { + return false; + } + const bookmarks::BookmarkNode* parent = + FindOrCreateBookmarkParent(entry, root_name); + if (!parent) { + return false; + } + const bookmarks::BookmarkNode* added = nullptr; + if (entry.is_folder) { + added = bookmark_model_->AddFolder( + parent, parent->children().size(), entry.title, nullptr, + entry.creation_time.is_null() + ? std::nullopt + : std::optional(entry.creation_time)); + std::vector path = entry.path; + path.push_back(entry.title); + if (added) { + bookmark_folders_[JoinPath(path)] = added; + } + } else { + added = bookmark_model_->AddURL( + parent, parent->children().size(), entry.title, entry.url, nullptr, + entry.creation_time.is_null() + ? std::nullopt + : std::optional(entry.creation_time)); + } + if (!added) { + return false; + } + added_bookmarks_.insert(BookmarkKey(entry)); + return true; +} + +bool DaoChromiumMigrationTarget::HasHistoryVisit( + const HistoryVisit& entry) const { + return added_history_.contains({entry.url.spec(), entry.visit_time}); +} + +void DaoChromiumMigrationTarget::AddHistoryVisit(const HistoryVisit& entry, + ItemWriteCallback callback) { + history::HistoryService* history = HistoryServiceFactory::GetForProfile( + profile_, ServiceAccessType::EXPLICIT_ACCESS); + if (!history) { + std::move(callback).Run(false); + return; + } + history->AddPage(entry.url, entry.visit_time, + history::SOURCE_OS_MIGRATION_IMPORTED); + history->SetPageTitle(entry.url, entry.title); + history->QueryURLAndVisits( + entry.url, history::VisitQuery404sPolicy::kInclude404s, + base::BindOnce(&DaoChromiumMigrationTarget::OnHistoryWriteVerified, + weak_ptr_factory_.GetWeakPtr(), entry, + std::move(callback)), + &history_task_tracker_); +} + +void DaoChromiumMigrationTarget::OnHistoryWriteVerified( + HistoryVisit entry, + ItemWriteCallback callback, + history::QueryURLAndVisitsResult result) { + const bool persisted = + result.success && + std::ranges::any_of(result.visits, + [&entry](const history::VisitRow& row) { + return row.visit_time == entry.visit_time; + }); + if (persisted) { + added_history_.insert({entry.url.spec(), entry.visit_time}); + } + std::move(callback).Run(persisted); +} + +PasswordMatch DaoChromiumMigrationTarget::MatchPassword( + const PasswordEntry& entry) const { + auto it = passwords_.find(PasswordKey(entry)); + if (it == passwords_.end()) { + return PasswordMatch::kNone; + } + return it->second == entry.password ? PasswordMatch::kSame + : PasswordMatch::kConflict; +} + +void DaoChromiumMigrationTarget::AddPassword(const PasswordEntry& entry, + ItemWriteCallback callback) { + if (!profile_->GetPrefs()->GetBoolean( + password_manager::prefs::kCredentialsEnableService)) { + std::move(callback).Run(false); + return; + } + scoped_refptr password_store = + ProfilePasswordStoreFactory::GetForProfile( + profile_, ServiceAccessType::EXPLICIT_ACCESS); + if (!password_store || + password_store->GetError() != + password_manager::ActionableError::kNoError || + !pending_password_callback_.is_null()) { + std::move(callback).Run(false); + return; + } + password_manager::PasswordForm form; + form.url = entry.origin; + form.signon_realm = entry.signon_realm; + form.username_value = entry.username; + form.password_value = entry.password; + form.date_created = entry.date_created; + form.type = password_manager::PasswordForm::Type::kImported; + pending_password_entry_ = entry; + pending_password_callback_ = std::move(callback); + password_store->AddLogin( + form, base::BindOnce(&DaoChromiumMigrationTarget::OnPasswordAdded, + weak_ptr_factory_.GetWeakPtr(), password_store)); +} + +void DaoChromiumMigrationTarget::OnPasswordAdded( + scoped_refptr store) { + if (!pending_password_entry_ || pending_password_callback_.is_null()) { + return; + } + if (store->GetError() != password_manager::ActionableError::kNoError) { + pending_password_entry_.reset(); + std::move(pending_password_callback_).Run(false); + return; + } + password_manager::PasswordForm form; + form.url = pending_password_entry_->origin; + form.signon_realm = pending_password_entry_->signon_realm; + store->GetLogins(password_manager::PasswordFormDigest(form), + weak_ptr_factory_.GetWeakPtr()); +} + +void DaoChromiumMigrationTarget::OnGetPasswordStoreResultsOrErrorFrom( + password_manager::PasswordStoreInterface*, + password_manager::LoginsResultOrError results_or_error) { + if (!pending_password_entry_ || pending_password_callback_.is_null()) { + return; + } + const PasswordEntry entry = *pending_password_entry_; + bool persisted = false; + if (std::holds_alternative( + results_or_error)) { + persisted = std::ranges::any_of( + std::get(results_or_error), + [&entry](const password_manager::StoredCredential& credential) { + return credential.signon_realm == entry.signon_realm && + credential.username_value == entry.username && + credential.password_value == entry.password; + }); + } + if (persisted) { + passwords_[PasswordKey(entry)] = entry.password; + } + pending_password_entry_.reset(); + std::move(pending_password_callback_).Run(persisted); +} + +bool DaoChromiumMigrationTarget::IsTabOpen(const GURL& url) const { + ProfileBrowserCollection* collection = + ProfileBrowserCollection::GetForProfile(profile_); + if (!collection) { + return false; + } + bool found = false; + collection->ForEach([&](BrowserWindowInterface* browser_window) { + TabStripModel* model = + browser_window->GetBrowserForMigrationOnly()->tab_strip_model(); + for (int index = 0; index < model->count(); ++index) { + content::WebContents* contents = model->GetWebContentsAt(index); + if (contents && contents->GetVisibleURL() == url) { + found = true; + return false; + } + } + return true; + }); + return found; +} + +std::string DaoChromiumMigrationTarget::EnsureImportedTabFolder( + const std::u16string& folder_name) { + if (!LoadFolderData()) { + return std::string(); + } + const std::string id = base::Uuid::GenerateRandomV4().AsLowercaseString(); + base::DictValue folder; + folder.Set("type", "folder"); + folder.Set("id", id); + folder.Set("name", base::UTF16ToUTF8(folder_name)); + folder.Set("collapsed", true); + folder.Set("children", base::ListValue()); + folder_items_->Append(std::move(folder)); + pending_folder_ = folder_items_->back().GetIfDict(); + return id; +} + +bool DaoChromiumMigrationTarget::AddDormantTab(const TabEntry& entry, + const std::string& folder_id) { + const std::string* pending_id = + pending_folder_ ? pending_folder_->FindString("id") : nullptr; + if (!pending_id || *pending_id != folder_id) { + return false; + } + BrowserWindowInterface* browser_window = + chrome::FindLastActiveWithProfile(profile_); + if (!browser_window) { + return false; + } + content::WebContents* contents = + chrome::AddAndReturnTabAt(browser_window, entry.url, -1, false); + if (!contents) { + return false; + } + const std::string tab_id = GetSidebarTabId(contents); + base::DictValue child; + child.Set("type", "tab"); + child.Set("tabId", tab_id); + child.Set("url", entry.url.spec()); + child.Set("title", base::UTF16ToUTF8(entry.title)); + pending_folder_->FindList("children")->Append(std::move(child)); + pending_folder_tab_ids_.push_back(tab_id); + + if (auto* lifecycle = + resource_coordinator::TabLifecycleUnitExternal::FromWebContents( + contents)) { + lifecycle->DiscardTab(::mojom::LifecycleUnitDiscardReason::EXTERNAL); + } + return true; +} + +bool DaoChromiumMigrationTarget::FinishImportedTabFolder( + const std::string& folder_id) { + const std::string* pending_id = + pending_folder_ ? pending_folder_->FindString("id") : nullptr; + if (!pending_id || *pending_id != folder_id) { + return false; + } + bool persisted = false; + std::string json; + if (base::JSONWriter::WriteWithOptions( + folder_data_, base::JSONWriter::OPTIONS_PRETTY_PRINT, &json)) { + persisted = WritePinnedTabsFileAtomically( + profile_->GetPath().AppendASCII("dao_folders.json"), json); + if (persisted) { + DaoSidebarUIHandler::NotifyFolderDataChanged(profile_); + } + } + if (persisted) { + pending_folder_ = nullptr; + pending_folder_tab_ids_.clear(); + } + return persisted; +} + +void DaoChromiumMigrationTarget::AbortImportedTabFolder( + const std::string& folder_id) { + if (folder_items_) { + folder_items_->EraseIf([&folder_id](const base::Value& value) { + const base::DictValue* folder = value.GetIfDict(); + const std::string* id = folder ? folder->FindString("id") : nullptr; + return id && *id == folder_id; + }); + } + pending_folder_ = nullptr; + + ProfileBrowserCollection* collection = + ProfileBrowserCollection::GetForProfile(profile_); + for (const std::string& tab_id : pending_folder_tab_ids_) { + if (!collection) { + continue; + } + collection->ForEach([&tab_id](BrowserWindowInterface* browser_window) { + TabStripModel* model = + browser_window->GetBrowserForMigrationOnly()->tab_strip_model(); + for (int index = 0; index < model->count(); ++index) { + content::WebContents* contents = model->GetWebContentsAt(index); + if (contents && GetSidebarTabId(contents) == tab_id) { + model->DetachAndDeleteWebContentsAt(index); + return false; + } + } + return true; + }); + } + pending_folder_tab_ids_.clear(); +} + +bool DaoChromiumMigrationTarget::IsExtensionInstalled( + const std::string& id) const { + return extensions::ExtensionRegistry::Get(profile_)->GetInstalledExtension( + id) != nullptr; +} + +bool DaoChromiumMigrationTarget::QueueExtensionInstall( + const ExtensionEntry& entry) { + if (cancel_extension_installs_) { + return false; + } + extension_queue_.push_back(entry); + StartNextExtensionInstall(); + return true; +} + +void DaoChromiumMigrationTarget::FinishExtensionInstalls( + base::OnceCallback callback) { + CHECK(extension_completion_callback_.is_null()); + extension_completion_callback_ = std::move(callback); + MaybeFinishExtensionInstalls(); +} + +void DaoChromiumMigrationTarget::CancelExtensionInstalls() { + cancel_extension_installs_ = true; + if (extension_installer_) { + ExtensionEntry active = std::move(extension_queue_.front()); + extension_queue_.clear(); + extension_queue_.push_back(std::move(active)); + } else { + extension_queue_.clear(); + } + MaybeFinishExtensionInstalls(); +} + +void DaoChromiumMigrationTarget::StartNextExtensionInstall() { + if (extension_installer_ || extension_queue_.empty() || + cancel_extension_installs_) { + MaybeFinishExtensionInstalls(); + return; + } + const ExtensionEntry& entry = extension_queue_.front(); + BrowserWindowInterface* browser_window = + chrome::FindLastActiveWithProfile(profile_); + gfx::NativeWindow parent = + browser_window ? browser_window->GetWindow()->GetNativeWindow() + : gfx::NativeWindow(); + extension_installer_ = + base::MakeRefCounted( + entry.id, profile_, parent, + base::BindOnce( + [](base::WeakPtr target, bool enabled, + std::string extension_id, bool success, const std::string&, + extensions::webstore_install::Result) { + if (target) { + target->OnExtensionInstallFinished( + enabled, std::move(extension_id), success); + } + }, + weak_ptr_factory_.GetWeakPtr(), entry.enabled, entry.id)); + extension_installer_->BeginInstall(); +} + +void DaoChromiumMigrationTarget::OnExtensionInstallFinished( + bool enabled, + std::string extension_id, + bool success) { + if (success && !enabled) { + extensions::ExtensionRegistrar::Get(profile_)->DisableExtension( + extension_id, {extensions::disable_reason::DISABLE_USER_ACTION}); + } + if (success) { + ++installed_extension_count_; + } else { + ++failed_extension_count_; + } + extension_installer_.reset(); + if (!extension_queue_.empty()) { + extension_queue_.pop_front(); + } + StartNextExtensionInstall(); +} + +void DaoChromiumMigrationTarget::MaybeFinishExtensionInstalls() { + if (extension_installer_ || !extension_queue_.empty() || + extension_completion_callback_.is_null()) { + return; + } + std::move(extension_completion_callback_) + .Run(installed_extension_count_, failed_extension_count_); +} + +void DaoChromiumMigrationTarget::SetExistingPasswords( + std::map, std::u16string> + passwords) { + passwords_ = std::move(passwords); +} + +std::string DaoChromiumMigrationTarget::BookmarkKey( + const BookmarkEntry& entry) const { + return JoinPath(entry.path) + base::UTF16ToUTF8(entry.title) + "\0" + + entry.url.spec(); +} + +const bookmarks::BookmarkNode* +DaoChromiumMigrationTarget::FindOrCreateBookmarkParent( + const BookmarkEntry& entry, + const std::u16string& root_name) { + const bookmarks::BookmarkNode* bar = + bookmark_model_->account_bookmark_bar_node() + ? bookmark_model_->account_bookmark_bar_node() + : bookmark_model_->bookmark_bar_node(); + const bookmarks::BookmarkNode* parent = nullptr; + for (const auto& child : bar->children()) { + if (child->is_folder() && child->GetTitle() == root_name) { + parent = child.get(); + break; + } + } + if (!parent) { + parent = bookmark_model_->AddFolder(bar, bar->children().size(), root_name); + } + std::vector resolved_path; + for (const std::u16string& component : entry.path) { + resolved_path.push_back(component); + const std::string key = JoinPath(resolved_path); + auto known = bookmark_folders_.find(key); + if (known != bookmark_folders_.end()) { + parent = known->second; + continue; + } + const bookmarks::BookmarkNode* existing = nullptr; + for (const auto& child : parent->children()) { + if (child->is_folder() && child->GetTitle() == component) { + existing = child.get(); + break; + } + } + parent = existing ? existing + : bookmark_model_->AddFolder( + parent, parent->children().size(), component); + bookmark_folders_[key] = parent; + } + return parent; +} + +bool DaoChromiumMigrationTarget::LoadFolderData() { + std::string contents; + const base::FilePath path = + profile_->GetPath().AppendASCII("dao_folders.json"); + if (base::PathExists(path)) { + if (!base::ReadFileToString(path, &contents)) { + return false; + } + std::optional parsed = + base::JSONReader::ReadDict(contents, base::JSON_PARSE_RFC); + if (!parsed) { + return false; + } + folder_data_ = std::move(*parsed); + } else { + folder_data_.Set("version", 1); + folder_data_.Set("items", base::ListValue()); + } + folder_items_ = folder_data_.FindList("items"); + return folder_items_ != nullptr; +} + +} // namespace dao::import diff --git a/src/dao/browser/import/dao_chromium_migration_target.h b/src/dao/browser/import/dao_chromium_migration_target.h new file mode 100644 index 00000000..2855e82f --- /dev/null +++ b/src/dao/browser/import/dao_chromium_migration_target.h @@ -0,0 +1,131 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef DAO_BROWSER_IMPORT_DAO_CHROMIUM_MIGRATION_TARGET_H_ +#define DAO_BROWSER_IMPORT_DAO_CHROMIUM_MIGRATION_TARGET_H_ + +#include +#include +#include +#include +#include +#include +#include + +#include "base/functional/callback.h" +#include "base/memory/raw_ptr.h" +#include "base/memory/scoped_refptr.h" +#include "base/memory/weak_ptr.h" +#include "base/task/cancelable_task_tracker.h" +#include "base/values.h" +#include "components/password_manager/core/browser/password_store/password_store_consumer.h" +#include "dao/browser/import/dao_migration_writer.h" + +class Profile; + +namespace bookmarks { +class BookmarkModel; +class BookmarkNode; +} // namespace bookmarks + +namespace extensions { +class WebstoreInstallWithPrompt; +} // namespace extensions + +namespace history { +struct QueryURLAndVisitsResult; +} // namespace history + +namespace dao::import { + +// Writes normalized migration records through Chromium's profile services. +// This class is UI-thread bound and intentionally never touches destination +// profile databases directly. +class DaoChromiumMigrationTarget + : public MigrationTarget, + public password_manager::PasswordStoreConsumer { + public: + explicit DaoChromiumMigrationTarget(Profile* profile); + DaoChromiumMigrationTarget(const DaoChromiumMigrationTarget&) = delete; + DaoChromiumMigrationTarget& operator=(const DaoChromiumMigrationTarget&) = + delete; + ~DaoChromiumMigrationTarget() override; + + bool HasBookmark(const BookmarkEntry& entry, + const std::u16string& root_name) const override; + bool AddBookmark(const BookmarkEntry& entry, + const std::u16string& root_name) override; + bool HasHistoryVisit(const HistoryVisit& entry) const override; + void AddHistoryVisit(const HistoryVisit& entry, + ItemWriteCallback callback) override; + PasswordMatch MatchPassword(const PasswordEntry& entry) const override; + void AddPassword(const PasswordEntry& entry, + ItemWriteCallback callback) override; + bool IsTabOpen(const GURL& url) const override; + std::string EnsureImportedTabFolder( + const std::u16string& folder_name) override; + bool AddDormantTab(const TabEntry& entry, + const std::string& folder_id) override; + bool FinishImportedTabFolder(const std::string& folder_id) override; + void AbortImportedTabFolder(const std::string& folder_id) override; + bool IsExtensionInstalled(const std::string& id) const override; + bool QueueExtensionInstall(const ExtensionEntry& entry) override; + void FinishExtensionInstalls( + base::OnceCallback callback); + void CancelExtensionInstalls(); + + // password_manager::PasswordStoreConsumer: + void OnGetPasswordStoreResultsOrErrorFrom( + password_manager::PasswordStoreInterface* store, + password_manager::LoginsResultOrError results_or_error) override; + + // Populates destination credential identities before password writes. The + // service calls this with forms returned by PasswordStoreInterface. + void SetExistingPasswords(std::map, + std::u16string> passwords); + + private: + std::string BookmarkKey(const BookmarkEntry& entry) const; + const bookmarks::BookmarkNode* FindOrCreateBookmarkParent( + const BookmarkEntry& entry, + const std::u16string& root_name); + bool LoadFolderData(); + void StartNextExtensionInstall(); + void MaybeFinishExtensionInstalls(); + void OnHistoryWriteVerified(HistoryVisit entry, + ItemWriteCallback callback, + history::QueryURLAndVisitsResult result); + void OnPasswordAdded( + scoped_refptr store); + void OnExtensionInstallFinished(bool enabled, + std::string extension_id, + bool success); + + raw_ptr profile_; + raw_ptr bookmark_model_ = nullptr; + std::set added_bookmarks_; + std::set> added_history_; + base::CancelableTaskTracker history_task_tracker_; + std::map, std::u16string> passwords_; + std::optional pending_password_entry_; + ItemWriteCallback pending_password_callback_; + std::map> + bookmark_folders_; + base::DictValue folder_data_; + raw_ptr folder_items_ = nullptr; + raw_ptr pending_folder_ = nullptr; + std::vector pending_folder_tab_ids_; + std::deque extension_queue_; + scoped_refptr extension_installer_; + base::OnceCallback + extension_completion_callback_; + uint64_t installed_extension_count_ = 0; + uint64_t failed_extension_count_ = 0; + bool cancel_extension_installs_ = false; + base::WeakPtrFactory weak_ptr_factory_{this}; +}; + +} // namespace dao::import + +#endif // DAO_BROWSER_IMPORT_DAO_CHROMIUM_MIGRATION_TARGET_H_ diff --git a/src/dao/browser/import/dao_chromium_password_decryptor_mac.h b/src/dao/browser/import/dao_chromium_password_decryptor_mac.h new file mode 100644 index 00000000..c3cdaa26 --- /dev/null +++ b/src/dao/browser/import/dao_chromium_password_decryptor_mac.h @@ -0,0 +1,38 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef DAO_BROWSER_IMPORT_DAO_CHROMIUM_PASSWORD_DECRYPTOR_MAC_H_ +#define DAO_BROWSER_IMPORT_DAO_CHROMIUM_PASSWORD_DECRYPTOR_MAC_H_ + +#include +#include + +#include "dao/browser/import/dao_migration_types.h" +#include "dao/browser/import/dao_source_adapter.h" + +namespace dao::import { + +class DaoChromiumPasswordDecryptorMac : public PasswordDecryptor { + public: + explicit DaoChromiumPasswordDecryptorMac(SourceKind source_kind); + DaoChromiumPasswordDecryptorMac(const DaoChromiumPasswordDecryptorMac&) = + delete; + DaoChromiumPasswordDecryptorMac& operator=( + const DaoChromiumPasswordDecryptorMac&) = delete; + ~DaoChromiumPasswordDecryptorMac() override; + + std::optional Decrypt( + base::span encrypted_value) override; + + private: + bool LoadKey(); + + SourceKind source_kind_; + std::optional> key_; + bool key_lookup_attempted_ = false; +}; + +} // namespace dao::import + +#endif // DAO_BROWSER_IMPORT_DAO_CHROMIUM_PASSWORD_DECRYPTOR_MAC_H_ diff --git a/src/dao/browser/import/dao_chromium_password_decryptor_mac.mm b/src/dao/browser/import/dao_chromium_password_decryptor_mac.mm new file mode 100644 index 00000000..b780e9d6 --- /dev/null +++ b/src/dao/browser/import/dao_chromium_password_decryptor_mac.mm @@ -0,0 +1,101 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/import/dao_chromium_password_decryptor_mac.h" + +#include +#include +#include +#include +#include + +#include "base/containers/span.h" +#include "base/notreached.h" +#include "base/strings/utf_string_conversions.h" +#include "crypto/aes_cbc.h" +#include "crypto/apple/keychain_v2.h" +#include "crypto/kdf.h" +#include "crypto/subtle_passkey.h" + +namespace dao::import { +namespace { + +struct KeychainIdentity { + const char* service; + const char* account; +}; + +KeychainIdentity IdentityForSource(SourceKind source_kind) { + switch (source_kind) { + case SourceKind::kChrome: + return {"Chrome Safe Storage", "Chrome"}; + case SourceKind::kArc: + return {"Arc Safe Storage", "Arc"}; + case SourceKind::kEdge: + return {"Microsoft Edge Safe Storage", "Microsoft Edge"}; + case SourceKind::kSafari: + case SourceKind::kFirefox: + return {nullptr, nullptr}; + } + NOTREACHED(); +} + +constexpr auto kSalt = + std::to_array({'s', 'a', 'l', 't', 'y', 's', 'a', 'l', 't'}); +constexpr auto kIv = + std::to_array({' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', + ' ', ' ', ' ', ' ', ' ', ' '}); + +} // namespace + +DaoChromiumPasswordDecryptorMac::DaoChromiumPasswordDecryptorMac( + SourceKind source_kind) + : source_kind_(source_kind) {} + +DaoChromiumPasswordDecryptorMac::~DaoChromiumPasswordDecryptorMac() = default; + +std::optional DaoChromiumPasswordDecryptorMac::Decrypt( + base::span encrypted_value) { + constexpr auto kVersion = std::to_array({'v', '1', '0'}); + if (encrypted_value.size() < kVersion.size() || + !std::equal(kVersion.begin(), kVersion.end(), encrypted_value.begin()) || + !LoadKey()) { + return std::nullopt; + } + + std::optional> plaintext = crypto::aes_cbc::Decrypt( + *key_, kIv, encrypted_value.subspan(kVersion.size())); + if (!plaintext) { + return std::nullopt; + } + return base::UTF8ToUTF16(std::string(plaintext->begin(), plaintext->end())); +} + +bool DaoChromiumPasswordDecryptorMac::LoadKey() { + if (key_) { + return true; + } + if (key_lookup_attempted_) { + return false; + } + key_lookup_attempted_ = true; + + KeychainIdentity identity = IdentityForSource(source_kind_); + if (!identity.service || !identity.account) { + return false; + } + auto password = crypto::apple::KeychainV2::GetInstance().FindGenericPassword( + identity.service, identity.account); + if (!password.has_value() || password->empty()) { + return false; + } + + std::array derived_key; + crypto::kdf::Pbkdf2HmacSha1({.iterations = 1003}, *password, kSalt, + derived_key, crypto::SubtlePassKey{}); + key_ = derived_key; + return true; +} + +} // namespace dao::import diff --git a/src/dao/browser/import/dao_chromium_profile_adapter.cc b/src/dao/browser/import/dao_chromium_profile_adapter.cc new file mode 100644 index 00000000..15008cb9 --- /dev/null +++ b/src/dao/browser/import/dao_chromium_profile_adapter.cc @@ -0,0 +1,357 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/import/dao_chromium_profile_adapter.h" + +#include +#include +#include +#include +#include + +#include "base/files/file_util.h" +#include "base/json/json_reader.h" +#include "base/memory/ref_counted.h" +#include "base/strings/cstring_view.h" +#include "base/strings/string_number_conversions.h" +#include "base/strings/utf_string_conversions.h" +#include "base/task/sequenced_task_runner.h" +#include "base/time/time.h" +#include "base/values.h" +#include "components/sessions/core/command_storage_backend.h" +#include "components/sessions/core/command_storage_manager.h" +#include "components/sessions/core/serialized_navigation_entry.h" +#include "components/sessions/core/session_service_commands.h" +#include "components/sessions/core/session_types.h" +#include "sql/database.h" +#include "sql/statement.h" + +namespace dao::import { +namespace { + +inline constexpr sql::Database::Tag kDatabaseTag{"DaoMigration"}; + +base::Time ChromiumTimeFromString(const std::string *value) { + int64_t microseconds = 0; + if (!value || !base::StringToInt64(*value, µseconds) || + microseconds <= 0) { + return base::Time(); + } + return base::Time::FromDeltaSinceWindowsEpoch( + base::Microseconds(microseconds)); +} + +void ReadBookmarkChildren(const base::ListValue *children, bool in_toolbar, + std::vector path, + std::vector *entries) { + if (!children) { + return; + } + for (const base::Value &child : *children) { + if (!child.is_dict()) { + continue; + } + const base::DictValue &node = child.GetDict(); + const std::string *type = node.FindString("type"); + const std::string *name = node.FindString("name"); + if (!type || !name) { + continue; + } + + BookmarkEntry entry; + entry.in_toolbar = in_toolbar; + entry.is_folder = *type == "folder"; + entry.path = path; + entry.title = base::UTF8ToUTF16(*name); + entry.creation_time = ChromiumTimeFromString(node.FindString("date_added")); + if (entry.is_folder) { + entries->push_back(entry); + path.push_back(entry.title); + ReadBookmarkChildren(node.FindList("children"), in_toolbar, + std::move(path), entries); + continue; + } + + const std::string *url = node.FindString("url"); + if (!url) { + continue; + } + entry.url = GURL(*url); + if (entry.url.is_valid()) { + entries->push_back(std::move(entry)); + } + } +} + +template ReadBatch ReadFailure(std::string error_code) { + ReadBatch batch; + batch.error_code = std::move(error_code); + return batch; +} + +std::optional ReadCount(sql::Database *database, + base::cstring_view query) { + sql::Statement statement(database->GetUniqueStatement(query)); + if (!statement.is_valid() || !statement.Step()) { + return std::nullopt; + } + const int64_t count = statement.ColumnInt64(0); + if (count < 0 || statement.Step() || !statement.Succeeded()) { + return std::nullopt; + } + return static_cast(count); +} + +} // namespace + +DaoChromiumProfileAdapter::DaoChromiumProfileAdapter( + base::FilePath profile_path, + std::unique_ptr password_decryptor) + : profile_path_(std::move(profile_path)), + password_decryptor_(std::move(password_decryptor)) {} + +DaoChromiumProfileAdapter::~DaoChromiumProfileAdapter() = default; + +ReadBatch DaoChromiumProfileAdapter::ReadBookmarks() { + std::string contents; + if (!base::ReadFileToString(profile_path_.AppendASCII("Bookmarks"), + &contents)) { + return ReadFailure("source_missing"); + } + std::optional value = + base::JSONReader::ReadDict(contents, base::JSON_PARSE_RFC); + if (!value) { + return ReadFailure("invalid_bookmarks"); + } + const base::DictValue *roots = value->FindDict("roots"); + if (!roots) { + return ReadFailure("invalid_bookmarks"); + } + + ReadBatch batch; + batch.success = true; + for (const auto [root_name, root_value] : *roots) { + if (!root_value.is_dict()) { + continue; + } + ReadBookmarkChildren(root_value.GetDict().FindList("children"), + root_name == "bookmark_bar", {}, &batch.records); + } + return batch; +} + +ReadBatch DaoChromiumProfileAdapter::ReadHistory() { + sql::Database database(kDatabaseTag); + if (!database.Open(profile_path_.AppendASCII("History"))) { + return ReadFailure("sqlite_open_failed"); + } + sql::Statement statement(database.GetUniqueStatement( + "SELECT urls.url, urls.title, visits.visit_time " + "FROM visits JOIN urls ON urls.id = visits.url " + "ORDER BY visits.visit_time, visits.id")); + if (!statement.is_valid()) { + return ReadFailure("sqlite_query_failed"); + } + + ReadBatch batch; + batch.success = true; + while (statement.Step()) { + HistoryVisit visit; + visit.url = GURL(statement.ColumnString(0)); + if (!visit.url.is_valid()) { + continue; + } + visit.title = statement.ColumnString16(1); + visit.visit_time = base::Time::FromDeltaSinceWindowsEpoch( + base::Microseconds(statement.ColumnInt64(2))); + batch.records.push_back(std::move(visit)); + } + if (!statement.Succeeded()) { + return ReadFailure("sqlite_query_failed"); + } + return batch; +} + +ReadBatch DaoChromiumProfileAdapter::ReadPasswords() { + if (!password_decryptor_) { + return ReadFailure("password_decryptor_unavailable"); + } + sql::Database database(kDatabaseTag); + if (!database.Open(profile_path_.AppendASCII("Login Data"))) { + return ReadFailure("sqlite_open_failed"); + } + sql::Statement statement(database.GetUniqueStatement( + "SELECT origin_url, username_value, password_value, signon_realm, " + "date_created FROM logins WHERE blacklisted_by_user = 0 " + "ORDER BY date_created, origin_url, username_value")); + if (!statement.is_valid()) { + return ReadFailure("sqlite_query_failed"); + } + + ReadBatch batch; + batch.success = true; + while (statement.Step()) { + std::optional password = + password_decryptor_->Decrypt(statement.ColumnBlob(2)); + if (!password) { + return ReadFailure("password_decryption_denied"); + } + PasswordEntry entry; + entry.origin = GURL(statement.ColumnString(0)); + if (!entry.origin.is_valid()) { + continue; + } + entry.username = statement.ColumnString16(1); + entry.password = std::move(*password); + entry.signon_realm = statement.ColumnString(3); + entry.date_created = base::Time::FromDeltaSinceWindowsEpoch( + base::Microseconds(statement.ColumnInt64(4))); + batch.records.push_back(std::move(entry)); + } + if (!statement.Succeeded()) { + return ReadFailure("sqlite_query_failed"); + } + return batch; +} + +ReadBatch DaoChromiumProfileAdapter::ReadTabs() { + if (!base::SequencedTaskRunner::HasCurrentDefault()) { + return ReadFailure("session_reader_unavailable"); + } + const base::FilePath sessions_path = profile_path_.AppendASCII("Sessions"); + if (!base::DirectoryExists(sessions_path)) { + ReadBatch empty; + empty.success = true; + return empty; + } + + auto backend = base::MakeRefCounted( + base::SequencedTaskRunner::GetCurrentDefault(), sessions_path, + sessions::CommandStorageManager::SessionType::kSessionRestore, + /*encryptor=*/nullptr); + backend->MoveCurrentSessionToLastSession(); + sessions::CommandStorageBackend::ReadCommandsResult commands = + backend->ReadLastSessionCommands(); + if (commands.error_reading) { + return ReadFailure("session_read_failed"); + } + + std::vector> windows; + SessionID active_window = SessionID::InvalidValue(); + std::string platform_session_id; + std::set discarded_windows; + sessions::RestoreSessionFromCommands(commands.commands, &windows, + &active_window, &platform_session_id, + &discarded_windows); + + ReadBatch batch; + batch.success = true; + for (size_t window_index = 0; window_index < windows.size(); ++window_index) { + const sessions::SessionWindow &window = *windows[window_index]; + for (size_t tab_index = 0; tab_index < window.tabs.size(); ++tab_index) { + const sessions::SessionTab &tab = *window.tabs[tab_index]; + if (tab.navigations.empty()) { + continue; + } + const sessions::SerializedNavigationEntry &navigation = + tab.navigations[tab.normalized_navigation_index()]; + if (!navigation.virtual_url().is_valid() || + !navigation.virtual_url().SchemeIsHTTPOrHTTPS()) { + continue; + } + TabEntry entry; + entry.url = navigation.virtual_url(); + entry.title = navigation.title(); + entry.window_index = static_cast(window_index); + entry.tab_index = static_cast(tab_index); + entry.pinned = tab.pinned; + batch.records.push_back(std::move(entry)); + } + } + return batch; +} + +ReadBatch DaoChromiumProfileAdapter::ReadExtensions() { + std::string contents; + if (!base::ReadFileToString(profile_path_.AppendASCII("Preferences"), + &contents)) { + return ReadFailure("source_missing"); + } + std::optional value = + base::JSONReader::ReadDict(contents, base::JSON_PARSE_RFC); + if (!value) { + return ReadFailure("invalid_preferences"); + } + const base::DictValue *settings = + value->FindDictByDottedPath("extensions.settings"); + if (!settings) { + ReadBatch empty; + empty.success = true; + return empty; + } + + ReadBatch batch; + batch.success = true; + for (const auto [extension_id, extension_value] : *settings) { + if (!extension_value.is_dict()) { + continue; + } + const base::DictValue &extension = extension_value.GetDict(); + if (!extension.FindBool("from_webstore").value_or(false)) { + continue; + } + const base::DictValue *manifest = extension.FindDict("manifest"); + ExtensionEntry entry; + entry.id = extension_id; + if (manifest) { + const std::string *name = manifest->FindString("name"); + if (name) { + entry.name = *name; + } + } + entry.enabled = extension.FindInt("state").value_or(0) == 1; + batch.records.push_back(std::move(entry)); + } + return batch; +} + +std::optional +DaoChromiumProfileAdapter::CountCandidates(DataCategory category) { + auto count_records = [](const auto &batch) -> std::optional { + if (!batch.success) { + return std::nullopt; + } + return static_cast(batch.records.size()); + }; + switch (category) { + case DataCategory::kBookmarks: + return count_records(ReadBookmarks()); + case DataCategory::kHistory: { + sql::Database database(sql::DatabaseOptions().set_read_only(true), + kDatabaseTag); + if (!database.Open(profile_path_.AppendASCII("History"))) { + return std::nullopt; + } + return ReadCount( + &database, + "SELECT COUNT(*) FROM visits JOIN urls ON urls.id = visits.url"); + } + case DataCategory::kPasswords: { + sql::Database database(sql::DatabaseOptions().set_read_only(true), + kDatabaseTag); + if (!database.Open(profile_path_.AppendASCII("Login Data"))) { + return std::nullopt; + } + return ReadCount( + &database, "SELECT COUNT(*) FROM logins WHERE blacklisted_by_user = 0"); + } + case DataCategory::kTabs: + return count_records(ReadTabs()); + case DataCategory::kExtensions: + return count_records(ReadExtensions()); + } + return std::nullopt; +} + +} // namespace dao::import diff --git a/src/dao/browser/import/dao_chromium_profile_adapter.h b/src/dao/browser/import/dao_chromium_profile_adapter.h new file mode 100644 index 00000000..cb7f6cac --- /dev/null +++ b/src/dao/browser/import/dao_chromium_profile_adapter.h @@ -0,0 +1,46 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef DAO_BROWSER_IMPORT_DAO_CHROMIUM_PROFILE_ADAPTER_H_ +#define DAO_BROWSER_IMPORT_DAO_CHROMIUM_PROFILE_ADAPTER_H_ + +#include +#include +#include + +#include "base/files/file_path.h" +#include "dao/browser/import/dao_migration_types.h" +#include "dao/browser/import/dao_source_adapter.h" + +namespace dao::import { + +class DaoChromiumProfileAdapter : public DaoSourceAdapter { +public: + explicit DaoChromiumProfileAdapter( + base::FilePath profile_path, + std::unique_ptr password_decryptor = nullptr); + DaoChromiumProfileAdapter(const DaoChromiumProfileAdapter &) = delete; + DaoChromiumProfileAdapter & + operator=(const DaoChromiumProfileAdapter &) = delete; + ~DaoChromiumProfileAdapter() override; + + ReadBatch ReadBookmarks() override; + ReadBatch ReadHistory() override; + ReadBatch ReadPasswords() override; + ReadBatch ReadTabs() override; + ReadBatch ReadExtensions() override; + + // Returns the number of source candidates without decrypting passwords. + // The caller must use a snapshot when counting tabs because the Chromium + // session reader may rotate session files. + std::optional CountCandidates(DataCategory category); + +private: + base::FilePath profile_path_; + std::unique_ptr password_decryptor_; +}; + +} // namespace dao::import + +#endif // DAO_BROWSER_IMPORT_DAO_CHROMIUM_PROFILE_ADAPTER_H_ diff --git a/src/dao/browser/import/dao_chromium_profile_adapter_unittest.cc b/src/dao/browser/import/dao_chromium_profile_adapter_unittest.cc new file mode 100644 index 00000000..c052fc43 --- /dev/null +++ b/src/dao/browser/import/dao_chromium_profile_adapter_unittest.cc @@ -0,0 +1,149 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/import/dao_chromium_profile_adapter.h" + +#include +#include +#include + +#include "base/files/file_util.h" +#include "base/files/scoped_temp_dir.h" +#include "base/strings/string_view_util.h" +#include "sql/database.h" +#include "sql/statement.h" +#include "sql/test/test_helpers.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace dao::import { +namespace { + +class FakePasswordDecryptor : public PasswordDecryptor { +public: + std::optional + Decrypt(base::span encrypted_value) override { + if (std::string(base::as_string_view(encrypted_value)) != "cipher") { + return std::nullopt; + } + return u"secret"; + } +}; + +TEST(DaoChromiumProfileAdapterTest, ReadsBookmarkHierarchyAndToolbarOrigin) { + base::ScopedTempDir profile_dir; + ASSERT_TRUE(profile_dir.CreateUniqueTempDir()); + ASSERT_TRUE(base::WriteFile( + profile_dir.GetPath().AppendASCII("Bookmarks"), + R"({"roots":{"bookmark_bar":{"type":"folder","name":"Bookmarks bar","children":[{"type":"folder","name":"Work","children":[{"type":"url","name":"Docs","url":"https://docs.example/","date_added":"13300000000000000"}]}]},"other":{"type":"folder","name":"Other bookmarks","children":[{"type":"url","name":"News","url":"https://news.example/"}]}}})")); + + DaoChromiumProfileAdapter adapter(profile_dir.GetPath()); + ReadBatch batch = adapter.ReadBookmarks(); + + ASSERT_TRUE(batch.success); + ASSERT_EQ(3u, batch.records.size()); + EXPECT_TRUE(batch.records[0].is_folder); + EXPECT_EQ(u"Work", batch.records[0].title); + EXPECT_TRUE(batch.records[1].in_toolbar); + EXPECT_EQ(std::vector({u"Work"}), batch.records[1].path); + EXPECT_EQ("https://docs.example/", batch.records[1].url.spec()); + EXPECT_FALSE(batch.records[2].in_toolbar); + EXPECT_EQ(3u, adapter.CountCandidates(DataCategory::kBookmarks)); +} + +TEST(DaoChromiumProfileAdapterTest, ReadsHistoryVisitsInStableOrder) { + base::ScopedTempDir profile_dir; + ASSERT_TRUE(profile_dir.CreateUniqueTempDir()); + sql::Database database(sql::test::kTestTag); + ASSERT_TRUE(database.Open(profile_dir.GetPath().AppendASCII("History"))); + ASSERT_TRUE(database.Execute( + "CREATE TABLE urls (id INTEGER PRIMARY KEY, url LONGVARCHAR, title " + "LONGVARCHAR)")); + ASSERT_TRUE(database.Execute( + "CREATE TABLE visits (id INTEGER PRIMARY KEY, url INTEGER, visit_time " + "INTEGER)")); + ASSERT_TRUE(database.Execute( + "INSERT INTO urls VALUES (1, 'https://example.com/', 'Example')")); + ASSERT_TRUE( + database.Execute("INSERT INTO visits VALUES (2, 1, 13300000000000002), " + "(1, 1, 13300000000000001)")); + database.Close(); + + DaoChromiumProfileAdapter adapter(profile_dir.GetPath()); + ReadBatch batch = adapter.ReadHistory(); + + ASSERT_TRUE(batch.success); + ASSERT_EQ(2u, batch.records.size()); + EXPECT_LT(batch.records[0].visit_time, batch.records[1].visit_time); + EXPECT_EQ(u"Example", batch.records[0].title); + EXPECT_EQ(2u, adapter.CountCandidates(DataCategory::kHistory)); +} + +TEST(DaoChromiumProfileAdapterTest, ReadsExtensionEnabledState) { + base::ScopedTempDir profile_dir; + ASSERT_TRUE(profile_dir.CreateUniqueTempDir()); + ASSERT_TRUE(base::WriteFile( + profile_dir.GetPath().AppendASCII("Preferences"), + R"({"extensions":{"settings":{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa":{"state":1,"from_webstore":true,"manifest":{"name":"Enabled"}},"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb":{"state":0,"from_webstore":true,"manifest":{"name":"Disabled"}},"cccccccccccccccccccccccccccccccc":{"state":1,"manifest":{"name":"Component"}}}}})")); + + DaoChromiumProfileAdapter adapter(profile_dir.GetPath()); + ReadBatch batch = adapter.ReadExtensions(); + + ASSERT_TRUE(batch.success); + ASSERT_EQ(2u, batch.records.size()); + EXPECT_EQ("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", batch.records[0].id); + EXPECT_TRUE(batch.records[0].enabled); + EXPECT_FALSE(batch.records[1].enabled); + EXPECT_EQ(2u, adapter.CountCandidates(DataCategory::kExtensions)); +} + +TEST(DaoChromiumProfileAdapterTest, ReadsPasswordMetadataWithDecryptor) { + base::ScopedTempDir profile_dir; + ASSERT_TRUE(profile_dir.CreateUniqueTempDir()); + sql::Database database(sql::test::kTestTag); + ASSERT_TRUE(database.Open(profile_dir.GetPath().AppendASCII("Login Data"))); + ASSERT_TRUE(database.Execute( + "CREATE TABLE logins (origin_url VARCHAR, username_value VARCHAR, " + "password_value BLOB, signon_realm VARCHAR, date_created INTEGER, " + "blacklisted_by_user INTEGER)")); + { + sql::Statement insert(database.GetUniqueStatement( + "INSERT INTO logins VALUES (?, ?, ?, ?, ?, 0)")); + insert.BindString(0, "https://example.com/login"); + insert.BindString16(1, u"person@example.com"); + insert.BindBlob(2, base::as_byte_span(std::string_view("cipher"))); + insert.BindString(3, "https://example.com/"); + insert.BindInt64(4, 13300000000000000); + ASSERT_TRUE(insert.Run()); + } + database.Close(); + + DaoChromiumProfileAdapter adapter(profile_dir.GetPath(), + std::make_unique()); + ReadBatch batch = adapter.ReadPasswords(); + + ASSERT_TRUE(batch.success); + ASSERT_EQ(1u, batch.records.size()); + EXPECT_EQ(u"person@example.com", batch.records[0].username); + EXPECT_EQ(u"secret", batch.records[0].password); + EXPECT_EQ("https://example.com/", batch.records[0].signon_realm); +} + +TEST(DaoChromiumProfileAdapterTest, + CountsPasswordCandidatesWithoutDecryptingThem) { + base::ScopedTempDir profile_dir; + ASSERT_TRUE(profile_dir.CreateUniqueTempDir()); + sql::Database database(sql::test::kTestTag); + ASSERT_TRUE(database.Open(profile_dir.GetPath().AppendASCII("Login Data"))); + ASSERT_TRUE( + database.Execute("CREATE TABLE logins (blacklisted_by_user INTEGER)")); + ASSERT_TRUE(database.Execute("INSERT INTO logins VALUES (0), (0), (1)")); + database.Close(); + + DaoChromiumProfileAdapter adapter(profile_dir.GetPath()); + + EXPECT_EQ(2u, adapter.CountCandidates(DataCategory::kPasswords)); +} + +} // namespace +} // namespace dao::import diff --git a/src/dao/browser/import/dao_legacy_profile_writer.cc b/src/dao/browser/import/dao_legacy_profile_writer.cc new file mode 100644 index 00000000..883bc625 --- /dev/null +++ b/src/dao/browser/import/dao_legacy_profile_writer.cc @@ -0,0 +1,124 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/import/dao_legacy_profile_writer.h" + +#include +#include + +#include "base/check.h" +#include "base/functional/bind.h" +#include "base/memory/ref_counted.h" +#include "components/history/core/browser/history_types.h" +#include "components/password_manager/core/browser/password_form.h" +#include "components/user_data_importer/common/imported_bookmark_entry.h" +#include "dao/browser/import/dao_chromium_migration_target.h" +#include "dao/browser/import/dao_migration_writer.h" + +namespace dao::import { +namespace { + +void MergeResult(CategoryResult* total, const WriteResult& result) { + total->imported += result.imported; + total->skipped += result.skipped; + total->conflicted += result.conflicted; + total->failed += result.failed; +} + +} // namespace + +DaoLegacyProfileWriter::DaoLegacyProfileWriter( + Profile* profile, + DaoChromiumMigrationTarget* target, + CategoryResult* result, + std::u16string bookmark_root) + : ProfileWriter(profile), + target_(target), + result_(result), + bookmark_root_(std::move(bookmark_root)) {} + +DaoLegacyProfileWriter::~DaoLegacyProfileWriter() = default; + +void DaoLegacyProfileWriter::AddPasswordForm( + const password_manager::PasswordForm& form) { + PasswordEntry entry; + entry.origin = form.url; + entry.signon_realm = form.signon_realm; + entry.username = form.username_value; + entry.password = form.password_value; + entry.date_created = form.date_created; + pending_passwords_.push_back(std::move(entry)); +} + +void DaoLegacyProfileWriter::AddHistoryPage(const history::URLRows& page, + history::VisitSource) { + std::vector visits; + visits.reserve(page.size()); + for (const history::URLRow& row : page) { + HistoryVisit visit; + visit.url = row.url(); + visit.title = row.title(); + visit.visit_time = row.last_visit(); + visits.push_back(std::move(visit)); + } + pending_history_.insert(pending_history_.end(), + std::make_move_iterator(visits.begin()), + std::make_move_iterator(visits.end())); +} + +void DaoLegacyProfileWriter::AddBookmarks( + const std::vector& bookmarks, + const std::u16string&) { + std::vector entries; + entries.reserve(bookmarks.size()); + for (const user_data_importer::ImportedBookmarkEntry& imported : bookmarks) { + BookmarkEntry entry; + entry.in_toolbar = imported.in_toolbar; + entry.is_folder = imported.is_folder; + entry.url = imported.url; + entry.path = imported.path; + entry.title = imported.title; + entry.creation_time = imported.creation_time; + entries.push_back(std::move(entry)); + } + DaoMigrationWriter writer(target_); + MergeResult(result_, writer.WriteBookmarks(entries, bookmark_root_)); +} + +void DaoLegacyProfileWriter::FinishWhenIdle(base::OnceClosure callback) { + scoped_refptr keep_alive(this); + CHECK(finish_callback_.is_null()); + finish_callback_ = std::move(callback); + const bool has_history = !pending_history_.empty(); + const bool has_passwords = !pending_passwords_.empty(); + pending_writes_ = + static_cast(has_history) + static_cast(has_passwords); + if (pending_writes_ == 0) { + std::move(finish_callback_).Run(); + return; + } + DaoMigrationWriter writer(target_); + if (has_history) { + writer.WriteHistory(std::move(pending_history_), + base::BindOnce(&DaoLegacyProfileWriter::OnWriteFinished, + base::RetainedRef(this))); + } + if (has_passwords) { + writer.WritePasswords( + std::move(pending_passwords_), + base::BindOnce(&DaoLegacyProfileWriter::OnWriteFinished, + base::RetainedRef(this))); + } +} + +void DaoLegacyProfileWriter::OnWriteFinished(WriteResult result) { + CHECK_GT(pending_writes_, 0u); + MergeResult(result_, result); + --pending_writes_; + if (pending_writes_ == 0 && !finish_callback_.is_null()) { + std::move(finish_callback_).Run(); + } +} + +} // namespace dao::import diff --git a/src/dao/browser/import/dao_legacy_profile_writer.h b/src/dao/browser/import/dao_legacy_profile_writer.h new file mode 100644 index 00000000..6316a7ab --- /dev/null +++ b/src/dao/browser/import/dao_legacy_profile_writer.h @@ -0,0 +1,55 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef DAO_BROWSER_IMPORT_DAO_LEGACY_PROFILE_WRITER_H_ +#define DAO_BROWSER_IMPORT_DAO_LEGACY_PROFILE_WRITER_H_ + +#include + +#include "base/functional/callback.h" +#include "base/memory/raw_ptr.h" +#include "chrome/browser/importer/profile_writer.h" +#include "dao/browser/import/dao_migration_types.h" +#include "dao/browser/import/dao_source_adapter.h" + +namespace dao::import { + +class DaoChromiumMigrationTarget; +struct WriteResult; + +// Adapts Chromium's sandboxed Safari/Firefox importers to the same +// destination merge policy used by the standalone migration service. +class DaoLegacyProfileWriter : public ProfileWriter { + public: + DaoLegacyProfileWriter(Profile* profile, + DaoChromiumMigrationTarget* target, + CategoryResult* result, + std::u16string bookmark_root); + + void AddPasswordForm(const password_manager::PasswordForm& form) override; + void AddHistoryPage(const history::URLRows& page, + history::VisitSource visit_source) override; + void AddBookmarks( + const std::vector& bookmarks, + const std::u16string& top_level_folder_name) override; + void FinishWhenIdle(base::OnceClosure callback); + + protected: + ~DaoLegacyProfileWriter() override; + + private: + void OnWriteFinished(WriteResult result); + + raw_ptr target_; + raw_ptr result_; + std::u16string bookmark_root_; + std::vector pending_history_; + std::vector pending_passwords_; + size_t pending_writes_ = 0; + base::OnceClosure finish_callback_; +}; + +} // namespace dao::import + +#endif // DAO_BROWSER_IMPORT_DAO_LEGACY_PROFILE_WRITER_H_ diff --git a/src/dao/browser/import/dao_migration_job.cc b/src/dao/browser/import/dao_migration_job.cc new file mode 100644 index 00000000..bdcac5c3 --- /dev/null +++ b/src/dao/browser/import/dao_migration_job.cc @@ -0,0 +1,165 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/import/dao_migration_job.h" + +#include +#include + +#include "base/check.h" + +namespace dao::import { + +DaoMigrationJob::DaoMigrationJob(SourceProfile source, + std::vector selected_categories) + : source_(std::move(source)), + selected_categories_(std::move(selected_categories)) { + for (DataCategory category : selected_categories_) { + category_states_.try_emplace(category); + } +} + +DaoMigrationJob::~DaoMigrationJob() = default; + +bool DaoMigrationJob::StartCategory(DataCategory category) { + CategoryState* state = GetMutableCategoryState(category); + if (!state || state->phase != CategoryPhase::kPending || + running_category_.has_value() || cancel_requested_ || IsTerminal()) { + return false; + } + + state->phase = CategoryPhase::kSnapshotting; + running_category_ = category; + return true; +} + +bool DaoMigrationJob::AdvanceCategory(DataCategory category, + CategoryPhase next_phase) { + CategoryState* state = GetMutableCategoryState(category); + if (!state || !IsRunningCategory(category)) { + return false; + } + + const bool valid_transition = (state->phase == CategoryPhase::kSnapshotting && + next_phase == CategoryPhase::kReading) || + (state->phase == CategoryPhase::kReading && + next_phase == CategoryPhase::kWriting); + if (!valid_transition) { + return false; + } + + state->phase = next_phase; + return true; +} + +bool DaoMigrationJob::CompleteCategory(DataCategory category, + CategoryResult result) { + CategoryState* state = GetMutableCategoryState(category); + if (!state || !IsRunningCategory(category) || + state->phase != CategoryPhase::kWriting) { + return false; + } + + state->phase = CategoryPhase::kSucceeded; + state->result = result; + state->error_code.clear(); + FinishRunningCategory(category); + return true; +} + +bool DaoMigrationJob::FailCategory(DataCategory category, + std::string error_code, + CategoryResult result) { + CategoryState* state = GetMutableCategoryState(category); + if (!state || !IsRunningCategory(category) || IsTerminalPhase(state->phase)) { + return false; + } + + state->phase = CategoryPhase::kFailed; + state->error_code = std::move(error_code); + state->result = result; + FinishRunningCategory(category); + return true; +} + +bool DaoMigrationJob::UpdateProgress(DataCategory category, + uint64_t completed_items, + uint64_t total_items) { + CategoryState* state = GetMutableCategoryState(category); + if (!state || !IsRunningCategory(category) || IsTerminalPhase(state->phase)) { + return false; + } + + state->total_items = total_items; + state->indeterminate = total_items == 0; + state->completed_items = total_items == 0 + ? completed_items + : std::min(completed_items, total_items); + return true; +} + +void DaoMigrationJob::RequestCancel() { + cancel_requested_ = true; + for (auto& entry : category_states_) { + CategoryState& state = entry.second; + if (state.phase == CategoryPhase::kPending) { + state.phase = CategoryPhase::kCancelled; + } + } +} + +bool DaoMigrationJob::CancelRunningCategoryAtBatchBoundary( + CategoryResult result) { + if (!cancel_requested_ || !running_category_) { + return false; + } + + CategoryState* state = GetMutableCategoryState(*running_category_); + CHECK(state); + state->phase = CategoryPhase::kCancelled; + state->result = result; + running_category_.reset(); + return true; +} + +const CategoryState& DaoMigrationJob::GetCategoryState( + DataCategory category) const { + auto it = category_states_.find(category); + CHECK(it != category_states_.end()); + return it->second; +} + +JobState DaoMigrationJob::GetState() const { + JobState state; + state.source_id = source_.id; + state.cancel_requested = cancel_requested_; + state.selected_categories = selected_categories_; + state.category_states.reserve(selected_categories_.size()); + for (DataCategory category : selected_categories_) { + state.category_states.push_back(GetCategoryState(category)); + } + return state; +} + +bool DaoMigrationJob::IsTerminal() const { + return std::all_of( + category_states_.begin(), category_states_.end(), + [](const auto& entry) { return IsTerminalPhase(entry.second.phase); }); +} + +CategoryState* DaoMigrationJob::GetMutableCategoryState(DataCategory category) { + auto it = category_states_.find(category); + return it == category_states_.end() ? nullptr : &it->second; +} + +bool DaoMigrationJob::IsRunningCategory(DataCategory category) const { + return running_category_ && *running_category_ == category; +} + +void DaoMigrationJob::FinishRunningCategory(DataCategory category) { + CHECK(IsRunningCategory(category)); + running_category_.reset(); +} + +} // namespace dao::import diff --git a/src/dao/browser/import/dao_migration_job.h b/src/dao/browser/import/dao_migration_job.h new file mode 100644 index 00000000..36040f89 --- /dev/null +++ b/src/dao/browser/import/dao_migration_job.h @@ -0,0 +1,60 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef DAO_BROWSER_IMPORT_DAO_MIGRATION_JOB_H_ +#define DAO_BROWSER_IMPORT_DAO_MIGRATION_JOB_H_ + +#include +#include +#include +#include + +#include "dao/browser/import/dao_migration_types.h" + +namespace dao::import { + +class DaoMigrationJob { + public: + DaoMigrationJob(SourceProfile source, + std::vector selected_categories); + DaoMigrationJob(const DaoMigrationJob&) = delete; + DaoMigrationJob& operator=(const DaoMigrationJob&) = delete; + ~DaoMigrationJob(); + + bool StartCategory(DataCategory category); + bool AdvanceCategory(DataCategory category, CategoryPhase next_phase); + bool CompleteCategory(DataCategory category, CategoryResult result); + bool FailCategory(DataCategory category, + std::string error_code, + CategoryResult result = CategoryResult()); + bool UpdateProgress(DataCategory category, + uint64_t completed_items, + uint64_t total_items); + + void RequestCancel(); + bool CancelRunningCategoryAtBatchBoundary( + CategoryResult result = CategoryResult()); + + const CategoryState& GetCategoryState(DataCategory category) const; + JobState GetState() const; + bool IsTerminal() const; + + bool cancel_requested() const { return cancel_requested_; } + const SourceProfile& source() const { return source_; } + + private: + CategoryState* GetMutableCategoryState(DataCategory category); + bool IsRunningCategory(DataCategory category) const; + void FinishRunningCategory(DataCategory category); + + SourceProfile source_; + std::vector selected_categories_; + std::map category_states_; + std::optional running_category_; + bool cancel_requested_ = false; +}; + +} // namespace dao::import + +#endif // DAO_BROWSER_IMPORT_DAO_MIGRATION_JOB_H_ diff --git a/src/dao/browser/import/dao_migration_job_unittest.cc b/src/dao/browser/import/dao_migration_job_unittest.cc new file mode 100644 index 00000000..1b057ff6 --- /dev/null +++ b/src/dao/browser/import/dao_migration_job_unittest.cc @@ -0,0 +1,145 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/import/dao_migration_job.h" + +#include +#include +#include + +#include "testing/gtest/include/gtest/gtest.h" + +namespace dao::import { +namespace { + +SourceProfile TestSource(std::string id) { + SourceProfile source; + source.id = std::move(id); + source.kind = SourceKind::kChrome; + source.supported_categories = { + DataCategory::kBookmarks, DataCategory::kHistory, + DataCategory::kPasswords, DataCategory::kTabs, DataCategory::kExtensions}; + return source; +} + +TEST(DaoMigrationJobTest, RunsSelectedCategoryThroughOrderedPhases) { + DaoMigrationJob job(TestSource("chrome:default"), + {DataCategory::kBookmarks, DataCategory::kPasswords}); + + EXPECT_TRUE(job.StartCategory(DataCategory::kBookmarks)); + EXPECT_EQ(CategoryPhase::kSnapshotting, + job.GetCategoryState(DataCategory::kBookmarks).phase); + EXPECT_TRUE( + job.AdvanceCategory(DataCategory::kBookmarks, CategoryPhase::kReading)); + EXPECT_TRUE( + job.AdvanceCategory(DataCategory::kBookmarks, CategoryPhase::kWriting)); + + CategoryResult result; + result.imported = 12; + result.skipped = 2; + EXPECT_TRUE(job.CompleteCategory(DataCategory::kBookmarks, result)); + EXPECT_EQ(CategoryPhase::kSucceeded, + job.GetCategoryState(DataCategory::kBookmarks).phase); + EXPECT_EQ(12u, + job.GetCategoryState(DataCategory::kBookmarks).result.imported); + EXPECT_TRUE(job.StartCategory(DataCategory::kPasswords)); +} + +TEST(DaoMigrationJobTest, RejectsPhaseJumpsAndUnselectedCategories) { + DaoMigrationJob job(TestSource("arc:personal"), {DataCategory::kHistory}); + + EXPECT_FALSE(job.StartCategory(DataCategory::kBookmarks)); + EXPECT_TRUE(job.StartCategory(DataCategory::kHistory)); + EXPECT_FALSE( + job.AdvanceCategory(DataCategory::kHistory, CategoryPhase::kWriting)); + EXPECT_EQ(CategoryPhase::kSnapshotting, + job.GetCategoryState(DataCategory::kHistory).phase); +} + +TEST(DaoMigrationJobTest, KeepsOtherCategoriesPendingAfterFailure) { + DaoMigrationJob job(TestSource("edge:default"), + {DataCategory::kBookmarks, DataCategory::kTabs}); + + ASSERT_TRUE(job.StartCategory(DataCategory::kBookmarks)); + ASSERT_TRUE(job.FailCategory(DataCategory::kBookmarks, "source_changing")); + + EXPECT_EQ(CategoryPhase::kFailed, + job.GetCategoryState(DataCategory::kBookmarks).phase); + EXPECT_EQ("source_changing", + job.GetCategoryState(DataCategory::kBookmarks).error_code); + EXPECT_EQ(CategoryPhase::kPending, + job.GetCategoryState(DataCategory::kTabs).phase); + EXPECT_TRUE(job.StartCategory(DataCategory::kTabs)); +} + +TEST(DaoMigrationJobTest, FailedCategoryPreservesPartialResult) { + DaoMigrationJob job(TestSource("chrome:default"), + {DataCategory::kExtensions}); + ASSERT_TRUE(job.StartCategory(DataCategory::kExtensions)); + ASSERT_TRUE( + job.AdvanceCategory(DataCategory::kExtensions, CategoryPhase::kReading)); + ASSERT_TRUE( + job.AdvanceCategory(DataCategory::kExtensions, CategoryPhase::kWriting)); + CategoryResult partial; + partial.imported = 2; + partial.failed = 1; + + ASSERT_TRUE(job.FailCategory(DataCategory::kExtensions, + "extension_install_failed", partial)); + + const CategoryState& state = job.GetCategoryState(DataCategory::kExtensions); + EXPECT_EQ(CategoryPhase::kFailed, state.phase); + EXPECT_EQ(2u, state.result.imported); + EXPECT_EQ(1u, state.result.failed); +} + +TEST(DaoMigrationJobTest, ClampsProgressToKnownTotal) { + DaoMigrationJob job(TestSource("chrome:work"), {DataCategory::kHistory}); + + ASSERT_TRUE(job.StartCategory(DataCategory::kHistory)); + ASSERT_TRUE(job.UpdateProgress(DataCategory::kHistory, 140, 100)); + + const CategoryState& state = job.GetCategoryState(DataCategory::kHistory); + EXPECT_EQ(100u, state.completed_items); + EXPECT_EQ(100u, state.total_items); + EXPECT_FALSE(state.indeterminate); +} + +TEST(DaoMigrationJobTest, CancelsPendingWorkAtSafeBatchBoundary) { + DaoMigrationJob job(TestSource("chrome:default"), + {DataCategory::kBookmarks, DataCategory::kPasswords}); + + ASSERT_TRUE(job.StartCategory(DataCategory::kBookmarks)); + ASSERT_TRUE( + job.AdvanceCategory(DataCategory::kBookmarks, CategoryPhase::kReading)); + + job.RequestCancel(); + + EXPECT_TRUE(job.cancel_requested()); + EXPECT_EQ(CategoryPhase::kReading, + job.GetCategoryState(DataCategory::kBookmarks).phase); + EXPECT_EQ(CategoryPhase::kCancelled, + job.GetCategoryState(DataCategory::kPasswords).phase); + CategoryResult partial; + partial.imported = 4; + EXPECT_TRUE(job.CancelRunningCategoryAtBatchBoundary(partial)); + EXPECT_EQ(CategoryPhase::kCancelled, + job.GetCategoryState(DataCategory::kBookmarks).phase); + EXPECT_EQ(4u, job.GetCategoryState(DataCategory::kBookmarks).result.imported); + EXPECT_TRUE(job.IsTerminal()); +} + +TEST(DaoMigrationJobTest, OwnsSelectedSourceAcrossCatalogRefresh) { + SourceProfile source = TestSource("chrome:default"); + source.profile_name = "Personal"; + DaoMigrationJob job(source, {DataCategory::kBookmarks}); + + source.profile_name.clear(); + + EXPECT_EQ("chrome:default", job.source().id); + EXPECT_EQ("Personal", job.source().profile_name); +} + +} // namespace +} // namespace dao::import diff --git a/src/dao/browser/import/dao_migration_service.cc b/src/dao/browser/import/dao_migration_service.cc new file mode 100644 index 00000000..78b46234 --- /dev/null +++ b/src/dao/browser/import/dao_migration_service.cc @@ -0,0 +1,827 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/import/dao_migration_service.h" + +#include +#include +#include +#include + +#include "base/functional/bind.h" +#include "base/notreached.h" +#include "base/strings/string_number_conversions.h" +#include "base/strings/utf_string_conversions.h" +#include "base/task/sequenced_task_runner.h" +#include "base/task/thread_pool.h" +#include "build/build_config.h" +#include "chrome/browser/browser_process.h" +#include "chrome/browser/importer/external_process_importer_host.h" +#include "chrome/browser/importer/importer_list.h" +#include "chrome/browser/password_manager/factories/profile_password_store_factory.h" +#include "chrome/browser/profiles/profile.h" +#include "components/keyed_service/core/service_access_type.h" +#include "components/password_manager/core/browser/password_store/password_store_interface.h" +#include "crypto/sha2.h" +#include "dao/browser/import/dao_chromium_migration_target.h" +#include "dao/browser/import/dao_chromium_profile_adapter.h" +#include "dao/browser/import/dao_legacy_profile_writer.h" +#include "dao/browser/strings/grit/dao_strings.h" +#include "ui/base/l10n/l10n_util.h" +#if BUILDFLAG(IS_MAC) +#include "dao/browser/import/dao_chromium_password_decryptor_mac.h" +#endif + +namespace dao::import { +namespace { + +bool SupportsChromiumAdapter(SourceKind kind) { + return kind == SourceKind::kChrome || kind == SourceKind::kArc || + kind == SourceKind::kEdge; +} + +std::string LegacySourceId(const user_data_importer::SourceProfile &profile) { + const std::string identity = base::UTF16ToUTF8(profile.importer_name) + ":" + + profile.source_path.AsUTF8Unsafe() + ":" + + base::UTF16ToUTF8(profile.profile); + return base::HexEncode(crypto::SHA256HashString(identity)); +} + +std::optional +LegacySourceKind(user_data_importer::ImporterType type) { + if (type == user_data_importer::TYPE_FIREFOX) { + return SourceKind::kFirefox; + } +#if BUILDFLAG(IS_MAC) + if (type == user_data_importer::TYPE_SAFARI) { + return SourceKind::kSafari; + } +#endif + return std::nullopt; +} + +std::vector LegacyCategories(uint16_t services) { + std::vector categories; + if (services & user_data_importer::FAVORITES) { + categories.push_back(DataCategory::kBookmarks); + } + if (services & user_data_importer::HISTORY) { + categories.push_back(DataCategory::kHistory); + } + if (services & user_data_importer::PASSWORDS) { + categories.push_back(DataCategory::kPasswords); + } + return categories; +} + +uint16_t LegacyImportItem(DataCategory category) { + switch (category) { + case DataCategory::kBookmarks: + return user_data_importer::FAVORITES; + case DataCategory::kHistory: + return user_data_importer::HISTORY; + case DataCategory::kPasswords: + return user_data_importer::PASSWORDS; + case DataCategory::kTabs: + case DataCategory::kExtensions: + return user_data_importer::NONE; + } + NOTREACHED(); +} + +void AddWriteResult(CategoryResult *totals, const WriteResult &result) { + totals->imported += result.imported; + totals->skipped += result.skipped; + totals->conflicted += result.conflicted; + totals->failed += result.failed; +} + +} // namespace + +DaoMigrationService::ReadResult::ReadResult() = default; +DaoMigrationService::ReadResult::ReadResult(const ReadResult &) = default; +DaoMigrationService::ReadResult & +DaoMigrationService::ReadResult::operator=(const ReadResult &) = default; +DaoMigrationService::ReadResult::ReadResult(ReadResult &&) = default; +DaoMigrationService::ReadResult & +DaoMigrationService::ReadResult::operator=(ReadResult &&) = default; +DaoMigrationService::ReadResult::~ReadResult() = default; + +DaoMigrationService::DaoMigrationService(Profile *profile) : profile_(profile) { + scoped_refptr password_store = + ProfilePasswordStoreFactory::GetForProfile( + profile_, ServiceAccessType::EXPLICIT_ACCESS); + if (password_store) { + password_store->GetAllLogins(weak_ptr_factory_.GetWeakPtr()); + } else { + passwords_ready_ = true; + } +} + +DaoMigrationService::~DaoMigrationService() = default; + +void DaoMigrationService::DetectSources(SourcesCallback callback) { + const uint64_t generation = ++source_detection_generation_; + pending_sources_callback_ = std::move(callback); + chromium_detection_done_ = false; + legacy_detection_done_ = false; + pending_detected_sources_.clear(); + pending_legacy_sources_.clear(); + detector_.Detect( + base::BindOnce(&DaoMigrationService::OnChromiumSourcesDetected, + weak_ptr_factory_.GetWeakPtr(), generation)); + importer_list_ = std::make_unique(); + importer_list_->DetectSourceProfiles( + g_browser_process->GetApplicationLocale(), true, + base::BindOnce(&DaoMigrationService::OnLegacySourcesDetected, + weak_ptr_factory_.GetWeakPtr(), generation)); +} + +void DaoMigrationService::CountSourceItems(const std::string &source_id, + DataCategory category, + CountCallback callback) { + const SourceProfile *source = FindDetectedSource(source_id); + if (!source || !SupportsChromiumAdapter(source->kind) || + std::ranges::find(source->supported_categories, category) == + source->supported_categories.end()) { + std::move(callback).Run(std::nullopt); + return; + } + std::optional profile_path = + detector_.ResolveProfilePath(source_id); + if (!profile_path) { + std::move(callback).Run(std::nullopt); + return; + } + + if (category == DataCategory::kTabs) { + SnapshotRequest request = BuildSnapshotRequest(category, *profile_path); + request.cancellation = base::MakeRefCounted(); + DaoProfileSnapshot::Create( + std::move(request), + base::BindOnce(&DaoMigrationService::OnCountSnapshotReady, + weak_ptr_factory_.GetWeakPtr(), category, + std::move(callback))); + return; + } + + base::ThreadPool::PostTaskAndReplyWithResult( + FROM_HERE, {base::MayBlock(), base::TaskPriority::USER_VISIBLE}, + base::BindOnce(&DaoMigrationService::CountProfileCandidates, category, + std::move(*profile_path)), + std::move(callback)); +} + +bool DaoMigrationService::Start(const std::string &source_id, + std::vector categories) { + if (job_ && !job_->IsTerminal()) { + return false; + } + const SourceProfile *source = FindDetectedSource(source_id); + if (!source || categories.empty()) { + return false; + } + std::erase_if(categories, [source](DataCategory category) { + return std::ranges::find(source->supported_categories, category) == + source->supported_categories.end(); + }); + if (categories.empty()) { + return false; + } + std::optional chromium_path; + std::optional legacy_source; + if (SupportsChromiumAdapter(source->kind)) { + chromium_path = detector_.ResolveProfilePath(source_id); + if (!chromium_path) { + return false; + } + } else { + const user_data_importer::SourceProfile *found = + FindLegacySource(source_id); + if (!found) { + return false; + } + legacy_source = *found; + } + job_ = std::make_unique(*source, std::move(categories)); + active_chromium_source_path_ = std::move(chromium_path); + active_legacy_source_ = std::move(legacy_source); + target_ = std::make_unique(profile_); + target_->SetExistingPasswords(existing_passwords_); + ProcessNextCategory(); + return true; +} + +bool DaoMigrationService::Retry(std::vector categories) { + if (!job_ || !job_->IsTerminal()) { + return false; + } + const JobState prior = job_->GetState(); + std::erase_if(categories, [&prior](DataCategory category) { + auto it = std::ranges::find(prior.selected_categories, category); + if (it == prior.selected_categories.end()) { + return true; + } + const size_t index = std::distance(prior.selected_categories.begin(), it); + return prior.category_states[index].phase != CategoryPhase::kFailed; + }); + return !categories.empty() && Start(prior.source_id, std::move(categories)); +} + +void DaoMigrationService::Cancel() { + if (!job_ || job_->IsTerminal()) { + return; + } + job_->RequestCancel(); + if (snapshot_cancellation_) { + snapshot_cancellation_->Cancel(); + } + if (legacy_importer_host_) { + legacy_importer_host_->Cancel(); + NotifyObservers(); + return; + } + if (active_legacy_writer_) { + NotifyObservers(); + return; + } + if (active_write_category_ == DataCategory::kExtensions && target_) { + if (!waiting_for_extension_installs_) { + waiting_for_extension_installs_ = true; + target_->FinishExtensionInstalls( + base::BindOnce(&DaoMigrationService::OnExtensionInstallsFinished, + weak_ptr_factory_.GetWeakPtr())); + } + target_->CancelExtensionInstalls(); + NotifyObservers(); + return; + } + if (active_write_) { + NotifyObservers(); + return; + } + job_->CancelRunningCategoryAtBatchBoundary( + active_write_category_ ? active_write_totals_ : CategoryResult()); + NotifyObservers(); +} + +std::optional DaoMigrationService::GetState() const { + return job_ ? std::optional(job_->GetState()) : std::nullopt; +} + +base::CallbackListSubscription +DaoMigrationService::AddObserver(StateObserver observer) { + return observers_.Add(std::move(observer)); +} + +void DaoMigrationService::Shutdown() { + if (snapshot_cancellation_) { + snapshot_cancellation_->Cancel(); + } + weak_ptr_factory_.InvalidateWeakPtrs(); + importer_list_.reset(); + if (legacy_importer_host_) { + legacy_importer_host_->set_observer(nullptr); + legacy_importer_host_->Cancel(); + legacy_importer_host_ = nullptr; + } + active_legacy_writer_.reset(); + job_.reset(); + target_.reset(); + profile_ = nullptr; +} + +void DaoMigrationService::ImportStarted() {} + +void DaoMigrationService::ImportItemStarted(user_data_importer::ImportItem) { + NotifyObservers(); +} + +void DaoMigrationService::ImportItemEnded(user_data_importer::ImportItem item) { + if (!job_ || !active_legacy_category_ || job_->IsTerminal() || + LegacyImportItem(*active_legacy_category_) != item) { + return; + } + legacy_item_ended_ = + job_->AdvanceCategory(*active_legacy_category_, CategoryPhase::kWriting); + NotifyObservers(); +} + +void DaoMigrationService::ImportEnded() { + legacy_importer_host_ = nullptr; + if (active_legacy_writer_) { + active_legacy_writer_->FinishWhenIdle( + base::BindOnce(&DaoMigrationService::OnLegacyWritesFinished, + weak_ptr_factory_.GetWeakPtr())); + return; + } + OnLegacyWritesFinished(); +} + +void DaoMigrationService::OnLegacyWritesFinished() { + active_legacy_writer_.reset(); + if (!job_ || !active_legacy_category_ || job_->IsTerminal()) { + active_legacy_category_.reset(); + return; + } + if (job_->cancel_requested()) { + job_->CancelRunningCategoryAtBatchBoundary(active_legacy_result_); + } else if (legacy_item_ended_) { + if (active_legacy_result_.failed > 0) { + job_->FailCategory(*active_legacy_category_, "destination_write_failed", + active_legacy_result_); + } else { + job_->CompleteCategory(*active_legacy_category_, active_legacy_result_); + } + } else { + job_->FailCategory(*active_legacy_category_, "legacy_import_failed"); + } + active_legacy_category_.reset(); + NotifyObservers(); + ProcessNextCategory(); +} + +void DaoMigrationService::OnGetPasswordStoreResultsOrErrorFrom( + password_manager::PasswordStoreInterface *, + password_manager::LoginsResultOrError results_or_error) { + passwords_ready_ = true; + if (std::holds_alternative( + results_or_error)) { + for (const password_manager::StoredCredential &credential : + std::get(results_or_error)) { + existing_passwords_[{credential.signon_realm, + credential.username_value}] = + credential.password_value; + } + } + if (target_) { + target_->SetExistingPasswords(existing_passwords_); + } + if (waiting_for_passwords_) { + waiting_for_passwords_ = false; + ProcessNextCategory(); + } +} + +// static +DaoMigrationService::ReadResult +DaoMigrationService::ReadSnapshot(DataCategory category, SourceKind source_kind, + base::FilePath snapshot_path) { + std::unique_ptr decryptor; +#if BUILDFLAG(IS_MAC) + if (category == DataCategory::kPasswords) { + decryptor = std::make_unique(source_kind); + } +#endif + DaoChromiumProfileAdapter adapter(std::move(snapshot_path), + std::move(decryptor)); + ReadResult result; + auto copy_batch = [&result](auto batch) { + result.success = batch.success; + result.error_code = std::move(batch.error_code); + result.records = std::move(batch.records); + }; + switch (category) { + case DataCategory::kBookmarks: + copy_batch(adapter.ReadBookmarks()); + break; + case DataCategory::kHistory: + copy_batch(adapter.ReadHistory()); + break; + case DataCategory::kPasswords: + copy_batch(adapter.ReadPasswords()); + break; + case DataCategory::kTabs: + copy_batch(adapter.ReadTabs()); + break; + case DataCategory::kExtensions: + copy_batch(adapter.ReadExtensions()); + break; + } + return result; +} + +// static +std::optional +DaoMigrationService::CountProfileCandidates(DataCategory category, + base::FilePath profile_path) { + DaoChromiumProfileAdapter adapter(std::move(profile_path)); + return adapter.CountCandidates(category); +} + +// static +std::optional +DaoMigrationService::CountSnapshotCandidates(DataCategory category, + SnapshotResult snapshot) { + if (!snapshot.success) { + return std::nullopt; + } + return CountProfileCandidates(category, std::move(snapshot.path)); +} + +SnapshotRequest +DaoMigrationService::BuildSnapshotRequest(DataCategory category, + const base::FilePath &source_path) { + SnapshotRequest request; + request.source_profile = source_path; + request.cancellation = snapshot_cancellation_; + switch (category) { + case DataCategory::kBookmarks: + request.relative_paths = {base::FilePath(FILE_PATH_LITERAL("Bookmarks"))}; + break; + case DataCategory::kHistory: + request.relative_paths = {base::FilePath(FILE_PATH_LITERAL("History"))}; + request.include_sqlite_sidecars = true; + break; + case DataCategory::kPasswords: + request.relative_paths = {base::FilePath(FILE_PATH_LITERAL("Login Data"))}; + request.include_sqlite_sidecars = true; + break; + case DataCategory::kTabs: + request.relative_paths = {base::FilePath(FILE_PATH_LITERAL("Sessions"))}; + break; + case DataCategory::kExtensions: + request.relative_paths = {base::FilePath(FILE_PATH_LITERAL("Preferences"))}; + break; + } + return request; +} + +void DaoMigrationService::ProcessNextCategory() { + if (!job_ || job_->IsTerminal() || job_->cancel_requested()) { + NotifyObservers(); + return; + } + const SourceProfile &selected = job_->source(); + if (!SupportsChromiumAdapter(selected.kind)) { + ProcessNextLegacyCategory(); + return; + } + const JobState state = job_->GetState(); + for (size_t index = 0; index < state.selected_categories.size(); ++index) { + const DataCategory category = state.selected_categories[index]; + if (state.category_states[index].phase != CategoryPhase::kPending) { + continue; + } + if (category == DataCategory::kPasswords && !passwords_ready_) { + waiting_for_passwords_ = true; + NotifyObservers(); + return; + } + if (!active_chromium_source_path_) { + if (job_->StartCategory(category)) { + job_->FailCategory(category, "source_missing"); + NotifyObservers(); + } + continue; + } + if (!job_->StartCategory(category)) { + return; + } + snapshot_cancellation_ = base::MakeRefCounted(); + NotifyObservers(); + DaoProfileSnapshot::Create( + BuildSnapshotRequest(category, *active_chromium_source_path_), + base::BindOnce(&DaoMigrationService::OnSnapshotReady, + weak_ptr_factory_.GetWeakPtr(), category)); + return; + } + NotifyObservers(); +} + +void DaoMigrationService::ProcessNextLegacyCategory() { + if (!job_ || job_->IsTerminal() || job_->cancel_requested() || + legacy_importer_host_) { + NotifyObservers(); + return; + } + const JobState state = job_->GetState(); + if (!active_legacy_source_) { + for (DataCategory category : state.selected_categories) { + const CategoryState &category_state = job_->GetCategoryState(category); + if (category_state.phase == CategoryPhase::kPending && + job_->StartCategory(category)) { + job_->FailCategory(category, "source_missing"); + NotifyObservers(); + } + } + return; + } + for (size_t index = 0; index < state.selected_categories.size(); ++index) { + const DataCategory category = state.selected_categories[index]; + if (state.category_states[index].phase != CategoryPhase::kPending) { + continue; + } + const uint16_t item = LegacyImportItem(category); + if (!item || !job_->StartCategory(category)) { + return; + } + job_->AdvanceCategory(category, CategoryPhase::kReading); + active_legacy_category_ = category; + active_legacy_result_ = CategoryResult(); + legacy_item_ended_ = false; + NotifyObservers(); + legacy_importer_host_ = new ExternalProcessImporterHost(); + legacy_importer_host_->set_observer(this); + active_legacy_writer_ = base::MakeRefCounted( + profile_, target_.get(), &active_legacy_result_, + l10n_util::GetStringUTF16(IDS_DAO_IMPORT_BOOKMARK_ROOT)); + legacy_importer_host_->StartImportSettings( + *active_legacy_source_, profile_, item, active_legacy_writer_.get()); + return; + } + NotifyObservers(); +} + +void DaoMigrationService::OnChromiumSourcesDetected( + uint64_t generation, std::vector profiles) { + if (generation != source_detection_generation_) { + return; + } + pending_detected_sources_.insert(pending_detected_sources_.begin(), + std::make_move_iterator(profiles.begin()), + std::make_move_iterator(profiles.end())); + chromium_detection_done_ = true; + MaybeFinishSourceDetection(); +} + +void DaoMigrationService::OnLegacySourcesDetected(uint64_t generation) { + if (generation != source_detection_generation_) { + return; + } + if (importer_list_) { + for (size_t index = 0; index < importer_list_->count(); ++index) { + const user_data_importer::SourceProfile &legacy = + importer_list_->GetSourceProfileAt(index); + std::optional kind = LegacySourceKind(legacy.importer_type); + if (!kind) { + continue; + } + SourceProfile source; + source.id = LegacySourceId(legacy); + source.kind = *kind; + source.browser_name = base::UTF16ToUTF8(legacy.importer_name); + source.profile_name = legacy.profile.empty() + ? source.browser_name + : base::UTF16ToUTF8(legacy.profile); + source.supported_categories = LegacyCategories(legacy.services_supported); + if (source.supported_categories.empty()) { + continue; + } + pending_legacy_sources_.emplace(source.id, legacy); + pending_detected_sources_.push_back(std::move(source)); + } + } + legacy_detection_done_ = true; + MaybeFinishSourceDetection(); +} + +void DaoMigrationService::MaybeFinishSourceDetection() { + if (!chromium_detection_done_ || !legacy_detection_done_ || + pending_sources_callback_.is_null()) { + return; + } + detected_sources_ = pending_detected_sources_; + legacy_sources_ = pending_legacy_sources_; + std::move(pending_sources_callback_).Run(detected_sources_); +} + +void DaoMigrationService::OnCountSnapshotReady(DataCategory category, + CountCallback callback, + SnapshotResult snapshot) { + base::ThreadPool::PostTaskAndReplyWithResult( + FROM_HERE, {base::MayBlock(), base::TaskPriority::USER_VISIBLE}, + base::BindOnce(&DaoMigrationService::CountSnapshotCandidates, category, + std::move(snapshot)), + std::move(callback)); +} + +void DaoMigrationService::OnSnapshotReady(DataCategory category, + SnapshotResult snapshot) { + snapshot_cancellation_.reset(); + if (!job_ || job_->IsTerminal()) { + return; + } + if (!snapshot.success) { + if (category == DataCategory::kTabs && + snapshot.error_code == "source_missing") { + job_->AdvanceCategory(category, CategoryPhase::kReading); + job_->AdvanceCategory(category, CategoryPhase::kWriting); + job_->CompleteCategory(category, CategoryResult()); + } else { + job_->FailCategory(category, std::move(snapshot.error_code)); + } + NotifyObservers(); + ProcessNextCategory(); + return; + } + if (!job_->AdvanceCategory(category, CategoryPhase::kReading)) { + return; + } + NotifyObservers(); + base::ThreadPool::PostTaskAndReplyWithResult( + FROM_HERE, + {base::MayBlock(), base::TaskPriority::USER_VISIBLE, + base::TaskShutdownBehavior::CONTINUE_ON_SHUTDOWN}, + base::BindOnce(&DaoMigrationService::ReadSnapshot, category, + job_->source().kind, snapshot.path), + base::BindOnce(&DaoMigrationService::OnReadComplete, + weak_ptr_factory_.GetWeakPtr(), category, + std::move(snapshot))); +} + +void DaoMigrationService::OnReadComplete(DataCategory category, SnapshotResult, + ReadResult result) { + if (!job_ || job_->IsTerminal()) { + return; + } + if (!result.success) { + job_->FailCategory(category, std::move(result.error_code)); + NotifyObservers(); + ProcessNextCategory(); + return; + } + BeginWriting(category, std::move(result)); +} + +void DaoMigrationService::BeginWriting(DataCategory category, + ReadResult result) { + if (!job_->AdvanceCategory(category, CategoryPhase::kWriting)) { + return; + } + const size_t total = std::visit( + [](const auto &records) { return records.size(); }, result.records); + job_->UpdateProgress(category, 0, total); + active_write_ = std::move(result); + active_write_category_ = category; + active_write_index_ = 0; + active_write_totals_ = CategoryResult(); + active_tab_folder_id_.clear(); + waiting_for_extension_installs_ = false; + NotifyObservers(); + WriteNextBatch(); +} + +void DaoMigrationService::WriteNextBatch() { + if (!job_ || !active_write_ || !active_write_category_) { + return; + } + const DataCategory category = *active_write_category_; + if (job_->cancel_requested()) { + FinishActiveTabFolder(); + job_->CancelRunningCategoryAtBatchBoundary(active_write_totals_); + active_write_.reset(); + active_write_category_.reset(); + NotifyObservers(); + return; + } + + const size_t total = + std::visit([](const auto &records) { return records.size(); }, + active_write_->records); + if (active_write_index_ >= total) { + if (category == DataCategory::kExtensions) { + if (!waiting_for_extension_installs_) { + waiting_for_extension_installs_ = true; + target_->FinishExtensionInstalls( + base::BindOnce(&DaoMigrationService::OnExtensionInstallsFinished, + weak_ptr_factory_.GetWeakPtr())); + } + return; + } + FinishActiveTabFolder(); + if (active_write_totals_.failed > 0) { + job_->FailCategory(category, "destination_write_failed", + active_write_totals_); + } else { + job_->CompleteCategory(category, active_write_totals_); + } + active_write_.reset(); + active_write_category_.reset(); + NotifyObservers(); + ProcessNextCategory(); + return; + } + + DaoMigrationWriter writer(target_.get()); + WriteResult write_result; + constexpr size_t kBatchSize = 200; + const size_t batch_size = std::min(kBatchSize, total - active_write_index_); + const size_t end = active_write_index_ + batch_size; + auto batch = [this, end](const auto &records) { + using Record = typename std::decay_t::value_type; + return std::vector(records.begin() + active_write_index_, + records.begin() + end); + }; + switch (category) { + case DataCategory::kBookmarks: + write_result = writer.WriteBookmarks( + batch(std::get>(active_write_->records)), + l10n_util::GetStringUTF16(IDS_DAO_IMPORT_BOOKMARK_ROOT)); + break; + case DataCategory::kHistory: + writer.WriteHistory( + batch(std::get>(active_write_->records)), + base::BindOnce(&DaoMigrationService::OnWriteBatchFinished, + weak_ptr_factory_.GetWeakPtr(), end)); + return; + case DataCategory::kPasswords: + writer.WritePasswords( + batch(std::get>(active_write_->records)), + base::BindOnce(&DaoMigrationService::OnWriteBatchFinished, + weak_ptr_factory_.GetWeakPtr(), end)); + return; + case DataCategory::kTabs: + write_result = writer.WriteTabsBatch( + batch(std::get>(active_write_->records)), + l10n_util::GetStringUTF16(IDS_DAO_IMPORT_TAB_FOLDER), + &active_tab_folder_id_); + break; + case DataCategory::kExtensions: + write_result = writer.WriteExtensions( + batch(std::get>(active_write_->records))); + break; + } + OnWriteBatchFinished(end, write_result); +} + +void DaoMigrationService::OnWriteBatchFinished(size_t end, + WriteResult write_result) { + if (!job_ || job_->IsTerminal() || !active_write_ || + !active_write_category_) { + return; + } + AddWriteResult(&active_write_totals_, write_result); + active_write_index_ = end; + const size_t total = + std::visit([](const auto &records) { return records.size(); }, + active_write_->records); + job_->UpdateProgress(*active_write_category_, active_write_index_, total); + NotifyObservers(); + base::SequencedTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, base::BindOnce(&DaoMigrationService::WriteNextBatch, + weak_ptr_factory_.GetWeakPtr())); +} + +void DaoMigrationService::FinishActiveTabFolder() { + if (!target_ || active_tab_folder_id_.empty()) { + return; + } + DaoMigrationWriter writer(target_.get()); + if (active_write_totals_.imported == 0) { + target_->AbortImportedTabFolder(active_tab_folder_id_); + } else if (!writer.FinishTabs(active_tab_folder_id_)) { + active_write_totals_.failed += active_write_totals_.imported; + active_write_totals_.imported = 0; + } + active_tab_folder_id_.clear(); +} + +void DaoMigrationService::OnExtensionInstallsFinished(uint64_t installed, + uint64_t failed) { + waiting_for_extension_installs_ = false; + if (!job_ || !active_write_category_ || + *active_write_category_ != DataCategory::kExtensions || + job_->IsTerminal()) { + return; + } + if (job_->cancel_requested()) { + active_write_totals_.imported = installed; + active_write_totals_.failed += failed; + job_->CancelRunningCategoryAtBatchBoundary(active_write_totals_); + } else { + active_write_totals_.imported = installed; + active_write_totals_.failed += failed; + if (failed > 0) { + job_->FailCategory(DataCategory::kExtensions, "extension_install_failed", + active_write_totals_); + } else { + job_->CompleteCategory(DataCategory::kExtensions, active_write_totals_); + } + } + active_write_.reset(); + active_write_category_.reset(); + NotifyObservers(); + ProcessNextCategory(); +} + +void DaoMigrationService::NotifyObservers() { + if (job_) { + observers_.Notify(job_->GetState()); + } +} + +const SourceProfile * +DaoMigrationService::FindDetectedSource(const std::string &source_id) const { + auto it = std::ranges::find(detected_sources_, source_id, &SourceProfile::id); + return it == detected_sources_.end() ? nullptr : &*it; +} + +const user_data_importer::SourceProfile * +DaoMigrationService::FindLegacySource(const std::string &source_id) const { + auto it = legacy_sources_.find(source_id); + return it == legacy_sources_.end() ? nullptr : &it->second; +} + +} // namespace dao::import diff --git a/src/dao/browser/import/dao_migration_service.h b/src/dao/browser/import/dao_migration_service.h new file mode 100644 index 00000000..5af288c7 --- /dev/null +++ b/src/dao/browser/import/dao_migration_service.h @@ -0,0 +1,162 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef DAO_BROWSER_IMPORT_DAO_MIGRATION_SERVICE_H_ +#define DAO_BROWSER_IMPORT_DAO_MIGRATION_SERVICE_H_ + +#include +#include +#include +#include +#include +#include +#include + +#include "base/callback_list.h" +#include "base/memory/raw_ptr.h" +#include "base/memory/scoped_refptr.h" +#include "base/memory/weak_ptr.h" +#include "chrome/browser/importer/importer_progress_observer.h" +#include "components/keyed_service/core/keyed_service.h" +#include "components/password_manager/core/browser/password_store/password_store_consumer.h" +#include "components/user_data_importer/common/importer_data_types.h" +#include "dao/browser/import/dao_migration_job.h" +#include "dao/browser/import/dao_profile_snapshot.h" +#include "dao/browser/import/dao_source_adapter.h" +#include "dao/browser/import/dao_source_detector.h" + +class Profile; +class ExternalProcessImporterHost; +class ImporterList; + +namespace dao::import { + +class DaoChromiumMigrationTarget; +class DaoLegacyProfileWriter; +struct WriteResult; + +class DaoMigrationService : public KeyedService, + public password_manager::PasswordStoreConsumer, + public importer::ImporterProgressObserver { +public: + using SourcesCallback = base::OnceCallback)>; + using CountCallback = base::OnceCallback)>; + using StateObserver = base::RepeatingCallback; + + explicit DaoMigrationService(Profile *profile); + DaoMigrationService(const DaoMigrationService &) = delete; + DaoMigrationService &operator=(const DaoMigrationService &) = delete; + ~DaoMigrationService() override; + + void DetectSources(SourcesCallback callback); + void CountSourceItems(const std::string &source_id, DataCategory category, + CountCallback callback); + bool Start(const std::string &source_id, + std::vector categories); + bool Retry(std::vector categories); + void Cancel(); + std::optional GetState() const; + base::CallbackListSubscription AddObserver(StateObserver observer); + + void Shutdown() override; + + // password_manager::PasswordStoreConsumer: + void OnGetPasswordStoreResultsOrErrorFrom( + password_manager::PasswordStoreInterface *store, + password_manager::LoginsResultOrError results_or_error) override; + + // importer::ImporterProgressObserver: + void ImportStarted() override; + void ImportItemStarted(user_data_importer::ImportItem item) override; + void ImportItemEnded(user_data_importer::ImportItem item) override; + void ImportEnded() override; + +private: + using Records = + std::variant, std::vector, + std::vector, std::vector, + std::vector>; + + struct ReadResult { + ReadResult(); + ReadResult(const ReadResult &); + ReadResult &operator=(const ReadResult &); + ReadResult(ReadResult &&); + ReadResult &operator=(ReadResult &&); + ~ReadResult(); + + bool success = false; + std::string error_code; + Records records = std::vector(); + }; + + static ReadResult ReadSnapshot(DataCategory category, SourceKind source_kind, + base::FilePath snapshot_path); + static std::optional + CountProfileCandidates(DataCategory category, base::FilePath profile_path); + static std::optional + CountSnapshotCandidates(DataCategory category, SnapshotResult snapshot); + SnapshotRequest BuildSnapshotRequest(DataCategory category, + const base::FilePath &source_path); + void ProcessNextCategory(); + void ProcessNextLegacyCategory(); + void OnChromiumSourcesDetected(uint64_t generation, + std::vector profiles); + void OnLegacySourcesDetected(uint64_t generation); + void MaybeFinishSourceDetection(); + void OnSnapshotReady(DataCategory category, SnapshotResult snapshot); + void OnCountSnapshotReady(DataCategory category, CountCallback callback, + SnapshotResult snapshot); + void OnReadComplete(DataCategory category, SnapshotResult snapshot, + ReadResult result); + void BeginWriting(DataCategory category, ReadResult result); + void WriteNextBatch(); + void OnWriteBatchFinished(size_t end, WriteResult result); + void FinishActiveTabFolder(); + void OnLegacyWritesFinished(); + void OnExtensionInstallsFinished(uint64_t installed, uint64_t failed); + void NotifyObservers(); + const SourceProfile *FindDetectedSource(const std::string &source_id) const; + const user_data_importer::SourceProfile * + FindLegacySource(const std::string &source_id) const; + + raw_ptr profile_; + DaoSourceDetector detector_; + std::unique_ptr importer_list_; + std::vector detected_sources_; + std::map legacy_sources_; + std::vector pending_detected_sources_; + std::map + pending_legacy_sources_; + SourcesCallback pending_sources_callback_; + uint64_t source_detection_generation_ = 0; + bool chromium_detection_done_ = false; + bool legacy_detection_done_ = false; + std::unique_ptr job_; + std::optional active_chromium_source_path_; + std::optional active_legacy_source_; + std::unique_ptr target_; + scoped_refptr snapshot_cancellation_; + base::RepeatingCallbackList observers_; + std::map, std::u16string> + existing_passwords_; + bool passwords_ready_ = false; + bool waiting_for_passwords_ = false; + std::optional active_write_; + std::optional active_write_category_; + size_t active_write_index_ = 0; + CategoryResult active_write_totals_; + std::string active_tab_folder_id_; + bool waiting_for_extension_installs_ = false; + raw_ptr legacy_importer_host_ = nullptr; + scoped_refptr active_legacy_writer_; + std::optional active_legacy_category_; + CategoryResult active_legacy_result_; + bool legacy_item_ended_ = false; + base::WeakPtrFactory weak_ptr_factory_{this}; +}; + +} // namespace dao::import + +#endif // DAO_BROWSER_IMPORT_DAO_MIGRATION_SERVICE_H_ diff --git a/src/dao/browser/import/dao_migration_service_factory.cc b/src/dao/browser/import/dao_migration_service_factory.cc new file mode 100644 index 00000000..6a2eb094 --- /dev/null +++ b/src/dao/browser/import/dao_migration_service_factory.cc @@ -0,0 +1,44 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/import/dao_migration_service_factory.h" + +#include "chrome/browser/profiles/profile.h" +#include "components/keyed_service/content/browser_context_dependency_manager.h" +#include "dao/browser/import/dao_migration_service.h" + +namespace dao::import { + +DaoMigrationService* DaoMigrationServiceFactory::GetForProfile( + Profile* profile) { + return static_cast( + GetInstance()->GetServiceForBrowserContext(profile, true)); +} + +DaoMigrationServiceFactory* DaoMigrationServiceFactory::GetInstance() { + static base::NoDestructor instance; + return instance.get(); +} + +DaoMigrationServiceFactory::DaoMigrationServiceFactory() + : BrowserContextKeyedServiceFactory( + "DaoMigrationService", + BrowserContextDependencyManager::GetInstance()) {} + +DaoMigrationServiceFactory::~DaoMigrationServiceFactory() = default; + +std::unique_ptr +DaoMigrationServiceFactory::BuildServiceInstanceForBrowserContext( + content::BrowserContext* context) const { + return std::make_unique( + Profile::FromBrowserContext(context)); +} + +content::BrowserContext* DaoMigrationServiceFactory::GetBrowserContextToUse( + content::BrowserContext* context) const { + Profile* profile = Profile::FromBrowserContext(context); + return profile->IsOffTheRecord() ? nullptr : context; +} + +} // namespace dao::import diff --git a/src/dao/browser/import/dao_migration_service_factory.h b/src/dao/browser/import/dao_migration_service_factory.h new file mode 100644 index 00000000..1706e023 --- /dev/null +++ b/src/dao/browser/import/dao_migration_service_factory.h @@ -0,0 +1,36 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef DAO_BROWSER_IMPORT_DAO_MIGRATION_SERVICE_FACTORY_H_ +#define DAO_BROWSER_IMPORT_DAO_MIGRATION_SERVICE_FACTORY_H_ + +#include "base/no_destructor.h" +#include "components/keyed_service/content/browser_context_keyed_service_factory.h" + +class Profile; + +namespace dao::import { + +class DaoMigrationService; + +class DaoMigrationServiceFactory : public BrowserContextKeyedServiceFactory { + public: + static DaoMigrationService* GetForProfile(Profile* profile); + static DaoMigrationServiceFactory* GetInstance(); + + private: + friend class base::NoDestructor; + + DaoMigrationServiceFactory(); + ~DaoMigrationServiceFactory() override; + + std::unique_ptr BuildServiceInstanceForBrowserContext( + content::BrowserContext* context) const override; + content::BrowserContext* GetBrowserContextToUse( + content::BrowserContext* context) const override; +}; + +} // namespace dao::import + +#endif // DAO_BROWSER_IMPORT_DAO_MIGRATION_SERVICE_FACTORY_H_ diff --git a/src/dao/browser/import/dao_migration_types.cc b/src/dao/browser/import/dao_migration_types.cc new file mode 100644 index 00000000..9c22d9c7 --- /dev/null +++ b/src/dao/browser/import/dao_migration_types.cc @@ -0,0 +1,82 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/import/dao_migration_types.h" + +#include "base/notreached.h" + +namespace dao::import { + +SourceProfile::SourceProfile() = default; +SourceProfile::SourceProfile(const SourceProfile&) = default; +SourceProfile& SourceProfile::operator=(const SourceProfile&) = default; +SourceProfile::SourceProfile(SourceProfile&&) = default; +SourceProfile& SourceProfile::operator=(SourceProfile&&) = default; +SourceProfile::~SourceProfile() = default; + +JobState::JobState() = default; +JobState::JobState(const JobState&) = default; +JobState& JobState::operator=(const JobState&) = default; +JobState::JobState(JobState&&) = default; +JobState& JobState::operator=(JobState&&) = default; +JobState::~JobState() = default; + +const char* SourceKindToString(SourceKind kind) { + switch (kind) { + case SourceKind::kChrome: + return "chrome"; + case SourceKind::kArc: + return "arc"; + case SourceKind::kSafari: + return "safari"; + case SourceKind::kEdge: + return "edge"; + case SourceKind::kFirefox: + return "firefox"; + } + NOTREACHED(); +} + +const char* DataCategoryToString(DataCategory category) { + switch (category) { + case DataCategory::kBookmarks: + return "bookmarks"; + case DataCategory::kHistory: + return "history"; + case DataCategory::kPasswords: + return "passwords"; + case DataCategory::kTabs: + return "tabs"; + case DataCategory::kExtensions: + return "extensions"; + } + NOTREACHED(); +} + +const char* CategoryPhaseToString(CategoryPhase phase) { + switch (phase) { + case CategoryPhase::kPending: + return "pending"; + case CategoryPhase::kSnapshotting: + return "snapshotting"; + case CategoryPhase::kReading: + return "reading"; + case CategoryPhase::kWriting: + return "writing"; + case CategoryPhase::kSucceeded: + return "succeeded"; + case CategoryPhase::kFailed: + return "failed"; + case CategoryPhase::kCancelled: + return "cancelled"; + } + NOTREACHED(); +} + +bool IsTerminalPhase(CategoryPhase phase) { + return phase == CategoryPhase::kSucceeded || + phase == CategoryPhase::kFailed || phase == CategoryPhase::kCancelled; +} + +} // namespace dao::import diff --git a/src/dao/browser/import/dao_migration_types.h b/src/dao/browser/import/dao_migration_types.h new file mode 100644 index 00000000..18227087 --- /dev/null +++ b/src/dao/browser/import/dao_migration_types.h @@ -0,0 +1,93 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef DAO_BROWSER_IMPORT_DAO_MIGRATION_TYPES_H_ +#define DAO_BROWSER_IMPORT_DAO_MIGRATION_TYPES_H_ + +#include +#include +#include + +namespace dao::import { + +enum class SourceKind { + kChrome, + kArc, + kSafari, + kEdge, + kFirefox, +}; + +enum class DataCategory { + kBookmarks, + kHistory, + kPasswords, + kTabs, + kExtensions, +}; + +enum class CategoryPhase { + kPending, + kSnapshotting, + kReading, + kWriting, + kSucceeded, + kFailed, + kCancelled, +}; + +struct SourceProfile { + SourceProfile(); + SourceProfile(const SourceProfile&); + SourceProfile& operator=(const SourceProfile&); + SourceProfile(SourceProfile&&); + SourceProfile& operator=(SourceProfile&&); + ~SourceProfile(); + + std::string id; + SourceKind kind = SourceKind::kChrome; + std::string browser_name; + std::string profile_name; + std::vector supported_categories; +}; + +struct CategoryResult { + uint64_t imported = 0; + uint64_t skipped = 0; + uint64_t conflicted = 0; + uint64_t failed = 0; +}; + +struct CategoryState { + CategoryPhase phase = CategoryPhase::kPending; + CategoryResult result; + std::string error_code; + uint64_t completed_items = 0; + uint64_t total_items = 0; + bool indeterminate = true; +}; + +struct JobState { + JobState(); + JobState(const JobState&); + JobState& operator=(const JobState&); + JobState(JobState&&); + JobState& operator=(JobState&&); + ~JobState(); + + std::string source_id; + bool cancel_requested = false; + std::vector selected_categories; + std::vector category_states; +}; + +const char* SourceKindToString(SourceKind kind); +const char* DataCategoryToString(DataCategory category); +const char* CategoryPhaseToString(CategoryPhase phase); + +bool IsTerminalPhase(CategoryPhase phase); + +} // namespace dao::import + +#endif // DAO_BROWSER_IMPORT_DAO_MIGRATION_TYPES_H_ diff --git a/src/dao/browser/import/dao_migration_writer.cc b/src/dao/browser/import/dao_migration_writer.cc new file mode 100644 index 00000000..97596127 --- /dev/null +++ b/src/dao/browser/import/dao_migration_writer.cc @@ -0,0 +1,196 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/import/dao_migration_writer.h" + +#include +#include + +#include "base/check.h" +#include "base/functional/bind.h" +#include "base/functional/callback.h" + +namespace dao::import { +namespace { + +template +struct AsyncWriteState { + std::vector entries; + size_t index = 0; + WriteResult result; + WriteResultCallback callback; +}; + +void WriteNextHistory(MigrationTarget* target, + std::unique_ptr> state) { + while (state->index < state->entries.size()) { + const HistoryVisit& entry = state->entries[state->index++]; + if (target->HasHistoryVisit(entry)) { + ++state->result.skipped; + continue; + } + target->AddHistoryVisit( + entry, base::BindOnce( + [](MigrationTarget* target, + std::unique_ptr> state, + bool success) { + if (success) { + ++state->result.imported; + } else { + ++state->result.failed; + } + WriteNextHistory(target, std::move(state)); + }, + target, std::move(state))); + return; + } + WriteResult result = state->result; + WriteResultCallback callback = std::move(state->callback); + std::move(callback).Run(result); +} + +void WriteNextPassword(MigrationTarget* target, + std::unique_ptr> state) { + while (state->index < state->entries.size()) { + const PasswordEntry& entry = state->entries[state->index++]; + switch (target->MatchPassword(entry)) { + case PasswordMatch::kSame: + ++state->result.skipped; + continue; + case PasswordMatch::kConflict: + ++state->result.conflicted; + continue; + case PasswordMatch::kNone: + target->AddPassword( + entry, base::BindOnce( + [](MigrationTarget* target, + std::unique_ptr> state, + bool success) { + if (success) { + ++state->result.imported; + } else { + ++state->result.failed; + } + WriteNextPassword(target, std::move(state)); + }, + target, std::move(state))); + return; + } + } + WriteResult result = state->result; + WriteResultCallback callback = std::move(state->callback); + std::move(callback).Run(result); +} + +} // namespace + +DaoMigrationWriter::DaoMigrationWriter(MigrationTarget* target) + : target_(target) { + CHECK(target_); +} + +DaoMigrationWriter::~DaoMigrationWriter() = default; + +WriteResult DaoMigrationWriter::WriteBookmarks( + const std::vector& entries, + const std::u16string& root_name) { + WriteResult result; + for (const BookmarkEntry& entry : entries) { + if (target_->HasBookmark(entry, root_name)) { + ++result.skipped; + continue; + } + if (target_->AddBookmark(entry, root_name)) { + ++result.imported; + } else { + ++result.failed; + } + } + return result; +} + +void DaoMigrationWriter::WriteHistory(const std::vector& entries, + WriteResultCallback callback) { + auto state = std::make_unique>(); + state->entries = entries; + state->callback = std::move(callback); + WriteNextHistory(target_, std::move(state)); +} + +void DaoMigrationWriter::WritePasswords( + const std::vector& entries, + WriteResultCallback callback) { + auto state = std::make_unique>(); + state->entries = entries; + state->callback = std::move(callback); + WriteNextPassword(target_, std::move(state)); +} + +WriteResult DaoMigrationWriter::WriteTabs(const std::vector& entries, + const std::u16string& folder_name) { + std::string folder_id; + WriteResult result = WriteTabsBatch(entries, folder_name, &folder_id); + if (!folder_id.empty()) { + if (result.imported == 0) { + target_->AbortImportedTabFolder(folder_id); + } else if (!FinishTabs(folder_id)) { + result.failed += result.imported; + result.imported = 0; + } + } + return result; +} + +WriteResult DaoMigrationWriter::WriteTabsBatch( + const std::vector& entries, + const std::u16string& folder_name, + std::string* folder_id) { + WriteResult result; + for (const TabEntry& entry : entries) { + if (target_->IsTabOpen(entry.url)) { + ++result.skipped; + continue; + } + if (folder_id->empty()) { + *folder_id = target_->EnsureImportedTabFolder(folder_name); + if (folder_id->empty()) { + ++result.failed; + continue; + } + } + if (target_->AddDormantTab(entry, *folder_id)) { + ++result.imported; + } else { + ++result.failed; + } + } + return result; +} + +bool DaoMigrationWriter::FinishTabs(const std::string& folder_id) { + if (target_->FinishImportedTabFolder(folder_id)) { + return true; + } + target_->AbortImportedTabFolder(folder_id); + return false; +} + +WriteResult DaoMigrationWriter::WriteExtensions( + const std::vector& entries) { + WriteResult result; + for (const ExtensionEntry& entry : entries) { + if (target_->IsExtensionInstalled(entry.id)) { + ++result.skipped; + continue; + } + if (target_->QueueExtensionInstall(entry)) { + ++result.imported; + } else { + ++result.failed; + } + } + return result; +} + +} // namespace dao::import diff --git a/src/dao/browser/import/dao_migration_writer.h b/src/dao/browser/import/dao_migration_writer.h new file mode 100644 index 00000000..4606f9f7 --- /dev/null +++ b/src/dao/browser/import/dao_migration_writer.h @@ -0,0 +1,86 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef DAO_BROWSER_IMPORT_DAO_MIGRATION_WRITER_H_ +#define DAO_BROWSER_IMPORT_DAO_MIGRATION_WRITER_H_ + +#include +#include +#include + +#include "base/functional/callback_forward.h" +#include "base/memory/raw_ptr.h" +#include "dao/browser/import/dao_source_adapter.h" + +namespace dao::import { + +struct WriteResult { + uint64_t imported = 0; + uint64_t skipped = 0; + uint64_t conflicted = 0; + uint64_t failed = 0; +}; + +enum class PasswordMatch { + kNone, + kSame, + kConflict, +}; + +using ItemWriteCallback = base::OnceCallback; +using WriteResultCallback = base::OnceCallback; + +class MigrationTarget { + public: + virtual ~MigrationTarget() = default; + + virtual bool HasBookmark(const BookmarkEntry& entry, + const std::u16string& root_name) const = 0; + virtual bool AddBookmark(const BookmarkEntry& entry, + const std::u16string& root_name) = 0; + virtual bool HasHistoryVisit(const HistoryVisit& entry) const = 0; + virtual void AddHistoryVisit(const HistoryVisit& entry, + ItemWriteCallback callback) = 0; + virtual PasswordMatch MatchPassword(const PasswordEntry& entry) const = 0; + virtual void AddPassword(const PasswordEntry& entry, + ItemWriteCallback callback) = 0; + virtual bool IsTabOpen(const GURL& url) const = 0; + virtual std::string EnsureImportedTabFolder( + const std::u16string& folder_name) = 0; + virtual bool AddDormantTab(const TabEntry& entry, + const std::string& folder_id) = 0; + virtual bool FinishImportedTabFolder(const std::string& folder_id) = 0; + virtual void AbortImportedTabFolder(const std::string& folder_id) = 0; + virtual bool IsExtensionInstalled(const std::string& id) const = 0; + virtual bool QueueExtensionInstall(const ExtensionEntry& entry) = 0; +}; + +class DaoMigrationWriter { + public: + explicit DaoMigrationWriter(MigrationTarget* target); + DaoMigrationWriter(const DaoMigrationWriter&) = delete; + DaoMigrationWriter& operator=(const DaoMigrationWriter&) = delete; + ~DaoMigrationWriter(); + + WriteResult WriteBookmarks(const std::vector& entries, + const std::u16string& root_name); + void WriteHistory(const std::vector& entries, + WriteResultCallback callback); + void WritePasswords(const std::vector& entries, + WriteResultCallback callback); + WriteResult WriteTabs(const std::vector& entries, + const std::u16string& folder_name); + WriteResult WriteTabsBatch(const std::vector& entries, + const std::u16string& folder_name, + std::string* folder_id); + bool FinishTabs(const std::string& folder_id); + WriteResult WriteExtensions(const std::vector& entries); + + private: + const raw_ptr target_; +}; + +} // namespace dao::import + +#endif // DAO_BROWSER_IMPORT_DAO_MIGRATION_WRITER_H_ diff --git a/src/dao/browser/import/dao_migration_writer_unittest.cc b/src/dao/browser/import/dao_migration_writer_unittest.cc new file mode 100644 index 00000000..74ee8f0d --- /dev/null +++ b/src/dao/browser/import/dao_migration_writer_unittest.cc @@ -0,0 +1,309 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/import/dao_migration_writer.h" + +#include +#include +#include +#include + +#include "base/test/test_future.h" +#include "base/time/time.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace dao::import { +namespace { + +class FakeMigrationTarget : public MigrationTarget { + public: + bool HasBookmark(const BookmarkEntry& entry, + const std::u16string& root_name) const override { + return bookmarks.contains(entry.url.spec()); + } + bool AddBookmark(const BookmarkEntry& entry, + const std::u16string& root_name) override { + if (!accept_writes) { + return false; + } + bookmarks.insert(entry.url.spec()); + bookmark_roots.push_back(root_name); + return true; + } + bool HasHistoryVisit(const HistoryVisit& entry) const override { + return history.contains({entry.url.spec(), entry.visit_time}); + } + void AddHistoryVisit(const HistoryVisit& entry, + ItemWriteCallback callback) override { + if (defer_history_writes) { + pending_history_entry = entry; + pending_history_callback = std::move(callback); + return; + } + if (!accept_writes) { + std::move(callback).Run(false); + return; + } + history.insert({entry.url.spec(), entry.visit_time}); + std::move(callback).Run(true); + } + PasswordMatch MatchPassword(const PasswordEntry& entry) const override { + return password_match; + } + void AddPassword(const PasswordEntry& entry, + ItemWriteCallback callback) override { + if (defer_password_writes) { + pending_password_callback = std::move(callback); + return; + } + if (!accept_writes) { + std::move(callback).Run(false); + return; + } + ++password_adds; + std::move(callback).Run(true); + } + bool IsTabOpen(const GURL& url) const override { + return open_tabs.contains(url.spec()); + } + std::string EnsureImportedTabFolder( + const std::u16string& folder_name) override { + imported_folder_name = folder_name; + return "folder-1"; + } + bool AddDormantTab(const TabEntry& entry, + const std::string& folder_id) override { + if (!accept_writes) { + return false; + } + dormant_tabs.push_back(entry.url.spec()); + return true; + } + bool FinishImportedTabFolder(const std::string& folder_id) override { + if (!accept_writes || !accept_tab_folder_finish) { + return false; + } + finished_folder_id = folder_id; + return true; + } + void AbortImportedTabFolder(const std::string& folder_id) override { + aborted_folder_id = folder_id; + dormant_tabs.clear(); + } + bool IsExtensionInstalled(const std::string& id) const override { + return installed_extensions.contains(id); + } + bool QueueExtensionInstall(const ExtensionEntry& entry) override { + installed_extensions.insert(entry.id); + return true; + } + + void CompleteHistoryWrite(bool success) { + ASSERT_FALSE(pending_history_callback.is_null()); + if (success) { + history.insert( + {pending_history_entry.url.spec(), pending_history_entry.visit_time}); + } + std::move(pending_history_callback).Run(success); + } + + void CompletePasswordWrite(bool success) { + ASSERT_FALSE(pending_password_callback.is_null()); + if (success) { + ++password_adds; + } + std::move(pending_password_callback).Run(success); + } + + std::set bookmarks; + std::vector bookmark_roots; + std::set> history; + HistoryVisit pending_history_entry; + ItemWriteCallback pending_history_callback; + PasswordMatch password_match = PasswordMatch::kNone; + int password_adds = 0; + ItemWriteCallback pending_password_callback; + std::set open_tabs; + std::u16string imported_folder_name; + std::vector dormant_tabs; + std::string finished_folder_id; + std::string aborted_folder_id; + std::set installed_extensions; + bool accept_writes = true; + bool accept_tab_folder_finish = true; + bool defer_history_writes = false; + bool defer_password_writes = false; +}; + +TEST(DaoMigrationWriterTest, RepeatedBookmarkAndHistoryWritesAreIdempotent) { + FakeMigrationTarget target; + DaoMigrationWriter writer(&target); + BookmarkEntry bookmark; + bookmark.url = GURL("https://docs.example/"); + bookmark.title = u"Docs"; + HistoryVisit visit; + visit.url = GURL("https://docs.example/"); + visit.visit_time = base::Time::FromSecondsSinceUnixEpoch(123); + + EXPECT_EQ(1u, writer.WriteBookmarks({bookmark}, u"Imported").imported); + EXPECT_EQ(1u, writer.WriteBookmarks({bookmark}, u"Imported").skipped); + base::test::TestFuture first_history; + writer.WriteHistory({visit}, first_history.GetCallback()); + EXPECT_EQ(1u, first_history.Get().imported); + base::test::TestFuture second_history; + writer.WriteHistory({visit}, second_history.GetCallback()); + EXPECT_EQ(1u, second_history.Get().skipped); +} + +TEST(DaoMigrationWriterTest, PreservesExistingPasswordOnConflict) { + FakeMigrationTarget target; + target.password_match = PasswordMatch::kConflict; + DaoMigrationWriter writer(&target); + PasswordEntry password; + password.origin = GURL("https://example.com/"); + password.username = u"person@example.com"; + password.password = u"new secret"; + + base::test::TestFuture result; + writer.WritePasswords({password}, result.GetCallback()); + + EXPECT_EQ(1u, result.Get().conflicted); + EXPECT_EQ(0, target.password_adds); +} + +TEST(DaoMigrationWriterTest, SkipsOpenTabsAndPreservesImportedOrder) { + FakeMigrationTarget target; + target.open_tabs.insert("https://already.example/"); + DaoMigrationWriter writer(&target); + TabEntry first; + first.url = GURL("https://already.example/"); + TabEntry second; + second.url = GURL("https://one.example/"); + TabEntry third; + third.url = GURL("https://two.example/"); + + WriteResult result = + writer.WriteTabs({first, second, third}, u"Imported from Chrome"); + + EXPECT_EQ(2u, result.imported); + EXPECT_EQ(1u, result.skipped); + EXPECT_EQ((std::vector{"https://one.example/", + "https://two.example/"}), + target.dormant_tabs); + EXPECT_EQ(u"Imported from Chrome", target.imported_folder_name); + EXPECT_EQ("folder-1", target.finished_folder_id); +} + +TEST(DaoMigrationWriterTest, WaitsForDestinationBeforeCountingHistoryWrite) { + FakeMigrationTarget target; + target.defer_history_writes = true; + DaoMigrationWriter writer(&target); + HistoryVisit visit; + visit.url = GURL("https://history.example/"); + visit.visit_time = base::Time::FromSecondsSinceUnixEpoch(123); + base::test::TestFuture result; + + writer.WriteHistory({visit}, result.GetCallback()); + + EXPECT_FALSE(result.IsReady()); + target.CompleteHistoryWrite(false); + EXPECT_EQ(1u, result.Get().failed); +} + +TEST(DaoMigrationWriterTest, WaitsForDestinationBeforeCountingPasswordWrite) { + FakeMigrationTarget target; + target.defer_password_writes = true; + DaoMigrationWriter writer(&target); + PasswordEntry password; + password.origin = GURL("https://password.example/"); + password.username = u"person@example.com"; + password.password = u"secret"; + base::test::TestFuture result; + + writer.WritePasswords({password}, result.GetCallback()); + + EXPECT_FALSE(result.IsReady()); + target.CompletePasswordWrite(false); + EXPECT_EQ(1u, result.Get().failed); +} + +TEST(DaoMigrationWriterTest, KeepsTabFolderAcrossBatches) { + FakeMigrationTarget target; + DaoMigrationWriter writer(&target); + TabEntry first; + first.url = GURL("https://one.example/"); + TabEntry second; + second.url = GURL("https://two.example/"); + std::string folder_id; + + EXPECT_EQ( + 1u, + writer.WriteTabsBatch({first}, u"Imported tabs", &folder_id).imported); + EXPECT_TRUE(target.finished_folder_id.empty()); + EXPECT_EQ( + 1u, + writer.WriteTabsBatch({second}, u"Imported tabs", &folder_id).imported); + EXPECT_TRUE(writer.FinishTabs(folder_id)); + + EXPECT_EQ("folder-1", target.finished_folder_id); + EXPECT_EQ((std::vector{"https://one.example/", + "https://two.example/"}), + target.dormant_tabs); +} + +TEST(DaoMigrationWriterTest, RollsBackTabsWhenFolderPersistenceFails) { + FakeMigrationTarget target; + target.accept_tab_folder_finish = false; + DaoMigrationWriter writer(&target); + TabEntry tab; + tab.url = GURL("https://tab.example/"); + + WriteResult result = writer.WriteTabs({tab}, u"Imported tabs"); + + EXPECT_EQ(0u, result.imported); + EXPECT_EQ(1u, result.failed); + EXPECT_EQ("folder-1", target.aborted_folder_id); + EXPECT_TRUE(target.dormant_tabs.empty()); +} + +TEST(DaoMigrationWriterTest, SkipsAlreadyInstalledExtensions) { + FakeMigrationTarget target; + target.installed_extensions.insert("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + DaoMigrationWriter writer(&target); + ExtensionEntry installed; + installed.id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + ExtensionEntry new_extension; + new_extension.id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + WriteResult result = writer.WriteExtensions({installed, new_extension}); + + EXPECT_EQ(1u, result.imported); + EXPECT_EQ(1u, result.skipped); +} + +TEST(DaoMigrationWriterTest, ReportsRejectedDestinationWrites) { + FakeMigrationTarget target; + target.accept_writes = false; + DaoMigrationWriter writer(&target); + BookmarkEntry bookmark; + bookmark.url = GURL("https://docs.example/"); + HistoryVisit visit; + visit.url = GURL("https://history.example/"); + PasswordEntry password; + password.origin = GURL("https://password.example/"); + TabEntry tab; + tab.url = GURL("https://tab.example/"); + + EXPECT_EQ(1u, writer.WriteBookmarks({bookmark}, u"Imported").failed); + base::test::TestFuture history_result; + writer.WriteHistory({visit}, history_result.GetCallback()); + EXPECT_EQ(1u, history_result.Get().failed); + base::test::TestFuture password_result; + writer.WritePasswords({password}, password_result.GetCallback()); + EXPECT_EQ(1u, password_result.Get().failed); + EXPECT_EQ(1u, writer.WriteTabs({tab}, u"Imported tabs").failed); +} + +} // namespace +} // namespace dao::import diff --git a/src/dao/browser/import/dao_profile_snapshot.cc b/src/dao/browser/import/dao_profile_snapshot.cc new file mode 100644 index 00000000..44151121 --- /dev/null +++ b/src/dao/browser/import/dao_profile_snapshot.cc @@ -0,0 +1,219 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/import/dao_profile_snapshot.h" + +#include +#include +#include + +#include "base/files/file.h" +#include "base/files/file_enumerator.h" +#include "base/files/file_util.h" +#include "base/functional/bind.h" +#include "base/notreached.h" +#include "base/task/thread_pool.h" + +namespace dao::import { +namespace { + +enum class CopyOutcome { + kCopied, + kMissing, + kPermissionDenied, + kChanging, + kFailed, + kCancelled, +}; + +bool FileInfoMatches(const base::File::Info& left, + const base::File::Info& right) { + return left.size == right.size && left.last_modified == right.last_modified; +} + +CopyOutcome CopyStableFile(const base::FilePath& source, + const base::FilePath& destination, + int max_attempts, + const SnapshotCancellationFlag* cancellation, + bool required) { + for (int attempt = 0; attempt < std::max(1, max_attempts); ++attempt) { + if (cancellation && cancellation->IsCancelled()) { + return CopyOutcome::kCancelled; + } + + base::File::Info before; + if (!base::GetFileInfo(source, &before)) { + if (!base::PathExists(source)) { + return required ? CopyOutcome::kMissing : CopyOutcome::kCopied; + } + return CopyOutcome::kPermissionDenied; + } + if (before.is_directory) { + return CopyOutcome::kFailed; + } + + if (!base::CreateDirectory(destination.DirName()) || + !base::CopyFile(source, destination)) { + return CopyOutcome::kFailed; + } + + base::File::Info after; + base::File::Info copied; + if (base::GetFileInfo(source, &after) && + base::GetFileInfo(destination, &copied) && + FileInfoMatches(before, after) && before.size == copied.size) { + return CopyOutcome::kCopied; + } + base::DeleteFile(destination); + } + return CopyOutcome::kChanging; +} + +CopyOutcome CopyStableDirectory(const base::FilePath& source, + const base::FilePath& destination, + int max_attempts, + const SnapshotCancellationFlag* cancellation) { + if (!base::DirectoryExists(source)) { + return CopyOutcome::kMissing; + } + if (!base::CreateDirectory(destination)) { + return CopyOutcome::kFailed; + } + base::FileEnumerator files(source, true, base::FileEnumerator::FILES); + for (base::FilePath file = files.Next(); !file.empty(); file = files.Next()) { + base::FilePath relative; + if (!source.AppendRelativePath(file, &relative)) { + return CopyOutcome::kFailed; + } + CopyOutcome outcome = CopyStableFile(file, destination.Append(relative), + max_attempts, cancellation, true); + if (outcome != CopyOutcome::kCopied) { + return outcome; + } + } + return cancellation && cancellation->IsCancelled() ? CopyOutcome::kCancelled + : CopyOutcome::kCopied; +} + +std::string ErrorCodeForOutcome(CopyOutcome outcome) { + switch (outcome) { + case CopyOutcome::kCopied: + return std::string(); + case CopyOutcome::kMissing: + return "source_missing"; + case CopyOutcome::kPermissionDenied: + return "permission_denied"; + case CopyOutcome::kChanging: + return "source_changing"; + case CopyOutcome::kFailed: + return "copy_failed"; + case CopyOutcome::kCancelled: + return "cancelled"; + } + NOTREACHED(); +} + +} // namespace + +SnapshotCancellationFlag::SnapshotCancellationFlag() = default; +SnapshotCancellationFlag::~SnapshotCancellationFlag() = default; + +void SnapshotCancellationFlag::Cancel() { + cancelled_.store(true, std::memory_order_relaxed); +} + +bool SnapshotCancellationFlag::IsCancelled() const { + return cancelled_.load(std::memory_order_relaxed); +} + +SnapshotRequest::SnapshotRequest() = default; +SnapshotRequest::SnapshotRequest(const SnapshotRequest&) = default; +SnapshotRequest& SnapshotRequest::operator=(const SnapshotRequest&) = default; +SnapshotRequest::~SnapshotRequest() = default; + +SnapshotResult::SnapshotResult() + : temp_dir(nullptr, base::OnTaskRunnerDeleter(nullptr)) {} +SnapshotResult::SnapshotResult(SnapshotResult&&) = default; +SnapshotResult& SnapshotResult::operator=(SnapshotResult&&) = default; +SnapshotResult::~SnapshotResult() = default; + +// static +void DaoProfileSnapshot::Create(SnapshotRequest request, + CreateCallback callback) { + base::ThreadPool::PostTaskAndReplyWithResult( + FROM_HERE, + {base::MayBlock(), base::TaskPriority::USER_VISIBLE, + base::TaskShutdownBehavior::CONTINUE_ON_SHUTDOWN}, + base::BindOnce(&DaoProfileSnapshot::CreateOnBlockingThread, + std::move(request)), + std::move(callback)); +} + +// static +SnapshotResult DaoProfileSnapshot::CreateForTesting( + const SnapshotRequest& request) { + return CreateOnBlockingThread(request); +} + +// static +SnapshotResult DaoProfileSnapshot::CreateOnBlockingThread( + SnapshotRequest request) { + SnapshotResult result; + if (request.cancellation && request.cancellation->IsCancelled()) { + result.error_code = "cancelled"; + return result; + } + + SnapshotResult::TempDirPtr temp_dir( + new base::ScopedTempDir(), + base::OnTaskRunnerDeleter(base::ThreadPool::CreateSequencedTaskRunner( + {base::MayBlock(), base::TaskPriority::USER_VISIBLE, + base::TaskShutdownBehavior::BLOCK_SHUTDOWN}))); + if (!temp_dir->CreateUniqueTempDir()) { + result.error_code = "copy_failed"; + return result; + } + + for (const base::FilePath& relative_path : request.relative_paths) { + if (relative_path.IsAbsolute() || relative_path.ReferencesParent()) { + result.error_code = "copy_failed"; + return result; + } + + const base::FilePath source = request.source_profile.Append(relative_path); + const base::FilePath destination = + temp_dir->GetPath().Append(relative_path); + CopyOutcome outcome = + base::DirectoryExists(source) + ? CopyStableDirectory(source, destination, request.max_attempts, + request.cancellation.get()) + : CopyStableFile(source, destination, request.max_attempts, + request.cancellation.get(), true); + if (outcome != CopyOutcome::kCopied) { + result.error_code = ErrorCodeForOutcome(outcome); + return result; + } + + if (!request.include_sqlite_sidecars || base::DirectoryExists(source)) { + continue; + } + for (std::string_view suffix : {"-wal", "-shm"}) { + outcome = CopyStableFile( + base::FilePath(source.value() + std::string(suffix)), + base::FilePath(destination.value() + std::string(suffix)), + request.max_attempts, request.cancellation.get(), false); + if (outcome != CopyOutcome::kCopied) { + result.error_code = ErrorCodeForOutcome(outcome); + return result; + } + } + } + + result.success = true; + result.path = temp_dir->GetPath(); + result.temp_dir = std::move(temp_dir); + return result; +} + +} // namespace dao::import diff --git a/src/dao/browser/import/dao_profile_snapshot.h b/src/dao/browser/import/dao_profile_snapshot.h new file mode 100644 index 00000000..5f6c01d4 --- /dev/null +++ b/src/dao/browser/import/dao_profile_snapshot.h @@ -0,0 +1,81 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef DAO_BROWSER_IMPORT_DAO_PROFILE_SNAPSHOT_H_ +#define DAO_BROWSER_IMPORT_DAO_PROFILE_SNAPSHOT_H_ + +#include +#include +#include +#include + +#include "base/files/file_path.h" +#include "base/files/scoped_temp_dir.h" +#include "base/functional/callback_forward.h" +#include "base/memory/ref_counted.h" +#include "base/task/sequenced_task_runner.h" + +namespace dao::import { + +class SnapshotCancellationFlag + : public base::RefCountedThreadSafe { + public: + SnapshotCancellationFlag(); + + void Cancel(); + bool IsCancelled() const; + + private: + friend class base::RefCountedThreadSafe; + ~SnapshotCancellationFlag(); + + std::atomic_bool cancelled_{false}; +}; + +struct SnapshotRequest { + SnapshotRequest(); + SnapshotRequest(const SnapshotRequest&); + SnapshotRequest& operator=(const SnapshotRequest&); + ~SnapshotRequest(); + + base::FilePath source_profile; + std::vector relative_paths; + scoped_refptr cancellation; + bool include_sqlite_sidecars = false; + int max_attempts = 3; +}; + +struct SnapshotResult { + SnapshotResult(); + SnapshotResult(SnapshotResult&&); + SnapshotResult& operator=(SnapshotResult&&); + SnapshotResult(const SnapshotResult&) = delete; + SnapshotResult& operator=(const SnapshotResult&) = delete; + ~SnapshotResult(); + + bool success = false; + std::string error_code; + base::FilePath path; + + private: + friend class DaoProfileSnapshot; + using TempDirPtr = + std::unique_ptr; + TempDirPtr temp_dir; +}; + +class DaoProfileSnapshot { + public: + using CreateCallback = base::OnceCallback; + + static void Create(SnapshotRequest request, CreateCallback callback); + static SnapshotResult CreateForTesting(const SnapshotRequest& request); + + private: + static SnapshotResult CreateOnBlockingThread(SnapshotRequest request); +}; + +} // namespace dao::import + +#endif // DAO_BROWSER_IMPORT_DAO_PROFILE_SNAPSHOT_H_ diff --git a/src/dao/browser/import/dao_profile_snapshot_unittest.cc b/src/dao/browser/import/dao_profile_snapshot_unittest.cc new file mode 100644 index 00000000..c76c6a4e --- /dev/null +++ b/src/dao/browser/import/dao_profile_snapshot_unittest.cc @@ -0,0 +1,153 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/import/dao_profile_snapshot.h" + +#include + +#include "base/files/file_util.h" +#include "base/files/scoped_temp_dir.h" +#include "base/functional/bind.h" +#include "base/run_loop.h" +#include "base/test/task_environment.h" +#include "base/threading/thread_restrictions.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace dao::import { +namespace { + +class DaoProfileSnapshotTest : public testing::Test { + protected: + base::test::TaskEnvironment task_environment_; +}; + +TEST_F(DaoProfileSnapshotTest, CopiesStableFileAndSqliteSidecars) { + base::ScopedTempDir source_dir; + ASSERT_TRUE(source_dir.CreateUniqueTempDir()); + ASSERT_TRUE( + base::WriteFile(source_dir.GetPath().AppendASCII("History"), "database")); + ASSERT_TRUE( + base::WriteFile(source_dir.GetPath().AppendASCII("History-wal"), "wal")); + ASSERT_TRUE( + base::WriteFile(source_dir.GetPath().AppendASCII("History-shm"), "shm")); + + SnapshotRequest request; + request.source_profile = source_dir.GetPath(); + request.relative_paths = {base::FilePath(FILE_PATH_LITERAL("History"))}; + request.include_sqlite_sidecars = true; + SnapshotResult result = DaoProfileSnapshot::CreateForTesting(request); + + ASSERT_TRUE(result.success); + EXPECT_TRUE(base::PathExists(result.path.AppendASCII("History"))); + EXPECT_TRUE(base::PathExists(result.path.AppendASCII("History-wal"))); + EXPECT_TRUE(base::PathExists(result.path.AppendASCII("History-shm"))); +} + +TEST_F(DaoProfileSnapshotTest, ReportsMissingSourceWithoutLeavingSnapshot) { + base::ScopedTempDir source_dir; + ASSERT_TRUE(source_dir.CreateUniqueTempDir()); + + SnapshotRequest request; + request.source_profile = source_dir.GetPath(); + request.relative_paths = {base::FilePath(FILE_PATH_LITERAL("Bookmarks"))}; + SnapshotResult result = DaoProfileSnapshot::CreateForTesting(request); + + EXPECT_FALSE(result.success); + EXPECT_EQ("source_missing", result.error_code); + EXPECT_TRUE(result.path.empty()); +} + +TEST_F(DaoProfileSnapshotTest, RecursivelyCopiesSessionDirectory) { + base::ScopedTempDir source_dir; + ASSERT_TRUE(source_dir.CreateUniqueTempDir()); + const base::FilePath sessions = source_dir.GetPath().AppendASCII("Sessions"); + ASSERT_TRUE(base::CreateDirectory(sessions)); + ASSERT_TRUE(base::WriteFile(sessions.AppendASCII("Session_1"), "session")); + + SnapshotRequest request; + request.source_profile = source_dir.GetPath(); + request.relative_paths = {base::FilePath(FILE_PATH_LITERAL("Sessions"))}; + SnapshotResult result = DaoProfileSnapshot::CreateForTesting(request); + + ASSERT_TRUE(result.success); + EXPECT_TRUE(base::PathExists( + result.path.AppendASCII("Sessions").AppendASCII("Session_1"))); +} + +TEST_F(DaoProfileSnapshotTest, StopsBeforeCopyWhenCancelled) { + base::ScopedTempDir source_dir; + ASSERT_TRUE(source_dir.CreateUniqueTempDir()); + ASSERT_TRUE(base::WriteFile(source_dir.GetPath().AppendASCII("Bookmarks"), + "bookmarks")); + + SnapshotRequest request; + request.source_profile = source_dir.GetPath(); + request.relative_paths = {base::FilePath(FILE_PATH_LITERAL("Bookmarks"))}; + request.cancellation = base::MakeRefCounted(); + request.cancellation->Cancel(); + SnapshotResult result = DaoProfileSnapshot::CreateForTesting(request); + + EXPECT_FALSE(result.success); + EXPECT_EQ("cancelled", result.error_code); + EXPECT_TRUE(result.path.empty()); +} + +TEST_F(DaoProfileSnapshotTest, RemovesTemporaryDirectoryWithResultLifetime) { + base::ScopedTempDir source_dir; + ASSERT_TRUE(source_dir.CreateUniqueTempDir()); + ASSERT_TRUE(base::WriteFile(source_dir.GetPath().AppendASCII("Bookmarks"), + "bookmarks")); + + base::FilePath snapshot_path; + { + SnapshotRequest request; + request.source_profile = source_dir.GetPath(); + request.relative_paths = {base::FilePath(FILE_PATH_LITERAL("Bookmarks"))}; + SnapshotResult result = DaoProfileSnapshot::CreateForTesting(request); + ASSERT_TRUE(result.success); + snapshot_path = result.path; + EXPECT_TRUE(base::DirectoryExists(snapshot_path)); + } + + task_environment_.RunUntilIdle(); + EXPECT_FALSE(base::PathExists(snapshot_path)); +} + +TEST_F(DaoProfileSnapshotTest, + RemovesTemporaryDirectoryWithoutBlockingCallingSequence) { + base::ScopedTempDir source_dir; + ASSERT_TRUE(source_dir.CreateUniqueTempDir()); + ASSERT_TRUE(base::WriteFile(source_dir.GetPath().AppendASCII("Bookmarks"), + "bookmarks")); + + SnapshotRequest request; + request.source_profile = source_dir.GetPath(); + request.relative_paths = {base::FilePath(FILE_PATH_LITERAL("Bookmarks"))}; + + base::FilePath snapshot_path; + base::RunLoop run_loop; + DaoProfileSnapshot::Create( + request, base::BindOnce( + [](base::FilePath* snapshot_path, base::OnceClosure quit, + SnapshotResult result) { + EXPECT_TRUE(result.success); + if (result.success) { + *snapshot_path = result.path; + EXPECT_TRUE(base::DirectoryExists(*snapshot_path)); + { + base::ScopedDisallowBlocking disallow_blocking; + result = SnapshotResult(); + } + } + std::move(quit).Run(); + }, + &snapshot_path, run_loop.QuitClosure())); + run_loop.Run(); + task_environment_.RunUntilIdle(); + + EXPECT_FALSE(base::PathExists(snapshot_path)); +} + +} // namespace +} // namespace dao::import diff --git a/src/dao/browser/import/dao_source_adapter.cc b/src/dao/browser/import/dao_source_adapter.cc new file mode 100644 index 00000000..d3afaf69 --- /dev/null +++ b/src/dao/browser/import/dao_source_adapter.cc @@ -0,0 +1,23 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/import/dao_source_adapter.h" + +namespace dao::import { + +BookmarkEntry::BookmarkEntry() = default; +BookmarkEntry::BookmarkEntry(const BookmarkEntry&) = default; +BookmarkEntry& BookmarkEntry::operator=(const BookmarkEntry&) = default; +BookmarkEntry::BookmarkEntry(BookmarkEntry&&) = default; +BookmarkEntry& BookmarkEntry::operator=(BookmarkEntry&&) = default; +BookmarkEntry::~BookmarkEntry() = default; + +PasswordEntry::PasswordEntry() = default; +PasswordEntry::PasswordEntry(const PasswordEntry&) = default; +PasswordEntry& PasswordEntry::operator=(const PasswordEntry&) = default; +PasswordEntry::PasswordEntry(PasswordEntry&&) = default; +PasswordEntry& PasswordEntry::operator=(PasswordEntry&&) = default; +PasswordEntry::~PasswordEntry() = default; + +} // namespace dao::import diff --git a/src/dao/browser/import/dao_source_adapter.h b/src/dao/browser/import/dao_source_adapter.h new file mode 100644 index 00000000..c7d46321 --- /dev/null +++ b/src/dao/browser/import/dao_source_adapter.h @@ -0,0 +1,97 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef DAO_BROWSER_IMPORT_DAO_SOURCE_ADAPTER_H_ +#define DAO_BROWSER_IMPORT_DAO_SOURCE_ADAPTER_H_ + +#include +#include +#include +#include + +#include "base/containers/span.h" +#include "base/time/time.h" +#include "url/gurl.h" + +namespace dao::import { + +struct BookmarkEntry { + BookmarkEntry(); + BookmarkEntry(const BookmarkEntry&); + BookmarkEntry& operator=(const BookmarkEntry&); + BookmarkEntry(BookmarkEntry&&); + BookmarkEntry& operator=(BookmarkEntry&&); + ~BookmarkEntry(); + + bool in_toolbar = false; + bool is_folder = false; + GURL url; + std::vector path; + std::u16string title; + base::Time creation_time; +}; + +struct HistoryVisit { + GURL url; + std::u16string title; + base::Time visit_time; +}; + +struct PasswordEntry { + PasswordEntry(); + PasswordEntry(const PasswordEntry&); + PasswordEntry& operator=(const PasswordEntry&); + PasswordEntry(PasswordEntry&&); + PasswordEntry& operator=(PasswordEntry&&); + ~PasswordEntry(); + + GURL origin; + std::u16string username; + std::u16string password; + std::string signon_realm; + base::Time date_created; +}; + +struct TabEntry { + GURL url; + std::u16string title; + int window_index = 0; + int tab_index = 0; + bool pinned = false; +}; + +struct ExtensionEntry { + std::string id; + std::string name; + bool enabled = false; +}; + +class PasswordDecryptor { + public: + virtual ~PasswordDecryptor() = default; + virtual std::optional Decrypt( + base::span encrypted_value) = 0; +}; + +template +struct ReadBatch { + bool success = false; + std::string error_code; + std::vector records; +}; + +class DaoSourceAdapter { + public: + virtual ~DaoSourceAdapter() = default; + + virtual ReadBatch ReadBookmarks() = 0; + virtual ReadBatch ReadHistory() = 0; + virtual ReadBatch ReadPasswords() = 0; + virtual ReadBatch ReadTabs() = 0; + virtual ReadBatch ReadExtensions() = 0; +}; + +} // namespace dao::import + +#endif // DAO_BROWSER_IMPORT_DAO_SOURCE_ADAPTER_H_ diff --git a/src/dao/browser/import/dao_source_detector.cc b/src/dao/browser/import/dao_source_detector.cc new file mode 100644 index 00000000..48386637 --- /dev/null +++ b/src/dao/browser/import/dao_source_detector.cc @@ -0,0 +1,181 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/import/dao_source_detector.h" + +#include +#include +#include + +#include "base/files/file_util.h" +#include "base/functional/bind.h" +#include "base/json/json_reader.h" +#include "base/strings/string_number_conversions.h" +#include "base/task/thread_pool.h" +#include "base/values.h" +#include "crypto/sha2.h" + +namespace dao::import { +namespace { + +std::vector ChromiumCategories() { + return { + DataCategory::kBookmarks, DataCategory::kHistory, + DataCategory::kPasswords, DataCategory::kTabs, + DataCategory::kExtensions, + }; +} + +std::string BuildProfileId(SourceKind kind, + const base::FilePath& profile_path) { + const std::string identity = std::string(SourceKindToString(kind)) + ":" + + profile_path.StripTrailingSeparators() + .NormalizePathSeparators() + .AsUTF8Unsafe(); + return base::HexEncode(crypto::SHA256HashString(identity)); +} + +bool ProfileOrder(const SourceProfile& left, const SourceProfile& right) { + const bool left_is_default = + left.profile_name == "Default" || left.profile_name == "Personal"; + const bool right_is_default = + right.profile_name == "Default" || right.profile_name == "Personal"; + if (left_is_default != right_is_default) { + return left_is_default; + } + return left.profile_name < right.profile_name; +} + +} // namespace + +DaoSourceDetector::DetectionResult::DetectionResult() = default; +DaoSourceDetector::DetectionResult::DetectionResult(const DetectionResult&) = + default; +DaoSourceDetector::DetectionResult& +DaoSourceDetector::DetectionResult::operator=(const DetectionResult&) = default; +DaoSourceDetector::DetectionResult::DetectionResult(DetectionResult&&) = + default; +DaoSourceDetector::DetectionResult& +DaoSourceDetector::DetectionResult::operator=(DetectionResult&&) = default; +DaoSourceDetector::DetectionResult::~DetectionResult() = default; + +DaoSourceDetector::DaoSourceDetector() = default; +DaoSourceDetector::~DaoSourceDetector() = default; + +void DaoSourceDetector::Detect(DetectCallback callback) { + const uint64_t generation = ++detection_generation_; + base::ThreadPool::PostTaskAndReplyWithResult( + FROM_HERE, + {base::MayBlock(), base::TaskPriority::USER_VISIBLE, + base::TaskShutdownBehavior::CONTINUE_ON_SHUTDOWN}, + base::BindOnce(&DaoSourceDetector::DetectFromRoots, + GetDefaultBrowserRoots()), + base::BindOnce(&DaoSourceDetector::OnDetectionComplete, + weak_ptr_factory_.GetWeakPtr(), generation, + std::move(callback))); +} + +std::optional DaoSourceDetector::ResolveProfilePath( + const std::string& profile_id) const { + auto it = profile_paths_.find(profile_id); + if (it == profile_paths_.end()) { + return std::nullopt; + } + return it->second; +} + +// static +DaoSourceDetector::DetectionResult DaoSourceDetector::DetectFromRootsForTesting( + std::vector roots) { + return DetectFromRoots(std::move(roots)); +} + +// static +std::string DaoSourceDetector::BuildProfileIdForTesting( + SourceKind kind, + const base::FilePath& profile_path) { + return BuildProfileId(kind, profile_path); +} + +// static +std::vector +DaoSourceDetector::GetDefaultBrowserRoots() { + const base::FilePath application_support = base::GetHomeDir().Append( + FILE_PATH_LITERAL("Library/Application Support")); + return { + {SourceKind::kChrome, "Google Chrome", + application_support.AppendASCII("Google/Chrome")}, + {SourceKind::kArc, "Arc", + application_support.AppendASCII("Arc/User Data")}, + {SourceKind::kEdge, "Microsoft Edge", + application_support.AppendASCII("Microsoft Edge")}, + }; +} + +// static +DaoSourceDetector::DetectionResult DaoSourceDetector::DetectFromRoots( + std::vector roots) { + DetectionResult result; + for (const BrowserRoot& root : roots) { + std::string local_state_contents; + if (!base::ReadFileToString(root.path.AppendASCII("Local State"), + &local_state_contents)) { + continue; + } + + std::optional local_state = base::JSONReader::ReadDict( + local_state_contents, base::JSON_PARSE_RFC); + if (!local_state) { + continue; + } + const base::DictValue* info_cache = + local_state->FindDictByDottedPath("profile.info_cache"); + if (!info_cache) { + continue; + } + + std::vector root_profiles; + for (const auto [profile_directory, profile_value] : *info_cache) { + if (!profile_value.is_dict()) { + continue; + } + const base::FilePath profile_path = + root.path.Append(base::FilePath::FromUTF8Unsafe(profile_directory)); + if (!base::DirectoryExists(profile_path)) { + continue; + } + + SourceProfile profile; + profile.id = BuildProfileId(root.kind, profile_path); + profile.kind = root.kind; + profile.browser_name = root.browser_name; + const std::string* display_name = + profile_value.GetDict().FindString("name"); + profile.profile_name = display_name && !display_name->empty() + ? *display_name + : profile_directory; + profile.supported_categories = ChromiumCategories(); + result.profile_paths.emplace(profile.id, profile_path); + root_profiles.push_back(std::move(profile)); + } + + std::sort(root_profiles.begin(), root_profiles.end(), ProfileOrder); + result.profiles.insert(result.profiles.end(), + std::make_move_iterator(root_profiles.begin()), + std::make_move_iterator(root_profiles.end())); + } + return result; +} + +void DaoSourceDetector::OnDetectionComplete(uint64_t generation, + DetectCallback callback, + DetectionResult result) { + if (generation != detection_generation_) { + return; + } + profile_paths_ = std::move(result.profile_paths); + std::move(callback).Run(std::move(result.profiles)); +} + +} // namespace dao::import diff --git a/src/dao/browser/import/dao_source_detector.h b/src/dao/browser/import/dao_source_detector.h new file mode 100644 index 00000000..9522819c --- /dev/null +++ b/src/dao/browser/import/dao_source_detector.h @@ -0,0 +1,73 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef DAO_BROWSER_IMPORT_DAO_SOURCE_DETECTOR_H_ +#define DAO_BROWSER_IMPORT_DAO_SOURCE_DETECTOR_H_ + +#include +#include +#include +#include +#include + +#include "base/files/file_path.h" +#include "base/functional/callback_forward.h" +#include "base/memory/weak_ptr.h" +#include "dao/browser/import/dao_migration_types.h" + +namespace dao::import { + +class DaoSourceDetector { + public: + struct BrowserRoot { + SourceKind kind; + std::string browser_name; + base::FilePath path; + }; + + struct DetectionResult { + DetectionResult(); + DetectionResult(const DetectionResult&); + DetectionResult& operator=(const DetectionResult&); + DetectionResult(DetectionResult&&); + DetectionResult& operator=(DetectionResult&&); + ~DetectionResult(); + + std::vector profiles; + std::map profile_paths; + }; + + using DetectCallback = + base::OnceCallback profiles)>; + + DaoSourceDetector(); + DaoSourceDetector(const DaoSourceDetector&) = delete; + DaoSourceDetector& operator=(const DaoSourceDetector&) = delete; + ~DaoSourceDetector(); + + void Detect(DetectCallback callback); + std::optional ResolveProfilePath( + const std::string& profile_id) const; + + static DetectionResult DetectFromRootsForTesting( + std::vector roots); + static std::string BuildProfileIdForTesting( + SourceKind kind, + const base::FilePath& profile_path); + + private: + static std::vector GetDefaultBrowserRoots(); + static DetectionResult DetectFromRoots(std::vector roots); + void OnDetectionComplete(uint64_t generation, + DetectCallback callback, + DetectionResult result); + + std::map profile_paths_; + uint64_t detection_generation_ = 0; + base::WeakPtrFactory weak_ptr_factory_{this}; +}; + +} // namespace dao::import + +#endif // DAO_BROWSER_IMPORT_DAO_SOURCE_DETECTOR_H_ diff --git a/src/dao/browser/import/dao_source_detector_unittest.cc b/src/dao/browser/import/dao_source_detector_unittest.cc new file mode 100644 index 00000000..c8a09355 --- /dev/null +++ b/src/dao/browser/import/dao_source_detector_unittest.cc @@ -0,0 +1,79 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/import/dao_source_detector.h" + +#include + +#include "base/files/file_util.h" +#include "base/files/scoped_temp_dir.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace dao::import { +namespace { + +TEST(DaoSourceDetectorTest, DetectsAndOrdersChromiumProfiles) { + base::ScopedTempDir temp_dir; + ASSERT_TRUE(temp_dir.CreateUniqueTempDir()); + const base::FilePath root = temp_dir.GetPath().AppendASCII("Chrome"); + ASSERT_TRUE(base::CreateDirectory(root.AppendASCII("Default"))); + ASSERT_TRUE(base::CreateDirectory(root.AppendASCII("Profile 2"))); + ASSERT_TRUE(base::WriteFile( + root.AppendASCII("Local State"), + R"({"profile":{"info_cache":{"Profile 2":{"name":"Work"},"Default":{"name":"Personal"}}}})")); + + DaoSourceDetector::DetectionResult result = + DaoSourceDetector::DetectFromRootsForTesting( + {{SourceKind::kChrome, "Google Chrome", root}}); + + ASSERT_EQ(2u, result.profiles.size()); + EXPECT_EQ("Personal", result.profiles[0].profile_name); + EXPECT_EQ("Work", result.profiles[1].profile_name); + EXPECT_EQ(SourceKind::kChrome, result.profiles[0].kind); + EXPECT_EQ(5u, result.profiles[0].supported_categories.size()); + EXPECT_FALSE(result.profiles[0].id.empty()); + EXPECT_EQ(std::string::npos, + result.profiles[0].id.find(temp_dir.GetPath().AsUTF8Unsafe())); + EXPECT_EQ(root.AppendASCII("Default"), + result.profile_paths.at(result.profiles[0].id)); +} + +TEST(DaoSourceDetectorTest, SkipsMissingCorruptAndUnknownProfiles) { + base::ScopedTempDir temp_dir; + ASSERT_TRUE(temp_dir.CreateUniqueTempDir()); + const base::FilePath missing_root = temp_dir.GetPath().AppendASCII("Missing"); + const base::FilePath corrupt_root = temp_dir.GetPath().AppendASCII("Corrupt"); + ASSERT_TRUE(base::CreateDirectory(corrupt_root.AppendASCII("Default"))); + ASSERT_TRUE(base::WriteFile(corrupt_root.AppendASCII("Local State"), "{")); + + DaoSourceDetector::DetectionResult result = + DaoSourceDetector::DetectFromRootsForTesting({ + {SourceKind::kArc, "Arc", missing_root}, + {SourceKind::kEdge, "Microsoft Edge", corrupt_root}, + }); + + EXPECT_TRUE(result.profiles.empty()); + EXPECT_TRUE(result.profile_paths.empty()); +} + +TEST(DaoSourceDetectorTest, ProfileIdsAreStableAndSourceSpecific) { + base::ScopedTempDir temp_dir; + ASSERT_TRUE(temp_dir.CreateUniqueTempDir()); + const base::FilePath profile_path = temp_dir.GetPath().AppendASCII("Default"); + ASSERT_TRUE(base::CreateDirectory(profile_path)); + + const std::string first = DaoSourceDetector::BuildProfileIdForTesting( + SourceKind::kChrome, profile_path); + const std::string second = DaoSourceDetector::BuildProfileIdForTesting( + SourceKind::kChrome, profile_path); + const std::string other_source = DaoSourceDetector::BuildProfileIdForTesting( + SourceKind::kArc, profile_path); + + EXPECT_EQ(first, second); + EXPECT_NE(first, other_source); + EXPECT_EQ(std::string::npos, first.find(profile_path.AsUTF8Unsafe())); +} + +} // namespace +} // namespace dao::import diff --git a/src/dao/browser/strings/dao_strings.grd b/src/dao/browser/strings/dao_strings.grd index 1478b17c..b00691bb 100644 --- a/src/dao/browser/strings/dao_strings.grd +++ b/src/dao/browser/strings/dao_strings.grd @@ -496,6 +496,67 @@ $13 more + + + Import browser data + Dao Migration + Step 1 of 4 · Choose a source + Where are you moving from? + Choose a browser profile on this Mac. Each profile is listed separately so you stay in control of what moves. + Looking for browser profiles… + No supported browser profiles were found on this Mac. + Scan again + Google Chrome + Arc + Microsoft Edge + Safari + Firefox + Not detected + Step 2 of 4 · Choose what moves + What would you like to bring? + Dao merges imported items with what you already have. Existing data is never replaced. + Bookmarks + Browsing history + Saved passwords + Open tabs + Extensions + Counting… + $11,234 candidates + Count unavailable + A macOS Keychain prompt may appear + Dao needs the selected browser’s Safe Storage key to decrypt saved passwords. If you deny access, only passwords are skipped and the rest of the migration continues. + Extensions are reinstalled from their official store + Your browser data stays on this Mac. Reinstalling compatible extensions is the only step that may connect to the internet; extension settings and sign-ins are not copied. + Step 3 of 4 · Moving your data + Moving your data into Dao + You can keep using the source browser. If one data type keeps changing, Dao will ask you to close it and retry only that part. + Source browser + Dao + Waiting + Preparing a local copy + Reading + Adding to Dao + Complete + Needs attention + Cancelled + Most of your data is here + Completed categories are ready to use. You can retry only the parts that need attention. + These items need to be retried + Migration failed + Your move is complete + Your imported data is ready in Dao. Open tabs are kept unloaded in a collapsed folder until you need them. + Migration stopped + Data imported before you stopped is ready to use. You can start another migration whenever you’re ready. + Imported items are merged without replacing existing data. + Your browser data is processed locally on this Mac. + Back + Start migration + Stop migration + Retry failed items + Continue + Imported browser data + Imported tabs + This is a legacy (Manifest V2) extension. Dao supports it; Chrome and most other browsers no longer do. diff --git a/src/dao/browser/strings/translations/dao_strings_zh-CN.xtb b/src/dao/browser/strings/translations/dao_strings_zh-CN.xtb index e6112b56..c1b16968 100644 --- a/src/dao/browser/strings/translations/dao_strings_zh-CN.xtb +++ b/src/dao/browser/strings/translations/dao_strings_zh-CN.xtb @@ -60,4 +60,61 @@ 复制链接 已归档不活跃的标签 切换侧边栏 +导入浏览器数据 +以下内容需要重试 +迁移失败 +Dao 数据迁移 +第 1 步,共 4 步 · 选择来源 +你要从哪里迁移? +选择这台 Mac 上的一个浏览器配置文件。每个配置文件会单独列出,让你清楚控制要迁移的来源。 +正在查找浏览器配置文件… +这台 Mac 上没有找到支持的浏览器配置文件。 +重新扫描 +Google Chrome +Arc +Microsoft Edge +Safari +Firefox +未检测到 +第 2 步,共 4 步 · 选择迁移内容 +你想带走哪些内容? +Dao 会把迁移内容与你已有的数据安全合并,不会覆盖现有数据。 +书签 +浏览历史 +保存的密码 +打开的标签页 +扩展程序 +正在统计… +扫描到 +暂不支持预先统计 +macOS 可能会显示钥匙串授权提示 +Dao 需要读取所选浏览器的“安全存储”密钥,才能解密已保存的密码。如果拒绝授权,只会跳过密码,其他内容仍会继续迁移。 +扩展程序会从官方商店重新安装 +浏览器数据只会在这台 Mac 上处理。重新安装兼容扩展是唯一可能联网的步骤;扩展设置和登录状态不会被复制。 +第 3 步,共 4 步 · 正在迁移数据 +正在把数据搬进 Dao +迁移时可以继续使用来源浏览器。如果某类数据持续变化,Dao 会提示你关闭浏览器,并且只重试这一部分。 +来源浏览器 +Dao +等待中 +正在准备本地副本 +正在读取 +正在添加到 Dao +已完成 +需要处理 +已取消 +大部分数据已经迁移完成 +已完成的内容现在可以使用。你可以只重试需要处理的部分。 +迁移完成 +迁移的数据已可以在 Dao 中使用。打开的标签页会放在一个折叠文件夹里,并保持未加载状态,直到你需要它们。 +迁移已停止 +停止前已导入的数据可以继续使用。准备好后,你可以重新开始迁移。 +迁移内容会安全合并,不会覆盖现有数据。 +浏览器数据只会在这台 Mac 上本地处理。 +开始迁移 +停止迁移 +重试失败内容 +继续 +导入的浏览器数据 +导入的标签页 diff --git a/src/dao/browser/ui/dao_ui_sources.gni b/src/dao/browser/ui/dao_ui_sources.gni index c2473da6..7555be58 100644 --- a/src/dao/browser/ui/dao_ui_sources.gni +++ b/src/dao/browser/ui/dao_ui_sources.gni @@ -3,6 +3,29 @@ # found in the LICENSE file. dao_browser_ui_sources = [ + "//dao/browser/import/dao_chromium_profile_adapter.cc", + "//dao/browser/import/dao_chromium_profile_adapter.h", + "//dao/browser/import/dao_chromium_migration_target.cc", + "//dao/browser/import/dao_chromium_migration_target.h", + "//dao/browser/import/dao_chromium_password_decryptor_mac.h", + "//dao/browser/import/dao_migration_job.cc", + "//dao/browser/import/dao_migration_job.h", + "//dao/browser/import/dao_legacy_profile_writer.cc", + "//dao/browser/import/dao_legacy_profile_writer.h", + "//dao/browser/import/dao_migration_service.cc", + "//dao/browser/import/dao_migration_service.h", + "//dao/browser/import/dao_migration_service_factory.cc", + "//dao/browser/import/dao_migration_service_factory.h", + "//dao/browser/import/dao_migration_types.cc", + "//dao/browser/import/dao_migration_types.h", + "//dao/browser/import/dao_migration_writer.cc", + "//dao/browser/import/dao_migration_writer.h", + "//dao/browser/import/dao_profile_snapshot.cc", + "//dao/browser/import/dao_profile_snapshot.h", + "//dao/browser/import/dao_source_detector.cc", + "//dao/browser/import/dao_source_detector.h", + "//dao/browser/import/dao_source_adapter.cc", + "//dao/browser/import/dao_source_adapter.h", "//dao/browser/agent/dao_agent_lock_tab_helper.cc", "//dao/browser/agent/dao_agent_lock_tab_helper.h", "//dao/browser/agent/dao_agent_settings_handler.cc", @@ -158,6 +181,8 @@ dao_browser_ui_sources = [ "//dao/browser/ui/views/split/dao_split_view.h", "//dao/browser/ui/webui/dao_agent_ui.cc", "//dao/browser/ui/webui/dao_agent_ui.h", + "//dao/browser/ui/webui/dao_import_ui.cc", + "//dao/browser/ui/webui/dao_import_ui.h", "//dao/browser/ui/webui/dao_pinned_tab_model.cc", "//dao/browser/ui/webui/dao_pinned_tab_model.h", "//dao/browser/ui/webui/dao_pinned_tab_storage.cc", @@ -170,19 +195,29 @@ dao_browser_ui_sources = [ dao_browser_ui_deps = [ "//components/favicon/content", + "//components/password_manager/core/browser:password_form", + "//components/password_manager/core/browser/password_store:password_store_interface", "//components/pdf/browser", "//components/qr_code_generator", + "//components/sessions", + "//crypto", "//dao/browser/extensions/legacy_mv2:dao_legacy_mv2", "//dao/browser/mcp:browser_mcp", "//dao/browser/qrcode:qr_decoder", "//dao/browser/strings:dao_strings", "//dao/browser/ui/webui/resources/agent:resources", "//dao/browser/ui/webui/resources/sidebar:resources", + "//dao/browser/ui/webui/resources/import:resources", "//dao/browser/ui/webui/resources/welcome:resources", "//sql", ] dao_browser_ui_test_sources = [ + "//dao/browser/import/dao_chromium_profile_adapter_unittest.cc", + "//dao/browser/import/dao_migration_job_unittest.cc", + "//dao/browser/import/dao_migration_writer_unittest.cc", + "//dao/browser/import/dao_profile_snapshot_unittest.cc", + "//dao/browser/import/dao_source_detector_unittest.cc", "//dao/browser/agent/dao_dream_browsertest.cc", "//dao/browser/automation/dao_devtools_tools_browsertest.cc", "//dao/browser/mcp/dao_mcp_end_to_end_browsertest.cc", @@ -209,5 +244,7 @@ dao_browser_ui_test_deps = [ ] if (is_mac) { + dao_browser_ui_sources += + [ "//dao/browser/import/dao_chromium_password_decryptor_mac.mm" ] dao_browser_ui_test_deps += [ "//dao/browser/mcp:dao_mcp_helper" ] } diff --git a/src/dao/browser/ui/views/dao_browser_browsertest.cc b/src/dao/browser/ui/views/dao_browser_browsertest.cc index a0d89157..0b50b668 100644 --- a/src/dao/browser/ui/views/dao_browser_browsertest.cc +++ b/src/dao/browser/ui/views/dao_browser_browsertest.cc @@ -108,6 +108,8 @@ #include "dao/browser/dao_auto_pip_visibility_helper.h" #include "dao/browser/dao_pref_names.h" #include "dao/browser/dao_webstore_branding_tab_helper.h" +#include "dao/browser/import/dao_chromium_migration_target.h" +#include "dao/browser/import/dao_migration_writer.h" #include "dao/browser/pip/dao_pip_bounds_prefs.h" #include "dao/browser/pip/dao_pip_interceptor.h" #include "dao/browser/pip/dao_pip_resize_utils.h" @@ -8880,6 +8882,70 @@ IN_PROC_BROWSER_TEST_F(DaoWelcomeWebUIBrowserTest, "dao://welcome startup tab instead of opening a duplicate tab."; } +class DaoImportWebUIBrowserTest : public InProcessBrowserTest {}; + +IN_PROC_BROWSER_TEST_F(DaoImportWebUIBrowserTest, + ImportSettingsCommandOpensStandaloneMigrationUI) { + ASSERT_TRUE(chrome::ExecuteCommand(browser(), IDC_IMPORT_SETTINGS)); + + content::WebContents* contents = + browser()->tab_strip_model()->GetActiveWebContents(); + ASSERT_NE(nullptr, contents); + ASSERT_TRUE(content::WaitForLoadStop(contents)); + EXPECT_EQ(GURL("dao://import/"), contents->GetVisibleURL()); +} + +IN_PROC_BROWSER_TEST_F(DaoImportWebUIBrowserTest, LoadsStandaloneMigrationUI) { + content::WebContents* contents = + browser()->tab_strip_model()->GetActiveWebContents(); + ASSERT_NE(nullptr, contents); + content::WebContentsConsoleObserver observer(contents); + + ASSERT_TRUE(ui_test_utils::NavigateToURL(browser(), GURL("dao://import/"))); + EXPECT_EQ(true, content::EvalJs(contents, R"( + (async () => { + await customElements.whenDefined('dao-import-app'); + return !!document.querySelector('dao-import-app'); + })() + )")); + + std::vector errors; + for (const auto& message : observer.messages()) { + if (message.log_level == blink::mojom::ConsoleMessageLevel::kError) { + errors.push_back(base::UTF16ToUTF8(message.message)); + } + } + EXPECT_TRUE(errors.empty()) + << "dao://import emitted console errors during load:\n - " + << base::JoinString(errors, "\n - "); +} + +IN_PROC_BROWSER_TEST_F(DaoImportWebUIBrowserTest, + FailedTabFolderWriteRollsBackImportedTabs) { + base::ScopedAllowBlockingForTesting allow_blocking; + const base::FilePath folder_path = + browser()->profile()->GetPath().AppendASCII("dao_folders.json"); + ASSERT_TRUE(base::WriteFile(folder_path, R"({"version":1,"items":[]})")); + const int original_tab_count = browser()->tab_strip_model()->count(); + dao::import::DaoChromiumMigrationTarget target(browser()->profile()); + dao::import::DaoMigrationWriter writer(&target); + dao::import::TabEntry tab; + tab.url = GURL("https://import-rollback.example/"); + tab.title = u"Rollback"; + std::string folder_id; + + const dao::import::WriteResult result = + writer.WriteTabsBatch({tab}, u"Imported tabs", &folder_id); + ASSERT_EQ(1u, result.imported); + ASSERT_FALSE(folder_id.empty()); + ASSERT_TRUE(base::DeleteFile(folder_path)); + ASSERT_TRUE(base::CreateDirectory(folder_path)); + + EXPECT_FALSE(writer.FinishTabs(folder_id)); + EXPECT_EQ(original_tab_count, browser()->tab_strip_model()->count()); + EXPECT_TRUE(base::DeletePathRecursively(folder_path)); +} + // ============================================================================= // DaoCornerOverlayPaintBrowserTest // diff --git a/src/dao/browser/ui/webui/dao_import_ui.cc b/src/dao/browser/ui/webui/dao_import_ui.cc new file mode 100644 index 00000000..cc31eaf9 --- /dev/null +++ b/src/dao/browser/ui/webui/dao_import_ui.cc @@ -0,0 +1,322 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "dao/browser/ui/webui/dao_import_ui.h" + +#include +#include +#include +#include + +#include "base/functional/bind.h" +#include "base/values.h" +#include "chrome/browser/profiles/profile.h" +#include "chrome/grit/dao_import_resources.h" +#include "chrome/grit/dao_import_resources_map.h" +#include "chrome/grit/chrome_unscaled_resources.h" +#include "content/public/browser/web_ui_data_source.h" +#include "content/public/common/url_constants.h" +#include "dao/browser/import/dao_migration_service.h" +#include "dao/browser/import/dao_migration_service_factory.h" +#include "dao/browser/strings/grit/dao_strings.h" +#include "services/network/public/mojom/content_security_policy.mojom.h" + +namespace dao { +namespace { + +base::DictValue SourceToValue(const import::SourceProfile &source) { + base::DictValue value; + value.Set("id", source.id); + value.Set("kind", import::SourceKindToString(source.kind)); + value.Set("browserName", source.browser_name); + value.Set("profileName", source.profile_name); + base::ListValue categories; + for (import::DataCategory category : source.supported_categories) { + categories.Append(import::DataCategoryToString(category)); + } + value.Set("supportedCategories", std::move(categories)); + return value; +} + +base::DictValue StateToValue(const import::JobState &state) { + base::DictValue value; + value.Set("sourceId", state.source_id); + value.Set("cancelRequested", state.cancel_requested); + base::ListValue categories; + bool terminal = !state.category_states.empty(); + for (size_t index = 0; index < state.category_states.size(); ++index) { + const import::CategoryState &category_state = state.category_states[index]; + terminal = terminal && import::IsTerminalPhase(category_state.phase); + base::DictValue category; + category.Set("category", import::DataCategoryToString( + state.selected_categories[index])); + category.Set("phase", import::CategoryPhaseToString(category_state.phase)); + category.Set("imported", + static_cast(category_state.result.imported)); + category.Set("skipped", static_cast(category_state.result.skipped)); + category.Set("conflicted", + static_cast(category_state.result.conflicted)); + category.Set("failed", static_cast(category_state.result.failed)); + category.Set("completedItems", + static_cast(category_state.completed_items)); + category.Set("totalItems", static_cast(category_state.total_items)); + category.Set("indeterminate", category_state.indeterminate); + category.Set("errorCode", category_state.error_code); + categories.Append(std::move(category)); + } + value.Set("terminal", terminal); + value.Set("categories", std::move(categories)); + return value; +} + +std::optional ParseCategory(std::string_view value) { + if (value == "bookmarks") { + return import::DataCategory::kBookmarks; + } + if (value == "history") { + return import::DataCategory::kHistory; + } + if (value == "passwords") { + return import::DataCategory::kPasswords; + } + if (value == "tabs") { + return import::DataCategory::kTabs; + } + if (value == "extensions") { + return import::DataCategory::kExtensions; + } + return std::nullopt; +} + +std::vector ParseCategories(const base::Value &value) { + std::vector categories; + const base::ListValue *list = value.GetIfList(); + if (!list) { + return categories; + } + for (const base::Value &item : *list) { + const std::string *name = item.GetIfString(); + std::optional category = + name ? ParseCategory(*name) : std::nullopt; + if (category && + std::ranges::find(categories, *category) == categories.end()) { + categories.push_back(*category); + } + } + return categories; +} + +} // namespace + +DaoImportUIConfig::DaoImportUIConfig() + : WebUIConfig(content::kChromeUIScheme, "import") {} + +std::unique_ptr +DaoImportUIConfig::CreateWebUIController(content::WebUI *web_ui, + const GURL &url) { + return std::make_unique(web_ui); +} + +DaoImportUIHandler::DaoImportUIHandler(import::DaoMigrationService *service) + : service_(service) {} + +DaoImportUIHandler::~DaoImportUIHandler() = default; + +void DaoImportUIHandler::RegisterMessages() { + web_ui()->RegisterMessageCallback( + "detectImportSources", + base::BindRepeating(&DaoImportUIHandler::HandleDetectSources, + base::Unretained(this))); + web_ui()->RegisterMessageCallback( + "getImportItemCount", + base::BindRepeating(&DaoImportUIHandler::HandleCountSourceItems, + base::Unretained(this))); + web_ui()->RegisterMessageCallback( + "getBrowserMigrationState", + base::BindRepeating(&DaoImportUIHandler::HandleGetState, + base::Unretained(this))); + web_ui()->RegisterMessageCallback( + "startBrowserMigration", + base::BindRepeating(&DaoImportUIHandler::HandleStart, + base::Unretained(this))); + web_ui()->RegisterMessageCallback( + "cancelBrowserMigration", + base::BindRepeating(&DaoImportUIHandler::HandleCancel, + base::Unretained(this))); + web_ui()->RegisterMessageCallback( + "retryBrowserMigrationCategories", + base::BindRepeating(&DaoImportUIHandler::HandleRetry, + base::Unretained(this))); + state_subscription_ = service_->AddObserver(base::BindRepeating( + &DaoImportUIHandler::OnStateChanged, base::Unretained(this))); +} + +void DaoImportUIHandler::HandleDetectSources(const base::ListValue &args) { + AllowJavascript(); + if (args.empty() || !args[0].is_string()) { + return; + } + service_->DetectSources(base::BindOnce(&DaoImportUIHandler::ReplyWithSources, + weak_ptr_factory_.GetWeakPtr(), + args[0].GetString())); +} + +void DaoImportUIHandler::HandleGetState(const base::ListValue &args) { + AllowJavascript(); + if (args.empty() || !args[0].is_string()) { + return; + } + std::optional state = service_->GetState(); + FireWebUIListener(args[0].GetString(), + state ? base::Value(StateToValue(*state)) : base::Value()); +} + +void DaoImportUIHandler::HandleCountSourceItems(const base::ListValue &args) { + AllowJavascript(); + if (args.empty() || !args[0].is_string()) { + return; + } + if (args.size() < 3 || !args[1].is_string() || !args[2].is_string()) { + FireWebUIListener(args[0].GetString(), base::Value()); + return; + } + std::optional category = + ParseCategory(args[2].GetString()); + if (!category) { + FireWebUIListener(args[0].GetString(), base::Value()); + return; + } + service_->CountSourceItems(args[1].GetString(), *category, + base::BindOnce(&DaoImportUIHandler::ReplyWithCount, + weak_ptr_factory_.GetWeakPtr(), + args[0].GetString())); +} + +void DaoImportUIHandler::HandleStart(const base::ListValue &args) { + AllowJavascript(); + if (args.size() < 2 || !args[0].is_string()) { + return; + } + service_->Start(args[0].GetString(), ParseCategories(args[1])); +} + +void DaoImportUIHandler::HandleCancel(const base::ListValue &args) { + service_->Cancel(); +} + +void DaoImportUIHandler::HandleRetry(const base::ListValue &args) { + AllowJavascript(); + if (args.empty()) { + return; + } + service_->Retry(ParseCategories(args[0])); +} + +void DaoImportUIHandler::ReplyWithSources( + std::string callback_id, std::vector sources) { + if (!IsJavascriptAllowed()) { + return; + } + base::ListValue values; + for (const import::SourceProfile &source : sources) { + values.Append(SourceToValue(source)); + } + FireWebUIListener(callback_id, values); + FireWebUIListener("browser-migration-sources-changed", values); +} + +void DaoImportUIHandler::ReplyWithCount(std::string callback_id, + std::optional count) { + if (!IsJavascriptAllowed()) { + return; + } + FireWebUIListener(callback_id, count + ? base::Value(static_cast(*count)) + : base::Value()); +} + +void DaoImportUIHandler::OnStateChanged(const import::JobState &state) { + if (IsJavascriptAllowed()) { + FireWebUIListener("browser-migration-state-changed", StateToValue(state)); + } +} + +DaoImportUI::DaoImportUI(content::WebUI *web_ui) : WebUIController(web_ui) { + Profile *profile = Profile::FromWebUI(web_ui); + content::WebUIDataSource *source = + content::WebUIDataSource::CreateAndAdd(profile, "import"); + source->AddResourcePaths(kDaoImportResources); + source->AddResourcePath("assets/dao.png", IDR_PRODUCT_LOGO_64); + source->SetDefaultResource(IDR_DAO_IMPORT_IMPORT_HTML); + source->AddLocalizedStrings({ + {"daoImportPageTitle", IDS_DAO_IMPORT_PAGE_TITLE}, + {"daoImportWizardName", IDS_DAO_IMPORT_WIZARD_NAME}, + {"daoImportStepSource", IDS_DAO_IMPORT_STEP_SOURCE}, + {"daoImportSourceTitle", IDS_DAO_IMPORT_SOURCE_TITLE}, + {"daoImportSourceDescription", IDS_DAO_IMPORT_SOURCE_DESCRIPTION}, + {"daoImportDetectingSources", IDS_DAO_IMPORT_DETECTING_SOURCES}, + {"daoImportNoSources", IDS_DAO_IMPORT_NO_SOURCES}, + {"daoImportScanAgain", IDS_DAO_IMPORT_SCAN_AGAIN}, + {"daoImportBrowserChrome", IDS_DAO_IMPORT_BROWSER_CHROME}, + {"daoImportBrowserArc", IDS_DAO_IMPORT_BROWSER_ARC}, + {"daoImportBrowserEdge", IDS_DAO_IMPORT_BROWSER_EDGE}, + {"daoImportBrowserSafari", IDS_DAO_IMPORT_BROWSER_SAFARI}, + {"daoImportBrowserFirefox", IDS_DAO_IMPORT_BROWSER_FIREFOX}, + {"daoImportSourceNotDetected", IDS_DAO_IMPORT_SOURCE_NOT_DETECTED}, + {"daoImportStepData", IDS_DAO_IMPORT_STEP_DATA}, + {"daoImportDataTitle", IDS_DAO_IMPORT_DATA_TITLE}, + {"daoImportDataDescription", IDS_DAO_IMPORT_DATA_DESCRIPTION}, + {"daoImportCategoryBookmarks", IDS_DAO_IMPORT_CATEGORY_BOOKMARKS}, + {"daoImportCategoryHistory", IDS_DAO_IMPORT_CATEGORY_HISTORY}, + {"daoImportCategoryPasswords", IDS_DAO_IMPORT_CATEGORY_PASSWORDS}, + {"daoImportCategoryTabs", IDS_DAO_IMPORT_CATEGORY_TABS}, + {"daoImportCategoryExtensions", IDS_DAO_IMPORT_CATEGORY_EXTENSIONS}, + {"daoImportCountScanning", IDS_DAO_IMPORT_COUNT_SCANNING}, + {"daoImportCountScanned", IDS_DAO_IMPORT_COUNT_SCANNED}, + {"daoImportCountUnavailable", IDS_DAO_IMPORT_COUNT_UNAVAILABLE}, + {"daoImportPasswordTipTitle", IDS_DAO_IMPORT_PASSWORD_TIP_TITLE}, + {"daoImportPasswordTipDescription", + IDS_DAO_IMPORT_PASSWORD_TIP_DESCRIPTION}, + {"daoImportExtensionTipTitle", IDS_DAO_IMPORT_EXTENSION_TIP_TITLE}, + {"daoImportExtensionTipDescription", + IDS_DAO_IMPORT_EXTENSION_TIP_DESCRIPTION}, + {"daoImportStepProgress", IDS_DAO_IMPORT_STEP_PROGRESS}, + {"daoImportProgressTitle", IDS_DAO_IMPORT_PROGRESS_TITLE}, + {"daoImportProgressDescription", IDS_DAO_IMPORT_PROGRESS_DESCRIPTION}, + {"daoImportSourceFallback", IDS_DAO_IMPORT_SOURCE_FALLBACK}, + {"daoImportDaoName", IDS_DAO_IMPORT_DAO_NAME}, + {"daoImportPhasePending", IDS_DAO_IMPORT_PHASE_PENDING}, + {"daoImportPhaseSnapshotting", IDS_DAO_IMPORT_PHASE_SNAPSHOTTING}, + {"daoImportPhaseReading", IDS_DAO_IMPORT_PHASE_READING}, + {"daoImportPhaseWriting", IDS_DAO_IMPORT_PHASE_WRITING}, + {"daoImportPhaseSucceeded", IDS_DAO_IMPORT_PHASE_SUCCEEDED}, + {"daoImportPhaseFailed", IDS_DAO_IMPORT_PHASE_FAILED}, + {"daoImportPhaseCancelled", IDS_DAO_IMPORT_PHASE_CANCELLED}, + {"daoImportPartialTitle", IDS_DAO_IMPORT_PARTIAL_TITLE}, + {"daoImportPartialDescription", IDS_DAO_IMPORT_PARTIAL_DESCRIPTION}, + {"daoImportFailedSummaryTitle", IDS_DAO_IMPORT_FAILED_SUMMARY_TITLE}, + {"daoImportFailedStatus", IDS_DAO_IMPORT_FAILED_STATUS}, + {"daoImportDoneTitle", IDS_DAO_IMPORT_DONE_TITLE}, + {"daoImportDoneDescription", IDS_DAO_IMPORT_DONE_DESCRIPTION}, + {"daoImportCancelledTitle", IDS_DAO_IMPORT_CANCELLED_TITLE}, + {"daoImportCancelledDescription", IDS_DAO_IMPORT_CANCELLED_DESCRIPTION}, + {"daoImportMergeHint", IDS_DAO_IMPORT_MERGE_HINT}, + {"daoImportLocalHint", IDS_DAO_IMPORT_LOCAL_HINT}, + {"daoImportBack", IDS_DAO_IMPORT_BACK}, + {"daoImportStart", IDS_DAO_IMPORT_START}, + {"daoImportCancel", IDS_DAO_IMPORT_CANCEL}, + {"daoImportRetryFailed", IDS_DAO_IMPORT_RETRY_FAILED}, + {"daoImportContinue", IDS_DAO_IMPORT_CONTINUE}, + }); + source->UseStringsJs(); + source->OverrideContentSecurityPolicy( + network::mojom::CSPDirectiveName::TrustedTypes, + "trusted-types default lit-html-desktop;"); + web_ui->AddMessageHandler(std::make_unique( + import::DaoMigrationServiceFactory::GetForProfile(profile))); +} + +DaoImportUI::~DaoImportUI() = default; + +} // namespace dao diff --git a/src/dao/browser/ui/webui/dao_import_ui.h b/src/dao/browser/ui/webui/dao_import_ui.h new file mode 100644 index 00000000..e25f85d9 --- /dev/null +++ b/src/dao/browser/ui/webui/dao_import_ui.h @@ -0,0 +1,67 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef DAO_BROWSER_UI_WEBUI_DAO_IMPORT_UI_H_ +#define DAO_BROWSER_UI_WEBUI_DAO_IMPORT_UI_H_ + +#include +#include +#include +#include +#include + +#include "base/callback_list.h" +#include "base/memory/raw_ptr.h" +#include "base/memory/weak_ptr.h" +#include "content/public/browser/web_ui_controller.h" +#include "content/public/browser/web_ui_message_handler.h" +#include "content/public/browser/webui_config.h" +#include "dao/browser/import/dao_migration_types.h" + +namespace dao::import { +class DaoMigrationService; +} + +namespace dao { + +class DaoImportUIConfig : public content::WebUIConfig { +public: + DaoImportUIConfig(); + std::unique_ptr + CreateWebUIController(content::WebUI *web_ui, const GURL &url) override; +}; + +class DaoImportUIHandler : public content::WebUIMessageHandler { +public: + explicit DaoImportUIHandler(import::DaoMigrationService *service); + ~DaoImportUIHandler() override; + + void RegisterMessages() override; + +private: + void HandleDetectSources(const base::ListValue &args); + void HandleCountSourceItems(const base::ListValue &args); + void HandleGetState(const base::ListValue &args); + void HandleStart(const base::ListValue &args); + void HandleCancel(const base::ListValue &args); + void HandleRetry(const base::ListValue &args); + void ReplyWithSources(std::string callback_id, + std::vector sources); + void ReplyWithCount(std::string callback_id, std::optional count); + void OnStateChanged(const import::JobState &state); + + raw_ptr service_; + base::CallbackListSubscription state_subscription_; + base::WeakPtrFactory weak_ptr_factory_{this}; +}; + +class DaoImportUI : public content::WebUIController { +public: + explicit DaoImportUI(content::WebUI *web_ui); + ~DaoImportUI() override; +}; + +} // namespace dao + +#endif // DAO_BROWSER_UI_WEBUI_DAO_IMPORT_UI_H_ diff --git a/src/dao/browser/ui/webui/dao_sidebar_ui.cc b/src/dao/browser/ui/webui/dao_sidebar_ui.cc index 16b51da5..1f16544f 100644 --- a/src/dao/browser/ui/webui/dao_sidebar_ui.cc +++ b/src/dao/browser/ui/webui/dao_sidebar_ui.cc @@ -558,6 +558,20 @@ void DaoSidebarUIHandler::SetBrowser(Browser* browser) { } } +// static +void DaoSidebarUIHandler::NotifyFolderDataChanged(Profile* profile) { + auto state_it = GetPinnedItemsProfileStates().find(profile); + if (state_it == GetPinnedItemsProfileStates().end()) { + return; + } + for (DaoSidebarUIHandler* handler : state_it->second.handlers) { + handler->folder_json_.clear(); + if (handler->IsJavascriptAllowed()) { + handler->FireWebUIListener("folderDataChanged"); + } + } +} + base::ListValue DaoSidebarUIHandler::GetPinnedItemsForTesting() { return BuildPinnedItems(); } diff --git a/src/dao/browser/ui/webui/dao_sidebar_ui.h b/src/dao/browser/ui/webui/dao_sidebar_ui.h index a60b3618..7ece9df4 100644 --- a/src/dao/browser/ui/webui/dao_sidebar_ui.h +++ b/src/dao/browser/ui/webui/dao_sidebar_ui.h @@ -79,6 +79,10 @@ class DaoSidebarUIHandler : public content::WebUIMessageHandler, // Set the browser this handler operates on. void SetBrowser(Browser* browser); + // Invalidates folder caches for all sidebar windows of `profile` after a + // profile-level writer updates dao_folders.json. + static void NotifyFolderDataChanged(Profile* profile); + // content::WebUIMessageHandler: void RegisterMessages() override; void OnJavascriptAllowed() override; diff --git a/src/dao/browser/ui/webui/resources/import/BUILD.gn b/src/dao/browser/ui/webui/resources/import/BUILD.gn new file mode 100644 index 00000000..fd39c035 --- /dev/null +++ b/src/dao/browser/ui/webui/resources/import/BUILD.gn @@ -0,0 +1,33 @@ +# Copyright 2026 Dao Browser Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +import("//ui/webui/resources/tools/build_webui.gni") + +build_webui("build") { + grd_prefix = "dao_import" + disable_css_lint = true + enable_type_aware_eslint_checks = false + + static_files = [ + "assets/arc.svg", + "assets/chrome.svg", + "assets/edge.svg", + "assets/firefox.svg", + "assets/safari.svg", + "import.css", + "import.html", + ] + + ts_files = [ + "import.ts", + "import_bridge.ts", + "dao_import_app.ts", + ] + + ts_definitions = [ "//tools/typescript/definitions/chrome_send.d.ts" ] + ts_deps = [ + "//third_party/lit/v3_0:build_ts", + "//ui/webui/resources/js:build_ts", + ] +} diff --git a/src/dao/browser/ui/webui/resources/import/__tests__/dao_import_app.test.ts b/src/dao/browser/ui/webui/resources/import/__tests__/dao_import_app.test.ts new file mode 100644 index 00000000..850a2e2e --- /dev/null +++ b/src/dao/browser/ui/webui/resources/import/__tests__/dao_import_app.test.ts @@ -0,0 +1,458 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const bridge = vi.hoisted(() => ({ + detectImportSources: vi.fn(), + getImportItemCount: vi.fn(), + getBrowserMigrationState: vi.fn(), + startBrowserMigration: vi.fn(), + cancelBrowserMigration: vi.fn(), + retryBrowserMigrationCategories: vi.fn(), + addListener: vi.fn(() => ({eventName: 'event', uid: 1})), + removeListener: vi.fn(), +})); + +vi.mock('//resources/lit/v3_0/lit.rollup.js', async () => { + return await import('../../sidebar/__tests__/lit_test_shim.js'); +}); + +vi.mock('//resources/js/load_time_data.js', () => ({ + loadTimeData: { + getString: (key: string) => + key === 'daoImportPageTitle' ? 'Import browser data' : key, + getStringF: (key: string, value: string) => `${key}:${value}`, + }, +})); + +vi.mock('../import_bridge.js', () => bridge); + +type ImportApp = HTMLElement&{ + sources_: Array<{ + id: string; + kind: string; + browserName: string; + profileName: string; + supportedCategories: string[]; + }>; + selectedSourceId_: string; + selectedCategories_: string[]; + categoryCounts_: Record; + step_: number; + loading_: boolean; + jobState_: unknown; + updateComplete: Promise; +}; + +async function createApp(): Promise { + await import('../dao_import_app.js'); + const appClass = customElements.get('dao-import-app') as typeof HTMLElement&{ + invokeLifecycleCallbacksForTesting?: boolean; + }; + appClass.invokeLifecycleCallbacksForTesting = true; + const element = document.createElement('dao-import-app') as ImportApp; + document.body.appendChild(element); + await vi.waitFor(() => expect(element.loading_).toBe(false)); + await element.updateComplete; + return element; +} + +describe('dao-import-app', () => { + beforeEach(() => { + document.body.innerHTML = ''; + bridge.detectImportSources.mockResolvedValue([]); + bridge.getImportItemCount.mockResolvedValue(null); + bridge.getBrowserMigrationState.mockResolvedValue(null); + }); + + afterEach(() => vi.clearAllMocks()); + + it('sets the localized document title from WebUI data', async () => { + document.title = ''; + + await import('../import.js'); + + expect(document.title).toBe('Import browser data'); + }); + + it('renders an actionable empty state when no profiles are detected', + async () => { + const app = await createApp(); + await app.updateComplete; + + expect(app.shadowRoot!.querySelector('[data-test="empty"]')) + .not.toBeNull(); + expect(app.shadowRoot!.querySelector('[data-test="rescan"]')) + .not.toBeNull(); + const cards = [...app.shadowRoot!.querySelectorAll( + '[data-source-kind]')]; + expect(cards.map(card => card.dataset.sourceKind)).toEqual( + ['chrome', 'arc', 'edge', 'safari', 'firefox']); + expect(cards.every(card => card.disabled)).toBe(true); + expect(app.shadowRoot!.querySelector( + '[data-test="continue"]')!.disabled) + .toBe(true); + }); + + it('disables only browser kinds without detected profiles', async () => { + const app = await createApp(); + app.sources_ = [ + { + id: 'chrome-personal', + kind: 'chrome', + browserName: 'Google Chrome', + profileName: 'Personal', + supportedCategories: ['bookmarks'], + }, + { + id: 'chrome-work', + kind: 'chrome', + browserName: 'Google Chrome', + profileName: 'Work', + supportedCategories: ['bookmarks'], + }, + { + id: 'firefox-default', + kind: 'firefox', + browserName: 'Firefox', + profileName: 'default-release', + supportedCategories: ['bookmarks'], + }, + ]; + await app.updateComplete; + + const cards = [...app.shadowRoot!.querySelectorAll( + '[data-source-kind]')]; + expect(cards.map(card => [card.dataset.sourceKind, card.disabled])).toEqual([ + ['chrome', false], + ['chrome', false], + ['arc', true], + ['edge', true], + ['safari', true], + ['firefox', false], + ]); + + cards[1]!.click(); + await app.updateComplete; + expect(app.selectedSourceId_).toBe('chrome-work'); + }); + + it('disables Continue when the selected profile disappears', async () => { + const app = await createApp(); + app.sources_ = [{ + id: 'chrome-default', + kind: 'chrome', + browserName: 'Google Chrome', + profileName: 'Default', + supportedCategories: ['bookmarks'], + }]; + await app.updateComplete; + + (app.shadowRoot!.querySelector( + '[data-source-id="chrome-default"]') as HTMLButtonElement) + .click(); + app.sources_ = []; + await app.updateComplete; + + expect(app.shadowRoot!.querySelector( + '[data-test="continue"]')!.disabled) + .toBe(true); + }); + + it('selects one profile and advances to supported categories', async () => { + const app = await createApp(); + app.sources_ = [{ + id: 'chrome-default', + kind: 'chrome', + browserName: 'Google Chrome', + profileName: 'Personal', + supportedCategories: ['bookmarks', 'history', 'passwords'], + }]; + await app.updateComplete; + + (app.shadowRoot!.querySelector('[data-source-id="chrome-default"]') as + HTMLButtonElement).click(); + await app.updateComplete; + expect(app.selectedSourceId_).toBe('chrome-default'); + (app.shadowRoot!.querySelector('[data-test="continue"]') as + HTMLButtonElement).click(); + await app.updateComplete; + + expect(app.step_).toBe(2); + expect(app.selectedCategories_).toEqual([ + 'bookmarks', 'history', 'passwords', + ]); + }); + + it('shows the Keychain expectation tip when passwords are selected', + async () => { + const app = await createApp(); + app.sources_ = [{ + id: 'arc-default', + kind: 'arc', + browserName: 'Arc', + profileName: 'Default', + supportedCategories: ['bookmarks', 'passwords'], + }]; + app.selectedSourceId_ = 'arc-default'; + app.selectedCategories_ = ['bookmarks', 'passwords']; + app.step_ = 2; + await app.updateComplete; + + expect(app.shadowRoot!.querySelector('[data-test="password-tip"]')) + .not.toBeNull(); + }); + + it('shows asynchronously scanned candidate counts by category', async () => { + bridge.getImportItemCount.mockImplementation( + (_sourceId: string, category: string) => + Promise.resolve(category === 'bookmarks' ? 1234 : null)); + const app = await createApp(); + app.sources_ = [{ + id: 'chrome-default', + kind: 'chrome', + browserName: 'Google Chrome', + profileName: 'Personal', + supportedCategories: ['bookmarks', 'history'], + }]; + await app.updateComplete; + + (app.shadowRoot!.querySelector('[data-source-id="chrome-default"]') as + HTMLButtonElement).click(); + await app.updateComplete; + (app.shadowRoot!.querySelector('[data-test="continue"]') as + HTMLButtonElement).click(); + await vi.waitFor(() => { + expect(app.shadowRoot!.querySelector( + '[data-category="bookmarks"] [data-test="candidate-count"]') + ?.textContent) + .toContain('1,234'); + }); + + expect(bridge.getImportItemCount).toHaveBeenCalledWith( + 'chrome-default', 'bookmarks'); + expect(bridge.getImportItemCount).toHaveBeenCalledWith( + 'chrome-default', 'history'); + }); + + it('ignores a late candidate count after switching profiles', async () => { + let resolveFirst!: (count: number|null) => void; + bridge.getImportItemCount.mockImplementation((sourceId: string) => { + if (sourceId === 'chrome-first') { + return new Promise(resolve => resolveFirst = resolve); + } + return Promise.resolve(2); + }); + const app = await createApp(); + app.sources_ = [ + { + id: 'chrome-first', + kind: 'chrome', + browserName: 'Google Chrome', + profileName: 'First', + supportedCategories: ['bookmarks'], + }, + { + id: 'edge-second', + kind: 'edge', + browserName: 'Microsoft Edge', + profileName: 'Second', + supportedCategories: ['bookmarks'], + }, + ]; + await app.updateComplete; + + (app.shadowRoot!.querySelector('[data-source-id="chrome-first"]') as + HTMLButtonElement).click(); + await app.updateComplete; + (app.shadowRoot!.querySelector('[data-source-id="edge-second"]') as + HTMLButtonElement).click(); + await vi.waitFor(() => expect(app.categoryCounts_.bookmarks).toBe(2)); + resolveFirst(999); + await Promise.resolve(); + + expect(app.categoryCounts_.bookmarks).toBe(2); + }); + + it('does not apply completion-view sizing to completed progress segments', + async () => { + const app = await createApp(); + app.step_ = 2; + await app.updateComplete; + + const appClass = customElements.get('dao-import-app') as unknown as { + styles: {strings: string[]; values: unknown[]}; + }; + const cssText = appClass.styles.strings.reduce( + (text, part, index) => + `${text}${part}${String(appClass.styles.values[index] ?? '')}`, + ''); + const style = document.createElement('style'); + style.textContent = cssText; + const fixture = document.createElement('div'); + fixture.innerHTML = ` +
+
+
`; + document.body.append(style, fixture); + + const completedSegment = fixture.querySelector('.rail span')!; + expect(getComputedStyle(completedSegment).minHeight).not.toBe('390px'); + }); + + it('starts migration with the selected profile and categories', async () => { + const app = await createApp(); + app.sources_ = [{ + id: 'edge-work', + kind: 'edge', + browserName: 'Microsoft Edge', + profileName: 'Work', + supportedCategories: ['bookmarks', 'tabs'], + }]; + app.selectedSourceId_ = 'edge-work'; + app.selectedCategories_ = ['bookmarks', 'tabs']; + app.step_ = 2; + await app.updateComplete; + + (app.shadowRoot!.querySelector('[data-test="start"]') as + HTMLButtonElement).click(); + + expect(bridge.startBrowserMigration).toHaveBeenCalledWith( + 'edge-work', ['bookmarks', 'tabs']); + }); + + it('reconnects directly to a running job', async () => { + bridge.getBrowserMigrationState.mockResolvedValue({ + sourceId: 'chrome-default', + terminal: false, + cancelRequested: false, + categories: [], + }); + + const app = await createApp(); + await app.updateComplete; + + expect(app.step_).toBe(3); + expect(app.selectedSourceId_).toBe('chrome-default'); + }); + + it('uses product logos for the source and Dao migration endpoints', + async () => { + bridge.detectImportSources.mockResolvedValue([{ + id: 'edge-work', + kind: 'edge', + browserName: 'Microsoft Edge', + profileName: 'Work', + supportedCategories: ['bookmarks'], + }]); + bridge.getBrowserMigrationState.mockResolvedValue({ + sourceId: 'edge-work', + terminal: false, + cancelRequested: false, + categories: [{ + category: 'bookmarks', + phase: 'reading', + imported: 0, + skipped: 0, + conflicted: 0, + failed: 0, + completedItems: 0, + totalItems: 1, + indeterminate: false, + errorCode: '', + }], + }); + + const app = await createApp(); + await app.updateComplete; + + expect(app.shadowRoot! + .querySelector( + '.migration-pipe .source-logo')! + .getAttribute('src')) + .toBe('assets/edge.svg'); + expect(app.shadowRoot! + .querySelector('[data-test="dao-logo"]')! + .getAttribute('src')) + .toBe('assets/dao.png'); + }); + + it('identifies failed categories in the completion summary', async () => { + bridge.getBrowserMigrationState.mockResolvedValue({ + sourceId: 'chrome-default', + terminal: true, + cancelRequested: false, + categories: [ + { + category: 'bookmarks', + phase: 'succeeded', + imported: 720, + skipped: 0, + conflicted: 0, + failed: 0, + completedItems: 720, + totalItems: 720, + indeterminate: false, + errorCode: '', + }, + { + category: 'passwords', + phase: 'failed', + imported: 1, + skipped: 0, + conflicted: 0, + failed: 0, + completedItems: 1, + totalItems: 2, + indeterminate: false, + errorCode: 'keychain_access_denied', + }, + ], + }); + + const app = await createApp(); + await app.updateComplete; + + const failureSummary = app.shadowRoot!.querySelector( + '[data-test="failure-summary"]'); + expect(failureSummary!.textContent) + .toContain('daoImportCategoryPasswords'); + + const failedStat = app.shadowRoot!.querySelector( + '[data-summary-category="passwords"]'); + expect(failedStat!.classList.contains('failed')).toBe(true); + expect(failedStat!.textContent).toContain('daoImportFailedStatus'); + + const succeededStat = app.shadowRoot!.querySelector( + '[data-summary-category="bookmarks"]'); + expect(succeededStat!.classList.contains('failed')).toBe(false); + }); + + it('renders cancellation instead of successful completion', async () => { + bridge.getBrowserMigrationState.mockResolvedValue({ + sourceId: 'chrome-default', + terminal: true, + cancelRequested: true, + categories: [{ + category: 'bookmarks', + phase: 'cancelled', + imported: 3, + skipped: 0, + conflicted: 0, + failed: 0, + completedItems: 3, + totalItems: 10, + indeterminate: false, + errorCode: '', + }], + }); + + const app = await createApp(); + await app.updateComplete; + + expect(app.shadowRoot!.textContent).toContain('daoImportCancelledTitle'); + expect(app.shadowRoot!.textContent).not.toContain('daoImportDoneTitle'); + }); +}); diff --git a/src/dao/browser/ui/webui/resources/import/assets/arc.svg b/src/dao/browser/ui/webui/resources/import/assets/arc.svg new file mode 100644 index 00000000..2566f337 --- /dev/null +++ b/src/dao/browser/ui/webui/resources/import/assets/arc.svg @@ -0,0 +1 @@ + diff --git a/src/dao/browser/ui/webui/resources/import/assets/chrome.svg b/src/dao/browser/ui/webui/resources/import/assets/chrome.svg new file mode 100644 index 00000000..b12aba9a --- /dev/null +++ b/src/dao/browser/ui/webui/resources/import/assets/chrome.svg @@ -0,0 +1 @@ + diff --git a/src/dao/browser/ui/webui/resources/import/assets/edge.svg b/src/dao/browser/ui/webui/resources/import/assets/edge.svg new file mode 100644 index 00000000..4f95f8de --- /dev/null +++ b/src/dao/browser/ui/webui/resources/import/assets/edge.svg @@ -0,0 +1 @@ + diff --git a/src/dao/browser/ui/webui/resources/import/assets/firefox.svg b/src/dao/browser/ui/webui/resources/import/assets/firefox.svg new file mode 100644 index 00000000..865424c0 --- /dev/null +++ b/src/dao/browser/ui/webui/resources/import/assets/firefox.svg @@ -0,0 +1 @@ + diff --git a/src/dao/browser/ui/webui/resources/import/assets/safari.svg b/src/dao/browser/ui/webui/resources/import/assets/safari.svg new file mode 100644 index 00000000..cd3b3f94 --- /dev/null +++ b/src/dao/browser/ui/webui/resources/import/assets/safari.svg @@ -0,0 +1 @@ + diff --git a/src/dao/browser/ui/webui/resources/import/dao_import_app.ts b/src/dao/browser/ui/webui/resources/import/dao_import_app.ts new file mode 100644 index 00000000..515ec8e0 --- /dev/null +++ b/src/dao/browser/ui/webui/resources/import/dao_import_app.ts @@ -0,0 +1,1001 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import {CrLitElement, css, html, nothing} from '//resources/lit/v3_0/lit.rollup.js'; +import {loadTimeData} from '//resources/js/load_time_data.js'; + +import { + addListener, + cancelBrowserMigration, + detectImportSources, + getBrowserMigrationState, + getImportItemCount, + removeListener, + retryBrowserMigrationCategories, + startBrowserMigration, +} from './import_bridge.js'; +import type { + CategoryState, + MigrationState, + SourceProfile, + WebUiListener, +} from './import_bridge.js'; + +const CATEGORY_ORDER = + ['bookmarks', 'history', 'passwords', 'tabs', 'extensions']; + +const SOURCE_KINDS = [ + {kind: 'chrome', nameKey: 'daoImportBrowserChrome'}, + {kind: 'arc', nameKey: 'daoImportBrowserArc'}, + {kind: 'edge', nameKey: 'daoImportBrowserEdge'}, + {kind: 'safari', nameKey: 'daoImportBrowserSafari'}, + {kind: 'firefox', nameKey: 'daoImportBrowserFirefox'}, +] as const; + +const TERMINAL_PHASES = new Set(['succeeded', 'failed', 'cancelled']); +const DAO_LOGO_URL = 'assets/dao.png'; + +type SourceCard = { + kind: string; + browserName: string; + source?: SourceProfile; +}; + +export class DaoImportApp extends CrLitElement { + static get is() { + return 'dao-import-app'; + } + + static override get properties() { + return { + step_: {type: Number}, + loading_: {type: Boolean}, + sources_: {type: Array}, + selectedSourceId_: {type: String}, + selectedCategories_: {type: Array}, + categoryCounts_: {type: Object}, + jobState_: {type: Object}, + }; + } + + static override get styles() { + return css` + :host { + --bg: rgb(231, 238, 245); + --panel: rgba(255, 255, 255, .88); + --surface: rgba(0, 0, 0, .055); + --surface-hover: rgba(0, 0, 0, .09); + --border: rgba(0, 0, 0, .09); + --text: rgba(30, 20, 40, .92); + --secondary: rgba(30, 20, 40, .6); + --tertiary: rgba(30, 20, 40, .42); + --accent: rgb(70, 120, 190); + --accent-strong: rgb(52, 96, 158); + --accent-soft: rgba(70, 120, 190, .12); + --success: rgb(46, 140, 92); + --danger: rgb(196, 65, 65); + --danger-soft: rgba(196, 65, 65, .1); + align-items: center; + background: var(--bg); + color: var(--text); + display: flex; + justify-content: center; + min-height: 100vh; + padding: 24px; + } + + @media (prefers-color-scheme: dark) { + :host { + --bg: rgb(54, 59, 64); + --panel: rgba(70, 76, 82, .92); + --surface: rgba(255, 255, 255, .065); + --surface-hover: rgba(255, 255, 255, .11); + --border: rgba(255, 255, 255, .12); + --text: rgba(245, 245, 245, .92); + --secondary: rgba(255, 255, 255, .62); + --tertiary: rgba(255, 255, 255, .42); + --accent-strong: rgb(112, 158, 216); + --accent-soft: rgba(70, 120, 190, .22); + --success: rgb(74, 176, 120); + --danger: rgb(245, 118, 118); + --danger-soft: rgba(245, 118, 118, .14); + } + } + + * { box-sizing: border-box; } + button { font: inherit; } + button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + + .shell { + background: + radial-gradient(100% 70% at 50% 0%, var(--accent-soft), transparent 58%), + var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 20px 60px rgba(25, 35, 48, .16); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + height: min(680px, calc(100vh - 48px)); + max-width: 820px; + overflow: hidden; + width: 100%; + } + + header, footer { + align-items: center; + display: flex; + justify-content: space-between; + padding: 18px 24px; + } + header { border-bottom: 1px solid var(--border); } + footer { border-top: 1px solid var(--border); gap: 16px; } + .brand { align-items: center; display: flex; gap: 10px; } + .brand-logo { + display: block; + height: 28px; + object-fit: contain; + width: 28px; + } + .brand-name { font-size: 13px; font-weight: 650; } + .rail { align-items: center; display: flex; gap: 6px; } + .rail span { + background: var(--surface-hover); + border-radius: 8px; + height: 4px; + transition: width 180ms ease, background 180ms ease; + width: 24px; + } + .rail span.done { background: var(--accent); } + .rail span.current { background: var(--accent); width: 36px; } + + main { min-height: 0; overflow: auto; padding: 28px 32px; } + .eyebrow { + color: var(--accent-strong); + font-size: 12px; + font-weight: 650; + letter-spacing: .02em; + margin-bottom: 8px; + } + h1 { + font-size: 24px; + letter-spacing: -.025em; + line-height: 1.2; + margin: 0; + } + .description { + color: var(--secondary); + line-height: 1.55; + margin: 7px 0 22px; + max-width: 62ch; + } + + .source-grid { display: grid; gap: 10px; grid-template-columns: 1fr 1fr; } + .source-card, .category-row { + align-items: center; + background: var(--surface); + border: 1px solid transparent; + border-radius: 12px; + color: var(--text); + cursor: pointer; + display: flex; + gap: 12px; + min-width: 0; + padding: 14px; + text-align: start; + transition: background 120ms ease, border-color 120ms ease, + transform 120ms ease; + width: 100%; + } + .source-card:hover, .category-row:hover { background: var(--surface-hover); } + .source-card.selected, .category-row.selected { + background: var(--accent-soft); + border-color: var(--accent); + } + .source-card:active, .category-row:active { transform: scale(.992); } + .source-card:disabled { + cursor: default; + opacity: .48; + } + .source-card:disabled:hover { background: var(--surface); } + .source-card:disabled:active { transform: none; } + .source-mark { + align-items: center; + background: var(--mark-color, var(--accent)); + border-radius: 11px; + color: white; + display: flex; + flex: 0 0 auto; + font-size: 15px; + font-weight: 750; + height: 42px; + justify-content: center; + width: 42px; + } + .source-logo { + height: 30px; + object-fit: contain; + width: 30px; + } + .source-mark.arc { --mark-color: rgb(255, 83, 106); } + .source-mark.safari { --mark-color: rgb(55, 137, 205); } + .source-mark.edge { --mark-color: rgb(24, 158, 180); } + .source-mark.firefox { --mark-color: rgb(220, 67, 109); } + .card-copy { min-width: 0; } + .card-title { display: block; font-size: 14px; font-weight: 650; } + .card-meta { + color: var(--secondary); + display: block; + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .selection { + border: 1px solid var(--border); + border-radius: 50%; + flex: 0 0 auto; + height: 20px; + margin-inline-start: auto; + position: relative; + width: 20px; + } + .selected .selection { background: var(--accent); border-color: var(--accent); } + .selected .selection::after { + border-bottom: 2px solid white; + border-right: 2px solid white; + content: ''; + height: 8px; + left: 6px; + position: absolute; + top: 3px; + transform: rotate(45deg); + width: 4px; + } + + .empty { + align-items: center; + border: 1px dashed var(--border); + border-radius: 12px; + color: var(--secondary); + display: flex; + flex-direction: column; + gap: 12px; + justify-content: center; + min-height: 230px; + padding: 32px; + text-align: center; + } + .empty-dot { + background: var(--accent-soft); + border: 1px solid var(--accent); + border-radius: 50%; + height: 42px; + width: 42px; + } + + .category-list { display: flex; flex-direction: column; gap: 8px; } + .category-row { padding: 12px 14px; } + .category-row .card-title { flex: 1; } + .category-count { + color: var(--secondary); + flex: 0 0 auto; + font-size: 12px; + font-variant-numeric: tabular-nums; + } + .category-glyph { + background: var(--accent-soft); + border-radius: 9px; + height: 34px; + position: relative; + width: 34px; + } + .category-glyph::before, .category-glyph::after { + background: var(--accent-strong); + border-radius: 2px; + content: ''; + left: 9px; + position: absolute; + top: 11px; + } + .category-glyph::before { height: 2px; width: 16px; } + .category-glyph::after { height: 2px; top: 20px; width: 11px; } + .switch { + background: var(--surface-hover); + border-radius: 16px; + height: 24px; + flex: 0 0 auto; + position: relative; + width: 40px; + } + .switch::after { + background: white; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, .25); + content: ''; + height: 18px; + left: 3px; + position: absolute; + top: 3px; + transition: transform 150ms ease; + width: 18px; + } + .selected .switch { background: var(--accent); } + .selected .switch::after { transform: translateX(16px); } + + .tip { + align-items: flex-start; + background: var(--accent-soft); + border: 1px solid rgba(70, 120, 190, .24); + border-radius: 12px; + display: flex; + gap: 11px; + line-height: 1.5; + margin-top: 12px; + padding: 13px 14px; + } + .tip-mark { + align-items: center; + border: 1px solid var(--accent); + border-radius: 50%; + color: var(--accent-strong); + display: flex; + flex: 0 0 auto; + font-size: 11px; + font-weight: 700; + height: 20px; + justify-content: center; + margin-top: 1px; + width: 20px; + } + .tip strong { display: block; font-weight: 650; margin-bottom: 2px; } + .tip span { color: var(--secondary); } + + .migration-pipe { + align-items: center; + display: grid; + grid-template-columns: auto 1fr auto; + margin: 34px 18px 30px; + } + .pipe-node { + align-items: center; + display: flex; + flex-direction: column; + font-weight: 650; + gap: 8px; + } + .pipe-disc { + align-items: center; + background: var(--surface-hover); + border-radius: 18px; + display: flex; + height: 64px; + justify-content: center; + width: 64px; + } + .pipe-disc.dao { background: transparent; } + .pipe-logo { + display: block; + height: 54px; + object-fit: contain; + width: 54px; + } + .conduit { height: 2px; background: var(--border); position: relative; } + .conduit::after { + animation: travel 1.6s ease-in-out infinite; + background: var(--accent); + border-radius: 8px; + box-shadow: 0 0 12px rgba(70, 120, 190, .45); + content: ''; + height: 6px; + left: 0; + position: absolute; + top: -2px; + width: 28px; + } + @keyframes travel { from { left: 0; } to { left: calc(100% - 28px); } } + + .overall { align-items: center; display: flex; gap: 12px; margin-bottom: 15px; } + .progress-track { + background: var(--surface-hover); + border-radius: 8px; + flex: 1; + height: 8px; + overflow: hidden; + } + .progress-fill { + background: var(--accent); + border-radius: inherit; + height: 100%; + transition: width 180ms ease; + } + .percent { font-size: 15px; font-variant-numeric: tabular-nums; font-weight: 650; } + .task-list { display: flex; flex-direction: column; gap: 7px; } + .task-row { + align-items: center; + background: var(--surface); + border-radius: 10px; + display: flex; + gap: 10px; + padding: 11px 13px; + } + .task-state { + border: 2px solid var(--tertiary); + border-radius: 50%; + height: 17px; + width: 17px; + } + .task-state.running { + animation: spin .7s linear infinite; + border-color: var(--accent-soft); + border-top-color: var(--accent); + } + .task-state.succeeded { background: var(--success); border-color: var(--success); } + .task-state.failed { border-color: var(--danger); } + @keyframes spin { to { transform: rotate(360deg); } } + .task-name { flex: 1; font-weight: 600; } + .task-progress { color: var(--secondary); font-variant-numeric: tabular-nums; } + .task-error { color: var(--danger); } + + .completion { + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + min-height: 390px; + text-align: center; + } + .done-ring { + align-items: center; + background: var(--accent-soft); + border-radius: 50%; + color: var(--accent-strong); + display: flex; + font-size: 30px; + height: 92px; + justify-content: center; + margin-bottom: 20px; + width: 92px; + } + .done-ring::before { + border-bottom: 3px solid currentColor; + border-right: 3px solid currentColor; + content: ''; + height: 25px; + transform: rotate(45deg) translate(-4px, -4px); + width: 13px; + } + .failure-summary { + align-items: flex-start; + background: var(--danger-soft); + border: 1px solid color-mix(in srgb, var(--danger) 45%, transparent); + border-radius: 10px; + color: var(--danger); + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 18px; + max-width: 100%; + padding: 10px 14px; + text-align: start; + } + .failure-summary strong { font-size: 13px; } + .failure-categories { display: flex; flex-wrap: wrap; gap: 6px; } + .failure-category { + background: var(--panel); + border-radius: 999px; + font-size: 12px; + font-weight: 650; + padding: 3px 9px; + } + .stats { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 22px; } + .failure-summary + .stats { margin-top: 12px; } + .stat { + background: var(--surface); + border: 1px solid transparent; + border-radius: 10px; + min-width: 105px; + padding: 10px 13px; + } + .stat.failed { + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 55%, transparent); + } + .stat strong { display: block; font-size: 20px; } + .stat span { color: var(--secondary); } + .stat.failed strong, .stat .stat-status { color: var(--danger); } + .stat .stat-status { + display: block; + font-size: 11px; + font-weight: 700; + margin-top: 4px; + } + + .hint { color: var(--tertiary); line-height: 1.4; } + .actions { display: flex; gap: 8px; margin-inline-start: auto; } + .button { + background: transparent; + border: 1px solid transparent; + border-radius: 12px; + color: var(--secondary); + cursor: pointer; + font-weight: 650; + min-height: 36px; + padding: 0 15px; + } + .button:hover { background: var(--surface-hover); color: var(--text); } + .button.primary { background: var(--accent); color: white; } + .button.primary:hover { background: var(--accent-strong); } + .button:disabled { cursor: default; opacity: .4; } + + @media (max-width: 620px) { + :host { padding: 0; } + .shell { border: 0; border-radius: 0; height: 100vh; } + main { padding: 24px 20px; } + header, footer { padding: 16px 20px; } + .source-grid { grid-template-columns: 1fr; } + .hint { display: none; } + } + + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; } + } + `; + } + + declare protected step_: number; + declare protected loading_: boolean; + declare protected sources_: SourceProfile[]; + declare protected selectedSourceId_: string; + declare protected selectedCategories_: string[]; + declare protected categoryCounts_: Record; + declare protected jobState_: MigrationState|null; + private listeners_: WebUiListener[]; + private countGeneration_: number; + + constructor() { + super(); + this.step_ = 1; + this.loading_ = true; + this.sources_ = []; + this.selectedSourceId_ = ''; + this.selectedCategories_ = []; + this.categoryCounts_ = {}; + this.jobState_ = null; + this.listeners_ = []; + this.countGeneration_ = 0; + } + + override connectedCallback() { + super.connectedCallback(); + this.listeners_.push(addListener( + 'browser-migration-sources-changed', (...args: unknown[]) => { + this.sources_ = args[0] as SourceProfile[]; + this.loading_ = false; + })); + this.listeners_.push(addListener( + 'browser-migration-state-changed', (...args: unknown[]) => { + this.applyJobState_(args[0] as MigrationState); + })); + void this.load_(); + } + + override disconnectedCallback() { + for (const listener of this.listeners_) { + removeListener(listener); + } + this.listeners_ = []; + super.disconnectedCallback?.(); + } + + private async load_() { + const [sources, state] = await Promise.all([ + detectImportSources(), getBrowserMigrationState(), + ]); + this.sources_ = sources; + this.loading_ = false; + if (state) { + this.applyJobState_(state); + } + } + + private applyJobState_(state: MigrationState) { + this.jobState_ = state; + this.selectedSourceId_ = state.sourceId; + this.selectedCategories_ = state.categories.map(item => item.category); + this.step_ = state.terminal ? 4 : 3; + } + + private string_(key: string): string { + return loadTimeData.getString(key); + } + + private selectedSource_(): SourceProfile|undefined { + return this.sources_.find(source => source.id === this.selectedSourceId_); + } + + private sourceCards_(): SourceCard[] { + const cards: SourceCard[] = []; + for (const definition of SOURCE_KINDS) { + const detected = this.sources_.filter( + source => source.kind === definition.kind); + if (detected.length > 0) { + cards.push(...detected.map(source => ({ + kind: source.kind, + browserName: source.browserName, + source, + }))); + } else { + cards.push({ + kind: definition.kind, + browserName: this.string_(definition.nameKey), + }); + } + } + return cards; + } + + private categoryLabel_(category: string): string { + const key = `daoImportCategory${category[0]!.toUpperCase()}${category.slice(1)}`; + return this.string_(key); + } + + private sourceInitial_(kind: string): string { + return kind.slice(0, 1).toUpperCase(); + } + + private sourceLogo_(kind: string): string { + return `assets/${kind}.svg`; + } + + private selectSource_(source: SourceProfile) { + this.selectedSourceId_ = source.id; + this.scanCandidateCounts_(source); + } + + private scanCandidateCounts_(source: SourceProfile) { + const generation = ++this.countGeneration_; + this.categoryCounts_ = Object.fromEntries( + source.supportedCategories.map(category => [category, undefined])); + for (const category of source.supportedCategories) { + void getImportItemCount(source.id, category).then(count => { + if (generation !== this.countGeneration_ || + source.id !== this.selectedSourceId_) { + return; + } + this.categoryCounts_ = {...this.categoryCounts_, [category]: count}; + }); + } + } + + private renderCandidateCount_(category: string) { + const count = this.categoryCounts_[category]; + if (count === undefined) { + return this.string_('daoImportCountScanning'); + } + if (count === null) { + return this.string_('daoImportCountUnavailable'); + } + return loadTimeData.getStringF( + 'daoImportCountScanned', count.toLocaleString()); + } + + private continueToCategories_() { + const source = this.selectedSource_(); + if (!source) { + return; + } + this.selectedCategories_ = CATEGORY_ORDER.filter( + category => source.supportedCategories.includes(category)); + this.step_ = 2; + } + + private toggleCategory_(category: string) { + this.selectedCategories_ = this.selectedCategories_.includes(category) ? + this.selectedCategories_.filter(item => item !== category) : + [...this.selectedCategories_, category]; + } + + private start_() { + if (!this.selectedSourceId_ || this.selectedCategories_.length === 0) { + return; + } + startBrowserMigration( + this.selectedSourceId_, [...this.selectedCategories_]); + this.step_ = 3; + } + + private rescan_() { + ++this.countGeneration_; + this.categoryCounts_ = {}; + this.loading_ = true; + void detectImportSources().then(sources => { + this.sources_ = sources; + this.loading_ = false; + }); + } + + private retryFailed_() { + const failed = this.jobState_?.categories + .filter(item => item.phase === 'failed') + .map(item => item.category) || []; + if (failed.length) { + retryBrowserMigrationCategories(failed); + this.step_ = 3; + } + } + + private progress_(): number { + const categories = this.jobState_?.categories || []; + if (!categories.length) { + return 0; + } + const sum = categories.reduce((total, item) => { + if (TERMINAL_PHASES.has(item.phase)) { + return total + 1; + } + return total + (item.totalItems > 0 ? + Math.min(1, item.completedItems / item.totalItems) : + 0); + }, 0); + return Math.round(sum / categories.length * 100); + } + + private renderHeader_() { + return html` +
+
+ + ${this.string_('daoImportWizardName')} +
+ +
`; + } + + private renderSources_() { + return html` +
${this.string_('daoImportStepSource')}
+

${this.string_('daoImportSourceTitle')}

+

${this.string_('daoImportSourceDescription')}

+ ${this.loading_ ? html` +
${this.string_('daoImportDetectingSources')}
` : + html` + ${this.sources_.length === 0 ? html` +
+ + ${this.string_('daoImportNoSources')} + +
` : nothing} +
+ ${this.sourceCards_().map(card => { + const source = card.source; + return html` + `; + })} +
`} + `; + } + + private renderCategories_() { + const source = this.selectedSource_(); + return html` +
${this.string_('daoImportStepData')}
+

${this.string_('daoImportDataTitle')}

+

${this.string_('daoImportDataDescription')}

+
+ ${(source?.supportedCategories || []).map(category => html` + `)} +
+ ${this.selectedCategories_.includes('passwords') ? html` +
+ +
+ ${this.string_('daoImportPasswordTipTitle')} + ${this.string_('daoImportPasswordTipDescription')} +
+
` : nothing} + ${this.selectedCategories_.includes('extensions') ? html` +
+ +
+ ${this.string_('daoImportExtensionTipTitle')} + ${this.string_('daoImportExtensionTipDescription')} +
+
` : nothing} + `; + } + + private renderMigration_() { + const source = this.selectedSource_(); + const progress = this.progress_(); + return html` +
${this.string_('daoImportStepProgress')}
+

${this.string_('daoImportProgressTitle')}

+

${this.string_('daoImportProgressDescription')}

+ +
+
+ ${progress}% +
+
+ ${(this.jobState_?.categories || this.selectedCategories_.map( + category => ({category, phase: 'pending'} as CategoryState))) + .map(item => this.renderTask_(item))} +
+ `; + } + + private renderTask_(item: CategoryState) { + const running = ['snapshotting', 'reading', 'writing'].includes(item.phase); + const count = item.totalItems > 0 ? + `${item.completedItems} / ${item.totalItems}` : + this.string_(`daoImportPhase${ + item.phase[0]!.toUpperCase()}${item.phase.slice(1)}`); + return html` +
+ + ${this.categoryLabel_(item.category)} + + ${count} + +
`; + } + + private renderDone_() { + const failed = this.jobState_?.categories.filter( + item => item.phase === 'failed') || []; + const cancelled = this.jobState_?.categories.some( + item => item.phase === 'cancelled') || false; + const totals = this.jobState_?.categories || []; + return html` +
+ +

${cancelled ? this.string_('daoImportCancelledTitle') : + failed.length ? this.string_('daoImportPartialTitle') : + this.string_('daoImportDoneTitle')}

+

${cancelled ? + this.string_('daoImportCancelledDescription') : failed.length ? + this.string_('daoImportPartialDescription') : + this.string_('daoImportDoneDescription')}

+ ${failed.length ? html` +
+ ${this.string_('daoImportFailedSummaryTitle')} +
+ ${failed.map(item => html` + + ${this.categoryLabel_(item.category)} + `)} +
+
` : nothing} +
+ ${totals.map(item => html` +
+ ${item.imported || 0} + ${this.categoryLabel_(item.category)} + ${item.phase === 'failed' ? html` + + ${this.string_('daoImportFailedStatus')} + ` : nothing} +
`)} +
+
`; + } + + private renderFooter_() { + const hasFailed = this.jobState_?.categories.some( + item => item.phase === 'failed'); + return html` +
+ ${this.step_ === 2 ? + this.string_('daoImportMergeHint') : + this.string_('daoImportLocalHint')} +
+ ${this.step_ === 2 ? html` + + ` : + this.step_ === 3 ? html` + ` : + this.step_ === 4 && hasFailed ? html` + ` : + this.step_ === 1 ? html` + ` : nothing} +
+
`; + } + + override render() { + return html` +
+ ${this.renderHeader_()} +
+ ${this.step_ === 1 ? this.renderSources_() : + this.step_ === 2 ? this.renderCategories_() : + this.step_ === 3 ? this.renderMigration_() : this.renderDone_()} +
+ ${this.renderFooter_()} +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'dao-import-app': DaoImportApp; + } +} + +customElements.define(DaoImportApp.is, DaoImportApp); diff --git a/src/dao/browser/ui/webui/resources/import/import.css b/src/dao/browser/ui/webui/resources/import/import.css new file mode 100644 index 00000000..97dc6170 --- /dev/null +++ b/src/dao/browser/ui/webui/resources/import/import.css @@ -0,0 +1,25 @@ +/* Copyright 2026 Dao Browser Authors. All rights reserved. */ + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; + margin: 0; +} + +body { + background: rgb(231, 238, 245); + color: rgba(30, 20, 40, 0.92); + font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 13px; +} + +@media (prefers-color-scheme: dark) { + body { + background: rgb(54, 59, 64); + color: rgba(245, 245, 245, 0.92); + } +} diff --git a/src/dao/browser/ui/webui/resources/import/import.html b/src/dao/browser/ui/webui/resources/import/import.html new file mode 100644 index 00000000..b885adb0 --- /dev/null +++ b/src/dao/browser/ui/webui/resources/import/import.html @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/dao/browser/ui/webui/resources/import/import.ts b/src/dao/browser/ui/webui/resources/import/import.ts new file mode 100644 index 00000000..c6587816 --- /dev/null +++ b/src/dao/browser/ui/webui/resources/import/import.ts @@ -0,0 +1,20 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import {loadTimeData} from '//resources/js/load_time_data.js'; + +{ + const trustedWindow = window as unknown as { + trustedTypes?: {createPolicy: (name: string, rules: object) => void}; + }; + trustedWindow.trustedTypes?.createPolicy('default', { + createHTML: (value: string) => value, + createScript: (value: string) => value, + createScriptURL: (value: string) => value, + }); +} + +document.title = loadTimeData.getString('daoImportPageTitle'); + +import './dao_import_app.js'; diff --git a/src/dao/browser/ui/webui/resources/import/import_bridge.ts b/src/dao/browser/ui/webui/resources/import/import_bridge.ts new file mode 100644 index 00000000..3419d6d4 --- /dev/null +++ b/src/dao/browser/ui/webui/resources/import/import_bridge.ts @@ -0,0 +1,111 @@ +// Copyright 2026 Dao Browser Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export interface SourceProfile { + id: string; + kind: string; + browserName: string; + profileName: string; + supportedCategories: string[]; +} + +export interface CategoryState { + category: string; + phase: string; + imported: number; + skipped: number; + conflicted: number; + failed: number; + completedItems: number; + totalItems: number; + indeterminate: boolean; + errorCode: string; +} + +export interface MigrationState { + sourceId: string; + terminal: boolean; + cancelRequested: boolean; + categories: CategoryState[]; +} + +export interface WebUiListener { + eventName: string; + uid: number; +} + +let callbackId = 0; +let listenerId = 0; +const listeners: + Record void>> = {}; + +function webUIListenerCallback(event: string, ...args: unknown[]): void { + const eventListeners = listeners[event]; + if (!eventListeners) { + return; + } + for (const listener of Object.values(eventListeners)) { + listener(...args); + } +} + +const bridgeWindow = window as unknown as { + cr?: {webUIListenerCallback: typeof webUIListenerCallback}; +}; +bridgeWindow.cr = {webUIListenerCallback}; + +export function addListener( + eventName: string, + listener: (...args: unknown[]) => void): WebUiListener { + listeners[eventName] ||= {}; + const uid = ++listenerId; + listeners[eventName]![uid] = listener; + return {eventName, uid}; +} + +export function removeListener(listener: WebUiListener): boolean { + const eventListeners = listeners[listener.eventName]; + if (!eventListeners?.[listener.uid]) { + return false; + } + delete eventListeners[listener.uid]; + return true; +} + +function sendAsync(method: string, ...args: unknown[]): Promise { + return new Promise(resolve => { + const callbackEvent = `${method}_${callbackId++}`; + const listener = addListener(callbackEvent, result => { + removeListener(listener); + resolve(result as T); + }); + chrome.send(method, [callbackEvent, ...args]); + }); +} + +export function detectImportSources(): Promise { + return sendAsync('detectImportSources'); +} + +export function getImportItemCount( + sourceId: string, category: string): Promise { + return sendAsync('getImportItemCount', sourceId, category); +} + +export function getBrowserMigrationState(): Promise { + return sendAsync('getBrowserMigrationState'); +} + +export function startBrowserMigration( + sourceId: string, categories: string[]): void { + chrome.send('startBrowserMigration', [sourceId, categories]); +} + +export function cancelBrowserMigration(): void { + chrome.send('cancelBrowserMigration'); +} + +export function retryBrowserMigrationCategories(categories: string[]): void { + chrome.send('retryBrowserMigrationCategories', [categories]); +} diff --git a/src/dao/browser/ui/webui/resources/sidebar/dao_sidebar_app.ts b/src/dao/browser/ui/webui/resources/sidebar/dao_sidebar_app.ts index c310d073..6ed6dbf3 100644 --- a/src/dao/browser/ui/webui/resources/sidebar/dao_sidebar_app.ts +++ b/src/dao/browser/ui/webui/resources/sidebar/dao_sidebar_app.ts @@ -395,6 +395,10 @@ export class DaoSidebarApp extends CrLitElement { this.addSidebarListener_( 'sidebarPointerExited', () => this.clearPointerState_()); + this.addSidebarListener_('folderDataChanged', () => { + void this.reloadFolders_(); + }); + this.addSidebarListener_( 'moveStaleTabsRequested', (...args: unknown[]) => { this.moveStaleTabsToFolder_(args[0]); @@ -471,6 +475,18 @@ export class DaoSidebarApp extends CrLitElement { } } + private async reloadFolders_() { + try { + const json = await loadFolders(); + this.folderModel_.loadFromJson(json); + this.folderModel_.reconcile(this.unpinnedTabs_); + this.foldersLoaded_ = true; + this.saveFolders_(); + } catch (e) { + console.error('DaoSidebarApp: failed to reload folders', e); + } + } + /** * Save current folder state and trigger re-render. */ diff --git a/src/patches/chrome/browser/profiles/chrome_browser_main_extra_parts_profiles.cc.patch b/src/patches/chrome/browser/profiles/chrome_browser_main_extra_parts_profiles.cc.patch index 9378fe5b..915bb352 100644 --- a/src/patches/chrome/browser/profiles/chrome_browser_main_extra_parts_profiles.cc.patch +++ b/src/patches/chrome/browser/profiles/chrome_browser_main_extra_parts_profiles.cc.patch @@ -20,7 +20,7 @@ index 1aab47b6d039a..783c86ba03c03 100644 #include "components/autofill/content/browser/autofill_log_router_factory.h" #include "components/breadcrumbs/core/breadcrumbs_status.h" #include "components/captive_portal/core/buildflags.h" -@@ -622,6 +625,12 @@ +@@ -622,6 +625,13 @@ #include "chrome/browser/net/nss_service_factory.h" #endif @@ -28,6 +28,7 @@ index 1aab47b6d039a..783c86ba03c03 100644 +#include "dao/browser/agent/dao_agent_skill_service_factory.h" +#include "dao/browser/agent/dao_agent_workspace_service_factory.h" +#include "dao/browser/agent/dao_dream_service_factory.h" ++#include "dao/browser/import/dao_migration_service_factory.h" +#include "dao/browser/mcp/dao_mcp_service.h" + void AddProfilesExtraParts(ChromeBrowserMainParts* main_parts) { @@ -45,7 +46,7 @@ index 1aab47b6d039a..783c86ba03c03 100644 // This method gets the instance of each ServiceFactory. We do this so that // each ServiceFactory initializes itself and registers its dependencies with -@@ -894,6 +904,10 @@ void ChromeBrowserMainExtraPartsProfiles:: +@@ -894,6 +905,11 @@ void ChromeBrowserMainExtraPartsProfiles:: #endif // BUILDFLAG(!IS_ANDROID) CrossDevicePrefTrackerFactory::GetInstance(); DataTypeStoreServiceFactory::GetInstance(); @@ -53,6 +54,7 @@ index 1aab47b6d039a..783c86ba03c03 100644 + dao::DaoAgentSkillServiceFactory::GetInstance(); + dao::DaoAgentWorkspaceServiceFactory::GetInstance(); + dao::DaoDreamServiceFactory::GetInstance(); ++ dao::import::DaoMigrationServiceFactory::GetInstance(); #if BUILDFLAG(ENTERPRISE_DATA_CONTROLS) data_controls::ChromeRulesServiceFactory::GetInstance(); #endif diff --git a/src/patches/chrome/browser/resources/settings/dao_page/dao_page.html.patch b/src/patches/chrome/browser/resources/settings/dao_page/dao_page.html.patch index 2fad4a87..dfa90147 100644 --- a/src/patches/chrome/browser/resources/settings/dao_page/dao_page.html.patch +++ b/src/patches/chrome/browser/resources/settings/dao_page/dao_page.html.patch @@ -3,7 +3,7 @@ new file mode 100644 index 0000000000..0000000001 --- /dev/null +++ b/chrome/browser/resources/settings/dao_page/dao_page.html -@@ -0,0 +1,625 @@ +@@ -0,0 +1,630 @@ +