From d111ade83aed60b8a21b7c06a264daaf845a66d4 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 4 Jul 2026 09:47:55 +0100 Subject: [PATCH] Support alphabet keyboard shortcuts --- .../Input/DesktopCommandExecutor.cs | 27 +++++++- src/SwitchifyPc.Protocol/ProtocolConstants.cs | 2 +- .../DesktopCommandExecutorTests.cs | 61 ++++++++++++++++++- .../ProtocolConstantsTests.cs | 12 ++++ .../ProtocolValidatorTests.cs | 6 ++ .../RemoteControlSessionTests.cs | 3 +- .../WindowsDesktopInputAdapterTests.cs | 28 +++++++++ .../Input/WindowsInputMapper.cs | 7 ++- 8 files changed, 140 insertions(+), 6 deletions(-) diff --git a/src/SwitchifyPc.Core/Input/DesktopCommandExecutor.cs b/src/SwitchifyPc.Core/Input/DesktopCommandExecutor.cs index 4a70540..9528e71 100644 --- a/src/SwitchifyPc.Core/Input/DesktopCommandExecutor.cs +++ b/src/SwitchifyPc.Core/Input/DesktopCommandExecutor.cs @@ -222,10 +222,35 @@ private async Task PressShortcutAsync(JsonElement keysEl return CommandExecutionResult.Failure("unsafe_payload", "Shortcut key count is invalid."); } - await adapter.PressShortcutAsync(keys, cancellationToken); + await PressShortcutRespectingHeldModifiersAsync(keys, cancellationToken); return CommandExecutionResult.Success; } + private async Task PressShortcutRespectingHeldModifiersAsync(IReadOnlyList keys, CancellationToken cancellationToken) + { + List temporaryKeys = []; + try + { + foreach (string key in keys) + { + if (activeModifierKeys.Contains(key)) + { + continue; + } + + await adapter.SetKeyDownAsync(key, true, cancellationToken).ConfigureAwait(false); + temporaryKeys.Add(key); + } + } + finally + { + foreach (string key in temporaryKeys.AsEnumerable().Reverse()) + { + await adapter.SetKeyDownAsync(key, false, cancellationToken).ConfigureAwait(false); + } + } + } + private async Task TypeTextAsync(string text, CancellationToken cancellationToken) { if (text.Length > ProtocolConstants.MaxTextLength) diff --git a/src/SwitchifyPc.Protocol/ProtocolConstants.cs b/src/SwitchifyPc.Protocol/ProtocolConstants.cs index 0845ee5..59ddb2a 100644 --- a/src/SwitchifyPc.Protocol/ProtocolConstants.cs +++ b/src/SwitchifyPc.Protocol/ProtocolConstants.cs @@ -104,7 +104,7 @@ public static class ProtocolConstants }; public static readonly IReadOnlySet ShortcutKeys = new HashSet( - KeyboardKeys.Concat(["Ctrl", "Alt", "Shift", "Meta", "A", "C", "V", "X", "Z", "Y"]), + KeyboardKeys.Concat(["Ctrl", "Alt", "Shift", "Meta"]).Concat(Enumerable.Range('A', 26).Select(code => ((char)code).ToString())), StringComparer.Ordinal); public static readonly IReadOnlySet ModifierKeys = new HashSet(StringComparer.Ordinal) diff --git a/src/SwitchifyPc.Tests/DesktopCommandExecutorTests.cs b/src/SwitchifyPc.Tests/DesktopCommandExecutorTests.cs index 20da156..122d824 100644 --- a/src/SwitchifyPc.Tests/DesktopCommandExecutorTests.cs +++ b/src/SwitchifyPc.Tests/DesktopCommandExecutorTests.cs @@ -51,8 +51,12 @@ public async Task MapsKeyboardTextMediaWindowAndPingCommands() Assert.Equal( [ "pressKey:Meta", - "pressShortcut:Meta", - "pressShortcut:Ctrl+C", + "setKeyDown:Meta:True", + "setKeyDown:Meta:False", + "setKeyDown:Ctrl:True", + "setKeyDown:C:True", + "setKeyDown:C:False", + "setKeyDown:Ctrl:False", "typeText:Hello", "mediaControl:playPause", "controlWindow:switchNext" @@ -61,6 +65,59 @@ public async Task MapsKeyboardTextMediaWindowAndPingCommands() Assert.Equal(7, overlay.HideCount); } + [Fact] + public async Task KeyboardShortcutsUseTemporaryKeyDownAndUp() + { + FakeInputAdapter adapter = new(); + DesktopCommandExecutor executor = new(adapter); + + await executor.ExecuteAsync(Command("keyboard.shortcut", new { keys = new[] { "Ctrl", "C" } })); + + Assert.Equal( + [ + "setKeyDown:Ctrl:True", + "setKeyDown:C:True", + "setKeyDown:C:False", + "setKeyDown:Ctrl:False" + ], + adapter.Calls); + } + + [Fact] + public async Task KeyboardShortcutsDoNotReleaseHeldModifiers() + { + FakeInputAdapter adapter = new(); + FakeModifierOverlay modifierOverlay = new(); + DesktopCommandExecutor executor = new(adapter, modifierOverlay: modifierOverlay); + + await executor.ExecuteAsync(Command("keyboard.modifierDown", new { key = "Ctrl" })); + await executor.ExecuteAsync(Command("keyboard.shortcut", new { keys = new[] { "Ctrl", "C" } })); + await executor.ExecuteAsync(Command("keyboard.modifierDown", new { key = "Shift" })); + await executor.ExecuteAsync(Command("keyboard.shortcut", new { keys = new[] { "Ctrl", "Shift", "Z" } })); + await executor.ExecuteAsync(Command("keyboard.shortcut", new { keys = new[] { "Ctrl", "Alt", "F" } })); + + Assert.Equal( + [ + "setKeyDown:Ctrl:True", + "setKeyDown:C:True", + "setKeyDown:C:False", + "setKeyDown:Shift:True", + "setKeyDown:Z:True", + "setKeyDown:Z:False", + "setKeyDown:Alt:True", + "setKeyDown:F:True", + "setKeyDown:F:False", + "setKeyDown:Alt:False" + ], + adapter.Calls); + Assert.Equal(["Ctrl", "Shift"], modifierOverlay.Changes.Last()); + + await executor.ReleaseHeldInputsAsync(); + + Assert.EndsWith("setKeyDown:Shift:False", adapter.Calls[^2]); + Assert.EndsWith("setKeyDown:Ctrl:False", adapter.Calls[^1]); + } + [Fact] public async Task StreamsTextCharactersKeysAndChunks() { diff --git a/src/SwitchifyPc.Tests/ProtocolConstantsTests.cs b/src/SwitchifyPc.Tests/ProtocolConstantsTests.cs index dbeb5ae..d5dea90 100644 --- a/src/SwitchifyPc.Tests/ProtocolConstantsTests.cs +++ b/src/SwitchifyPc.Tests/ProtocolConstantsTests.cs @@ -23,6 +23,18 @@ public void PreservesCurrentProtocolLimits() Assert.Equal(300, ProtocolConstants.MaxErrorMessageLength); } + [Fact] + public void ShortcutKeysIncludeUppercaseAlphabetOnly() + { + foreach (string key in Enumerable.Range('A', 26).Select(code => ((char)code).ToString())) + { + Assert.Contains(key, ProtocolConstants.ShortcutKeys); + } + + Assert.DoesNotContain("a", ProtocolConstants.ShortcutKeys); + Assert.Equal(6, ProtocolConstants.MaxShortcutKeys); + } + [Fact] public void IncludesCurrentCommandTypes() { diff --git a/src/SwitchifyPc.Tests/ProtocolValidatorTests.cs b/src/SwitchifyPc.Tests/ProtocolValidatorTests.cs index 882cfa2..944db29 100644 --- a/src/SwitchifyPc.Tests/ProtocolValidatorTests.cs +++ b/src/SwitchifyPc.Tests/ProtocolValidatorTests.cs @@ -29,6 +29,9 @@ public void AcceptsCurrentCommandPayloads() new { type = "keyboard.modifierUp", payload = new { key = "Shift" } }, new { type = "keyboard.modifierUp", payload = new { key = "Meta" } }, new { type = "keyboard.shortcut", payload = new { keys = new[] { "Ctrl", "C" } } }, + new { type = "keyboard.shortcut", payload = new { keys = new[] { "Ctrl", "A" } } }, + new { type = "keyboard.shortcut", payload = new { keys = new[] { "Ctrl", "Shift", "Z" } } }, + new { type = "keyboard.shortcut", payload = new { keys = new[] { "Alt", "F" } } }, new { type = "keyboard.shortcut", payload = new { keys = new[] { "Meta" } } }, new { type = "keyboard.typeText", payload = new { text = "Hello" } }, new { type = "keyboard.textStream.open", payload = new { streamId = "android-stream-1" } }, @@ -134,6 +137,9 @@ public void RejectsUnsafePayloads() [ BaseCommand("mouse.move", new { dx = 501, dy = 0 }), BaseCommand("keyboard.shortcut", new { keys = Array.Empty() }), + BaseCommand("keyboard.shortcut", new { keys = new[] { "Ctrl", "a" } }), + BaseCommand("keyboard.shortcut", new { keys = new[] { "Ctrl", "1" } }), + BaseCommand("keyboard.shortcut", new { keys = new[] { "Ctrl", "." } }), BaseCommand("keyboard.key", new { key = "F13" }), BaseCommand("keyboard.key", new { key = "Win" }), BaseCommand("keyboard.key", new { key = "Windows" }), diff --git a/src/SwitchifyPc.Tests/RemoteControlSessionTests.cs b/src/SwitchifyPc.Tests/RemoteControlSessionTests.cs index fb8266f..a3f0239 100644 --- a/src/SwitchifyPc.Tests/RemoteControlSessionTests.cs +++ b/src/SwitchifyPc.Tests/RemoteControlSessionTests.cs @@ -206,7 +206,8 @@ public async Task DelegatesShortcutTextMediaAndWindowCommandsToCommandSession() result => Assert.Equal("ble-1", Assert.Single(result.OutgoingMessages).ConnectionId)); Assert.Equal( [ - "pressShortcut:Meta", + "setKeyDown:Meta:True", + "setKeyDown:Meta:False", "typeText:Hello", "mediaControl:playPause", "controlWindow:showDesktop" diff --git a/src/SwitchifyPc.Tests/WindowsDesktopInputAdapterTests.cs b/src/SwitchifyPc.Tests/WindowsDesktopInputAdapterTests.cs index acd37b7..0f091e4 100644 --- a/src/SwitchifyPc.Tests/WindowsDesktopInputAdapterTests.cs +++ b/src/SwitchifyPc.Tests/WindowsDesktopInputAdapterTests.cs @@ -74,6 +74,34 @@ public async Task PressesMetaAsWindowsKey() Assert.Equal(["key:91:down", "key:91:up"], native.Calls); } + [Fact] + public async Task MapsUppercaseShortcutLettersToVirtualKeys() + { + FakeNativeInput native = new(); + WindowsDesktopInputAdapter adapter = new(native); + + await adapter.PressShortcutAsync(["A", "M", "Z"]); + + Assert.Equal( + [ + "key:65:down", + "key:77:down", + "key:90:down", + "key:90:up", + "key:77:up", + "key:65:up" + ], + native.Calls); + } + + [Fact] + public async Task RejectsLowercaseShortcutLetters() + { + WindowsDesktopInputAdapter adapter = new(new FakeNativeInput()); + + await Assert.ThrowsAsync(() => adapter.PressShortcutAsync(["a"])); + } + [Fact] public async Task SetsModifierKeysDownAndUp() { diff --git a/src/SwitchifyPc.Windows/Input/WindowsInputMapper.cs b/src/SwitchifyPc.Windows/Input/WindowsInputMapper.cs index aa7b2c1..7084595 100644 --- a/src/SwitchifyPc.Windows/Input/WindowsInputMapper.cs +++ b/src/SwitchifyPc.Windows/Input/WindowsInputMapper.cs @@ -65,7 +65,7 @@ public static ushort KeyboardVirtualKey(string key) "Alt" => VkAlt, "Shift" => VkShift, "Meta" => VkLeftWindows, - "A" or "C" or "V" or "X" or "Y" or "Z" => key[0], + _ when IsUppercaseLetterKey(key) => key[0], _ when IsFunctionKey(key, out ushort virtualKey) => virtualKey, _ => throw new ArgumentOutOfRangeException(nameof(key), key, null) }; @@ -106,4 +106,9 @@ private static bool IsFunctionKey(string key, out ushort virtualKey) virtualKey = (ushort)(VkF1 + functionIndex - 1); return true; } + + private static bool IsUppercaseLetterKey(string key) + { + return key.Length == 1 && key[0] is >= 'A' and <= 'Z'; + } }