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/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 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..fa851ed401 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': { - input: 'entry-points/android.js', - output: ['../build/android/autofillPasswordImport.js'], + 'android-autofill-import': { + input: 'entry-points/android-adsjs.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 a2f821c64b..54c53fd949 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', @@ -49,7 +49,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 79% rename from injected/src/features/autofill-password-import.js rename to injected/src/features/autofill-import.js index c6c3bf3a1c..eb0b202653 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 { ActionExecutorBase } from './broker-protection'; +import { ErrorResponse } from './broker-protection/types'; export const ANIMATION_DURATION_MS = 1000; export const ANIMATION_ITERATIONS = Infinity; @@ -7,6 +8,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 TAKEOUT_DOWNLOAD_URL_BASE = '/takeout/download'; /** * @typedef ButtonAnimationStyle @@ -33,7 +35,7 @@ export const DELAY_BEFORE_ANIMATION = 300; * 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 ActionExecutorBase { #exportButtonSettings; #settingsButtonSettings; @@ -53,6 +55,12 @@ export default class AutofillPasswordImport extends ContentFeature { #domLoaded; + #exportId; + + #processingBookmark; + + #isBookmarkModalVisible = false; + /** @type {WeakSet} */ #tappedElements = new WeakSet(); @@ -135,10 +143,10 @@ export default class AutofillPasswordImport extends ContentFeature { /** * @returns {Promise} */ - async runWithRetry(fn) { + async runWithRetry(fn, maxAttempts = 4, delay = 500, strategy = 'exponential') { try { - return await withExponentialBackoff(fn); - } catch { + return await withRetry(fn, maxAttempts, delay, strategy); + } catch (error) { return null; } } @@ -457,11 +465,11 @@ export default class AutofillPasswordImport extends ContentFeature { ].includes(path); } - async handlePath(path) { + async 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) { @@ -469,8 +477,41 @@ export default class AutofillPasswordImport extends ContentFeature { } } } catch { - console.error('password-import: failed for path:', path); + console.error('password-import: failed for path:', pathname); + } + } + } + + /** + * @returns {Array>} + */ + get bookmarkImportActionSettings() { + return this.getFeatureSetting('actions') || []; + } + + /** + * @returns {Record} + */ + get bookmarkImportSelectorSettings() { + return this.getFeatureSetting('selectors'); + } + + /** + * @param {Location} location + * + */ + async handleLocation(location) { + const { pathname } = location; + if (this.bookmarkImportActionSettings.length > 0) { + if (this.#processingBookmark) { + return; } + this.#processingBookmark = true; + await this.handleBookmarkImportPath(pathname); + } else if (this.getFeatureSetting('settingsButton')) { + await this.handlePasswordManagerPath(pathname); + } else { + // Unknown feature, we bail out } } @@ -547,24 +588,98 @@ export default class AutofillPasswordImport extends ContentFeature { return `${this.#settingsButtonSettings?.selectors?.join(',')}, ${this.settingsLabelTextSelector}`; } - setButtonSettings() { + /** Bookmark import code */ + 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.bookmarkImportSelectorSettings.userIdLink)?.getAttribute('href')?.split('&user=')[1]; + await this.runWithRetry(() => document.querySelector(`a[href="./manage/archive/${this.#exportId}"]`), 15, 2000, 'linear'); + 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('actionCompleted', { + result: new ErrorResponse({ + actionID: 'download-data', + message: 'No user id or export id found', + }), + }); + } + } + + /** + * 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, + }; + } + + 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, {}); + } + + async handleBookmarkImportPath(pathname) { + if (pathname === '/' && !this.#isBookmarkModalVisible) { + 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(); + } + + await this.patchMessagingAndProcessAction(action); + } + await this.downloadData(); + } + } + + setPasswordImportSettings() { this.#exportButtonSettings = this.getFeatureSetting('exportButton'); this.#signInButtonSettings = this.getFeatureSetting('signInButton'); this.#settingsButtonSettings = this.getFeatureSetting('settingsButton'); this.#exportConfirmButtonSettings = this.getFeatureSetting('exportConfirmButton'); } + findExportId() { + const panels = document.querySelectorAll(this.bookmarkImportSelectorSettings.tabPanel); + const exportPanel = panels[panels.length - 1]; + return exportPanel.querySelector('div[data-archive-id]')?.getAttribute('data-archive-id'); + } + + async storeExportId() { + this.#exportId = await this.runWithRetry(() => this.findExportId(), 30, 1000, 'linear'); + } + urlChanged() { - this.handlePath(window.location.pathname); + this.handleLocation(window.location); } init() { if (isBeingFramed()) { return; } - this.setButtonSettings(); - const handlePath = this.handlePath.bind(this); + if (this.getFeatureSetting('settingsButton')) { + this.setPasswordImportSettings(); + } + const handleLocation = this.handleLocation.bind(this); this.#domLoaded = new Promise((resolve) => { if (document.readyState !== 'loading') { @@ -578,8 +693,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 }, ); diff --git a/injected/src/features/broker-protection.js b/injected/src/features/broker-protection.js index 58fe55aa74..a473490b9f 100644 --- a/injected/src/features/broker-protection.js +++ b/injected/src/features/broker-protection.js @@ -3,50 +3,46 @@ 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) => { - try { - const action = params.state.action; - const data = params.state.data; - - if (!action) { - return this.messaging.notify('actionError', { error: 'No action found.' }); - } +export class ActionExecutorBase extends ContentFeature { + /** + * @param {any} action + * @param {Record} data + */ + async processActionAndNotify(action, data) { + try { + if (!action) { + return this.messaging.notify('actionError', { error: 'No action found.' }); + } - const { results, exceptions } = await this.exec(action, data); + const { results, exceptions } = await this.exec(action, data); - 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); - 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() }); + } } /** @@ -78,6 +74,25 @@ export default class BrokerProtection extends ContentFeature { return { results: [], exceptions }; } + /** + * @returns {any} + */ + retryConfigFor(action) { + this.log.error('unimplemented method: retryConfigFor:', action); + } +} + +/** + * @typedef {import("./broker-protection/types.js").ActionResponse} ActionResponse + */ +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); + }); + } + /** * Define default retry configurations for certain actions * 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 ab3c9aa1c4..4489440f3e 100644 --- a/injected/src/utils.js +++ b/injected/src/utils.js @@ -808,30 +808,32 @@ 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} */ -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 = () => { attempts += 1; - const error = new Error('Element not found'); + const error = new Error('Result is invalid or max attempts reached'); 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)); + 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); }