1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-22758] Configurable Keyboard Shortcut for Autotype (#16613)

* [PM-22785] Initial push with configuration and ipc changes for the configurable autotype keyboard shortcut

* [PM-22785] Add messy code with working configurable hotkey

* [PM-22785] Add more messy rust code

* [PM-22785] Add temp changes with configurable hotkey ui

* Add shortcut display to settings

* [PM-22785] Logic updates. Ran npm run prettier and lint:fix.

* [PM-22785] Add back disableAutotype with refactors.

* [PM-22785] Clean up Rust code

* [PM-22785] Clean up Rust code v2

* [PM-22785] Add unicode bounds in Rust code

* [PM-22785] Update rust code comments

* [PM-22785] Add unicode_value byte length check post-encoding

* [PM-22785] Extract encoding to a separate function

* Various fixes for the autotype setting label

* Misc component fixes

* Disallow nunmbers and allow Win key

* Themify edit shortcut

* Change display of Super to Win

* Create autotype format method

* Autotpe modal cleanup

* [PM-22785] Some cleanup

* Add unit tests and adjust error handling

* [PM-22785] Fix build issues on Mac and Linux

* [PM-22785] Linting fix

* Remove unused message

* [PM-22785] Linting fix

* [PM-22785] More linting fix

* [PM-22785] Address initial PR comments

* [PM-22785] Comment change

* [PM-22785] If statement change

* [PM-22785] Update with fixes from PR comments

* [PM-22785] Update with fixes from PR comments version ?

* add unit tests for get_alphabetic_hot_key()

* Fix tests

* Add missing mock to tests

* [PM-22785] Update with small fixes via PR comments

---------

Co-authored-by: Robyn MacCallum <robyntmaccallum@gmail.com>
Co-authored-by: neuronull <9162534+neuronull@users.noreply.github.com>
This commit is contained in:
Colton Hurst
2025-09-29 10:20:15 -04:00
committed by GitHub
parent 018b4d5eb4
commit fc53eae4c5
18 changed files with 802 additions and 50 deletions

View File

@@ -17,6 +17,6 @@ pub fn get_foreground_window_title() -> std::result::Result<String, ()> {
///
/// TODO: The error handling will be improved in a future PR: PM-23615
#[allow(clippy::result_unit_err)]
pub fn type_input(input: Vec<u16>) -> std::result::Result<(), ()> {
windowing::type_input(input)
pub fn type_input(input: Vec<u16>, keyboard_shortcut: Vec<String>) -> std::result::Result<(), ()> {
windowing::type_input(input, keyboard_shortcut)
}

View File

@@ -2,6 +2,9 @@ pub fn get_foreground_window_title() -> std::result::Result<String, ()> {
todo!("Bitwarden does not yet support Linux autotype");
}
pub fn type_input(_input: Vec<u16>) -> std::result::Result<(), ()> {
pub fn type_input(
_input: Vec<u16>,
_keyboard_shortcut: Vec<String>,
) -> std::result::Result<(), ()> {
todo!("Bitwarden does not yet support Linux autotype");
}

View File

@@ -1,7 +1,10 @@
pub fn get_foreground_window_title() -> std::result::Result<String, ()> {
todo!("Bitwarden does not yet support Mac OS autotype");
todo!("Bitwarden does not yet support macOS autotype");
}
pub fn type_input(_input: Vec<u16>) -> std::result::Result<(), ()> {
todo!("Bitwarden does not yet support Mac OS autotype");
pub fn type_input(
_input: Vec<u16>,
_keyboard_shortcut: Vec<String>,
) -> std::result::Result<(), ()> {
todo!("Bitwarden does not yet support macOS autotype");
}

View File

@@ -25,25 +25,29 @@ pub fn get_foreground_window_title() -> std::result::Result<String, ()> {
/// Attempts to type the input text wherever the user's cursor is.
///
/// `input` must be an array of utf-16 encoded characters to insert.
/// `input` must be a vector of utf-16 encoded characters to insert.
/// `keyboard_shortcut` must be a vector of Strings, where valid shortcut keys: Control, Alt, Super, Shift, letters a - Z
///
/// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput
pub fn type_input(input: Vec<u16>) -> Result<(), ()> {
const TAB_KEY: u16 = 9;
pub fn type_input(input: Vec<u16>, keyboard_shortcut: Vec<String>) -> Result<(), ()> {
const TAB_KEY: u8 = 9;
let mut keyboard_inputs: Vec<INPUT> = Vec::new();
// Release hotkeys
keyboard_inputs.push(build_virtual_key_input(InputKeyPress::Up, 0x11)); // ctrl
keyboard_inputs.push(build_virtual_key_input(InputKeyPress::Up, 0x10)); // shift
keyboard_inputs.push(build_unicode_input(InputKeyPress::Up, 42)); // b
// Add key "up" inputs for the shortcut
for key in keyboard_shortcut {
keyboard_inputs.push(convert_shortcut_key_to_up_input(key)?);
}
// Add key "down" and "up" inputs for the input
// (currently in this form: {username}/t{password})
for i in input {
let next_down_input = if i == TAB_KEY {
let next_down_input = if i == TAB_KEY.into() {
build_virtual_key_input(InputKeyPress::Down, i as u8)
} else {
build_unicode_input(InputKeyPress::Down, i)
};
let next_up_input = if i == TAB_KEY {
let next_up_input = if i == TAB_KEY.into() {
build_virtual_key_input(InputKeyPress::Up, i as u8)
} else {
build_unicode_input(InputKeyPress::Up, i)
@@ -56,6 +60,51 @@ pub fn type_input(input: Vec<u16>) -> Result<(), ()> {
send_input(keyboard_inputs)
}
/// Converts a valid shortcut key to an "up" keyboard input.
///
/// `input` must be a valid shortcut key: Control, Alt, Super, Shift, letters [a-z][A-Z]
fn convert_shortcut_key_to_up_input(key: String) -> Result<INPUT, ()> {
const SHIFT_KEY: u8 = 0x10;
const SHIFT_KEY_STR: &str = "Shift";
const CONTROL_KEY: u8 = 0x11;
const CONTROL_KEY_STR: &str = "Control";
const ALT_KEY: u8 = 0x12;
const ALT_KEY_STR: &str = "Alt";
const LEFT_WINDOWS_KEY: u8 = 0x5B;
const LEFT_WINDOWS_KEY_STR: &str = "Super";
Ok(match key.as_str() {
SHIFT_KEY_STR => build_virtual_key_input(InputKeyPress::Up, SHIFT_KEY),
CONTROL_KEY_STR => build_virtual_key_input(InputKeyPress::Up, CONTROL_KEY),
ALT_KEY_STR => build_virtual_key_input(InputKeyPress::Up, ALT_KEY),
LEFT_WINDOWS_KEY_STR => build_virtual_key_input(InputKeyPress::Up, LEFT_WINDOWS_KEY),
_ => build_unicode_input(InputKeyPress::Up, get_alphabetic_hotkey(key)?),
})
}
/// Given a letter that is a String, get the utf16 encoded
/// decimal version of the letter as long as it meets the
/// [a-z][A-Z] restriction.
///
/// Because we only accept [a-z][A-Z], the decimal u16
/// cast of the letter is safe because the unicode code point
/// of these characters fits in a u16.
fn get_alphabetic_hotkey(letter: String) -> Result<u16, ()> {
if letter.len() != 1 {
return Err(());
}
let c = letter.chars().next().expect("letter is size 1");
// is_ascii_alphabetic() checks for:
// U+0041 `A` ..= U+005A `Z`, or U+0061 `a` ..= U+007A `z`
if !c.is_ascii_alphabetic() {
return Err(());
}
Ok(c as u16)
}
/// Gets the foreground window handle.
///
/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getforegroundwindow
@@ -198,3 +247,32 @@ fn send_input(inputs: Vec<INPUT>) -> Result<(), ()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_alphabetic_hot_key_happy() {
for c in ('a'..='z').chain('A'..='Z') {
let letter = c.to_string();
println!("{}", letter);
let converted = get_alphabetic_hotkey(letter).unwrap();
assert_eq!(converted, c as u16);
}
}
#[test]
#[should_panic = ""]
fn get_alphabetic_hot_key_fail_not_single_char() {
let letter = String::from("foo");
get_alphabetic_hotkey(letter).unwrap();
}
#[test]
#[should_panic = ""]
fn get_alphabetic_hot_key_fail_not_alphabetic() {
let letter = String::from("🚀");
get_alphabetic_hotkey(letter).unwrap();
}
}

View File

@@ -234,5 +234,5 @@ export declare namespace chromium_importer {
}
export declare namespace autotype {
export function getForegroundWindowTitle(): string
export function typeInput(input: Array<number>): void
export function typeInput(input: Array<number>, keyboardShortcut: Array<string>): void
}

View File

@@ -1044,8 +1044,11 @@ pub mod autotype {
}
#[napi]
pub fn type_input(input: Vec<u16>) -> napi::Result<(), napi::Status> {
autotype::type_input(input).map_err(|_| {
pub fn type_input(
input: Vec<u16>,
keyboard_shortcut: Vec<String>,
) -> napi::Result<(), napi::Status> {
autotype::type_input(input, keyboard_shortcut).map_err(|_| {
napi::Error::from_reason("Autotype Error: failed to type input".to_string())
})
}

View File

@@ -340,7 +340,10 @@
(change)="saveEnableAutotype()"
/>
<div class="tw-flex tw-items-center tw-gap-2">
{{ "enableAutotypeTransitionKey" | i18n }}
{{ "enableAutotypeShortcutPreview" | i18n }}
<div>
<code>{{ form.value.autotypeShortcut }}</code>
</div>
<app-premium-badge></app-premium-badge>
</div>
</label>
@@ -348,7 +351,13 @@
<small class="help-block" *ngIf="form.value.enableAutotype">
<b>{{ "important" | i18n }}</b>
{{ "enableAutotypeDescriptionTransitionKey" | i18n }}
<b>{{ "editShortcut" | i18n }}</b></small
<span
class="settings-link"
*ngIf="this.form.value.enableAutotype"
(click)="saveAutotypeShortcut()"
>
{{ "editShortcut" | i18n }}
</span></small
>
</div>
<div class="form-group">

View File

@@ -183,6 +183,7 @@ describe("SettingsComponent", () => {
policyService.policiesByType$.mockReturnValue(of([null]));
desktopAutotypeService.resolvedAutotypeEnabled$ = of(false);
desktopAutotypeService.autotypeEnabledUserSetting$ = of(false);
desktopAutotypeService.autotypeKeyboardShortcut$ = of(["Control", "Shift", "B"]);
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
configService.getFeatureFlag$.mockReturnValue(of(true));
});

View File

@@ -58,6 +58,7 @@ import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/
import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
import { SetPinComponent } from "../../auth/components/set-pin.component";
import { AutotypeShortcutComponent } from "../../autofill/components/autotype-shortcut.component";
import { SshAgentPromptType } from "../../autofill/models/ssh-agent-setting";
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
import { DesktopAutotypeService } from "../../autofill/services/desktop-autotype.service";
@@ -111,6 +112,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
requireEnableTray = false;
showDuckDuckGoIntegrationOption = false;
showEnableAutotype = false;
autotypeShortcut: string;
showOpenAtLoginOption = false;
isWindows: boolean;
isLinux: boolean;
@@ -173,6 +175,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
value: false,
disabled: true,
}),
autotypeShortcut: [null as string | null],
theme: [null as Theme | null],
locale: [null as string | null],
});
@@ -397,6 +400,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
),
allowScreenshots: !(await firstValueFrom(this.desktopSettingsService.preventScreenshots$)),
enableAutotype: await firstValueFrom(this.desktopAutotypeService.autotypeEnabledUserSetting$),
autotypeShortcut: this.getFormattedAutotypeShortcutText(
(await firstValueFrom(this.desktopAutotypeService.autotypeKeyboardShortcut$)) ?? [],
),
theme: await firstValueFrom(this.themeStateService.selectedTheme$),
locale: await firstValueFrom(this.i18nService.userSetLocale$),
};
@@ -897,6 +903,29 @@ export class SettingsComponent implements OnInit, OnDestroy {
async saveEnableAutotype() {
await this.desktopAutotypeService.setAutotypeEnabledState(this.form.value.enableAutotype);
const currentShortcut = await firstValueFrom(
this.desktopAutotypeService.autotypeKeyboardShortcut$,
);
if (currentShortcut) {
this.form.controls.autotypeShortcut.setValue(
this.getFormattedAutotypeShortcutText(currentShortcut),
);
}
}
async saveAutotypeShortcut() {
const dialogRef = AutotypeShortcutComponent.open(this.dialogService);
const newShortcutArray = await firstValueFrom(dialogRef.closed);
if (!newShortcutArray) {
return;
}
this.form.controls.autotypeShortcut.setValue(
this.getFormattedAutotypeShortcutText(newShortcutArray),
);
await this.desktopAutotypeService.setAutotypeKeyboardShortcutState(newShortcutArray);
}
private async generateVaultTimeoutOptions(): Promise<VaultTimeoutOption[]> {
@@ -944,4 +973,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
throw new Error("Unsupported platform");
}
}
getFormattedAutotypeShortcutText(shortcut: string[]) {
return shortcut ? shortcut.join("+").replace("Super", "Win") : null;
}
}

View File

@@ -0,0 +1,33 @@
<form [bitSubmit]="submit" [formGroup]="setShortcutForm">
<bit-dialog>
<div class="tw-font-semibold" bitDialogTitle>
{{ "typeShortcut" | i18n }}
</div>
<div bitDialogContent>
<p>
{{ "editAutotypeShortcutDescription" | i18n }}
</p>
<bit-form-field>
<bit-label>{{ "typeShortcut" | i18n }}</bit-label>
<input
class="tw-font-mono"
bitInput
type="text"
formControlName="shortcut"
(keydown)="onShortcutKeydown($event)"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
/>
</bit-form-field>
</div>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
<span>{{ "save" | i18n }}</span>
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -0,0 +1,281 @@
import { AbstractControl, FormBuilder, ValidationErrors } from "@angular/forms";
import { mock, MockProxy } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { AutotypeShortcutComponent } from "./autotype-shortcut.component";
describe("AutotypeShortcutComponent", () => {
let component: AutotypeShortcutComponent;
let validator: (control: AbstractControl) => ValidationErrors | null;
let formBuilder: MockProxy<FormBuilder>;
let i18nService: MockProxy<I18nService>;
beforeEach(() => {
formBuilder = mock<FormBuilder>();
i18nService = mock<I18nService>();
i18nService.t.mockReturnValue("Invalid shortcut");
component = new AutotypeShortcutComponent(null as any, formBuilder, i18nService);
validator = component["shortcutCombinationValidator"]();
});
describe("shortcutCombinationValidator", () => {
const createControl = (value: string | null): AbstractControl =>
({
value,
}) as AbstractControl;
describe("valid shortcuts", () => {
it("should accept single modifier with letter", () => {
const validShortcuts = [
"Control+A",
"Alt+B",
"Shift+C",
"Win+D",
"control+e", // case insensitive
"ALT+F",
"SHIFT+G",
"WIN+H",
];
validShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
const result = validator(control);
expect(result).toBeNull();
});
});
it("should accept two modifiers with letter", () => {
const validShortcuts = [
"Control+Alt+A",
"Control+Shift+B",
"Control+Win+C",
"Alt+Shift+D",
"Alt+Win+E",
"Shift+Win+F",
];
validShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
const result = validator(control);
expect(result).toBeNull();
});
});
it("should accept modifiers in different orders", () => {
const validShortcuts = ["Alt+Control+A", "Shift+Control+B", "Win+Alt+C"];
validShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
const result = validator(control);
expect(result).toBeNull();
});
});
});
describe("invalid shortcuts", () => {
it("should reject shortcuts without modifiers", () => {
const invalidShortcuts = ["A", "B", "Z", "1", "9"];
invalidShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
const result = validator(control);
expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } });
});
});
it("should reject shortcuts with invalid base keys", () => {
const invalidShortcuts = [
"Control+1",
"Alt+2",
"Shift+3",
"Win+4",
"Control+!",
"Alt+@",
"Shift+#",
"Win+$",
"Control+Space",
"Alt+Enter",
"Shift+Tab",
"Win+Escape",
];
invalidShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
const result = validator(control);
expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } });
});
});
it("should reject shortcuts with only modifiers", () => {
const invalidShortcuts = [
"Control",
"Alt",
"Shift",
"Win",
"Control+Alt",
"Control+Shift",
"Alt+Shift",
"Control+Alt+Shift",
];
invalidShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
const result = validator(control);
expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } });
});
});
it("should reject shortcuts with invalid modifier names", () => {
const invalidShortcuts = ["Ctrl+A", "Command+A", "Super+A", "Meta+A", "Cmd+A", "Invalid+A"];
invalidShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
const result = validator(control);
expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } });
});
});
it("should reject shortcuts with multiple base keys", () => {
const invalidShortcuts = ["Control+A+B", "Alt+Ctrl+Shift"];
invalidShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
const result = validator(control);
expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } });
});
});
it("should reject shortcuts with more than two modifiers", () => {
const invalidShortcuts = [
"Control+Alt+Shift+A",
"Control+Alt+Win+B",
"Control+Shift+Win+C",
"Alt+Shift+Win+D",
"Control+Alt+Shift+Win+E",
];
invalidShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
const result = validator(control);
expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } });
});
});
it("should reject shortcuts with extra characters", () => {
const invalidShortcuts = [
"Control+A+",
"+Control+A",
"Control++A",
"Control+A+Extra",
"Control A",
"Control-A",
"Control.A",
];
invalidShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
const result = validator(control);
expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } });
});
});
it("should reject empty or whitespace shortcuts", () => {
// Empty string is handled by required validator
const controlEmpty = createControl("");
expect(validator(controlEmpty)).toBeNull();
// Whitespace strings are invalid shortcuts
const whitespaceShortcuts = [" ", " ", "\t", "\n"];
whitespaceShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
const result = validator(control);
expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } });
});
});
});
describe("edge cases", () => {
it("should handle null and undefined values", () => {
const controlNull = createControl(null);
const controlUndefined = createControl(undefined as any);
expect(validator(controlNull)).toBeNull();
expect(validator(controlUndefined)).toBeNull();
});
it("should handle non-string values", () => {
const controlNumber = createControl(123 as any);
const controlObject = createControl({} as any);
const controlArray = createControl([] as any);
expect(validator(controlNumber)).toEqual({
invalidShortcut: { message: "Invalid shortcut" },
});
expect(validator(controlObject)).toEqual({
invalidShortcut: { message: "Invalid shortcut" },
});
// Empty array becomes empty string when converted to string, which is handled by required validator
expect(validator(controlArray)).toBeNull();
});
it("should handle very long strings", () => {
const longString = "Control+Alt+Shift+Win+A".repeat(100);
const control = createControl(longString);
const result = validator(control);
expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } });
});
});
describe("modifier combinations", () => {
it("should accept all possible single modifier combinations", () => {
const modifiers = ["Control", "Alt", "Shift", "Win"];
modifiers.forEach((modifier) => {
const control = createControl(`${modifier}+A`);
const result = validator(control);
expect(result).toBeNull();
});
});
it("should accept all possible two-modifier combinations", () => {
const combinations = [
"Control+Alt+A",
"Control+Shift+A",
"Control+Win+A",
"Alt+Shift+A",
"Alt+Win+A",
"Shift+Win+A",
];
combinations.forEach((shortcut) => {
const control = createControl(shortcut);
const result = validator(control);
expect(result).toBeNull();
});
});
it("should reject all three-modifier combinations", () => {
const combinations = [
"Control+Alt+Shift+A",
"Control+Alt+Win+A",
"Control+Shift+Win+A",
"Alt+Shift+Win+A",
];
combinations.forEach((shortcut) => {
const control = createControl(shortcut);
const result = validator(control);
expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } });
});
});
it("should reject all four modifiers combination", () => {
const control = createControl("Control+Alt+Shift+Win+A");
const result = validator(control);
expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } });
});
});
});
});

