Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions applications/pass-extension/manifest-chrome.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 6 additions & 0 deletions applications/pass-extension/manifest-firefox.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };

Expand Down Expand Up @@ -372,7 +377,33 @@ export const createAutofillService = ({ controller }: ContentScriptContextFactor
}
);

const onAutofillTrigger: FrameMessageHandler<WorkerMessageType.AUTOFILL_TRIGGER> = 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 (!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);
controller.channel.register(WorkerMessageType.AUTOFILL_TRIGGER, onAutofillTrigger);

return {
get processing() {
Expand All @@ -391,6 +422,7 @@ export const createAutofillService = ({ controller }: ContentScriptContextFactor
sync,
destroy: () => {
controller.channel.unregister(WorkerMessageType.AUTOFILL_SEQUENCE, onAutofillRequest);
controller.channel.unregister(WorkerMessageType.AUTOFILL_TRIGGER, onAutofillTrigger);
},
};
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -148,6 +151,26 @@ export const AutofillLogin: FC<Props> = ({ 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 `<button>`); Escape dismisses the dropdown. */
const rootRef = useRef<HTMLDivElement>(null);
const documentRef = useRef<Document>(document);
const { shortcutHandlers } = useDropdownArrowNavigation({ rootRef });
const hotkeys: HotkeyTuple[] = [
...shortcutHandlers,
[
'Escape',
(e) => {
e.preventDefault();
controller.close({ userAction: true });
},
],
];
useHotkeys(documentRef, hotkeys);

if (loading) return <CircleLoader className="absolute inset-center m-auto" />;

return (
Expand All @@ -163,16 +186,18 @@ export const AutofillLogin: FC<Props> = ({ startsWith, action, ...payload }) =>
/>
}
/>
{dropdownItems.length > 0 ? (
<ScrollableItemsList>{dropdownItems}</ScrollableItemsList>
) : (
<ListItem
icon={{ type: 'status', icon: PassIconStatus.ACTIVE }}
onClick={controller.close}
title={PASS_APP_NAME}
subTitle={c('Info').t`No login found`}
/>
)}
<div ref={rootRef}>
{dropdownItems.length > 0 ? (
<ScrollableItemsList>{dropdownItems}</ScrollableItemsList>
) : (
<ListItem
icon={{ type: 'status', icon: PassIconStatus.ACTIVE }}
onClick={controller.close}
title={PASS_APP_NAME}
subTitle={c('Info').t`No login found`}
/>
)}
</div>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface DropdownHandler {
close: (target?: InlineFieldTarget | InlineFrameTarget) => void;
destroy: () => void;
toggle: (request: DropdownRequest) => void;
requestFocus: () => Promise<void>;
sendMessage: (message: InlineMessage) => void;
getState: (checkInFlight?: boolean) => Promise<DropdownStateDTO>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface DropdownApp extends InlineAppHandler<DropdownRequest> {
* UX decisions with regards to dropdown interaction */
anchor: MaybeNull<DropdownAnchor>;
focused: boolean;
requestFocus: () => Promise<void>;
}

export const createDropdown = (popover: PopoverController): DropdownApp => {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const DROPDOWN_FOCUS_TRAP_TIMEOUT = 500;
export interface DropdownFocusController {
focused: boolean;
willFocus: boolean;
requestFocus: () => Promise<void>;
disconnect: () => void;
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -165,6 +171,7 @@ export const createDropdownFocusController = ({
get willFocus() {
return state.willFocus;
},
requestFocus: onFocusRequest,
disconnect,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,13 @@ button.pass-injected-dropdown--item {
cursor: default;
background-color: transparent;
}

// Keyboard navigation: highlight the focused suggestion like hover so the active item is
// obvious when navigating the list with the arrow keys. `:focus` (not only `:focus-visible`)
// because navigation focuses items programmatically, which some engines do not treat as
// focus-visible.
&:focus,
&:focus-visible {
background-color: var(--interaction-default-hover);
}
}
58 changes: 58 additions & 0 deletions applications/pass-extension/src/lib/extension/commands.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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 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');
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();
});
});
12 changes: 11 additions & 1 deletion applications/pass-extension/src/lib/extension/commands.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof browser.commands.getAll>>[number];
Expand All @@ -10,12 +13,19 @@ export const resolveShortcuts = (commands: BrowserCommand[], supported: Record<s
.filter((cmd): cmd is BrowserCommand & { name: string } => 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({
url: browser.runtime.getURL('popup.html#'),
})
.catch(noop);
}

if (command === 'autofill') {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (tab?.id != null) {
browser.tabs.sendMessage(tab.id, backgroundMessage({ type: WorkerMessageType.AUTOFILL_TRIGGER })).catch(noop);
}
}
};
3 changes: 3 additions & 0 deletions applications/pass-extension/src/types/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -212,6 +213,7 @@ export type AutofillOTPCheckMessage = { type: WorkerMessageType.AUTOFILL_OTP_CHE
export type AutofillPasswordOptionsMessage = { type: WorkerMessageType.AUTOSUGGEST_PASSWORD };
export type AutofillSequenceMessage = WithPayload<WorkerMessageType.AUTOFILL_SEQUENCE, AutofillRequest>;
export type AutofillSyncMessage = { type: WorkerMessageType.AUTOFILL_SYNC };
export type AutofillTriggerMessage = { type: WorkerMessageType.AUTOFILL_TRIGGER };

export type AutoSaveRequestMessage = WithPayload<WorkerMessageType.AUTOSAVE_REQUEST, AutosaveRequest>;
export type AutosuggestAliasMessage = { type: WorkerMessageType.AUTOSUGGEST_ALIAS };
Expand Down Expand Up @@ -315,6 +317,7 @@ export type WorkerMessage =
| AutofillPasswordOptionsMessage
| AutofillSequenceMessage
| AutofillSyncMessage
| AutofillTriggerMessage
| AutoSaveRequestMessage
| AutosuggestAliasMessage
| B2BEventMessage
Expand Down