1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-01 11:01:17 +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

@@ -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") } };
};
}
}