View File

@@ -0,0 +1,139 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import {
FormBuilder,
ReactiveFormsModule,
Validators,
ValidatorFn,
AbstractControl,
ValidationErrors,
} from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
AsyncActionsModule,
ButtonModule,
DialogModule,
DialogRef,
DialogService,
FormFieldModule,
IconButtonModule,
} from "@bitwarden/components";
@Component({
templateUrl: "autotype-shortcut.component.html",
imports: [
DialogModule,
CommonModule,
JslibModule,
ButtonModule,
IconButtonModule,
ReactiveFormsModule,
AsyncActionsModule,
FormFieldModule,
],
})
export class AutotypeShortcutComponent {
constructor(
private dialogRef: DialogRef,
private formBuilder: FormBuilder,
private i18nService: I18nService,
) {}
private shortcutArray: string[] = [];
setShortcutForm = this.formBuilder.group({
shortcut: ["", [Validators.required, this.shortcutCombinationValidator()]],
requireMasterPasswordOnClientRestart: true,
});
submit = async () => {
const shortcutFormControl = this.setShortcutForm.controls.shortcut;
if (Utils.isNullOrWhitespace(shortcutFormControl.value) || shortcutFormControl.invalid) {
return;
}
this.dialogRef.close(this.shortcutArray);
};
static open(dialogService: DialogService) {
return dialogService.open<string[]>(AutotypeShortcutComponent);
}
onShortcutKeydown(event: KeyboardEvent): void {
event.preventDefault();
const shortcut = this.buildShortcutFromEvent(event);
if (shortcut != null) {
this.setShortcutForm.controls.shortcut.setValue(shortcut);
this.setShortcutForm.controls.shortcut.markAsDirty();
this.setShortcutForm.controls.shortcut.updateValueAndValidity();
}
}
private buildShortcutFromEvent(event: KeyboardEvent): string | null {
const hasCtrl = event.ctrlKey;
const hasAlt = event.altKey;
const hasShift = event.shiftKey;
const hasMeta = event.metaKey; // Windows key on Windows, Command on macOS
// Require at least one modifier (Control, Alt, Shift, or Super)
if (!hasCtrl && !hasAlt && !hasShift && !hasMeta) {
return null;
}
const key = event.key;
// Ignore pure modifier keys themselves
if (key === "Control" || key === "Alt" || key === "Shift" || key === "Meta") {
return null;
}
// Accept a single alphabetical letter as the base key
const isAlphabetical = typeof key === "string" && /^[a-z]$/i.test(key);
if (!isAlphabetical) {
return null;
}
const parts: string[] = [];
if (hasCtrl) {
parts.push("Control");
}
if (hasAlt) {
parts.push("Alt");
}
if (hasShift) {
parts.push("Shift");
}
if (hasMeta) {
parts.push("Super");
}
parts.push(key.toUpperCase());
this.shortcutArray = parts;
return parts.join("+").replace("Super", "Win");
}
private shortcutCombinationValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = (control.value ?? "").toString();
if (value.length === 0) {
return null; // handled by required
}
// Must include exactly 1-2 modifiers and end with a single letter
// Valid examples: Ctrl+A, Shift+Z, Ctrl+Shift+X, Alt+Shift+Q
// Allow modifiers in any order, but only 1-2 modifiers total
const pattern =
/^(?=.*\b(Control|Alt|Shift|Win)\b)(?:Control\+|Alt\+|Shift\+|Win\+){1,2}[A-Z]$/i;
return pattern.test(value)
? null
: { invalidShortcut: { message: this.i18nService.t("invalidShortcut") } };
};
}
}

