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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
2
apps/desktop/desktop_native/napi/index.d.ts
vendored
2
apps/desktop/desktop_native/napi/index.d.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") } };
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -360,6 +360,13 @@ form,
|
||||
}
|
||||
}
|
||||
|
||||
.settings-link {
|
||||
@include themify($themes) {
|
||||
color: themed("primaryColor");
|
||||
}
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
app-root > #loading,
|
||||
.loading {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user