Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
da20453
separate password and bookmark flow
dbajpeyi Sep 3, 2025
fcdefe5
checkpoint automation changes
dbajpeyi Sep 3, 2025
86cf6c2
wip: complete flow
dbajpeyi Sep 5, 2025
d28b97e
[PoC] try DBP click for bookmark import (#1965)
dbajpeyi Sep 17, 2025
1dc1b6a
chore: merge main
dbajpeyi Sep 17, 2025
44424cd
fix: use adsjs entrypoint for messaging
dbajpeyi Sep 17, 2025
fbe404d
fix: sleep a second before downloading
dbajpeyi Sep 18, 2025
1987305
fix: add custom context for autofill import
dbajpeyi Sep 22, 2025
b9f4c18
refactor: extend actionexecutor
dbajpeyi Sep 23, 2025
d87cf8f
fix: simplify config reading
dbajpeyi Sep 23, 2025
9900494
feat: patch .notify to send a simple payload
dbajpeyi Sep 23, 2025
c481311
fix: use actionCompleted for errors
dbajpeyi Sep 24, 2025
516c87d
Merge branch 'main' into dbajpeyi/hack/autofill-bookmark-import
dbajpeyi Sep 24, 2025
75baa36
Merge branch 'main' into dbajpeyi/hack/autofill-bookmark-import
dbajpeyi Sep 25, 2025
2b4a69f
fix: check settings before execution
dbajpeyi Sep 25, 2025
fe101bb
Merge branch 'dbajpeyi/hack/autofill-bookmark-import' of github.com:d…
dbajpeyi Sep 25, 2025
53cfcb6
chore: don't need separate context for now
dbajpeyi Sep 26, 2025
e3f5906
Merge branch 'main' into dbajpeyi/hack/autofill-bookmark-import
dbajpeyi Sep 26, 2025
6215605
fix: retryconfigfor must be implemented per class
dbajpeyi Sep 26, 2025
81974f8
Merge branch 'dbajpeyi/hack/autofill-bookmark-import' of github.com:d…
dbajpeyi Sep 26, 2025
2742c20
test: config path
dbajpeyi Sep 29, 2025
cbe09e8
Merge branch 'main' into dbajpeyi/hack/autofill-bookmark-import
dbajpeyi Sep 29, 2025
f6af935
test: config path
dbajpeyi Sep 29, 2025
1ced8d1
Merge branch 'dbajpeyi/hack/autofill-bookmark-import' of github.com:d…
dbajpeyi Sep 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion injected/entry-points/android-adsjs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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
*/
Expand Down
6 changes: 3 additions & 3 deletions injected/integration-test/page-objects/results-collector.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export class ResultsCollector {
android: async () => {
// noop
},
'android-autofill-password-import': async () => {
'android-autofill-import': async () => {
// noop
},
});
Expand All @@ -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, {
Expand All @@ -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,
});

Expand Down
13 changes: 2 additions & 11 deletions injected/integration-test/type-helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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;
}
Expand Down
6 changes: 3 additions & 3 deletions injected/playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 3 additions & 3 deletions injected/scripts/entry-points.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions injected/src/features.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const otherFeatures = /** @type {const} */ ([
'brokerProtection',
'performanceMetrics',
'breakageReporting',
'autofillPasswordImport',
'autofillImport',
'favicon',
'webTelemetry',
'pageContext',
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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;
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
Expand All @@ -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;
Expand All @@ -53,6 +55,12 @@ export default class AutofillPasswordImport extends ContentFeature {

#domLoaded;

#exportId;

#processingBookmark;

#isBookmarkModalVisible = false;

/** @type {WeakSet<Element>} */
#tappedElements = new WeakSet();

Expand Down Expand Up @@ -135,10 +143,10 @@ export default class AutofillPasswordImport extends ContentFeature {
/**
* @returns {Promise<Element|HTMLElement|null>}
*/
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;
}
}
Expand Down Expand Up @@ -457,23 +465,56 @@ 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) {
this.#tappedElements.add(this.currentElementConfig.element);
}
}
} catch {
console.error('password-import: failed for path:', path);
console.error('password-import: failed for path:', pathname);
}
}
}

/**
* @returns {Array<Record<string, any>>}
*/
get bookmarkImportActionSettings() {
return this.getFeatureSetting('actions');
}

/**
* @returns {Record<string, string>}
*/
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
}
}

/**
* 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.
Expand Down Expand Up @@ -547,24 +588,94 @@ 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',
}),
});
}
}

get retryConfig() {
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, {}, this.retryConfig);
}

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') {
Expand All @@ -578,8 +689,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 },
);
Expand Down
Loading
Loading