View File

@@ -5,21 +5,45 @@ import { LogService } from "@bitwarden/logging";
import { WindowMain } from "../../main/window.main";
import { stringIsNotUndefinedNullAndEmpty } from "../../utils";
import { AutotypeKeyboardShortcut } from "../models/main-autotype-keyboard-shortcut";
export class MainDesktopAutotypeService {
keySequence: string = "CommandOrControl+Shift+B";
autotypeKeyboardShortcut: AutotypeKeyboardShortcut;
constructor(
private logService: LogService,
private windowMain: WindowMain,
) {}
) {
this.autotypeKeyboardShortcut = new AutotypeKeyboardShortcut();
}
init() {
ipcMain.on("autofill.configureAutotype", (event, data) => {
if (data.enabled === true && !globalShortcut.isRegistered(this.keySequence)) {
this.enableAutotype();
} else if (data.enabled === false && globalShortcut.isRegistered(this.keySequence)) {
if (data.enabled) {
const newKeyboardShortcut = new AutotypeKeyboardShortcut();
const newKeyboardShortcutIsValid = newKeyboardShortcut.set(data.keyboardShortcut);
if (newKeyboardShortcutIsValid) {
this.disableAutotype();
this.autotypeKeyboardShortcut = newKeyboardShortcut;
this.enableAutotype();
} else {
this.logService.error(
"Attempting to configure autotype but the shortcut given is invalid.",
);
}
} else {
this.disableAutotype();
// Deregister the incoming keyboard shortcut if needed
const setCorrectly = this.autotypeKeyboardShortcut.set(data.keyboardShortcut);
if (
setCorrectly &&
globalShortcut.isRegistered(this.autotypeKeyboardShortcut.getElectronFormat())
) {
globalShortcut.unregister(this.autotypeKeyboardShortcut.getElectronFormat());
this.logService.info("Autotype disabled.");
}
}
});
@@ -30,34 +54,42 @@ export class MainDesktopAutotypeService {
stringIsNotUndefinedNullAndEmpty(response.username) &&
stringIsNotUndefinedNullAndEmpty(response.password)
) {
this.doAutotype(response.username, response.password);
this.doAutotype(
response.username,
response.password,
this.autotypeKeyboardShortcut.getArrayFormat(),
);
}
});
}
disableAutotype() {
if (globalShortcut.isRegistered(this.keySequence)) {
globalShortcut.unregister(this.keySequence);
// Deregister the current keyboard shortcut if needed
const formattedKeyboardShortcut = this.autotypeKeyboardShortcut.getElectronFormat();
if (globalShortcut.isRegistered(formattedKeyboardShortcut)) {
globalShortcut.unregister(formattedKeyboardShortcut);
this.logService.info("Autotype disabled.");
}
this.logService.info("Autotype disabled.");
}
private enableAutotype() {
const result = globalShortcut.register(this.keySequence, () => {
const windowTitle = autotype.getForegroundWindowTitle();
const result = globalShortcut.register(
this.autotypeKeyboardShortcut.getElectronFormat(),
() => {
const windowTitle = autotype.getForegroundWindowTitle();
this.windowMain.win.webContents.send("autofill.listenAutotypeRequest", {
windowTitle,
});
});
this.windowMain.win.webContents.send("autofill.listenAutotypeRequest", {
windowTitle,
});
},
);
result
? this.logService.info("Autotype enabled.")
: this.logService.info("Enabling autotype failed.");
}
private doAutotype(username: string, password: string) {
private doAutotype(username: string, password: string, keyboardShortcut: string[]) {
const inputPattern = username + "\t" + password;
const inputArray = new Array<number>(inputPattern.length);
@@ -65,6 +97,6 @@ export class MainDesktopAutotypeService {
inputArray[i] = inputPattern.charCodeAt(i);
}
autotype.typeInput(inputArray);
autotype.typeInput(inputArray, keyboardShortcut);
}
}

View File

@@ -0,0 +1,98 @@
import { defaultWindowsAutotypeKeyboardShortcut } from "../services/desktop-autotype.service";
/*
This class provides the following:
- A way to get and set an AutotypeKeyboardShortcut value within the main process
- A way to set an AutotypeKeyboardShortcut with validation
- A way to "get" the value in string array format or a single string format for electron
- Default shortcut support
This is currently only supported for Windows operating systems.
*/
export class AutotypeKeyboardShortcut {
private autotypeKeyboardShortcut: string[];
constructor() {
this.autotypeKeyboardShortcut = defaultWindowsAutotypeKeyboardShortcut;
}
/*
Returns a boolean value indicating if the autotypeKeyboardShortcut
was valid and set or not.
*/
set(newAutotypeKeyboardShortcut: string[]) {
if (!this.#keyboardShortcutIsValid(newAutotypeKeyboardShortcut)) {
return false;
}
this.autotypeKeyboardShortcut = newAutotypeKeyboardShortcut;
return true;
}
/*
Returns the autotype keyboard shortcut as a string array.
*/
getArrayFormat() {
return this.autotypeKeyboardShortcut;
}
/*
Returns the autotype keyboard shortcut as a single string, as
Electron expects. Please note this does not reorder the keys.
See Electron keyboard shorcut docs for more info:
https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts
*/
getElectronFormat() {
return this.autotypeKeyboardShortcut.join("+");
}
/*
This private function validates the strArray input to make sure the array contains
valid, currently accepted shortcut keys for Windows.
Valid windows shortcut keys: Control, Alt, Super, Shift, letters A - Z
Valid macOS shortcut keys: Control, Alt, Command, Shift, letters A - Z (not yet supported)
See Electron keyboard shorcut docs for more info:
https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts
*/
#keyboardShortcutIsValid(strArray: string[]) {
const VALID_SHORTCUT_CONTROL_KEYS: string[] = ["Control", "Alt", "Super", "Shift"];
const UNICODE_LOWER_BOUND = 65; // unicode 'A'
const UNICODE_UPPER_BOUND = 90; // unicode 'Z'
const MIN_LENGTH: number = 2;
const MAX_LENGTH: number = 3;
// Ensure strArray is a string array of valid length
if (
strArray === undefined ||
strArray === null ||
strArray.length < MIN_LENGTH ||
strArray.length > MAX_LENGTH
) {
return false;
}
// Ensure strArray is all modifier keys, and that the last key is a letter
for (let i = 0; i < strArray.length; i++) {
if (i < strArray.length - 1) {
if (!VALID_SHORTCUT_CONTROL_KEYS.includes(strArray[i])) {
return false;
}
} else {
const unicodeValue: number = strArray[i].charCodeAt(0);
if (
Number.isNaN(unicodeValue) ||
unicodeValue < UNICODE_LOWER_BOUND ||
unicodeValue > UNICODE_UPPER_BOUND
) {
return false;
}
}
}
return true;
}
}

