Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/desktop/desktop_native/autotype/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
use anyhow::Result;

#[cfg(target_os = "windows")]
mod modifier_keys;

#[cfg(target_os = "windows")]
pub(crate) use modifier_keys::*;

#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]
#[cfg_attr(target_os = "windows", path = "windows/mod.rs")]
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/desktop_native/autotype/src/modifier_keys.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
use std::{collections::HashMap, sync::OnceLock};

// Electron modifier keys
// <https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts#cross-platform-modifiers>
pub(crate) const CONTROL_KEY_STR: &str = "Control";
pub(crate) const ALT_KEY_STR: &str = "Alt";
pub(crate) const SUPER_KEY_STR: &str = "Super";

// numeric values for modifier keys
pub(crate) const CONTROL_KEY: u16 = 0x11;
pub(crate) const ALT_KEY: u16 = 0x12;
pub(crate) const SUPER_KEY: u16 = 0x5B;

/// A mapping of <Electron modifier key string> to <numeric representation>
static MODIFIER_KEYS: OnceLock<HashMap<&str, u16>> = OnceLock::new();

/// Provides a mapping of the valid modifier keys' electron
/// string representation to the numeric representation.
pub(crate) fn get_modifier_keys() -> &'static HashMap<&'static str, u16> {
MODIFIER_KEYS.get_or_init(|| {
HashMap::from([
(CONTROL_KEY_STR, CONTROL_KEY),
(ALT_KEY_STR, ALT_KEY),
(SUPER_KEY_STR, SUPER_KEY),
])
})
}

#[cfg(test)]
mod test {
use super::get_modifier_keys;

#[test]
fn valid_modifier_keys() {
assert_eq!(get_modifier_keys().get("Control").unwrap(), &0x11);
assert_eq!(get_modifier_keys().get("Alt").unwrap(), &0x12);
assert_eq!(get_modifier_keys().get("Super").unwrap(), &0x5B);
}

#[test]
fn does_not_contain_invalid_modifier_keys() {
assert!(!get_modifier_keys().contains_key("Shift"));
}
}
1 change: 0 additions & 1 deletion apps/desktop/desktop_native/autotype/src/windows/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ pub fn get_foreground_window_title() -> Result<String> {
/// - Control
/// - Alt
/// - Super
/// - Shift
/// - \[a-z\]\[A-Z\]
struct KeyboardShortcutInput(INPUT);

Expand Down
48 changes: 27 additions & 21 deletions apps/desktop/desktop_native/autotype/src/windows/type_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,9 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{
VIRTUAL_KEY,
};

use super::{ErrorOperations, KeyboardShortcutInput, Win32ErrorOperations};
use crate::get_modifier_keys;

const SHIFT_KEY_STR: &str = "Shift";
const CONTROL_KEY_STR: &str = "Control";
const ALT_KEY_STR: &str = "Alt";
const LEFT_WINDOWS_KEY_STR: &str = "Super";
use super::{ErrorOperations, KeyboardShortcutInput, Win32ErrorOperations};

const IS_VIRTUAL_KEY: bool = true;
const IS_REAL_KEY: bool = false;
Expand Down Expand Up @@ -92,22 +89,19 @@ impl TryFrom<&str> for KeyboardShortcutInput {
type Error = anyhow::Error;

fn try_from(key: &str) -> std::result::Result<Self, Self::Error> {
const SHIFT_KEY: u16 = 0x10;
const CONTROL_KEY: u16 = 0x11;
const ALT_KEY: u16 = 0x12;
const LEFT_WINDOWS_KEY: u16 = 0x5B;

// not modifier key
if key.len() == 1 {
let input = build_unicode_input(InputKeyPress::Up, get_alphabetic_hotkey(key)?);
return Ok(KeyboardShortcutInput(input));
}
// the modifier keys are using the Up keypress variant because the user has already
// pressed those keys in order to trigger the feature.
let input = match key {
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)?),
};

Ok(KeyboardShortcutInput(input))
if let Some(numeric_modifier_key) = get_modifier_keys().get(key) {
let input = build_virtual_key_input(InputKeyPress::Up, *numeric_modifier_key);
return Ok(KeyboardShortcutInput(input));
} else {
return Err(anyhow!("Unsupported modifier key: {key}"));
}
}
}

