From e178fcb8a370e5e5a1fe1699556bbf2a7ffc1bd9 Mon Sep 17 00:00:00 2001 From: yuribodo Date: Sun, 29 Mar 2026 13:13:13 -0300 Subject: [PATCH 1/7] feat(pass-extension): add AUTOFILL_TRIGGER message type --- applications/pass-extension/src/types/messages.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/applications/pass-extension/src/types/messages.ts b/applications/pass-extension/src/types/messages.ts index 00d13211425..b89d6a43749 100644 --- a/applications/pass-extension/src/types/messages.ts +++ b/applications/pass-extension/src/types/messages.ts @@ -104,6 +104,7 @@ export enum WorkerMessageType { AUTOFILL_OTP_CHECK = 'AUTOFILL_OTP_CHECK', AUTOFILL_SEQUENCE = 'AUTOFILL_SEQUENCE', AUTOFILL_SYNC = 'AUTOFILL_SYNC', + AUTOFILL_TRIGGER = 'AUTOFILL_TRIGGER', AUTOSAVE_REQUEST = 'AUTOSAVE_REQUEST', AUTOSUGGEST_ALIAS = 'AUTOSUGGEST_ALIAS', From e5649056ea013a5eb58cfd92128b84bc3e1341a2 Mon Sep 17 00:00:00 2001 From: yuribodo Date: Sun, 29 Mar 2026 13:13:56 -0300 Subject: [PATCH 2/7] feat(pass-extension): add autofill command to browser manifests --- applications/pass-extension/manifest-chrome.json | 6 ++++++ applications/pass-extension/manifest-firefox.json | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/applications/pass-extension/manifest-chrome.json b/applications/pass-extension/manifest-chrome.json index f79bda556ff..97673958046 100644 --- a/applications/pass-extension/manifest-chrome.json +++ b/applications/pass-extension/manifest-chrome.json @@ -77,6 +77,12 @@ "default": "Ctrl+Shift+L" }, "description": "Open Proton Pass in a larger window" + }, + "autofill": { + "suggested_key": { + "default": "Ctrl+Shift+U" + }, + "description": "Autofill login credentials" } }, "icons": { diff --git a/applications/pass-extension/manifest-firefox.json b/applications/pass-extension/manifest-firefox.json index f7a66ac42c3..3b23d2e3761 100644 --- a/applications/pass-extension/manifest-firefox.json +++ b/applications/pass-extension/manifest-firefox.json @@ -76,6 +76,12 @@ "default": "Ctrl+Shift+L" }, "description": "Open Proton Pass in a larger window" + }, + "autofill": { + "suggested_key": { + "default": "Ctrl+Shift+U" + }, + "description": "Autofill login credentials" } }, "icons": { From 49e4a809e9efef19af50c0a6b7ca1ca5e39c58b2 Mon Sep 17 00:00:00 2001 From: yuribodo Date: Sun, 29 Mar 2026 13:15:12 -0300 Subject: [PATCH 3/7] feat(pass-extension): handle autofill keyboard command in background script --- .../src/lib/extension/commands.spec.ts | 49 +++++++++++++++++++ .../src/lib/extension/commands.ts | 12 ++++- 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 applications/pass-extension/src/lib/extension/commands.spec.ts diff --git a/applications/pass-extension/src/lib/extension/commands.spec.ts b/applications/pass-extension/src/lib/extension/commands.spec.ts new file mode 100644 index 00000000000..4d77662ae45 --- /dev/null +++ b/applications/pass-extension/src/lib/extension/commands.spec.ts @@ -0,0 +1,49 @@ +import { handleExtensionCommand } from './commands'; +import browser from '@proton/pass/lib/globals/browser'; +import { WorkerMessageType } from 'proton-pass-extension/types/messages'; + +jest.mock('@proton/pass/lib/globals/browser', () => ({ + tabs: { + create: jest.fn(() => Promise.resolve()), + query: jest.fn(() => Promise.resolve([{ id: 42 }])), + sendMessage: jest.fn(() => Promise.resolve()), + }, + runtime: { + getURL: jest.fn((path: string) => `chrome-extension://abc/${path}`), + }, + commands: { + getAll: jest.fn(() => Promise.resolve([])), + }, +})); + +describe('handleExtensionCommand', () => { + beforeEach(() => jest.clearAllMocks()); + + it('should open larger window for open-larger-window command', async () => { + await handleExtensionCommand('open-larger-window'); + expect(browser.tabs.create).toHaveBeenCalledWith({ + url: 'chrome-extension://abc/popup.html#', + }); + }); + + it('should send AUTOFILL_TRIGGER to active tab for autofill command', async () => { + await handleExtensionCommand('autofill'); + expect(browser.tabs.query).toHaveBeenCalledWith({ active: true, currentWindow: true }); + expect(browser.tabs.sendMessage).toHaveBeenCalledWith( + 42, + expect.objectContaining({ type: WorkerMessageType.AUTOFILL_TRIGGER }) + ); + }); + + it('should not send message when no active tab', async () => { + (browser.tabs.query as jest.Mock).mockResolvedValueOnce([]); + await handleExtensionCommand('autofill'); + expect(browser.tabs.sendMessage).not.toHaveBeenCalled(); + }); + + it('should do nothing for unknown commands', async () => { + await handleExtensionCommand('unknown-command'); + expect(browser.tabs.create).not.toHaveBeenCalled(); + expect(browser.tabs.sendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/applications/pass-extension/src/lib/extension/commands.ts b/applications/pass-extension/src/lib/extension/commands.ts index fce78a34622..c033f544886 100644 --- a/applications/pass-extension/src/lib/extension/commands.ts +++ b/applications/pass-extension/src/lib/extension/commands.ts @@ -1,6 +1,9 @@ import browser from '@proton/pass/lib/globals/browser'; import noop from '@proton/utils/noop'; +import { backgroundMessage } from 'proton-pass-extension/lib/message/send-message'; +import { WorkerMessageType } from 'proton-pass-extension/types/messages'; + export type Shortcut = { name: string; description: string; shortcut: string }; type BrowserCommand = Awaited>[number]; @@ -10,7 +13,7 @@ export const resolveShortcuts = (commands: BrowserCommand[], supported: Record Boolean(cmd.name && cmd.name in supported)) .map(({ name, shortcut }) => ({ name, shortcut: shortcut ?? '', description: supported[name] })); -export const handleExtensionCommand = (command: string) => { +export const handleExtensionCommand = async (command: string) => { if (command === 'open-larger-window') { browser.tabs .create({ @@ -18,4 +21,11 @@ export const handleExtensionCommand = (command: string) => { }) .catch(noop); } + + if (command === 'autofill') { + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + if (tab?.id) { + browser.tabs.sendMessage(tab.id, backgroundMessage({ type: WorkerMessageType.AUTOFILL_TRIGGER })).catch(noop); + } + } }; From 2d429850b76bc55f87b1de5955b782e0f48716c7 Mon Sep 17 00:00:00 2001 From: yuribodo Date: Sun, 29 Mar 2026 13:16:28 -0300 Subject: [PATCH 4/7] feat(pass-extension): register autofill trigger handler in content script --- .../services/autofill/autofill.service.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/applications/pass-extension/src/app/content/services/autofill/autofill.service.ts b/applications/pass-extension/src/app/content/services/autofill/autofill.service.ts index f7633c7db93..d32e1d72bf8 100644 --- a/applications/pass-extension/src/app/content/services/autofill/autofill.service.ts +++ b/applications/pass-extension/src/app/content/services/autofill/autofill.service.ts @@ -372,7 +372,25 @@ export const createAutofillService = ({ controller }: ContentScriptContextFactor } ); + const onAutofillTrigger = withContext((ctx) => { + const fields = ctx?.service.formManager.getFields(); + const loginField = fields?.find( + (field) => field.action?.type === DropdownAction.AUTOFILL_LOGIN + ); + + if (loginField) { + ctx?.service.inline.dropdown.toggle({ + type: 'field', + action: DropdownAction.AUTOFILL_LOGIN, + autofocused: false, + autofilled: loginField.autofilled !== null, + field: loginField, + }); + } + }); + controller.channel.register(WorkerMessageType.AUTOFILL_SEQUENCE, onAutofillRequest); + controller.channel.register(WorkerMessageType.AUTOFILL_TRIGGER, onAutofillTrigger); return { get processing() { @@ -391,6 +409,7 @@ export const createAutofillService = ({ controller }: ContentScriptContextFactor sync, destroy: () => { controller.channel.unregister(WorkerMessageType.AUTOFILL_SEQUENCE, onAutofillRequest); + controller.channel.unregister(WorkerMessageType.AUTOFILL_TRIGGER, onAutofillTrigger); }, }; }; From e83356748bcb2659992ccf909e23350a39bb9452 Mon Sep 17 00:00:00 2001 From: yuribodo Date: Sun, 29 Mar 2026 15:20:59 -0300 Subject: [PATCH 5/7] fix(pass-extension): add AUTOFILL_TRIGGER to typed message contract and handle tab id 0 --- .../pass-extension/src/lib/extension/commands.spec.ts | 9 +++++++++ .../pass-extension/src/lib/extension/commands.ts | 2 +- applications/pass-extension/src/types/messages.ts | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/applications/pass-extension/src/lib/extension/commands.spec.ts b/applications/pass-extension/src/lib/extension/commands.spec.ts index 4d77662ae45..8e07aa533ab 100644 --- a/applications/pass-extension/src/lib/extension/commands.spec.ts +++ b/applications/pass-extension/src/lib/extension/commands.spec.ts @@ -35,6 +35,15 @@ describe('handleExtensionCommand', () => { ); }); + it('should send message when tab id is 0', async () => { + (browser.tabs.query as jest.Mock).mockResolvedValueOnce([{ id: 0 }]); + await handleExtensionCommand('autofill'); + expect(browser.tabs.sendMessage).toHaveBeenCalledWith( + 0, + expect.objectContaining({ type: WorkerMessageType.AUTOFILL_TRIGGER }) + ); + }); + it('should not send message when no active tab', async () => { (browser.tabs.query as jest.Mock).mockResolvedValueOnce([]); await handleExtensionCommand('autofill'); diff --git a/applications/pass-extension/src/lib/extension/commands.ts b/applications/pass-extension/src/lib/extension/commands.ts index c033f544886..068963638f1 100644 --- a/applications/pass-extension/src/lib/extension/commands.ts +++ b/applications/pass-extension/src/lib/extension/commands.ts @@ -24,7 +24,7 @@ export const handleExtensionCommand = async (command: string) => { if (command === 'autofill') { const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); - if (tab?.id) { + if (tab?.id != null) { browser.tabs.sendMessage(tab.id, backgroundMessage({ type: WorkerMessageType.AUTOFILL_TRIGGER })).catch(noop); } } diff --git a/applications/pass-extension/src/types/messages.ts b/applications/pass-extension/src/types/messages.ts index b89d6a43749..4f1875fdb91 100644 --- a/applications/pass-extension/src/types/messages.ts +++ b/applications/pass-extension/src/types/messages.ts @@ -213,6 +213,7 @@ export type AutofillOTPCheckMessage = { type: WorkerMessageType.AUTOFILL_OTP_CHE export type AutofillPasswordOptionsMessage = { type: WorkerMessageType.AUTOSUGGEST_PASSWORD }; export type AutofillSequenceMessage = WithPayload; export type AutofillSyncMessage = { type: WorkerMessageType.AUTOFILL_SYNC }; +export type AutofillTriggerMessage = { type: WorkerMessageType.AUTOFILL_TRIGGER }; export type AutoSaveRequestMessage = WithPayload; export type AutosuggestAliasMessage = { type: WorkerMessageType.AUTOSUGGEST_ALIAS }; @@ -316,6 +317,7 @@ export type WorkerMessage = | AutofillPasswordOptionsMessage | AutofillSequenceMessage | AutofillSyncMessage + | AutofillTriggerMessage | AutoSaveRequestMessage | AutosuggestAliasMessage | B2BEventMessage From e2560a4af7d56b155aae2999cdf4b31ef58767ee Mon Sep 17 00:00:00 2001 From: yuribodo Date: Wed, 3 Jun 2026 23:38:09 -0300 Subject: [PATCH 6/7] feat(pass-extension): focus autofill dropdown when opened via shortcut The Ctrl+Shift+U shortcut opens the dropdown with autofocused:false, so neither the page field nor the iframe receives keyboard focus. Expose requestFocus() on the dropdown that reuses the existing focus-lock bypass, and call it once the dropdown is visible so the suggestions can be reached from the keyboard. --- .../services/autofill/autofill.service.ts | 41 ++++++++++++------- .../inline/dropdown/dropdown.abstract.ts | 1 + .../services/inline/dropdown/dropdown.app.ts | 2 + .../inline/dropdown/dropdown.focus.ts | 9 +++- .../inline/dropdown/dropdown.handler.ts | 2 + 5 files changed, 40 insertions(+), 15 deletions(-) diff --git a/applications/pass-extension/src/app/content/services/autofill/autofill.service.ts b/applications/pass-extension/src/app/content/services/autofill/autofill.service.ts index d32e1d72bf8..efea1492876 100644 --- a/applications/pass-extension/src/app/content/services/autofill/autofill.service.ts +++ b/applications/pass-extension/src/app/content/services/autofill/autofill.service.ts @@ -21,6 +21,7 @@ import { first } from '@proton/pass/utils/array/first'; import { truthy } from '@proton/pass/utils/fp/predicates'; import { asyncLock } from '@proton/pass/utils/fp/promises'; import { safeCall } from '@proton/pass/utils/fp/safe-call'; +import { waitUntil } from '@proton/pass/utils/fp/wait-until'; import { serialize } from '@proton/pass/utils/object/serialize'; import { uniqueId } from '@proton/pass/utils/string/unique-id'; import { getEpoch } from '@proton/pass/utils/time/epoch'; @@ -77,6 +78,10 @@ const autofillCounter = (key: keyof AutofillCounters, state: AutofillCounters) = * preventing race conditions where focus-to-next-field logic interferes with autofill. */ const AUTOFILL_LOCK_TIME = BUILD_TARGET === 'safari' ? 250 : 50; +/** Maximum time to wait for the dropdown to become visible before moving keyboard + * focus into it when triggered via the autofill shortcut. */ +const DROPDOWN_AUTOFOCUS_TIMEOUT = 1_000; + export const createAutofillService = ({ controller }: ContentScriptContextFactoryOptions) => { const state: AutofillState = { processing: false }; @@ -372,21 +377,29 @@ export const createAutofillService = ({ controller }: ContentScriptContextFactor } ); - const onAutofillTrigger = withContext((ctx) => { + const onAutofillTrigger: FrameMessageHandler = withContext(async (ctx) => { + const dropdown = ctx?.service.inline.dropdown; const fields = ctx?.service.formManager.getFields(); - const loginField = fields?.find( - (field) => field.action?.type === DropdownAction.AUTOFILL_LOGIN - ); - - if (loginField) { - ctx?.service.inline.dropdown.toggle({ - type: 'field', - action: DropdownAction.AUTOFILL_LOGIN, - autofocused: false, - autofilled: loginField.autofilled !== null, - field: loginField, - }); - } + const loginField = fields?.find((field) => field.action?.type === DropdownAction.AUTOFILL_LOGIN); + + if (!dropdown || !loginField) return; + + dropdown.toggle({ + type: 'field', + action: DropdownAction.AUTOFILL_LOGIN, + autofocused: false, + autofilled: loginField.autofilled !== null, + field: loginField, + }); + + /** Keyboard-only flow: once the dropdown is visible, move keyboard focus into it + * so the user can navigate the login suggestions with the arrow keys and select + * one with Enter — without touching the mouse. Unlike the focus-on-field flow, + * the shortcut opens the dropdown with `autofocused: false`, so nothing has moved + * focus into the iframe yet. */ + await waitUntil(() => dropdown.getState().then(({ visible }) => visible), 25, DROPDOWN_AUTOFOCUS_TIMEOUT) + .then(() => dropdown.requestFocus()) + .catch(noop); }); controller.channel.register(WorkerMessageType.AUTOFILL_SEQUENCE, onAutofillRequest); diff --git a/applications/pass-extension/src/app/content/services/inline/dropdown/dropdown.abstract.ts b/applications/pass-extension/src/app/content/services/inline/dropdown/dropdown.abstract.ts index 9bd11857e2c..b45d6d5f7a5 100644 --- a/applications/pass-extension/src/app/content/services/inline/dropdown/dropdown.abstract.ts +++ b/applications/pass-extension/src/app/content/services/inline/dropdown/dropdown.abstract.ts @@ -15,6 +15,7 @@ export interface DropdownHandler { close: (target?: InlineFieldTarget | InlineFrameTarget) => void; destroy: () => void; toggle: (request: DropdownRequest) => void; + requestFocus: () => Promise; sendMessage: (message: InlineMessage) => void; getState: (checkInFlight?: boolean) => Promise; } diff --git a/applications/pass-extension/src/app/content/services/inline/dropdown/dropdown.app.ts b/applications/pass-extension/src/app/content/services/inline/dropdown/dropdown.app.ts index 180f1dd2ee5..570c1b50f5c 100644 --- a/applications/pass-extension/src/app/content/services/inline/dropdown/dropdown.app.ts +++ b/applications/pass-extension/src/app/content/services/inline/dropdown/dropdown.app.ts @@ -48,6 +48,7 @@ export interface DropdownApp extends InlineAppHandler { * UX decisions with regards to dropdown interaction */ anchor: MaybeNull; focused: boolean; + requestFocus: () => Promise; } export const createDropdown = (popover: PopoverController): DropdownApp => { @@ -170,6 +171,7 @@ export const createDropdown = (popover: PopoverController): DropdownApp => { return focus.focused || focus.willFocus; }, + requestFocus: focus.requestFocus, close: iframe.close, destroy: iframe.destroy, getState: () => iframe.state, diff --git a/applications/pass-extension/src/app/content/services/inline/dropdown/dropdown.focus.ts b/applications/pass-extension/src/app/content/services/inline/dropdown/dropdown.focus.ts index e08ba8df7a6..17befdda5f4 100644 --- a/applications/pass-extension/src/app/content/services/inline/dropdown/dropdown.focus.ts +++ b/applications/pass-extension/src/app/content/services/inline/dropdown/dropdown.focus.ts @@ -29,6 +29,7 @@ export const DROPDOWN_FOCUS_TRAP_TIMEOUT = 500; export interface DropdownFocusController { focused: boolean; willFocus: boolean; + requestFocus: () => Promise; disconnect: () => void; } @@ -92,7 +93,12 @@ export const createDropdownFocusController = ({ }; const onWillFocus = () => { - if (anchor.current?.type === 'field') anchor.current.field.preventAction(); + /** Only arm the field action-trap when the anchor field is the active element — i.e. the + * focus-recovery scenario. The shortcut flow opens the dropdown without focusing the field, + * so arming it there would needlessly suppress the field's normal autofocus dropdown. */ + if (anchor.current?.type === 'field' && isActiveElement(anchor.current.field.element)) { + anchor.current.field.preventAction(); + } clearTimeout(state.willFocusTimer); state.willFocus = true; state.willFocusTimer = setTimeout(disconnect, DROPDOWN_FOCUS_TRAP_TIMEOUT); @@ -165,6 +171,7 @@ export const createDropdownFocusController = ({ get willFocus() { return state.willFocus; }, + requestFocus: onFocusRequest, disconnect, }; }; diff --git a/applications/pass-extension/src/app/content/services/inline/dropdown/dropdown.handler.ts b/applications/pass-extension/src/app/content/services/inline/dropdown/dropdown.handler.ts index eb74dead9b2..16f714ce6d3 100644 --- a/applications/pass-extension/src/app/content/services/inline/dropdown/dropdown.handler.ts +++ b/applications/pass-extension/src/app/content/services/inline/dropdown/dropdown.handler.ts @@ -134,6 +134,8 @@ export const createDropdownHandler = (registry: InlineRegistry): DropdownHandler registry.dropdown?.destroy(); }, + requestFocus: () => registry.dropdown?.requestFocus() ?? Promise.resolve(), + sendMessage: (message) => registry.dropdown?.sendMessage(message), getState: async (checkInFlight) => { From d639fe267e5337df8ad3d6d15d0b362265505481 Mon Sep 17 00:00:00 2001 From: yuribodo Date: Wed, 3 Jun 2026 23:38:16 -0300 Subject: [PATCH 7/7] feat(pass-extension): navigate autofill login suggestions with arrow keys Wire useDropdownArrowNavigation + useHotkeys (bound to the iframe document) in the login view so the suggestions can be moved through with the arrow keys, selected with Enter, and dismissed with Escape. Highlight the focused item in the injected dropdown styles. --- .../dropdown/app/views/AutofillLogin.tsx | 47 ++++++++++++++----- .../src/lib/components/Inline/ListItem.scss | 9 ++++ 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/applications/pass-extension/src/app/content/services/inline/dropdown/app/views/AutofillLogin.tsx b/applications/pass-extension/src/app/content/services/inline/dropdown/app/views/AutofillLogin.tsx index 206f8cbe540..1d7eaaff50d 100644 --- a/applications/pass-extension/src/app/content/services/inline/dropdown/app/views/AutofillLogin.tsx +++ b/applications/pass-extension/src/app/content/services/inline/dropdown/app/views/AutofillLogin.tsx @@ -1,4 +1,4 @@ -import { type FC, useCallback, useEffect, useMemo } from 'react'; +import { type FC, useCallback, useEffect, useMemo, useRef } from 'react'; import type { DropdownAction } from 'proton-pass-extension/app/content/constants.runtime'; import { DropdownHeader } from 'proton-pass-extension/app/content/services/inline/dropdown/app/components/DropdownHeader'; @@ -19,6 +19,9 @@ import { c } from 'ttag'; import { CircleLoader } from '@proton/atoms/CircleLoader/CircleLoader'; import Marks from '@proton/components/components/text/Marks'; +import useDropdownArrowNavigation from '@proton/components/hooks/useDropdownArrowNavigation'; +import type { HotkeyTuple } from '@proton/components/hooks/useHotkeys'; +import { useHotkeys } from '@proton/components/hooks/useHotkeys'; import { usePassCore } from '@proton/pass/components/Core/PassCoreProvider'; import { UpsellRef } from '@proton/pass/constants'; import { useMountedState } from '@proton/pass/hooks/useEnsureMounted'; @@ -148,6 +151,26 @@ export const AutofillLogin: FC = ({ startsWith, action, ...payload }) => [state, filter] ); + /** Keyboard navigation over the suggestions. When the dropdown is opened via the autofill + * shortcut, focus is moved into the iframe, so the listener is bound to the iframe `document` + * (focus lands on the body, outside `rootRef`). Arrow keys move focus across the rows inside + * `rootRef` (the header is excluded; the upgrade and empty-state rows stay reachable); Enter + * activates the focused item natively (each is a `