From fc53eae4c5df8d0d4b696ea2fa8b09f39e13d5d7 Mon Sep 17 00:00:00 2001 From: Colton Hurst Date: Mon, 29 Sep 2025 10:20:15 -0400 Subject: [PATCH] [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 Co-authored-by: neuronull <9162534+neuronull@users.noreply.github.com> --- .../desktop_native/autotype/src/lib.rs | 4 +- .../desktop_native/autotype/src/linux.rs | 5 +- .../desktop_native/autotype/src/macos.rs | 9 +- .../desktop_native/autotype/src/windows.rs | 96 +++++- apps/desktop/desktop_native/napi/index.d.ts | 2 +- apps/desktop/desktop_native/napi/src/lib.rs | 7 +- .../src/app/accounts/settings.component.html | 13 +- .../app/accounts/settings.component.spec.ts | 1 + .../src/app/accounts/settings.component.ts | 33 ++ .../autotype-shortcut.component.html | 33 ++ .../autotype-shortcut.component.spec.ts | 281 ++++++++++++++++++ .../components/autotype-shortcut.component.ts | 139 +++++++++ .../main/main-desktop-autotype.service.ts | 68 +++-- .../models/main-autotype-keyboard-shortcut.ts | 98 ++++++ apps/desktop/src/autofill/preload.ts | 4 +- .../services/desktop-autotype.service.ts | 36 ++- apps/desktop/src/locales/en/messages.json | 16 +- apps/desktop/src/scss/misc.scss | 7 + 18 files changed, 802 insertions(+), 50 deletions(-) create mode 100644 apps/desktop/src/autofill/components/autotype-shortcut.component.html create mode 100644 apps/desktop/src/autofill/components/autotype-shortcut.component.spec.ts create mode 100644 apps/desktop/src/autofill/components/autotype-shortcut.component.ts create mode 100644 apps/desktop/src/autofill/models/main-autotype-keyboard-shortcut.ts diff --git a/apps/desktop/desktop_native/autotype/src/lib.rs b/apps/desktop/desktop_native/autotype/src/lib.rs index 6d7b9f9db8..f1aab2ba16 100644 --- a/apps/desktop/desktop_native/autotype/src/lib.rs +++ b/apps/desktop/desktop_native/autotype/src/lib.rs @@ -17,6 +17,6 @@ pub fn get_foreground_window_title() -> std::result::Result { /// /// TODO: The error handling will be improved in a future PR: PM-23615 #[allow(clippy::result_unit_err)] -pub fn type_input(input: Vec) -> std::result::Result<(), ()> { - windowing::type_input(input) +pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> std::result::Result<(), ()> { + windowing::type_input(input, keyboard_shortcut) } diff --git a/apps/desktop/desktop_native/autotype/src/linux.rs b/apps/desktop/desktop_native/autotype/src/linux.rs index d53d7af0bd..148b1aab6e 100644 --- a/apps/desktop/desktop_native/autotype/src/linux.rs +++ b/apps/desktop/desktop_native/autotype/src/linux.rs @@ -2,6 +2,9 @@ pub fn get_foreground_window_title() -> std::result::Result { todo!("Bitwarden does not yet support Linux autotype"); } -pub fn type_input(_input: Vec) -> std::result::Result<(), ()> { +pub fn type_input( + _input: Vec, + _keyboard_shortcut: Vec, +) -> std::result::Result<(), ()> { todo!("Bitwarden does not yet support Linux autotype"); } diff --git a/apps/desktop/desktop_native/autotype/src/macos.rs b/apps/desktop/desktop_native/autotype/src/macos.rs index 7ab9f5441b..5542e7a3a6 100644 --- a/apps/desktop/desktop_native/autotype/src/macos.rs +++ b/apps/desktop/desktop_native/autotype/src/macos.rs @@ -1,7 +1,10 @@ pub fn get_foreground_window_title() -> std::result::Result { - todo!("Bitwarden does not yet support Mac OS autotype"); + todo!("Bitwarden does not yet support macOS autotype"); } -pub fn type_input(_input: Vec) -> std::result::Result<(), ()> { - todo!("Bitwarden does not yet support Mac OS autotype"); +pub fn type_input( + _input: Vec, + _keyboard_shortcut: Vec, +) -> std::result::Result<(), ()> { + todo!("Bitwarden does not yet support macOS autotype"); } diff --git a/apps/desktop/desktop_native/autotype/src/windows.rs b/apps/desktop/desktop_native/autotype/src/windows.rs index f1f9bee7f6..1d39d3f7ae 100644 --- a/apps/desktop/desktop_native/autotype/src/windows.rs +++ b/apps/desktop/desktop_native/autotype/src/windows.rs @@ -25,25 +25,29 @@ pub fn get_foreground_window_title() -> std::result::Result { /// 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) -> Result<(), ()> { - const TAB_KEY: u16 = 9; +pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<(), ()> { + const TAB_KEY: u8 = 9; + let mut keyboard_inputs: Vec = 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) -> 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 { + 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 { + 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) -> 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(); + } +} diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 2212c03f4f..030bf4c964 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -234,5 +234,5 @@ export declare namespace chromium_importer { } export declare namespace autotype { export function getForegroundWindowTitle(): string - export function typeInput(input: Array): void + export function typeInput(input: Array, keyboardShortcut: Array): void } diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 4731166852..327c7c1c8e 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -1044,8 +1044,11 @@ pub mod autotype { } #[napi] - pub fn type_input(input: Vec) -> napi::Result<(), napi::Status> { - autotype::type_input(input).map_err(|_| { + pub fn type_input( + input: Vec, + keyboard_shortcut: Vec, + ) -> napi::Result<(), napi::Status> { + autotype::type_input(input, keyboard_shortcut).map_err(|_| { napi::Error::from_reason("Autotype Error: failed to type input".to_string()) }) } diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index 18f7f67abc..a0380a8b5c 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -340,7 +340,10 @@ (change)="saveEnableAutotype()" />
- {{ "enableAutotypeTransitionKey" | i18n }} + {{ "enableAutotypeShortcutPreview" | i18n }} +
+ {{ form.value.autotypeShortcut }} +
@@ -348,7 +351,13 @@ {{ "important" | i18n }} {{ "enableAutotypeDescriptionTransitionKey" | i18n }} - {{ "editShortcut" | i18n }} + {{ "editShortcut" | i18n }} +
diff --git a/apps/desktop/src/app/accounts/settings.component.spec.ts b/apps/desktop/src/app/accounts/settings.component.spec.ts index 082801cba0..a791fd7b9a 100644 --- a/apps/desktop/src/app/accounts/settings.component.spec.ts +++ b/apps/desktop/src/app/accounts/settings.component.spec.ts @@ -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)); }); diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 0ec77419d0..030027913b 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -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 { @@ -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; + } } diff --git a/apps/desktop/src/autofill/components/autotype-shortcut.component.html b/apps/desktop/src/autofill/components/autotype-shortcut.component.html new file mode 100644 index 0000000000..774c299e0b --- /dev/null +++ b/apps/desktop/src/autofill/components/autotype-shortcut.component.html @@ -0,0 +1,33 @@ +
+ +
+ {{ "typeShortcut" | i18n }} +
+
+