Expand Down Expand Up @@ -279,7 +273,7 @@ mod tests {
#[test]
#[serial]
fn keyboard_shortcut_conversion_succeeds() {
let keyboard_shortcut = [CONTROL_KEY_STR, SHIFT_KEY_STR, "B"];
let keyboard_shortcut = ["Control", "Alt", "B"];
let _: Vec<KeyboardShortcutInput> = keyboard_shortcut
.iter()
.map(|s| KeyboardShortcutInput::try_from(*s))
Expand All @@ -291,7 +285,19 @@ mod tests {
#[serial]
#[should_panic = "Letter is not ASCII Alphabetic ([a-z][A-Z]): '1'"]
fn keyboard_shortcut_conversion_fails_invalid_key() {
let keyboard_shortcut = [CONTROL_KEY_STR, SHIFT_KEY_STR, "1"];
let keyboard_shortcut = ["Control", "Alt", "1"];
let _: Vec<KeyboardShortcutInput> = keyboard_shortcut
.iter()
.map(|s| KeyboardShortcutInput::try_from(*s))
.try_collect()
.unwrap();
}

#[test]
#[serial]
#[should_panic(expected = "Unsupported modifier key: Shift")]
fn keyboard_shortcut_conversion_fails_with_shift() {
let keyboard_shortcut = ["Control", "Shift", "B"];
let _: Vec<KeyboardShortcutInput> = keyboard_shortcut
.iter()
.map(|s| KeyboardShortcutInput::try_from(*s))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</div>
<div bitDialogContent>
<p>
{{ "editAutotypeShortcutDescription" | i18n }}
{{ "editAutotypeKeyboardModifiersDescription" | i18n }}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ The i18n key changed from editAutotypeShortcutDescription to editAutotypeKeyboardModifiersDescription. Please verify that:

  1. All language translation files (not just en/messages.json) have been updated with this new key
  2. The old key has been removed from all language files to avoid confusion

Otherwise, non-English users may see missing translations.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this happens as a follow up in some shape or form

</p>
<bit-form-field>
<bit-label>{{ "typeShortcut" | i18n }}</bit-label>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,9 @@ describe("AutotypeShortcutComponent", () => {
const validShortcuts = [
"Control+A",
"Alt+B",
"Shift+C",
"Win+D",
"control+e", // case insensitive
"ALT+F",
"SHIFT+G",
"WIN+H",
];

Expand All @@ -46,14 +44,7 @@ describe("AutotypeShortcutComponent", () => {
});

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",
];
const validShortcuts = ["Control+Alt+A", "Control+Win+C", "Alt+Win+D", "Alt+Win+E"];

validShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
Expand All @@ -63,7 +54,7 @@ describe("AutotypeShortcutComponent", () => {
});

it("should accept modifiers in different orders", () => {
const validShortcuts = ["Alt+Control+A", "Shift+Control+B", "Win+Alt+C"];
const validShortcuts = ["Alt+Control+A", "Win+Control+B", "Win+Alt+C"];

validShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
Expand All @@ -88,15 +79,14 @@ describe("AutotypeShortcutComponent", () => {
const invalidShortcuts = [
"Control+1",
"Alt+2",
"Shift+3",
"Win+4",
"Control+!",
"Alt+@",
"Shift+#",
"Alt+#",
"Win+$",
"Control+Space",
"Alt+Enter",
"Shift+Tab",
"Control+Tab",
"Win+Escape",
];

Expand All @@ -111,12 +101,10 @@ describe("AutotypeShortcutComponent", () => {
const invalidShortcuts = [
"Control",
"Alt",
"Shift",
"Win",
"Control+Alt",
"Control+Shift",
"Alt+Shift",
"Control+Alt+Shift",
"Control+Win",
"Control+Alt+Win",
];

invalidShortcuts.forEach((shortcut) => {
Expand All @@ -127,7 +115,7 @@ describe("AutotypeShortcutComponent", () => {
});

it("should reject shortcuts with invalid modifier names", () => {
const invalidShortcuts = ["Ctrl+A", "Command+A", "Super+A", "Meta+A", "Cmd+A", "Invalid+A"];
const invalidShortcuts = ["Ctrl+A", "Command+A", "Meta+A", "Cmd+A", "Invalid+A"];

invalidShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
Expand All @@ -137,7 +125,7 @@ describe("AutotypeShortcutComponent", () => {
});

it("should reject shortcuts with multiple base keys", () => {
const invalidShortcuts = ["Control+A+B", "Alt+Ctrl+Shift"];
const invalidShortcuts = ["Control+A+B", "Alt+Ctrl+Win"];

invalidShortcuts.forEach((shortcut) => {
const control = createControl(shortcut);
Expand All @@ -148,11 +136,10 @@ describe("AutotypeShortcutComponent", () => {

it("should reject shortcuts with more than two modifiers", () => {
const invalidShortcuts = [
"Control+Alt+Shift+A",
"Control+Alt+Win+A",
"Control+Alt+Win+B",
"Control+Shift+Win+C",
"Alt+Shift+Win+D",
"Control+Alt+Shift+Win+E",
"Control+Alt+Win+C",
"Alt+Control+Win+D",
];

invalidShortcuts.forEach((shortcut) => {
Expand Down Expand Up @@ -221,7 +208,7 @@ describe("AutotypeShortcutComponent", () => {
});

it("should handle very long strings", () => {
const longString = "Control+Alt+Shift+Win+A".repeat(100);
const longString = "Control+Alt+Win+A".repeat(100);
const control = createControl(longString);
const result = validator(control);
expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } });
Expand All @@ -230,7 +217,7 @@ describe("AutotypeShortcutComponent", () => {

describe("modifier combinations", () => {
it("should accept all possible single modifier combinations", () => {
const modifiers = ["Control", "Alt", "Shift", "Win"];
const modifiers = ["Control", "Alt", "Win"];

modifiers.forEach((modifier) => {
const control = createControl(`${modifier}+A`);
Expand All @@ -240,14 +227,7 @@ describe("AutotypeShortcutComponent", () => {
});

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",
];
const combinations = ["Control+Alt+A", "Control+Win+A", "Alt+Win+A"];

combinations.forEach((shortcut) => {
const control = createControl(shortcut);
Expand All @@ -257,25 +237,14 @@ describe("AutotypeShortcutComponent", () => {
});

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",
];
const combinations = ["Control+Alt+Win+A", "Alt+Control+Win+A", "Win+Alt+Control+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" } });
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -77,25 +77,31 @@ export class AutotypeShortcutComponent {
}
}

// <https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent>
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
const hasSuper = event.metaKey; // Windows key on Windows, Command on macOS

// Require at least one modifier (Control, Alt, Shift, or Super)
if (!hasCtrl && !hasAlt && !hasShift && !hasMeta) {
// Require at least one valid modifier (Control, Alt, Super)
if (!hasCtrl && !hasAlt && !hasSuper) {
return null;
}

const key = event.key;

// Ignore pure modifier keys themselves
if (key === "Control" || key === "Alt" || key === "Shift" || key === "Meta") {
// disallow pure modifier keys themselves
if (key === "Control" || key === "Alt" || key === "Super") {
return null;
}

// Accept a single alphabetical letter as the base key
// disallow shift modifier
if (hasShift) {
return null;
}

// require a single alphabetical letter as the base key
const isAlphabetical = typeof key === "string" && /^[a-z]$/i.test(key);
if (!isAlphabetical) {
return null;
Expand All @@ -108,10 +114,7 @@ export class AutotypeShortcutComponent {
if (hasAlt) {
parts.push("Alt");
}
if (hasShift) {
parts.push("Shift");
}
if (hasMeta) {
if (hasSuper) {
parts.push("Super");
}
parts.push(key.toUpperCase());
Expand All @@ -129,10 +132,9 @@ export class AutotypeShortcutComponent {
}

// Must include exactly 1-2 modifiers and end with a single letter
// Valid examples: Ctrl+A, Shift+Z, Ctrl+Shift+X, Alt+Shift+Q
// Valid examples: Ctrl+A, Alt+B, Ctrl+Alt+X, Alt+Control+Q, Win+B, Ctrl+Win+A
// 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;
const pattern = /^(?=.*\b(Control|Alt|Win)\b)(?:Control\+|Alt\+|Win\+){1,2}[A-Z]$/i;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ This regex pattern will accept duplicate modifiers like Control+Control+A as valid. While this may not cause immediate issues, it's semantically incorrect.

Suggested improvement

Consider adding validation to reject duplicate modifiers:

const pattern = /^(?:(?:Control|Alt|Win)\+){1,2}[A-Z]$/i;
if (!pattern.test(value)) {
  return { invalidShortcut: { message: this.i18nService.t("invalidShortcut") } };
}

// Validate no duplicate modifiers
const parts = value.split('+');
const modifiers = parts.slice(0, -1);
const uniqueModifiers = new Set(modifiers.map(m => m.toLowerCase()));
return uniqueModifiers.size === modifiers.length 
  ? null 
  : { invalidShortcut: { message: this.i18nService.t("invalidShortcut") } };

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't introduce this, it was existing, but it seems legit. If so, I'd vote to open a separate bug

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, it shouldn't be physically possible to do a double modifier key, because the first is already pressed down. but it doesn't hurt to guard against or at least comment on

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โŒ Critical: Regex allows duplicate modifiers

While you noted this was pre-existing, the current regex pattern accepts invalid shortcuts like Control+Control+A or Alt+Alt+B. This is semantically incorrect and should be fixed.

Why it happens:
The pattern (?:Control\+|Alt\+|Win\+){1,2} matches any sequence of 1-2 modifier+plus combinations without checking for uniqueness.

Recommended fix:

const pattern = /^(?=.*\b(Control|Alt|Win)\b)(?:Control\+|Alt\+|Win\+){1,2}[A-Z]$/i;
if (!pattern.test(value)) {
  return { invalidShortcut: { message: this.i18nService.t("invalidShortcut") } };
}

// Additional check: ensure no duplicate modifiers
const parts = value.split('+');
const modifiers = parts.slice(0, -1).map(m => m.toLowerCase());
const uniqueModifiers = new Set(modifiers);
if (uniqueModifiers.size !== modifiers.length) {
  return { invalidShortcut: { message: this.i18nService.t("invalidShortcut") } };
}

return null;

While physically triggering duplicate modifiers may be impossible, validating against it prevents edge cases and maintains data integrity.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Validation gap: Regex allows duplicate modifiers

While you noted this is pre-existing and physically impossible to trigger, the regex accepts Control+Control+A or Alt+Alt+B. Consider adding explicit duplicate detection for data integrity:

const pattern = /^(?=.*\b(Control|Alt|Win)\b)(?:Control\+|Alt\+|Win\+){1,2}[A-Z]$/i;
if (!pattern.test(value)) {
  return { invalidShortcut: { message: this.i18nService.t("invalidShortcut") } };
}

// Validate no duplicate modifiers
const parts = value.split('+');
const modifiers = parts.slice(0, -1).map(m => m.toLowerCase());
const uniqueModifiers = new Set(modifiers);
if (uniqueModifiers.size !== modifiers.length) {
  return { invalidShortcut: { message: this.i18nService.t("invalidShortcut") } };
}

return null;

return pattern.test(value)
? null
: { invalidShortcut: { message: this.i18nService.t("invalidShortcut") } };
Expand Down
Loading
Loading