View File

@@ -127,8 +127,8 @@ export default {
},
);
},
configureAutotype: (enabled: boolean) => {
ipcRenderer.send("autofill.configureAutotype", { enabled });
configureAutotype: (enabled: boolean, keyboardShortcut: string[]) => {
ipcRenderer.send("autofill.configureAutotype", { enabled, keyboardShortcut });
},
listenAutotypeRequest: (
fn: (

View File

@@ -19,17 +19,36 @@ import { UserId } from "@bitwarden/user-core";
import { DesktopAutotypeDefaultSettingPolicy } from "./desktop-autotype-policy.service";
export const defaultWindowsAutotypeKeyboardShortcut: string[] = ["Control", "Shift", "B"];
export const AUTOTYPE_ENABLED = new KeyDefinition<boolean | null>(
AUTOTYPE_SETTINGS_DISK,
"autotypeEnabled",
{ deserializer: (b) => b },
);
/*
Valid windows shortcut keys: Control, Alt, Super, Shift, letters A - Z
Valid macOS shortcut keys: Control, Alt, Command, Shift, letters A - Z
See Electron keyboard shorcut docs for more info:
https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts
*/
export const AUTOTYPE_KEYBOARD_SHORTCUT = new KeyDefinition<string[]>(
AUTOTYPE_SETTINGS_DISK,
"autotypeKeyboardShortcut",
{ deserializer: (b) => b },
);
export class DesktopAutotypeService {
private readonly autotypeEnabledState = this.globalStateProvider.get(AUTOTYPE_ENABLED);
private readonly autotypeKeyboardShortcut = this.globalStateProvider.get(
AUTOTYPE_KEYBOARD_SHORTCUT,
);
autotypeEnabledUserSetting$: Observable<boolean> = of(false);
resolvedAutotypeEnabled$: Observable<boolean> = of(false);
autotypeKeyboardShortcut$: Observable<string[]> = of(defaultWindowsAutotypeKeyboardShortcut);
constructor(
private accountService: AccountService,
@@ -53,6 +72,9 @@ export class DesktopAutotypeService {
}
async init() {
this.autotypeEnabledUserSetting$ = this.autotypeEnabledState.state$;
this.autotypeKeyboardShortcut$ = this.autotypeKeyboardShortcut.state$;
// Currently Autotype is only supported for Windows
if (this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop) {
// If `autotypeDefaultPolicy` is `true` for a user's organization, and the
@@ -102,11 +124,11 @@ export class DesktopAutotypeService {
),
);
// When the resolvedAutotypeEnabled$ value changes, this might require
// hotkey registration / deregistration in the main process.
this.resolvedAutotypeEnabled$.subscribe((enabled) => {
ipc.autofill.configureAutotype(enabled);
});
combineLatest([this.resolvedAutotypeEnabled$, this.autotypeKeyboardShortcut$]).subscribe(
([resolvedAutotypeEnabled, autotypeKeyboardShortcut]) => {
ipc.autofill.configureAutotype(resolvedAutotypeEnabled, autotypeKeyboardShortcut);
},
);
}
}
@@ -116,6 +138,10 @@ export class DesktopAutotypeService {
});
}
async setAutotypeKeyboardShortcutState(keyboardShortcut: string[]): Promise<void> {
await this.autotypeKeyboardShortcut.update(() => keyboardShortcut);
}
async matchCiphersToWindowTitle(windowTitle: string): Promise<CipherView[]> {
const URI_PREFIX = "apptitle://";
windowTitle = windowTitle.toLowerCase();

View File

@@ -4080,12 +4080,18 @@
"showLess": {
"message": "Show less"
},
"enableAutotype": {
"message": "Enable Autotype"
},
"enableAutotypeDescription": {
"message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut."
},
"typeShortcut": {
"message": "Type shortcut"
},
"editAutotypeShortcutDescription": {
"message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter."
},
"invalidShortcut": {
"message": "Invalid shortcut"
},
"moreBreadcrumbs": {
"message": "More breadcrumbs",
"description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed."
@@ -4099,8 +4105,8 @@
"confirm": {
"message": "Confirm"
},
"enableAutotypeTransitionKey": {
"message": "Enable autotype shortcut"
"enableAutotypeShortcutPreview": {
"message": "Enable autotype shortcut (Feature Preview)"
},
"enableAutotypeDescriptionTransitionKey": {
"message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place."

View File

@@ -360,6 +360,13 @@ form,
}
}
.settings-link {
@include themify($themes) {
color: themed("primaryColor");
}
font-weight: bold;
}
app-root > #loading,
.loading {
display: flex;