+ {{ "editAutotypeShortcutDescription" | i18n }} +

+ + {{ "typeShortcut" | i18n }} + + +
+ + + + +
+
diff --git a/apps/desktop/src/autofill/components/autotype-shortcut.component.spec.ts b/apps/desktop/src/autofill/components/autotype-shortcut.component.spec.ts new file mode 100644 index 0000000000..90aa493c59 --- /dev/null +++ b/apps/desktop/src/autofill/components/autotype-shortcut.component.spec.ts @@ -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; + let i18nService: MockProxy; + + beforeEach(() => { + formBuilder = mock(); + i18nService = mock(); + 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" } }); + }); + }); + }); +}); diff --git a/apps/desktop/src/autofill/components/autotype-shortcut.component.ts b/apps/desktop/src/autofill/components/autotype-shortcut.component.ts new file mode 100644 index 0000000000..5cf1d90cb7 --- /dev/null +++ b/apps/desktop/src/autofill/components/autotype-shortcut.component.ts @@ -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(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") } }; + }; + } +} diff --git a/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts b/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts index 884a4fc1ce..09f03d2ef8 100644 --- a/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts +++ b/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts @@ -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(inputPattern.length); @@ -65,6 +97,6 @@ export class MainDesktopAutotypeService { inputArray[i] = inputPattern.charCodeAt(i); } - autotype.typeInput(inputArray); + autotype.typeInput(inputArray, keyboardShortcut); } } diff --git a/apps/desktop/src/autofill/models/main-autotype-keyboard-shortcut.ts b/apps/desktop/src/autofill/models/main-autotype-keyboard-shortcut.ts new file mode 100644 index 0000000000..b26be92585 --- /dev/null +++ b/apps/desktop/src/autofill/models/main-autotype-keyboard-shortcut.ts @@ -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; + } +} diff --git a/apps/desktop/src/autofill/preload.ts b/apps/desktop/src/autofill/preload.ts index af238b17e8..fcb2f64674 100644 --- a/apps/desktop/src/autofill/preload.ts +++ b/apps/desktop/src/autofill/preload.ts @@ -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: ( diff --git a/apps/desktop/src/autofill/services/desktop-autotype.service.ts b/apps/desktop/src/autofill/services/desktop-autotype.service.ts index b156ffd359..34f70be64c 100644 --- a/apps/desktop/src/autofill/services/desktop-autotype.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autotype.service.ts @@ -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( 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( + 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 = of(false); resolvedAutotypeEnabled$: Observable = of(false); + autotypeKeyboardShortcut$: Observable = 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 { + await this.autotypeKeyboardShortcut.update(() => keyboardShortcut); + } + async matchCiphersToWindowTitle(windowTitle: string): Promise { const URI_PREFIX = "apptitle://"; windowTitle = windowTitle.toLowerCase(); diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index aa597a6bf9..08ec76af87 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -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." diff --git a/apps/desktop/src/scss/misc.scss b/apps/desktop/src/scss/misc.scss index 3c3d4ff508..b64bdd9212 100644 --- a/apps/desktop/src/scss/misc.scss +++ b/apps/desktop/src/scss/misc.scss @@ -360,6 +360,13 @@ form, } } +.settings-link { + @include themify($themes) { + color: themed("primaryColor"); + } + font-weight: bold; +} + app-root > #loading, .loading { display: flex;