From da20453725794631f1828f4332a9fba2bea7c95b Mon Sep 17 00:00:00 2001 From: Deepankar Bajpeyi Date: Wed, 3 Sep 2025 10:47:22 +0200 Subject: [PATCH 01/16] separate password and bookmark flow --- .../src/features/autofill-password-import.js | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/injected/src/features/autofill-password-import.js b/injected/src/features/autofill-password-import.js index 4d89d3305b..dcb5227324 100644 --- a/injected/src/features/autofill-password-import.js +++ b/injected/src/features/autofill-password-import.js @@ -7,6 +7,7 @@ export const BACKGROUND_COLOR_START = 'rgba(85, 127, 243, 0.10)'; export const BACKGROUND_COLOR_END = 'rgba(85, 127, 243, 0.25)'; export const OVERLAY_ID = 'ddg-password-import-overlay'; export const DELAY_BEFORE_ANIMATION = 300; +const BOOKMARK_IMPORT_DOMAIN = 'takeout.google.com'; /** * @typedef ButtonAnimationStyle @@ -403,11 +404,12 @@ export default class AutofillPasswordImport extends ContentFeature { return [this.#exportButtonSettings?.path, this.#settingsButtonSettings?.path, this.#signInButtonSettings?.path].includes(path); } - async handlePath(path) { + async handlePasswordManagerPath(pathname) { + console.log('DEEP DEBUG autofill-password-import: handlePasswordManagerPath', pathname); this.removeOverlayIfNeeded(); - if (this.isSupportedPath(path)) { + if (this.isSupportedPath(pathname)) { try { - this.setCurrentElementConfig(await this.getElementAndStyleFromPath(path)); + this.setCurrentElementConfig(await this.getElementAndStyleFromPath(pathname)); if (this.currentElementConfig?.element && !this.#tappedElements.has(this.currentElementConfig?.element)) { await this.animateOrTapElement(); if (this.currentElementConfig?.shouldTap && this.currentElementConfig?.tapOnce) { @@ -415,11 +417,27 @@ export default class AutofillPasswordImport extends ContentFeature { } } } catch { - console.error('password-import: failed for path:', path); + console.error('password-import: failed for path:', pathname); } } } + handleBookmarkImportPath(pathname) { + console.log('DEEP DEBUG autofill-password-import: handleBookmarkImportPath', pathname); + } + + /** + * @param {Location} location + */ + async handleLocation(location) { + const { pathname, hostname } = location; + if (hostname === BOOKMARK_IMPORT_DOMAIN) { + this.handleBookmarkImportPath(pathname); + } else { + await this.handlePasswordManagerPath(pathname); + } + } + /** * Based on the current element config, animates the element or taps it. * If the element should be watched for removal, it sets up a mutation observer. @@ -491,16 +509,17 @@ export default class AutofillPasswordImport extends ContentFeature { } urlChanged() { - this.handlePath(window.location.pathname); + this.handleLocation(window.location); } init() { + console.log('DEEP DEBUG autofill-password-import: init'); if (isBeingFramed()) { return; } - this.setButtonSettings(); - const handlePath = this.handlePath.bind(this); + this.setButtonSettings(); + const handleLocation = this.handleLocation.bind(this); this.#domLoaded = new Promise((resolve) => { if (document.readyState !== 'loading') { @@ -514,8 +533,7 @@ export default class AutofillPasswordImport extends ContentFeature { async () => { // @ts-expect-error - caller doesn't expect a value here resolve(); - const path = window.location.pathname; - await handlePath(path); + await handleLocation(window.location); }, { once: true }, ); From fcdefe5f657885612ceb75e1fe2c9e95f192c59d Mon Sep 17 00:00:00 2001 From: Deepankar Bajpeyi Date: Wed, 3 Sep 2025 18:16:02 +0200 Subject: [PATCH 02/16] checkpoint automation changes --- .../src/features/autofill-password-import.js | 99 ++++++++++++++++++- injected/src/utils.js | 12 +-- 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/injected/src/features/autofill-password-import.js b/injected/src/features/autofill-password-import.js index dcb5227324..f7f78aa145 100644 --- a/injected/src/features/autofill-password-import.js +++ b/injected/src/features/autofill-password-import.js @@ -52,6 +52,11 @@ export default class AutofillPasswordImport extends ContentFeature { #domLoaded; + #currentLocation; + + #isBookmarkModalVisible = false; + #isBookmarkProcessed = false; + /** @type {WeakSet} */ #tappedElements = new WeakSet(); @@ -422,8 +427,12 @@ export default class AutofillPasswordImport extends ContentFeature { } } - handleBookmarkImportPath(pathname) { + async handleBookmarkImportPath(pathname) { console.log('DEEP DEBUG autofill-password-import: handleBookmarkImportPath', pathname); + if (pathname === '/' && !this.#isBookmarkModalVisible) { + await this.clickDisselectAllButton(); + await this.selectBookmark(); + } } /** @@ -508,7 +517,95 @@ export default class AutofillPasswordImport extends ContentFeature { this.#settingsButtonSettings = this.getFeatureSetting('settingsButton'); } + /** Bookmark import code */ + get disselectAllButtonSelector() { + return 'c-wiz[data-node-index="4;0"] button'; + } + + get bookmarkSelectButtonSelector() { + return 'fieldset.rcetic input'; + } + + get chromeSectionSelector() { + return 'c-wiz [data-id="chrome"]'; + } + + get nextStepButtonSelector() { + return 'div[data-jobid] > div:nth-of-type(2) button'; + } + + get createExportButtonSelector() { + return 'div[data-configure-step] button'; + } + + async findDisselectAllButton() { + return await withExponentialBackoff(() => document.querySelectorAll(this.disselectAllButtonSelector)[1]); + } + async selectBookmark() { + if (this.#isBookmarkProcessed) { + return; + } + const chromeDataButtonSelector = `${this.chromeSectionSelector} button`; + const chromeDataButton = /** @type HTMLButtonElement */ ( + await withExponentialBackoff(() => document.querySelectorAll(chromeDataButtonSelector)[1], 5) + ); + chromeDataButton?.focus(); + chromeDataButton?.click(); + this.#isBookmarkModalVisible = true; + await this.domLoaded; + const disselectAllButton = /** @type HTMLButtonElement */ ( + await withExponentialBackoff(() => document.querySelectorAll('fieldset.rcetic button')[1]) + ); + + disselectAllButton?.click(); + + const bookmarkSelectButton = /** @type HTMLInputElement */ ( + await withExponentialBackoff(() => document.querySelectorAll(this.bookmarkSelectButtonSelector)[1]) + ); + + await withExponentialBackoff(() => !bookmarkSelectButton?.checked); + + bookmarkSelectButton?.click(); + + const okButton = /** @type HTMLButtonElement */ (document.querySelectorAll('div[role="button"]')[7]); + + await withExponentialBackoff(() => okButton.ariaDisabled !== 'true'); + + okButton?.click(); + this.#isBookmarkModalVisible = false; + this.#isBookmarkProcessed = true; + + const nextStepButton = /** @type HTMLButtonElement */ (document.querySelectorAll(this.nextStepButtonSelector)[0]); + nextStepButton?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); + nextStepButton?.click(); + + const createExportButton = /** @type HTMLButtonElement */ (document.querySelectorAll(this.createExportButtonSelector)[0]); + createExportButton?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); + createExportButton?.click(); + } + + async clickDisselectAllButton() { + const element = /** @type HTMLButtonElement */ (await this.findDisselectAllButton()); + console.log('Deep element', element); + if (element != null) { + element.click(); + } + + const chromeSectionElement = /** @type HTMLInputElement */ ( + await withExponentialBackoff(() => document.querySelectorAll(this.chromeSectionSelector)[0].querySelector('input')) + ); + console.log('DEEP chromeSectionElement', chromeSectionElement); + + // First wait for the element to become unchecked (due to slow disselection) + await withExponentialBackoff(() => !chromeSectionElement?.checked); + + chromeSectionElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); + + chromeSectionElement?.click(); + } + urlChanged() { + console.log('DEEP DEBUG autofill-password-import: urlChanged', window.location); this.handleLocation(window.location); } diff --git a/injected/src/utils.js b/injected/src/utils.js index 29b0365f5d..64c8703f9c 100644 --- a/injected/src/utils.js +++ b/injected/src/utils.js @@ -803,22 +803,22 @@ export function legacySendMessage(messageType, options) { } /** - * Takes a function that returns an element and tries to find it with exponential backoff. + * Takes a function that returns an element and tries to execute it until it returns a valid result or the max attempts are reached. * @param {number} delay * @param {number} [maxAttempts=4] - The maximum number of attempts to find the element. * @param {number} [delay=500] - The initial delay to be used to create the exponential backoff. - * @returns {Promise} + * @returns {Promise} */ export function withExponentialBackoff(fn, maxAttempts = 4, delay = 500) { return new Promise((resolve, reject) => { let attempts = 0; const tryFn = () => { attempts += 1; - const error = new Error('Element not found'); + const error = new Error('Result is invalid'); try { - const element = fn(); - if (element) { - resolve(element); + const result = fn(); + if (result) { + resolve(result); } else if (attempts < maxAttempts) { setTimeout(tryFn, delay * Math.pow(2, attempts)); } else { From 86cf6c21c5be278654be129835dde4c780972d09 Mon Sep 17 00:00:00 2001 From: Deepankar Bajpeyi Date: Fri, 5 Sep 2025 18:05:36 +0200 Subject: [PATCH 03/16] wip: complete flow --- .../src/features/autofill-password-import.js | 58 +++++++++++++++---- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/injected/src/features/autofill-password-import.js b/injected/src/features/autofill-password-import.js index f7f78aa145..b4648948a1 100644 --- a/injected/src/features/autofill-password-import.js +++ b/injected/src/features/autofill-password-import.js @@ -8,6 +8,7 @@ export const BACKGROUND_COLOR_END = 'rgba(85, 127, 243, 0.25)'; export const OVERLAY_ID = 'ddg-password-import-overlay'; export const DELAY_BEFORE_ANIMATION = 300; const BOOKMARK_IMPORT_DOMAIN = 'takeout.google.com'; +const TAKEOUT_DOWNLOAD_URL_BASE = '/takeout/download'; /** * @typedef ButtonAnimationStyle @@ -52,7 +53,7 @@ export default class AutofillPasswordImport extends ContentFeature { #domLoaded; - #currentLocation; + #exportId; #isBookmarkModalVisible = false; #isBookmarkProcessed = false; @@ -427,16 +428,30 @@ export default class AutofillPasswordImport extends ContentFeature { } } + async downloadData() { + const userId = document.querySelector('a[href*="&user="]')?.getAttribute('href')?.split('&user=')[1]; + console.log('DEEP DEBUG autofill-password-import: userId', userId); + await withExponentialBackoff(() => document.querySelector(`a[href="./manage/archive/${this.#exportId}"]`), 8); + const downloadURL = `${TAKEOUT_DOWNLOAD_URL_BASE}?j=${this.#exportId}&i=0&user=${userId}`; + window.location.href = downloadURL; + } + async handleBookmarkImportPath(pathname) { console.log('DEEP DEBUG autofill-password-import: handleBookmarkImportPath', pathname); if (pathname === '/' && !this.#isBookmarkModalVisible) { await this.clickDisselectAllButton(); await this.selectBookmark(); + this.startExportProcess(); + await this.storeExportId(); + const manageButton = /** @type HTMLAnchorElement */ (document.querySelector('a[href="manage"]')); + manageButton?.click(); + await this.downloadData(); } } /** * @param {Location} location + * */ async handleLocation(location) { const { pathname, hostname } = location; @@ -541,15 +556,42 @@ export default class AutofillPasswordImport extends ContentFeature { async findDisselectAllButton() { return await withExponentialBackoff(() => document.querySelectorAll(this.disselectAllButtonSelector)[1]); } + + async findExportId() { + const panels = document.querySelectorAll('div[role="tabpanel"]'); + const exportPanel = panels[panels.length - 1]; + return await withExponentialBackoff(() => exportPanel.querySelector('div[data-archive-id]')?.getAttribute('data-archive-id')); + } + + async storeExportId() { + this.#exportId = await this.findExportId(); + console.log('DEEP DEBUG autofill-password-import: stored export id', this.#exportId); + } + + startExportProcess() { + const nextStepButton = /** @type HTMLButtonElement */ (document.querySelectorAll(this.nextStepButtonSelector)[0]); + nextStepButton?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); + nextStepButton?.click(); + + const createExportButton = /** @type HTMLButtonElement */ (document.querySelectorAll(this.createExportButtonSelector)[0]); + createExportButton?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); + createExportButton?.click(); + } + async selectBookmark() { if (this.#isBookmarkProcessed) { return; } const chromeDataButtonSelector = `${this.chromeSectionSelector} button`; const chromeDataButton = /** @type HTMLButtonElement */ ( - await withExponentialBackoff(() => document.querySelectorAll(chromeDataButtonSelector)[1], 5) + await withExponentialBackoff(() => { + const button = /** @type HTMLButtonElement */ (document.querySelectorAll(chromeDataButtonSelector)[1]); + if (button.checkVisibility()) { + return button; + } + return null; + }) ); - chromeDataButton?.focus(); chromeDataButton?.click(); this.#isBookmarkModalVisible = true; await this.domLoaded; @@ -570,18 +612,10 @@ export default class AutofillPasswordImport extends ContentFeature { const okButton = /** @type HTMLButtonElement */ (document.querySelectorAll('div[role="button"]')[7]); await withExponentialBackoff(() => okButton.ariaDisabled !== 'true'); - + okButton?.click(); this.#isBookmarkModalVisible = false; this.#isBookmarkProcessed = true; - - const nextStepButton = /** @type HTMLButtonElement */ (document.querySelectorAll(this.nextStepButtonSelector)[0]); - nextStepButton?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); - nextStepButton?.click(); - - const createExportButton = /** @type HTMLButtonElement */ (document.querySelectorAll(this.createExportButtonSelector)[0]); - createExportButton?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); - createExportButton?.click(); } async clickDisselectAllButton() { From d28b97e800164edbb4b2cbab3e8f5a9f0cc917f4 Mon Sep 17 00:00:00 2001 From: Deepankar Bajpeyi Date: Wed, 17 Sep 2025 18:16:12 +0000 Subject: [PATCH 04/16] [PoC] try DBP click for bookmark import (#1965) * Use DBP for running execution --- CODEOWNERS | 2 +- ...import.spec.js => autofill-import.spec.js} | 10 +- .../page-objects/results-collector.js | 6 +- injected/integration-test/type-helpers.mjs | 13 +- injected/playwright.config.js | 6 +- injected/scripts/entry-points.js | 4 +- injected/src/features.js | 4 +- ...-password-import.js => autofill-import.js} | 187 ++++++------------ injected/src/features/broker-protection.js | 82 ++++---- .../broker-protection/actions/actions.js | 1 + .../broker-protection/actions/scroll.js | 15 ++ .../src/features/broker-protection/execute.js | 5 +- .../src/features/broker-protection/types.js | 3 +- injected/src/globals.d.ts | 2 +- injected/src/utils.js | 11 +- 15 files changed, 148 insertions(+), 203 deletions(-) rename injected/integration-test/{autofill-password-import.spec.js => autofill-import.spec.js} (81%) rename injected/src/features/{autofill-password-import.js => autofill-import.js} (76%) create mode 100644 injected/src/features/broker-protection/actions/scroll.js diff --git a/CODEOWNERS b/CODEOWNERS index a290d974ae..0c712a2f26 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -10,7 +10,7 @@ injected/src/element-hiding.js @duckduckgo/content-scope-scripts-owners @jonatha injected/src/features/click-to-load.js @duckduckgo/content-scope-scripts-owners @kzar @ladamski @franfaccin @jonathanKingston @shakyShane injected/src/features/click-to-load/ @duckduckgo/content-scope-scripts-owners @kzar @ladamski @franfaccin @jonathanKingston @shakyShane injected/src/locales/click-to-load/ @duckduckgo/content-scope-scripts-owners @kzar @ladamski @franfaccin @jonathanKingston @shakyShane -injected/src/features/autofill-password-import.js @duckduckgo/content-scope-scripts-owners @dbajpeyi +injected/src/features/autofill-import.js @duckduckgo/content-scope-scripts-owners @dbajpeyi # Broker protection injected/src/features/broker-protection.js @duckduckgo/content-scope-scripts-owners @duckduckgo/injected-broker-protection diff --git a/injected/integration-test/autofill-password-import.spec.js b/injected/integration-test/autofill-import.spec.js similarity index 81% rename from injected/integration-test/autofill-password-import.spec.js rename to injected/integration-test/autofill-import.spec.js index 198d64aa18..8d8538c4f7 100644 --- a/injected/integration-test/autofill-password-import.spec.js +++ b/injected/integration-test/autofill-import.spec.js @@ -1,15 +1,15 @@ import { test, expect } from '@playwright/test'; -import { OVERLAY_ID } from '../src/features/autofill-password-import'; +import { OVERLAY_ID } from '../src/features/autofill-import'; import { ResultsCollector } from './page-objects/results-collector.js'; -const HTML = '/autofill-password-import/index.html'; -const CONFIG = './integration-test/test-pages/autofill-password-import/config/config.json'; +const HTML = '/autofill-import/index.html'; +const CONFIG = './integration-test/test-pages/autofill-import/config/config.json'; test('Password import feature', async ({ page }, testInfo) => { const collector = ResultsCollector.create(page, testInfo.project.use); await collector.load(HTML, CONFIG); - const passwordImportFeature = new AutofillPasswordImportSpec(page); + const passwordImportFeature = new AutofillImportSpec(page); await passwordImportFeature.clickOnElement('Home page'); await passwordImportFeature.waitForAnimation(); @@ -25,7 +25,7 @@ test('Password import feature', async ({ page }, testInfo) => { await expect(overlay).not.toBeVisible(); }); -class AutofillPasswordImportSpec { +class AutofillImportSpec { /** * @param {import("@playwright/test").Page} page */ diff --git a/injected/integration-test/page-objects/results-collector.js b/injected/integration-test/page-objects/results-collector.js index 5df44c70d2..debf17638b 100644 --- a/injected/integration-test/page-objects/results-collector.js +++ b/injected/integration-test/page-objects/results-collector.js @@ -163,7 +163,7 @@ export class ResultsCollector { android: async () => { // noop }, - 'android-autofill-password-import': async () => { + 'android-autofill-import': async () => { // noop }, }); @@ -173,7 +173,7 @@ export class ResultsCollector { 'apple-isolated': () => mockWebkitMessaging, windows: () => mockWindowsMessaging, android: () => mockAndroidMessaging, - 'android-autofill-password-import': () => mockAndroidMessaging, + 'android-autofill-import': () => mockAndroidMessaging, }); await this.page.addInitScript(messagingMock, { @@ -187,7 +187,7 @@ export class ResultsCollector { 'apple-isolated': () => wrapWebkitScripts, apple: () => wrapWebkitScripts, android: () => wrapWebkitScripts, - 'android-autofill-password-import': () => wrapWebkitScripts, + 'android-autofill-import': () => wrapWebkitScripts, windows: () => wrapWindowsScripts, }); diff --git a/injected/integration-test/type-helpers.mjs b/injected/integration-test/type-helpers.mjs index 5b348a85d4..b07842141d 100644 --- a/injected/integration-test/type-helpers.mjs +++ b/injected/integration-test/type-helpers.mjs @@ -61,7 +61,7 @@ export class Build { android: () => '../build/android/contentScope.js', apple: () => '../build/apple/contentScope.js', 'apple-isolated': () => '../build/apple/contentScopeIsolated.js', - 'android-autofill-password-import': () => '../build/android/autofillPasswordImport.js', + 'android-autofill-import': () => '../build/android/autofillImport.js', 'android-broker-protection': () => '../build/android/brokerProtection.js', }); return readFileSync(path, 'utf8'); @@ -73,16 +73,7 @@ export class Build { */ static supported(name) { /** @type {ImportMeta['injectName'][]} */ - const items = [ - 'apple', - 'apple-isolated', - 'windows', - 'integration', - 'android', - 'android-autofill-password-import', - 'chrome-mv3', - 'firefox', - ]; + const items = ['apple', 'apple-isolated', 'windows', 'integration', 'android', 'android-autofill-import', 'chrome-mv3', 'firefox']; if (items.includes(name)) { return name; } diff --git a/injected/playwright.config.js b/injected/playwright.config.js index 9998380b86..9ff3a70c0c 100644 --- a/injected/playwright.config.js +++ b/injected/playwright.config.js @@ -55,9 +55,9 @@ export default defineConfig({ use: { injectName: 'android', platform: 'android', ...devices['Galaxy S5'] }, }, { - name: 'android-autofill-password-import', - testMatch: ['integration-test/autofill-password-import.spec.js'], - use: { injectName: 'android-autofill-password-import', platform: 'android', ...devices['Galaxy S5'] }, + name: 'android-autofill-import', + testMatch: ['integration-test/autofill-import.spec.js'], + use: { injectName: 'android-autofill-import', platform: 'android', ...devices['Galaxy S5'] }, }, { name: 'chrome-mv3', diff --git a/injected/scripts/entry-points.js b/injected/scripts/entry-points.js index 651d81f256..a31073322a 100644 --- a/injected/scripts/entry-points.js +++ b/injected/scripts/entry-points.js @@ -31,9 +31,9 @@ const builds = { input: 'entry-points/android.js', output: ['../build/android/brokerProtection.js'], }, - 'android-autofill-password-import': { + 'android-autofill-import': { input: 'entry-points/android.js', - output: ['../build/android/autofillPasswordImport.js'], + output: ['../build/android/autofillImport.js'], }, 'android-adsjs': { input: 'entry-points/android-adsjs.js', diff --git a/injected/src/features.js b/injected/src/features.js index 552c61b1d9..b6192a7662 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -28,7 +28,7 @@ const otherFeatures = /** @type {const} */ ([ 'brokerProtection', 'performanceMetrics', 'breakageReporting', - 'autofillPasswordImport', + 'autofillImport', 'favicon', 'webTelemetry', 'pageContext', @@ -50,7 +50,7 @@ export const platformSupport = { ], android: [...baseFeatures, 'webCompat', 'breakageReporting', 'duckPlayer', 'messageBridge'], 'android-broker-protection': ['brokerProtection'], - 'android-autofill-password-import': ['autofillPasswordImport'], + 'android-autofill-import': ['autofillImport'], 'android-adsjs': [ 'apiManipulation', 'webCompat', diff --git a/injected/src/features/autofill-password-import.js b/injected/src/features/autofill-import.js similarity index 76% rename from injected/src/features/autofill-password-import.js rename to injected/src/features/autofill-import.js index b4648948a1..4e815bae91 100644 --- a/injected/src/features/autofill-password-import.js +++ b/injected/src/features/autofill-import.js @@ -1,5 +1,6 @@ import ContentFeature from '../content-feature'; -import { isBeingFramed, withExponentialBackoff } from '../utils'; +import { isBeingFramed, withRetry } from '../utils'; +import { ActionExecutorMixin } from './broker-protection'; export const ANIMATION_DURATION_MS = 1000; export const ANIMATION_ITERATIONS = Infinity; @@ -7,7 +8,6 @@ export const BACKGROUND_COLOR_START = 'rgba(85, 127, 243, 0.10)'; export const BACKGROUND_COLOR_END = 'rgba(85, 127, 243, 0.25)'; export const OVERLAY_ID = 'ddg-password-import-overlay'; export const DELAY_BEFORE_ANIMATION = 300; -const BOOKMARK_IMPORT_DOMAIN = 'takeout.google.com'; const TAKEOUT_DOWNLOAD_URL_BASE = '/takeout/download'; /** @@ -35,7 +35,7 @@ const TAKEOUT_DOWNLOAD_URL_BASE = '/takeout/download'; * 2. Find the element to animate based on the path - using structural selectors first and then fallback to label texts), * 3. Animate the element, or tap it if it should be autotapped. */ -export default class AutofillPasswordImport extends ContentFeature { +export default class AutofillImport extends ActionExecutorMixin(ContentFeature) { #exportButtonSettings; #settingsButtonSettings; @@ -55,8 +55,9 @@ export default class AutofillPasswordImport extends ContentFeature { #exportId; + #processingBookmark; + #isBookmarkModalVisible = false; - #isBookmarkProcessed = false; /** @type {WeakSet} */ #tappedElements = new WeakSet(); @@ -137,6 +138,14 @@ export default class AutofillPasswordImport extends ContentFeature { return this.#domLoaded; } + async runWithRetry(fn, maxAttempts = 4, delay = 500, strategy = 'exponential') { + try { + return await withRetry(fn, maxAttempts, delay, strategy); + } catch (error) { + return null; + } + } + /** * Takes a path and returns the element and style to animate. * @param {string} path @@ -361,7 +370,7 @@ export default class AutofillPasswordImport extends ContentFeature { return document.querySelector(this.exportButtonLabelTextSelector); }; - return await withExponentialBackoff(() => findInContainer() ?? findWithLabel()); + return await withRetry(() => findInContainer() ?? findWithLabel()); } /** @@ -372,14 +381,14 @@ export default class AutofillPasswordImport extends ContentFeature { const settingsButton = document.querySelector(this.settingsButtonSelector); return settingsButton; }; - return await withExponentialBackoff(fn); + return await withRetry(fn); } /** * @returns {Promise} */ async findSignInButton() { - return await withExponentialBackoff(() => document.querySelector(this.signinButtonSelector)); + return await withRetry(() => document.querySelector(this.signinButtonSelector)); } /** @@ -411,7 +420,6 @@ export default class AutofillPasswordImport extends ContentFeature { } async handlePasswordManagerPath(pathname) { - console.log('DEEP DEBUG autofill-password-import: handlePasswordManagerPath', pathname); this.removeOverlayIfNeeded(); if (this.isSupportedPath(pathname)) { try { @@ -428,37 +436,22 @@ export default class AutofillPasswordImport extends ContentFeature { } } - async downloadData() { - const userId = document.querySelector('a[href*="&user="]')?.getAttribute('href')?.split('&user=')[1]; - console.log('DEEP DEBUG autofill-password-import: userId', userId); - await withExponentialBackoff(() => document.querySelector(`a[href="./manage/archive/${this.#exportId}"]`), 8); - const downloadURL = `${TAKEOUT_DOWNLOAD_URL_BASE}?j=${this.#exportId}&i=0&user=${userId}`; - window.location.href = downloadURL; - } - - async handleBookmarkImportPath(pathname) { - console.log('DEEP DEBUG autofill-password-import: handleBookmarkImportPath', pathname); - if (pathname === '/' && !this.#isBookmarkModalVisible) { - await this.clickDisselectAllButton(); - await this.selectBookmark(); - this.startExportProcess(); - await this.storeExportId(); - const manageButton = /** @type HTMLAnchorElement */ (document.querySelector('a[href="manage"]')); - manageButton?.click(); - await this.downloadData(); - } - } - /** * @param {Location} location * */ async handleLocation(location) { - const { pathname, hostname } = location; - if (hostname === BOOKMARK_IMPORT_DOMAIN) { + const { pathname } = location; + if (this.getFeatureSetting('actions')[0].length > 0) { + if (this.#processingBookmark) { + return; + } + this.#processingBookmark = true; this.handleBookmarkImportPath(pathname); - } else { + } else if (this.getFeatureSetting('settingsButton')) { await this.handlePasswordManagerPath(pathname); + } else { + // Unknown feature, we bail out } } @@ -526,130 +519,62 @@ export default class AutofillPasswordImport extends ContentFeature { return `${this.#settingsButtonSettings?.selectors?.join(',')}, ${this.settingsLabelTextSelector}`; } - setButtonSettings() { - this.#exportButtonSettings = this.getFeatureSetting('exportButton'); - this.#signInButtonSettings = this.getFeatureSetting('signInButton'); - this.#settingsButtonSettings = this.getFeatureSetting('settingsButton'); - } - /** Bookmark import code */ - get disselectAllButtonSelector() { - return 'c-wiz[data-node-index="4;0"] button'; - } - - get bookmarkSelectButtonSelector() { - return 'fieldset.rcetic input'; + downloadData() { + const userId = document.querySelector(this.getFeatureSetting('selectors').userIdLink)?.getAttribute('href')?.split('&user=')[1]; + const downloadURL = `${TAKEOUT_DOWNLOAD_URL_BASE}?j=${this.#exportId}&i=0&user=${userId}`; + window.location.href = downloadURL; } - get chromeSectionSelector() { - return 'c-wiz [data-id="chrome"]'; + get retryConfig() { + return { + interval: { ms: 1000 }, + maxAttempts: 30, + }; } - get nextStepButtonSelector() { - return 'div[data-jobid] > div:nth-of-type(2) button'; - } + async handleBookmarkImportPath(pathname) { + if (pathname === '/' && !this.#isBookmarkModalVisible) { + for (const action of this.getFeatureSetting('actions')[0]) { + // Before clicking on the manage button, we need to store the export id + if (action.id === 'manage-button-click') { + await this.storeExportId(); + } - get createExportButtonSelector() { - return 'div[data-configure-step] button'; + await this.processActionAndNotify(action, {}, this.messaging, this.retryConfig); + } + this.downloadData(); + } } - async findDisselectAllButton() { - return await withExponentialBackoff(() => document.querySelectorAll(this.disselectAllButtonSelector)[1]); + setPasswordImportSettings() { + this.#exportButtonSettings = this.getFeatureSetting('exportButton'); + this.#signInButtonSettings = this.getFeatureSetting('signInButton'); + this.#settingsButtonSettings = this.getFeatureSetting('settingsButton'); } - async findExportId() { - const panels = document.querySelectorAll('div[role="tabpanel"]'); + findExportId() { + const panels = document.querySelectorAll(this.getFeatureSetting('selectors').tabPanel); const exportPanel = panels[panels.length - 1]; - return await withExponentialBackoff(() => exportPanel.querySelector('div[data-archive-id]')?.getAttribute('data-archive-id')); + return exportPanel.querySelector('div[data-archive-id]')?.getAttribute('data-archive-id'); } async storeExportId() { - this.#exportId = await this.findExportId(); - console.log('DEEP DEBUG autofill-password-import: stored export id', this.#exportId); - } - - startExportProcess() { - const nextStepButton = /** @type HTMLButtonElement */ (document.querySelectorAll(this.nextStepButtonSelector)[0]); - nextStepButton?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); - nextStepButton?.click(); - - const createExportButton = /** @type HTMLButtonElement */ (document.querySelectorAll(this.createExportButtonSelector)[0]); - createExportButton?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); - createExportButton?.click(); - } - - async selectBookmark() { - if (this.#isBookmarkProcessed) { - return; - } - const chromeDataButtonSelector = `${this.chromeSectionSelector} button`; - const chromeDataButton = /** @type HTMLButtonElement */ ( - await withExponentialBackoff(() => { - const button = /** @type HTMLButtonElement */ (document.querySelectorAll(chromeDataButtonSelector)[1]); - if (button.checkVisibility()) { - return button; - } - return null; - }) - ); - chromeDataButton?.click(); - this.#isBookmarkModalVisible = true; - await this.domLoaded; - const disselectAllButton = /** @type HTMLButtonElement */ ( - await withExponentialBackoff(() => document.querySelectorAll('fieldset.rcetic button')[1]) - ); - - disselectAllButton?.click(); - - const bookmarkSelectButton = /** @type HTMLInputElement */ ( - await withExponentialBackoff(() => document.querySelectorAll(this.bookmarkSelectButtonSelector)[1]) - ); - - await withExponentialBackoff(() => !bookmarkSelectButton?.checked); - - bookmarkSelectButton?.click(); - - const okButton = /** @type HTMLButtonElement */ (document.querySelectorAll('div[role="button"]')[7]); - - await withExponentialBackoff(() => okButton.ariaDisabled !== 'true'); - - okButton?.click(); - this.#isBookmarkModalVisible = false; - this.#isBookmarkProcessed = true; - } - - async clickDisselectAllButton() { - const element = /** @type HTMLButtonElement */ (await this.findDisselectAllButton()); - console.log('Deep element', element); - if (element != null) { - element.click(); - } - - const chromeSectionElement = /** @type HTMLInputElement */ ( - await withExponentialBackoff(() => document.querySelectorAll(this.chromeSectionSelector)[0].querySelector('input')) - ); - console.log('DEEP chromeSectionElement', chromeSectionElement); - - // First wait for the element to become unchecked (due to slow disselection) - await withExponentialBackoff(() => !chromeSectionElement?.checked); - - chromeSectionElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); - - chromeSectionElement?.click(); + this.#exportId = await this.runWithRetry(() => this.findExportId(), 30, 1000, 'linear'); } urlChanged() { - console.log('DEEP DEBUG autofill-password-import: urlChanged', window.location); this.handleLocation(window.location); } init() { - console.log('DEEP DEBUG autofill-password-import: init'); if (isBeingFramed()) { return; } - this.setButtonSettings(); + if (this.getFeatureSetting('settingsButton')) { + this.setPasswordImportSettings(); + } const handleLocation = this.handleLocation.bind(this); this.#domLoaded = new Promise((resolve) => { diff --git a/injected/src/features/broker-protection.js b/injected/src/features/broker-protection.js index 58fe55aa74..3f1a027c4c 100644 --- a/injected/src/features/broker-protection.js +++ b/injected/src/features/broker-protection.js @@ -3,21 +3,15 @@ import { execute } from './broker-protection/execute.js'; import { retry } from '../timer-utils.js'; import { ErrorResponse } from './broker-protection/types.js'; -/** - * @typedef {import("./broker-protection/types.js").ActionResponse} ActionResponse - */ -export default class BrokerProtection extends ContentFeature { - init() { - this.messaging.subscribe('onActionReceived', async (/** @type {any} */ params) => { +export function ActionExecutorMixin(BaseClass) { + return class Mixin extends BaseClass { + async processActionAndNotify(action, data, messaging, retryConfig) { try { - const action = params.state.action; - const data = params.state.data; - if (!action) { - return this.messaging.notify('actionError', { error: 'No action found.' }); + return messaging.notify('actionError', { error: 'No action found.' }); } - const { results, exceptions } = await this.exec(action, data); + const { results, exceptions } = await this.exec(action, data, retryConfig); if (results) { // there might only be a single result. @@ -26,7 +20,7 @@ export default class BrokerProtection extends ContentFeature { // if there are no secondary actions, or just no errors in general, just report the parent action if (results.length === 1 || errors.length === 0) { - return this.messaging.notify('actionCompleted', { result: parent }); + return messaging.notify('actionCompleted', { result: parent }); } // here we must have secondary actions that failed. @@ -38,44 +32,56 @@ export default class BrokerProtection extends ContentFeature { message: 'Secondary actions failed: ' + joinedErrors, }); - return this.messaging.notify('actionCompleted', { result: response }); + return messaging.notify('actionCompleted', { result: response }); } else { - return this.messaging.notify('actionError', { error: 'No response found, exceptions: ' + exceptions.join(', ') }); + return messaging.notify('actionError', { error: 'No response found, exceptions: ' + exceptions.join(', ') }); } } catch (e) { console.log('unhandled exception: ', e); - this.messaging.notify('actionError', { error: e.toString() }); + return messaging.notify('actionError', { error: e.toString() }); } - }); - } + } - /** - * Recursively execute actions with the same dataset, collecting all results/exceptions for - * later analysis - * @param {any} action - * @param {Record} data - * @return {Promise<{results: ActionResponse[], exceptions: string[]}>} - */ - async exec(action, data) { - const retryConfig = this.retryConfigFor(action); - const { result, exceptions } = await retry(() => execute(action, data, document), retryConfig); + /** + * Recursively execute actions with the same dataset, collecting all results/exceptions for + * later analysis + * @param {any} action + * @param {Record} data + * @param {any} retryConfig + * @return {Promise<{results: ActionResponse[], exceptions: string[]}>} + */ + async exec(action, data, retryConfig) { + const { result, exceptions } = await retry(() => execute(action, data, document), retryConfig); - if (result) { - if ('success' in result && Array.isArray(result.success.next)) { - const nextResults = []; - const nextExceptions = []; + if (result) { + if ('success' in result && Array.isArray(result.success.next)) { + const nextResults = []; + const nextExceptions = []; - for (const nextAction of result.success.next) { - const { results: subResults, exceptions: subExceptions } = await this.exec(nextAction, data); + for (const nextAction of result.success.next) { + const { results: subResults, exceptions: subExceptions } = await this.exec(nextAction, data, retryConfig); - nextResults.push(...subResults); - nextExceptions.push(...subExceptions); + nextResults.push(...subResults); + nextExceptions.push(...subExceptions); + } + return { results: [result, ...nextResults], exceptions: exceptions.concat(nextExceptions) }; } - return { results: [result, ...nextResults], exceptions: exceptions.concat(nextExceptions) }; + return { results: [result], exceptions: [] }; } - return { results: [result], exceptions: [] }; + return { results: [], exceptions }; } - return { results: [], exceptions }; + }; +} + +/** + * @typedef {import("./broker-protection/types.js").ActionResponse} ActionResponse + */ +export default class BrokerProtection extends ActionExecutorMixin(ContentFeature) { + init() { + this.messaging.subscribe('onActionReceived', async (/** @type {any} */ params) => { + const { action, data } = params.state; + await this.processActionAndNotify(action, data, this.messaging, this.retryConfigFor(action)); + }); } /** diff --git a/injected/src/features/broker-protection/actions/actions.js b/injected/src/features/broker-protection/actions/actions.js index 579327dd91..9471631f37 100644 --- a/injected/src/features/broker-protection/actions/actions.js +++ b/injected/src/features/broker-protection/actions/actions.js @@ -5,3 +5,4 @@ export { expectation } from './expectation.js'; export { navigate } from './navigate.js'; export { getCaptchaInfo, solveCaptcha } from '../captcha-services/captcha.service.js'; export { condition } from './condition.js'; +export { scroll } from './scroll.js'; diff --git a/injected/src/features/broker-protection/actions/scroll.js b/injected/src/features/broker-protection/actions/scroll.js new file mode 100644 index 0000000000..f72182ceae --- /dev/null +++ b/injected/src/features/broker-protection/actions/scroll.js @@ -0,0 +1,15 @@ +import { ErrorResponse, SuccessResponse } from '../types'; +import { getElement } from '../utils/utils'; + +/** + * @param {Record} action + * @param {Document} root + * @return {import('../types.js').ActionResponse} + */ +// eslint-disable-next-line no-redeclare +export function scroll(action, root = document) { + const element = getElement(root, action.selector); + if (!element) return new ErrorResponse({ actionID: action.id, message: 'missing element' }); + element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); + return new SuccessResponse({ actionID: action.id, actionType: action.actionType, response: null }); +} diff --git a/injected/src/features/broker-protection/execute.js b/injected/src/features/broker-protection/execute.js index 78408d376a..784cac1ade 100644 --- a/injected/src/features/broker-protection/execute.js +++ b/injected/src/features/broker-protection/execute.js @@ -1,4 +1,5 @@ -import { navigate, extract, click, expectation, fillForm, getCaptchaInfo, solveCaptcha, condition } from './actions/actions'; +// eslint-disable-next-line no-redeclare +import { navigate, extract, click, scroll, expectation, fillForm, getCaptchaInfo, solveCaptcha, condition } from './actions/actions'; import { ErrorResponse } from './types'; /** @@ -26,6 +27,8 @@ export async function execute(action, inputData, root = document) { return solveCaptcha(action, data(action, inputData, 'token'), root); case 'condition': return condition(action, root); + case 'scroll': + return scroll(action, root); default: { return new ErrorResponse({ actionID: action.id, diff --git a/injected/src/features/broker-protection/types.js b/injected/src/features/broker-protection/types.js index 45a2793477..58c4cbe762 100644 --- a/injected/src/features/broker-protection/types.js +++ b/injected/src/features/broker-protection/types.js @@ -7,11 +7,12 @@ /** * @typedef {object} PirAction * @property {string} id - * @property {"extract" | "fillForm" | "click" | "expectation" | "getCaptchaInfo" | "solveCaptcha" | "navigate" | "condition"} actionType + * @property {"extract" | "fillForm" | "click" | "expectation" | "getCaptchaInfo" | "solveCaptcha" | "navigate" | "condition" | "scroll"} actionType * @property {string} [selector] * @property {string} [captchaType] * @property {string} [injectCaptchaHandler] * @property {string} [dataSource] + * @property {string} [url] */ /** diff --git a/injected/src/globals.d.ts b/injected/src/globals.d.ts index ff925ba034..58bbcbac5f 100644 --- a/injected/src/globals.d.ts +++ b/injected/src/globals.d.ts @@ -20,7 +20,7 @@ interface ImportMeta { | 'integration' | 'chrome-mv3' | 'android-broker-protection' - | 'android-autofill-password-import' + | 'android-autofill-import' | 'android-adsjs'; trackerLookup?: Record; pageName?: string; diff --git a/injected/src/utils.js b/injected/src/utils.js index 64c8703f9c..9bdb6eb031 100644 --- a/injected/src/utils.js +++ b/injected/src/utils.js @@ -806,10 +806,11 @@ export function legacySendMessage(messageType, options) { * Takes a function that returns an element and tries to execute it until it returns a valid result or the max attempts are reached. * @param {number} delay * @param {number} [maxAttempts=4] - The maximum number of attempts to find the element. - * @param {number} [delay=500] - The initial delay to be used to create the exponential backoff. + * @param {number} [delay=500] - The delay to be used for retries. + * @param {string} [strategy='exponential'] - The retry strategy: 'exponential' or 'linear'. * @returns {Promise} */ -export function withExponentialBackoff(fn, maxAttempts = 4, delay = 500) { +export function withRetry(fn, maxAttempts = 4, delay = 500, strategy = 'exponential') { return new Promise((resolve, reject) => { let attempts = 0; const tryFn = () => { @@ -820,13 +821,15 @@ export function withExponentialBackoff(fn, maxAttempts = 4, delay = 500) { if (result) { resolve(result); } else if (attempts < maxAttempts) { - setTimeout(tryFn, delay * Math.pow(2, attempts)); + const retryDelay = strategy === 'linear' ? delay : delay * Math.pow(2, attempts); + setTimeout(tryFn, retryDelay); } else { reject(error); } } catch { if (attempts < maxAttempts) { - setTimeout(tryFn, delay * Math.pow(2, attempts)); + const retryDelay = strategy === 'linear' ? delay : delay * Math.pow(2, attempts); + setTimeout(tryFn, retryDelay); } else { reject(error); } From 44424cd4682df2e53dfefa89d7ef615c05ff0946 Mon Sep 17 00:00:00 2001 From: Deepankar Bajpeyi Date: Wed, 17 Sep 2025 20:33:20 +0200 Subject: [PATCH 05/16] fix: use adsjs entrypoint for messaging --- injected/scripts/entry-points.js | 2 +- injected/src/features/autofill-import.js | 4 ++-- injected/src/features/broker-protection.js | 26 ++++++++++++++-------- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/injected/scripts/entry-points.js b/injected/scripts/entry-points.js index a31073322a..fa851ed401 100644 --- a/injected/scripts/entry-points.js +++ b/injected/scripts/entry-points.js @@ -32,7 +32,7 @@ const builds = { output: ['../build/android/brokerProtection.js'], }, 'android-autofill-import': { - input: 'entry-points/android.js', + input: 'entry-points/android-adsjs.js', output: ['../build/android/autofillImport.js'], }, 'android-adsjs': { diff --git a/injected/src/features/autofill-import.js b/injected/src/features/autofill-import.js index c242a1c29c..1535f50784 100644 --- a/injected/src/features/autofill-import.js +++ b/injected/src/features/autofill-import.js @@ -493,7 +493,7 @@ export default class AutofillImport extends ActionExecutorMixin(ContentFeature) return; } this.#processingBookmark = true; - this.handleBookmarkImportPath(pathname); + await this.handleBookmarkImportPath(pathname); } else if (this.getFeatureSetting('settingsButton')) { await this.handlePasswordManagerPath(pathname); } else { @@ -596,7 +596,7 @@ export default class AutofillImport extends ActionExecutorMixin(ContentFeature) await this.storeExportId(); } - await this.processActionAndNotify(action, {}, this.messaging, this.retryConfig); + await this.processActionAndNotify(action, {}, this.retryConfig); } this.downloadData(); } diff --git a/injected/src/features/broker-protection.js b/injected/src/features/broker-protection.js index 3f1a027c4c..0c92be8ac8 100644 --- a/injected/src/features/broker-protection.js +++ b/injected/src/features/broker-protection.js @@ -3,12 +3,20 @@ import { execute } from './broker-protection/execute.js'; import { retry } from '../timer-utils.js'; import { ErrorResponse } from './broker-protection/types.js'; -export function ActionExecutorMixin(BaseClass) { - return class Mixin extends BaseClass { - async processActionAndNotify(action, data, messaging, retryConfig) { +/** + * @param {typeof ContentFeature} ContentFeatureClass + */ +export function ActionExecutorMixin(ContentFeatureClass) { + return class Mixin extends ContentFeatureClass { + /** + * @param {any} action + * @param {Record} data + * @param {any} retryConfig + */ + async processActionAndNotify(action, data, retryConfig) { try { if (!action) { - return messaging.notify('actionError', { error: 'No action found.' }); + return this.messaging.notify('actionError', { error: 'No action found.' }); } const { results, exceptions } = await this.exec(action, data, retryConfig); @@ -20,7 +28,7 @@ export function ActionExecutorMixin(BaseClass) { // if there are no secondary actions, or just no errors in general, just report the parent action if (results.length === 1 || errors.length === 0) { - return messaging.notify('actionCompleted', { result: parent }); + return this.messaging.notify('actionCompleted', { result: parent }); } // here we must have secondary actions that failed. @@ -32,13 +40,13 @@ export function ActionExecutorMixin(BaseClass) { message: 'Secondary actions failed: ' + joinedErrors, }); - return messaging.notify('actionCompleted', { result: response }); + return this.messaging.notify('actionCompleted', { result: response }); } else { - return messaging.notify('actionError', { error: 'No response found, exceptions: ' + exceptions.join(', ') }); + return this.messaging.notify('actionError', { error: 'No response found, exceptions: ' + exceptions.join(', ') }); } } catch (e) { console.log('unhandled exception: ', e); - return messaging.notify('actionError', { error: e.toString() }); + return this.messaging.notify('actionError', { error: e.toString() }); } } @@ -80,7 +88,7 @@ export default class BrokerProtection extends ActionExecutorMixin(ContentFeature init() { this.messaging.subscribe('onActionReceived', async (/** @type {any} */ params) => { const { action, data } = params.state; - await this.processActionAndNotify(action, data, this.messaging, this.retryConfigFor(action)); + return await this.processActionAndNotify(action, data, this.retryConfigFor(action)); }); } From fbe404deb1a0eac65e23bb8d78b7a579dfbac25e Mon Sep 17 00:00:00 2001 From: Deepankar Bajpeyi Date: Thu, 18 Sep 2025 22:45:08 +0200 Subject: [PATCH 06/16] fix: sleep a second before downloading --- injected/src/features/autofill-import.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/injected/src/features/autofill-import.js b/injected/src/features/autofill-import.js index 1535f50784..c484dc5775 100644 --- a/injected/src/features/autofill-import.js +++ b/injected/src/features/autofill-import.js @@ -482,13 +482,18 @@ export default class AutofillImport extends ActionExecutorMixin(ContentFeature) } } + get dbpActions() { + return this.getFeatureSetting('actions')?.[0] ?? []; + } + /** * @param {Location} location * */ async handleLocation(location) { const { pathname } = location; - if (this.getFeatureSetting('actions')[0].length > 0) { + const dbpActions = this.dbpActions; + if (dbpActions.length > 0) { if (this.#processingBookmark) { return; } @@ -575,8 +580,12 @@ export default class AutofillImport extends ActionExecutorMixin(ContentFeature) } /** Bookmark import code */ - downloadData() { + async downloadData() { + // sleep for a second, sometimes download link is not yet available + await new Promise((resolve) => setTimeout(resolve, 1000)); + const userId = document.querySelector(this.getFeatureSetting('selectors').userIdLink)?.getAttribute('href')?.split('&user=')[1]; + await this.runWithRetry(() => document.querySelector(`a[href="./manage/archive/${this.#exportId}"]`), 15, 2000, 'linear'); const downloadURL = `${TAKEOUT_DOWNLOAD_URL_BASE}?j=${this.#exportId}&i=0&user=${userId}`; window.location.href = downloadURL; } @@ -590,7 +599,7 @@ export default class AutofillImport extends ActionExecutorMixin(ContentFeature) async handleBookmarkImportPath(pathname) { if (pathname === '/' && !this.#isBookmarkModalVisible) { - for (const action of this.getFeatureSetting('actions')[0]) { + for (const action of this.dbpActions) { // Before clicking on the manage button, we need to store the export id if (action.id === 'manage-button-click') { await this.storeExportId(); @@ -598,7 +607,7 @@ export default class AutofillImport extends ActionExecutorMixin(ContentFeature) await this.processActionAndNotify(action, {}, this.retryConfig); } - this.downloadData(); + await this.downloadData(); } } From 1987305b43aea145620f562f6d10ea59fc7c8784 Mon Sep 17 00:00:00 2001 From: Deepankar Bajpeyi Date: Mon, 22 Sep 2025 18:50:33 +0200 Subject: [PATCH 07/16] fix: add custom context for autofill import --- injected/entry-points/android-adsjs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/injected/entry-points/android-adsjs.js b/injected/entry-points/android-adsjs.js index 9a94a44578..0deb8813b6 100644 --- a/injected/entry-points/android-adsjs.js +++ b/injected/entry-points/android-adsjs.js @@ -22,7 +22,7 @@ async function sendInitialPingAndUpdate(messagingConfig, processedConfig) { try { // Create messaging context for the initial ping const messagingContext = new MessagingContext({ - context: 'contentScopeScripts', + context: import.meta.injectName === 'android-autofill-import' ? 'autofillImport' : 'contentScopeScripts', env: processedConfig.debug ? 'development' : 'production', featureName: 'messaging', }); From b9f4c185c1071a10b26bd2d93070ffdd615dabc2 Mon Sep 17 00:00:00 2001 From: Deepankar Bajpeyi Date: Tue, 23 Sep 2025 11:59:56 +0200 Subject: [PATCH 08/16] refactor: extend actionexecutor --- injected/src/features/autofill-import.js | 5 +- injected/src/features/broker-protection.js | 121 ++++++++++----------- messaging/lib/android-adsjs.js | 1 + 3 files changed, 61 insertions(+), 66 deletions(-) diff --git a/injected/src/features/autofill-import.js b/injected/src/features/autofill-import.js index c484dc5775..e13f43e7e3 100644 --- a/injected/src/features/autofill-import.js +++ b/injected/src/features/autofill-import.js @@ -1,6 +1,5 @@ -import ContentFeature from '../content-feature'; import { isBeingFramed, withRetry } from '../utils'; -import { ActionExecutorMixin } from './broker-protection'; +import { ActionExecutorBase } from './broker-protection'; export const ANIMATION_DURATION_MS = 1000; export const ANIMATION_ITERATIONS = Infinity; @@ -35,7 +34,7 @@ const TAKEOUT_DOWNLOAD_URL_BASE = '/takeout/download'; * 2. Find the element to animate based on the path - using structural selectors first and then fallback to label texts), * 3. Animate the element, or tap it if it should be autotapped. */ -export default class AutofillImport extends ActionExecutorMixin(ContentFeature) { +export default class AutofillImport extends ActionExecutorBase { #exportButtonSettings; #settingsButtonSettings; diff --git a/injected/src/features/broker-protection.js b/injected/src/features/broker-protection.js index 0c92be8ac8..e1418361d5 100644 --- a/injected/src/features/broker-protection.js +++ b/injected/src/features/broker-protection.js @@ -3,88 +3,83 @@ import { execute } from './broker-protection/execute.js'; import { retry } from '../timer-utils.js'; import { ErrorResponse } from './broker-protection/types.js'; -/** - * @param {typeof ContentFeature} ContentFeatureClass - */ -export function ActionExecutorMixin(ContentFeatureClass) { - return class Mixin extends ContentFeatureClass { - /** - * @param {any} action - * @param {Record} data - * @param {any} retryConfig - */ - async processActionAndNotify(action, data, retryConfig) { - try { - if (!action) { - return this.messaging.notify('actionError', { error: 'No action found.' }); - } +export class ActionExecutorBase extends ContentFeature { + /** + * @param {any} action + * @param {Record} data + * @param {any} retryConfig + */ + async processActionAndNotify(action, data, retryConfig) { + try { + if (!action) { + return this.messaging.notify('actionError', { error: 'No action found.' }); + } - const { results, exceptions } = await this.exec(action, data, retryConfig); + const { results, exceptions } = await this.exec(action, data, retryConfig); - if (results) { - // there might only be a single result. - const parent = results[0]; - const errors = results.filter((x) => 'error' in x); + if (results) { + // there might only be a single result. + const parent = results[0]; + const errors = results.filter((x) => 'error' in x); - // if there are no secondary actions, or just no errors in general, just report the parent action - if (results.length === 1 || errors.length === 0) { - return this.messaging.notify('actionCompleted', { result: parent }); - } + // if there are no secondary actions, or just no errors in general, just report the parent action + if (results.length === 1 || errors.length === 0) { + return this.messaging.notify('actionCompleted', { result: parent }); + } - // here we must have secondary actions that failed. - // so we want to create an error response with the parent ID, but with the errors messages from - // the children - const joinedErrors = errors.map((x) => x.error.message).join(', '); - const response = new ErrorResponse({ - actionID: action.id, - message: 'Secondary actions failed: ' + joinedErrors, - }); + // here we must have secondary actions that failed. + // so we want to create an error response with the parent ID, but with the errors messages from + // the children + const joinedErrors = errors.map((x) => x.error.message).join(', '); + const response = new ErrorResponse({ + actionID: action.id, + message: 'Secondary actions failed: ' + joinedErrors, + }); - return this.messaging.notify('actionCompleted', { result: response }); - } else { - return this.messaging.notify('actionError', { error: 'No response found, exceptions: ' + exceptions.join(', ') }); - } - } catch (e) { - console.log('unhandled exception: ', e); - return this.messaging.notify('actionError', { error: e.toString() }); + return this.messaging.notify('actionCompleted', { result: response }); + } else { + return this.messaging.notify('actionError', { error: 'No response found, exceptions: ' + exceptions.join(', ') }); } + } catch (e) { + console.log('unhandled exception: ', e); + return this.messaging.notify('actionError', { error: e.toString() }); } + } - /** - * Recursively execute actions with the same dataset, collecting all results/exceptions for - * later analysis - * @param {any} action - * @param {Record} data - * @param {any} retryConfig - * @return {Promise<{results: ActionResponse[], exceptions: string[]}>} - */ - async exec(action, data, retryConfig) { - const { result, exceptions } = await retry(() => execute(action, data, document), retryConfig); + /** + * Recursively execute actions with the same dataset, collecting all results/exceptions for + * later analysis + * @param {any} action + * @param {Record} data + * @param {any} retryConfig + * @return {Promise<{results: ActionResponse[], exceptions: string[]}>} + */ + async exec(action, data, retryConfig) { + const { result, exceptions } = await retry(() => execute(action, data, document), retryConfig); - if (result) { - if ('success' in result && Array.isArray(result.success.next)) { - const nextResults = []; - const nextExceptions = []; + if (result) { + if ('success' in result && Array.isArray(result.success.next)) { + const nextResults = []; + const nextExceptions = []; - for (const nextAction of result.success.next) { - const { results: subResults, exceptions: subExceptions } = await this.exec(nextAction, data, retryConfig); + for (const nextAction of result.success.next) { + const { results: subResults, exceptions: subExceptions } = await this.exec(nextAction, data, retryConfig); - nextResults.push(...subResults); - nextExceptions.push(...subExceptions); - } - return { results: [result, ...nextResults], exceptions: exceptions.concat(nextExceptions) }; + nextResults.push(...subResults); + nextExceptions.push(...subExceptions); } - return { results: [result], exceptions: [] }; + return { results: [result, ...nextResults], exceptions: exceptions.concat(nextExceptions) }; } - return { results: [], exceptions }; + return { results: [result], exceptions: [] }; } - }; + return { results: [], exceptions }; + } } /** * @typedef {import("./broker-protection/types.js").ActionResponse} ActionResponse */ -export default class BrokerProtection extends ActionExecutorMixin(ContentFeature) { +export default class BrokerProtection extends ActionExecutorBase { init() { this.messaging.subscribe('onActionReceived', async (/** @type {any} */ params) => { const { action, data } = params.state; diff --git a/messaging/lib/android-adsjs.js b/messaging/lib/android-adsjs.js index 1bab6d131c..cdca617816 100644 --- a/messaging/lib/android-adsjs.js +++ b/messaging/lib/android-adsjs.js @@ -189,6 +189,7 @@ export class AndroidAdsjsMessagingConfig { * @internal */ sendMessageThrows(message) { + console.log('DEEP sendMessageThrows', message); if (!this.objectName) { throw new Error('Object name not set for WebMessageListener'); } From d87cf8ff5b0027cc8e04db54c5f492fd15626eb7 Mon Sep 17 00:00:00 2001 From: Deepankar Bajpeyi Date: Tue, 23 Sep 2025 13:01:17 +0200 Subject: [PATCH 09/16] fix: simplify config reading --- injected/src/features/autofill-import.js | 23 ++++++++++++++++------- messaging/lib/android-adsjs.js | 1 - 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/injected/src/features/autofill-import.js b/injected/src/features/autofill-import.js index e13f43e7e3..f00b9c4e8c 100644 --- a/injected/src/features/autofill-import.js +++ b/injected/src/features/autofill-import.js @@ -481,8 +481,18 @@ export default class AutofillImport extends ActionExecutorBase { } } - get dbpActions() { - return this.getFeatureSetting('actions')?.[0] ?? []; + /** + * @returns {Array>} + */ + get bookmarkImportActionSettings() { + return this.getFeatureSetting('actions'); + } + + /** + * @returns {Record} + */ + get bookmarkImportSelectorSettings() { + return this.getFeatureSetting('selectors'); } /** @@ -491,8 +501,7 @@ export default class AutofillImport extends ActionExecutorBase { */ async handleLocation(location) { const { pathname } = location; - const dbpActions = this.dbpActions; - if (dbpActions.length > 0) { + if (this.bookmarkImportActionSettings.length > 0) { if (this.#processingBookmark) { return; } @@ -583,7 +592,7 @@ export default class AutofillImport extends ActionExecutorBase { // sleep for a second, sometimes download link is not yet available await new Promise((resolve) => setTimeout(resolve, 1000)); - const userId = document.querySelector(this.getFeatureSetting('selectors').userIdLink)?.getAttribute('href')?.split('&user=')[1]; + const userId = document.querySelector(this.bookmarkImportSelectorSettings.userIdLink)?.getAttribute('href')?.split('&user=')[1]; await this.runWithRetry(() => document.querySelector(`a[href="./manage/archive/${this.#exportId}"]`), 15, 2000, 'linear'); const downloadURL = `${TAKEOUT_DOWNLOAD_URL_BASE}?j=${this.#exportId}&i=0&user=${userId}`; window.location.href = downloadURL; @@ -598,7 +607,7 @@ export default class AutofillImport extends ActionExecutorBase { async handleBookmarkImportPath(pathname) { if (pathname === '/' && !this.#isBookmarkModalVisible) { - for (const action of this.dbpActions) { + for (const action of this.bookmarkImportActionSettings) { // Before clicking on the manage button, we need to store the export id if (action.id === 'manage-button-click') { await this.storeExportId(); @@ -618,7 +627,7 @@ export default class AutofillImport extends ActionExecutorBase { } findExportId() { - const panels = document.querySelectorAll(this.getFeatureSetting('selectors').tabPanel); + const panels = document.querySelectorAll(this.bookmarkImportSelectorSettings.tabPanel); const exportPanel = panels[panels.length - 1]; return exportPanel.querySelector('div[data-archive-id]')?.getAttribute('data-archive-id'); } diff --git a/messaging/lib/android-adsjs.js b/messaging/lib/android-adsjs.js index cdca617816..1bab6d131c 100644 --- a/messaging/lib/android-adsjs.js +++ b/messaging/lib/android-adsjs.js @@ -189,7 +189,6 @@ export class AndroidAdsjsMessagingConfig { * @internal */ sendMessageThrows(message) { - console.log('DEEP sendMessageThrows', message); if (!this.objectName) { throw new Error('Object name not set for WebMessageListener'); } From 99004947d7e6be802fba8906e83e730ceb9423f6 Mon Sep 17 00:00:00 2001 From: Deepankar Bajpeyi Date: Tue, 23 Sep 2025 14:18:36 +0200 Subject: [PATCH 10/16] feat: patch .notify to send a simple payload --- injected/src/features/autofill-import.js | 27 +++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/injected/src/features/autofill-import.js b/injected/src/features/autofill-import.js index f00b9c4e8c..17c3dbf2d1 100644 --- a/injected/src/features/autofill-import.js +++ b/injected/src/features/autofill-import.js @@ -594,8 +594,13 @@ export default class AutofillImport extends ActionExecutorBase { const userId = document.querySelector(this.bookmarkImportSelectorSettings.userIdLink)?.getAttribute('href')?.split('&user=')[1]; await this.runWithRetry(() => document.querySelector(`a[href="./manage/archive/${this.#exportId}"]`), 15, 2000, 'linear'); - const downloadURL = `${TAKEOUT_DOWNLOAD_URL_BASE}?j=${this.#exportId}&i=0&user=${userId}`; - window.location.href = downloadURL; + if (userId != null && this.#exportId != null) { + const downloadURL = `${TAKEOUT_DOWNLOAD_URL_BASE}?j=${this.#exportId}&i=0&user=${userId}`; + window.location.href = downloadURL; + } else { + // If there's no user id or export id, we post an action failed message + this.postBookmarkImportMessage('actionFailed', { error: 'No user id or export id found' }); + } } get retryConfig() { @@ -605,6 +610,22 @@ export default class AutofillImport extends ActionExecutorBase { }; } + postBookmarkImportMessage(name, data) { + globalThis.ddgBookmarkImport?.postMessage( + JSON.stringify({ + name, + data, + }), + ); + } + + patchMessagingAndProcessAction(action) { + // Ideally we should be usuing standard messaging in Android, but we are not ready yet + // So just patching the notify method to post a message to the Android side + this.messaging.notify = this.postBookmarkImportMessage.bind(this); + return this.processActionAndNotify(action, {}, this.retryConfig); + } + async handleBookmarkImportPath(pathname) { if (pathname === '/' && !this.#isBookmarkModalVisible) { for (const action of this.bookmarkImportActionSettings) { @@ -613,7 +634,7 @@ export default class AutofillImport extends ActionExecutorBase { await this.storeExportId(); } - await this.processActionAndNotify(action, {}, this.retryConfig); + await this.patchMessagingAndProcessAction(action); } await this.downloadData(); } From c48131192c2dd035327ad781c80c28b98ccce765 Mon Sep 17 00:00:00 2001 From: Deepankar Bajpeyi Date: Wed, 24 Sep 2025 14:33:42 +0200 Subject: [PATCH 11/16] fix: use actionCompleted for errors --- injected/src/features/autofill-import.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/injected/src/features/autofill-import.js b/injected/src/features/autofill-import.js index 17c3dbf2d1..b607dc5e7c 100644 --- a/injected/src/features/autofill-import.js +++ b/injected/src/features/autofill-import.js @@ -1,5 +1,6 @@ import { isBeingFramed, withRetry } from '../utils'; import { ActionExecutorBase } from './broker-protection'; +import { ErrorResponse } from './broker-protection/types'; export const ANIMATION_DURATION_MS = 1000; export const ANIMATION_ITERATIONS = Infinity; @@ -599,7 +600,12 @@ export default class AutofillImport extends ActionExecutorBase { window.location.href = downloadURL; } else { // If there's no user id or export id, we post an action failed message - this.postBookmarkImportMessage('actionFailed', { error: 'No user id or export id found' }); + this.postBookmarkImportMessage('actionCompleted', { + result: new ErrorResponse({ + actionID: 'download-data', + message: 'No user id or export id found', + }), + }); } } From 2b4a69f2ebd8ea8d5155398e07ebccbcd0d23c83 Mon Sep 17 00:00:00 2001 From: Deepankar Bajpeyi Date: Thu, 25 Sep 2025 15:50:57 +0200 Subject: [PATCH 12/16] fix: check settings before execution --- injected/src/features/autofill-import.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/injected/src/features/autofill-import.js b/injected/src/features/autofill-import.js index b607dc5e7c..e3b52b774a 100644 --- a/injected/src/features/autofill-import.js +++ b/injected/src/features/autofill-import.js @@ -486,7 +486,7 @@ export default class AutofillImport extends ActionExecutorBase { * @returns {Array>} */ get bookmarkImportActionSettings() { - return this.getFeatureSetting('actions'); + return this.getFeatureSetting('actions') || []; } /** From 53cfcb618237800d44f5085947e71a78627e3bc4 Mon Sep 17 00:00:00 2001 From: Deepankar Bajpeyi Date: Fri, 26 Sep 2025 09:57:06 +0200 Subject: [PATCH 13/16] chore: don't need separate context for now --- injected/entry-points/android-adsjs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/injected/entry-points/android-adsjs.js b/injected/entry-points/android-adsjs.js index 0deb8813b6..9a94a44578 100644 --- a/injected/entry-points/android-adsjs.js +++ b/injected/entry-points/android-adsjs.js @@ -22,7 +22,7 @@ async function sendInitialPingAndUpdate(messagingConfig, processedConfig) { try { // Create messaging context for the initial ping const messagingContext = new MessagingContext({ - context: import.meta.injectName === 'android-autofill-import' ? 'autofillImport' : 'contentScopeScripts', + context: 'contentScopeScripts', env: processedConfig.debug ? 'development' : 'production', featureName: 'messaging', }); From 62156052429392e7b48ee783676628af8347f353 Mon Sep 17 00:00:00 2001 From: Deepankar Bajpeyi Date: Fri, 26 Sep 2025 17:42:31 +0200 Subject: [PATCH 14/16] fix: retryconfigfor must be implemented per class --- injected/src/features/autofill-import.js | 8 ++++++-- injected/src/features/broker-protection.js | 20 +++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/injected/src/features/autofill-import.js b/injected/src/features/autofill-import.js index e3b52b774a..eb0b202653 100644 --- a/injected/src/features/autofill-import.js +++ b/injected/src/features/autofill-import.js @@ -609,7 +609,11 @@ export default class AutofillImport extends ActionExecutorBase { } } - get retryConfig() { + /** + * Here we ignore the action and return a default retry config + * as for now the retry doesn't need to be per action. + */ + retryConfigFor(_) { return { interval: { ms: 1000 }, maxAttempts: 30, @@ -629,7 +633,7 @@ export default class AutofillImport extends ActionExecutorBase { // Ideally we should be usuing standard messaging in Android, but we are not ready yet // So just patching the notify method to post a message to the Android side this.messaging.notify = this.postBookmarkImportMessage.bind(this); - return this.processActionAndNotify(action, {}, this.retryConfig); + return this.processActionAndNotify(action, {}); } async handleBookmarkImportPath(pathname) { diff --git a/injected/src/features/broker-protection.js b/injected/src/features/broker-protection.js index e1418361d5..a473490b9f 100644 --- a/injected/src/features/broker-protection.js +++ b/injected/src/features/broker-protection.js @@ -7,15 +7,14 @@ export class ActionExecutorBase extends ContentFeature { /** * @param {any} action * @param {Record} data - * @param {any} retryConfig */ - async processActionAndNotify(action, data, retryConfig) { + async processActionAndNotify(action, data) { try { if (!action) { return this.messaging.notify('actionError', { error: 'No action found.' }); } - const { results, exceptions } = await this.exec(action, data, retryConfig); + const { results, exceptions } = await this.exec(action, data); if (results) { // there might only be a single result. @@ -51,10 +50,10 @@ export class ActionExecutorBase extends ContentFeature { * later analysis * @param {any} action * @param {Record} data - * @param {any} retryConfig * @return {Promise<{results: ActionResponse[], exceptions: string[]}>} */ - async exec(action, data, retryConfig) { + async exec(action, data) { + const retryConfig = this.retryConfigFor(action); const { result, exceptions } = await retry(() => execute(action, data, document), retryConfig); if (result) { @@ -63,7 +62,7 @@ export class ActionExecutorBase extends ContentFeature { const nextExceptions = []; for (const nextAction of result.success.next) { - const { results: subResults, exceptions: subExceptions } = await this.exec(nextAction, data, retryConfig); + const { results: subResults, exceptions: subExceptions } = await this.exec(nextAction, data); nextResults.push(...subResults); nextExceptions.push(...subExceptions); @@ -74,6 +73,13 @@ export class ActionExecutorBase extends ContentFeature { } return { results: [], exceptions }; } + + /** + * @returns {any} + */ + retryConfigFor(action) { + this.log.error('unimplemented method: retryConfigFor:', action); + } } /** @@ -83,7 +89,7 @@ export default class BrokerProtection extends ActionExecutorBase { init() { this.messaging.subscribe('onActionReceived', async (/** @type {any} */ params) => { const { action, data } = params.state; - return await this.processActionAndNotify(action, data, this.retryConfigFor(action)); + return await this.processActionAndNotify(action, data); }); } From 2742c20b2ba282f1071fcb0ab0cd783556e2952d Mon Sep 17 00:00:00 2001 From: Deepankar Bajpeyi Date: Mon, 29 Sep 2025 11:38:04 +0200 Subject: [PATCH 15/16] test: config path --- .../config/config.json | 0 .../{autofill-password-import => autofill-import}/index.html | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename injected/integration-test/test-pages/{autofill-password-import => autofill-import}/config/config.json (100%) rename injected/integration-test/test-pages/{autofill-password-import => autofill-import}/index.html (100%) diff --git a/injected/integration-test/test-pages/autofill-password-import/config/config.json b/injected/integration-test/test-pages/autofill-import/config/config.json similarity index 100% rename from injected/integration-test/test-pages/autofill-password-import/config/config.json rename to injected/integration-test/test-pages/autofill-import/config/config.json diff --git a/injected/integration-test/test-pages/autofill-password-import/index.html b/injected/integration-test/test-pages/autofill-import/index.html similarity index 100% rename from injected/integration-test/test-pages/autofill-password-import/index.html rename to injected/integration-test/test-pages/autofill-import/index.html From f6af93540f11483fefaa31f5654e32d520b1396e Mon Sep 17 00:00:00 2001 From: Deepankar Bajpeyi Date: Mon, 29 Sep 2025 11:38:04 +0200 Subject: [PATCH 16/16] test: config path --- .../config/config.json | 2 +- .../{autofill-password-import => autofill-import}/index.html | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename injected/integration-test/test-pages/{autofill-password-import => autofill-import}/config/config.json (98%) rename injected/integration-test/test-pages/{autofill-password-import => autofill-import}/index.html (100%) diff --git a/injected/integration-test/test-pages/autofill-password-import/config/config.json b/injected/integration-test/test-pages/autofill-import/config/config.json similarity index 98% rename from injected/integration-test/test-pages/autofill-password-import/config/config.json rename to injected/integration-test/test-pages/autofill-import/config/config.json index 4c969f1179..d0a251a59a 100644 --- a/injected/integration-test/test-pages/autofill-password-import/config/config.json +++ b/injected/integration-test/test-pages/autofill-import/config/config.json @@ -2,7 +2,7 @@ "readme": "This config is used to test the autofill password import feature.", "version": 1, "features": { - "autofillPasswordImport": { + "autofillImport": { "state": "enabled", "exceptions": [], "settings": { diff --git a/injected/integration-test/test-pages/autofill-password-import/index.html b/injected/integration-test/test-pages/autofill-import/index.html similarity index 100% rename from injected/integration-test/test-pages/autofill-password-import/index.html rename to injected/integration-test/test-pages/autofill-import/index.html