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:
@@ -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>
|
||||
@@ -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" } });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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") } };
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user