Skip to content

Commit 2b9aed7

Browse files
authored
Merge pull request #366 from switchifyapp/feat/modifier-key-toggle-365
Add toggleable modifier key support
2 parents 6974747 + 98d9174 commit 2b9aed7

20 files changed

Lines changed: 709 additions & 16 deletions

src/SwitchifyPc.App/App.xaml.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
using SwitchifyPc.Windows.Bluetooth;
2020
using SwitchifyPc.Windows.CursorOverlay;
2121
using SwitchifyPc.Windows.Input;
22+
using SwitchifyPc.Windows.ModifierOverlay;
2223
using SwitchifyPc.Windows.Startup;
2324
using SwitchifyPc.Windows.Updates;
2425
using SwitchifyPc.Protocol;
@@ -44,6 +45,7 @@ public partial class App : System.Windows.Application
4445
private DesktopCommandExecutor? commandExecutor;
4546
private MouseRepeatController? mouseRepeatController;
4647
private WindowsCursorOverlayNotifier? cursorOverlay;
48+
private WindowsModifierKeyOverlayNotifier? modifierOverlay;
4749
private DispatcherTimer? pairingExpiryTimer;
4850
private AppThemeManager? themeManager;
4951
private bool isQuitting;
@@ -117,6 +119,8 @@ protected override void OnExit(ExitEventArgs e)
117119
bluetoothServer = null;
118120
cursorOverlay?.Dispose();
119121
cursorOverlay = null;
122+
modifierOverlay?.Dispose();
123+
modifierOverlay = null;
120124
themeManager?.Dispose();
121125
themeManager = null;
122126
commandExecutor = null;
@@ -275,7 +279,8 @@ private async Task StartBluetoothAsync()
275279
SendInputWindowsNativeInput nativeInput = new();
276280
WindowsDesktopInputAdapter inputAdapter = new(nativeInput, pointerSettingsStore.Load());
277281
cursorOverlay = new WindowsCursorOverlayNotifier(nativeInput, cursorOverlaySettingsStore);
278-
commandExecutor = new DesktopCommandExecutor(inputAdapter, cursorOverlay);
282+
modifierOverlay = new WindowsModifierKeyOverlayNotifier(nativeInput);
283+
commandExecutor = new DesktopCommandExecutor(inputAdapter, cursorOverlay, modifierOverlay: modifierOverlay);
279284
mouseRepeatController = new MouseRepeatController(commandExecutor, mouseRepeatSettingsStore);
280285
ControlSession controlSession = new(
281286
new CommandAuthValidator(pairingStore),
@@ -328,10 +333,11 @@ private void HandleBluetoothEvent(BluetoothHelperEvent helperEvent)
328333

329334
if (commandExecutor is not null)
330335
{
331-
await commandExecutor.ReleaseHeldMouseButtonsAsync();
336+
await commandExecutor.ReleaseHeldInputsAsync();
332337
}
333338

334339
cursorOverlay?.EndControlSession();
340+
modifierOverlay?.EndControlSession();
335341
}
336342
break;
337343
case BluetoothDiagnosticEvent diagnostic:

src/SwitchifyPc.Core/Control/ControlSession.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,16 @@ public async Task<ControlSessionResult> ProcessMessageAsync(string rawMessage, C
8686
await StopRepeatAsync(failedDeviceId!).ConfigureAwait(false);
8787
}
8888

89+
await commandExecutor.ReleaseHeldInputsAsync(cancellationToken).ConfigureAwait(false);
90+
commandExecutor.EndControlSession();
8991
return ControlSessionResult.Response(ErrorResponse(RequestIdOrNull(request), auth.Reason ?? "invalid_auth", "Command authentication failed."))
9092
with { AuthFailureReason = auth.Reason ?? "invalid_auth" };
9193
}
9294

9395
if (type == "connection.disconnecting")
9496
{
9597
await StopRepeatAsync(auth.DeviceId ?? "").ConfigureAwait(false);
96-
await commandExecutor.ReleaseHeldMouseButtonsAsync(cancellationToken).ConfigureAwait(false);
98+
await commandExecutor.ReleaseHeldInputsAsync(cancellationToken).ConfigureAwait(false);
9799
commandExecutor.EndControlSession();
98100
return WithAuth(AckOrNoResponse(request), auth);
99101
}
@@ -147,6 +149,13 @@ public async Task<ControlSessionResult> ProcessMessageAsync(string rawMessage, C
147149

148150
public Task StopAllRepeatsAsync() => mouseRepeatController?.StopAllAsync() ?? Task.CompletedTask;
149151

152+
public async Task EndControlSessionAsync(CancellationToken cancellationToken = default)
153+
{
154+
await StopAllRepeatsAsync().ConfigureAwait(false);
155+
await commandExecutor.ReleaseHeldInputsAsync(cancellationToken).ConfigureAwait(false);
156+
commandExecutor.EndControlSession();
157+
}
158+
150159
private static ControlSessionResult WithAuth(ControlSessionResult result, AuthValidationResult auth)
151160
{
152161
return result with

src/SwitchifyPc.Core/Control/RemoteControlSession.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ public RemoteSessionResult ExpirePendingPairingRequests()
133133

134134
public void RemoveConnection(string connectionId)
135135
{
136-
_ = commandSession.StopAllRepeatsAsync();
136+
_ = commandSession.EndControlSessionAsync();
137137
string[] requestIds = pendingConnectionsByRequestId
138138
.Where(entry => entry.Value.ConnectionId == connectionId)
139139
.Select(entry => entry.Key)

src/SwitchifyPc.Core/Input/DesktopCommandExecutor.cs

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,21 @@ public sealed class DesktopCommandExecutor
1515

1616
private readonly IDesktopInputAdapter adapter;
1717
private readonly ICursorOverlayNotifier? cursorOverlay;
18+
private readonly IModifierKeyOverlayNotifier? modifierOverlay;
1819
private readonly Func<double> now;
1920
private readonly Dictionary<string, TextInputStreamState> textInputStreams = new(StringComparer.Ordinal);
21+
private readonly HashSet<string> activeModifierKeys = new(StringComparer.Ordinal);
2022
private string? activeDragButton;
2123

22-
public DesktopCommandExecutor(IDesktopInputAdapter adapter, ICursorOverlayNotifier? cursorOverlay = null, Func<double>? now = null)
24+
public DesktopCommandExecutor(
25+
IDesktopInputAdapter adapter,
26+
ICursorOverlayNotifier? cursorOverlay = null,
27+
Func<double>? now = null,
28+
IModifierKeyOverlayNotifier? modifierOverlay = null)
2329
{
2430
this.adapter = adapter;
2531
this.cursorOverlay = cursorOverlay;
32+
this.modifierOverlay = modifierOverlay;
2633
this.now = now ?? (() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
2734
}
2835

@@ -52,6 +59,8 @@ public async Task<CommandExecutionResult> ExecuteAsync(JsonElement command, Canc
5259
"mouse.rightClick" => await RightClickMouseAsync(cancellationToken),
5360
"mouse.scroll" => await ScrollMouseAsync(payload, cancellationToken),
5461
"keyboard.key" => await PressKeyAsync(payload.GetProperty("key").GetString() ?? "", cancellationToken),
62+
"keyboard.modifierDown" => await SetModifierAsync(payload.GetProperty("key").GetString() ?? "", down: true, cancellationToken),
63+
"keyboard.modifierUp" => await SetModifierAsync(payload.GetProperty("key").GetString() ?? "", down: false, cancellationToken),
5564
"keyboard.shortcut" => await PressShortcutAsync(payload.GetProperty("keys"), cancellationToken),
5665
"keyboard.typeText" => await TypeTextAsync(payload.GetProperty("text").GetString() ?? "", cancellationToken),
5766
"keyboard.textStream.open" => OpenTextInputStream(command.GetProperty("deviceId").GetString() ?? "", payload.GetProperty("streamId").GetString() ?? ""),
@@ -79,18 +88,21 @@ public async Task<CommandExecutionResult> ExecuteAsync(JsonElement command, Canc
7988

8089
public async Task ReleaseHeldMouseButtonsAsync(CancellationToken cancellationToken = default)
8190
{
82-
if (activeDragButton is null) return;
83-
string button = activeDragButton;
84-
await adapter.SetMouseButtonDownAsync(button, false, cancellationToken);
85-
activeDragButton = null;
86-
cursorOverlay?.SetDragActive(false);
87-
cursorOverlay?.Hide();
91+
await ReleaseHeldInputsAsync(cancellationToken).ConfigureAwait(false);
92+
}
93+
94+
public async Task ReleaseHeldInputsAsync(CancellationToken cancellationToken = default)
95+
{
96+
await ReleaseHeldMouseButtonAsync(cancellationToken).ConfigureAwait(false);
97+
await ReleaseHeldModifiersAsync(cancellationToken).ConfigureAwait(false);
8898
}
8999

90100
public void EndControlSession()
91101
{
92102
activeDragButton = null;
103+
activeModifierKeys.Clear();
93104
cursorOverlay?.EndControlSession();
105+
modifierOverlay?.EndControlSession();
94106
}
95107

96108
private async Task<CommandExecutionResult> MoveMouseAsync(JsonElement payload, CancellationToken cancellationToken)
@@ -171,6 +183,37 @@ private async Task<CommandExecutionResult> PressKeyAsync(string key, Cancellatio
171183
return CommandExecutionResult.Success;
172184
}
173185

186+
private async Task<CommandExecutionResult> SetModifierAsync(string key, bool down, CancellationToken cancellationToken)
187+
{
188+
if (down)
189+
{
190+
if (activeModifierKeys.Contains(key))
191+
{
192+
return CommandExecutionResult.Success;
193+
}
194+
195+
await adapter.SetKeyDownAsync(key, true, cancellationToken);
196+
activeModifierKeys.Add(key);
197+
UpdateModifierOverlay();
198+
return CommandExecutionResult.Success;
199+
}
200+
201+
if (!activeModifierKeys.Remove(key))
202+
{
203+
return CommandExecutionResult.Success;
204+
}
205+
206+
try
207+
{
208+
await adapter.SetKeyDownAsync(key, false, cancellationToken);
209+
return CommandExecutionResult.Success;
210+
}
211+
finally
212+
{
213+
UpdateModifierOverlay();
214+
}
215+
}
216+
174217
private async Task<CommandExecutionResult> PressShortcutAsync(JsonElement keysElement, CancellationToken cancellationToken)
175218
{
176219
string[] keys = keysElement.EnumerateArray().Select(key => key.GetString() ?? "").ToArray();
@@ -310,6 +353,56 @@ private static bool IsMouseCommand(string type)
310353
return type is "mouse.move" or "mouse.click" or "mouse.doubleClick" or "mouse.rightClick" or "mouse.scroll" or "mouse.dragStart" or "mouse.dragEnd";
311354
}
312355

356+
private async Task ReleaseHeldMouseButtonAsync(CancellationToken cancellationToken)
357+
{
358+
if (activeDragButton is null) return;
359+
string button = activeDragButton;
360+
activeDragButton = null;
361+
await adapter.SetMouseButtonDownAsync(button, false, cancellationToken);
362+
cursorOverlay?.SetDragActive(false);
363+
cursorOverlay?.Hide();
364+
}
365+
366+
private async Task ReleaseHeldModifiersAsync(CancellationToken cancellationToken)
367+
{
368+
foreach (string key in new[] { "Meta", "Shift", "Alt", "Ctrl" })
369+
{
370+
if (!activeModifierKeys.Remove(key)) continue;
371+
try
372+
{
373+
await adapter.SetKeyDownAsync(key, false, cancellationToken);
374+
}
375+
finally
376+
{
377+
UpdateModifierOverlay();
378+
}
379+
}
380+
}
381+
382+
private void UpdateModifierOverlay()
383+
{
384+
modifierOverlay?.SetActiveModifiers(ActiveModifierLabels());
385+
}
386+
387+
private IReadOnlyList<string> ActiveModifierLabels()
388+
{
389+
List<string> labels = [];
390+
foreach (string key in new[] { "Ctrl", "Alt", "Shift", "Meta" })
391+
{
392+
if (activeModifierKeys.Contains(key))
393+
{
394+
labels.Add(DisplayModifierLabel(key));
395+
}
396+
}
397+
398+
return labels;
399+
}
400+
401+
private static string DisplayModifierLabel(string key)
402+
{
403+
return key == "Meta" ? "Start" : key;
404+
}
405+
313406
private static string TextInputStreamKey(string deviceId, string streamId)
314407
{
315408
return $"{deviceId}:{streamId}";

src/SwitchifyPc.Core/Input/DesktopInputAdapter.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public interface IDesktopInputAdapter
88
Task DoubleClickMouseAsync(string button, CancellationToken cancellationToken = default);
99
Task ScrollMouseAsync(double dx, double dy, CancellationToken cancellationToken = default);
1010
Task PressKeyAsync(string key, CancellationToken cancellationToken = default);
11+
Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default);
1112
Task PressShortcutAsync(IReadOnlyList<string> keys, CancellationToken cancellationToken = default);
1213
Task TypeTextAsync(string text, CancellationToken cancellationToken = default);
1314
Task TypeCharacterAsync(string text, CancellationToken cancellationToken = default);
@@ -33,3 +34,9 @@ public interface ICursorOverlayNotifier
3334
void MarkControlActive();
3435
void SetDragActive(bool active);
3536
}
37+
38+
public interface IModifierKeyOverlayNotifier
39+
{
40+
void SetActiveModifiers(IReadOnlyCollection<string> activeModifiers);
41+
void EndControlSession();
42+
}

src/SwitchifyPc.Protocol/ProtocolConstants.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public static class ProtocolConstants
2424
"mouse.dragStart",
2525
"mouse.dragEnd",
2626
"keyboard.key",
27+
"keyboard.modifierDown",
28+
"keyboard.modifierUp",
2729
"keyboard.shortcut",
2830
"keyboard.typeText",
2931
"keyboard.textStream.open",
@@ -53,6 +55,8 @@ public static class ProtocolConstants
5355
"mouse.dragStart",
5456
"mouse.dragEnd",
5557
"keyboard.key",
58+
"keyboard.modifierDown",
59+
"keyboard.modifierUp",
5660
"keyboard.shortcut",
5761
"keyboard.typeText",
5862
"keyboard.textStream.char",
@@ -103,6 +107,14 @@ public static class ProtocolConstants
103107
KeyboardKeys.Concat(["Ctrl", "Alt", "Shift", "Meta", "A", "C", "V", "X", "Z", "Y"]),
104108
StringComparer.Ordinal);
105109

110+
public static readonly IReadOnlySet<string> ModifierKeys = new HashSet<string>(StringComparer.Ordinal)
111+
{
112+
"Ctrl",
113+
"Alt",
114+
"Shift",
115+
"Meta"
116+
};
117+
106118
public static readonly IReadOnlySet<string> MediaActions = new HashSet<string>(StringComparer.Ordinal)
107119
{
108120
"playPause",

src/SwitchifyPc.Protocol/ProtocolValidator.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ private static ProtocolValidationResult ValidateCommandPayload(string type, Json
148148
TryGetString(payload, "key", out string? key) && ProtocolConstants.KeyboardKeys.Contains(key)
149149
? Valid(payload)
150150
: Invalid("invalid_payload", "Keyboard key is invalid."),
151+
"keyboard.modifierDown" or "keyboard.modifierUp" =>
152+
TryGetString(payload, "key", out string? modifierKey) && ProtocolConstants.ModifierKeys.Contains(modifierKey)
153+
? Valid(payload)
154+
: Invalid("invalid_payload", "Modifier key is invalid."),
151155
"keyboard.shortcut" => ValidateShortcutPayload(payload),
152156
"keyboard.typeText" =>
153157
TryGetString(payload, "text", out string? text) && IsSafeTextPayload(text)

src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,12 @@ public Task PressKeyAsync(string key, CancellationToken cancellationToken = defa
198198
return Task.CompletedTask;
199199
}
200200

201+
public Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default)
202+
{
203+
Calls.Add($"setKeyDown:{key}:{down}");
204+
return Task.CompletedTask;
205+
}
206+
201207
public Task PressShortcutAsync(IReadOnlyList<string> keys, CancellationToken cancellationToken = default)
202208
{
203209
Calls.Add($"pressShortcut:{string.Join("+", keys)}");

src/SwitchifyPc.Tests/BluetoothRemoteFrameProcessorTests.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,12 @@ public Task PressKeyAsync(string key, CancellationToken cancellationToken = defa
320320
return Task.CompletedTask;
321321
}
322322

323+
public Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default)
324+
{
325+
Calls.Add($"setKeyDown:{key}:{down}");
326+
return Task.CompletedTask;
327+
}
328+
323329
public Task PressShortcutAsync(IReadOnlyList<string> keys, CancellationToken cancellationToken = default)
324330
{
325331
Calls.Add($"pressShortcut:{string.Join("+", keys)}");

src/SwitchifyPc.Tests/ControlSessionTests.cs

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,20 @@ public async Task SuppressesResponseForNoAckCommands()
5656
Assert.Equal(["moveMouseBy:10,-2"], adapter.Calls);
5757
}
5858

59+
[Fact]
60+
public async Task AuthenticatedModifierCommandsRouteToExecutor()
61+
{
62+
FakeInputAdapter adapter = new();
63+
ControlSession session = CreateSession(adapter);
64+
65+
ControlSessionResult down = await session.ProcessMessageAsync(SignedCommand("keyboard.modifierDown", new { key = "Ctrl" }));
66+
ControlSessionResult up = await session.ProcessMessageAsync(SignedCommand("keyboard.modifierUp", new { key = "Ctrl" }, id: "request-2"));
67+
68+
Assert.True(down.HasResponse);
69+
Assert.True(up.HasResponse);
70+
Assert.Equal(["setKeyDown:Ctrl:True", "setKeyDown:Ctrl:False"], adapter.Calls);
71+
}
72+
5973
[Fact]
6074
public async Task RejectsInvalidJsonAndMalformedPayloads()
6175
{
@@ -121,24 +135,27 @@ public async Task AuthenticatedRepeatStartExecutesInitialCommandAndStopAcknowled
121135
}
122136

123137
[Fact]
124-
public async Task DisconnectingReleasesHeldMouseButtons()
138+
public async Task DisconnectingReleasesHeldInputs()
125139
{
126140
FakeInputAdapter adapter = new();
127141
FakeCursorOverlay overlay = new();
128142
ControlSession session = CreateSession(adapter, overlay);
129143

130144
await session.ProcessMessageAsync(SignedCommand("mouse.dragStart", new { button = "left" }, id: "request-1"));
131-
ControlSessionResult result = await session.ProcessMessageAsync(SignedCommand("connection.disconnecting", new { }, id: "request-2"));
145+
await session.ProcessMessageAsync(SignedCommand("keyboard.modifierDown", new { key = "Ctrl" }, id: "request-2"));
146+
ControlSessionResult result = await session.ProcessMessageAsync(SignedCommand("connection.disconnecting", new { }, id: "request-3"));
132147

133148
Assert.True(result.HasResponse);
134149
Assert.Equal(
135150
[
136151
"setMouseButtonDown:left:True",
137-
"setMouseButtonDown:left:False"
152+
"setKeyDown:Ctrl:True",
153+
"setMouseButtonDown:left:False",
154+
"setKeyDown:Ctrl:False"
138155
],
139156
adapter.Calls);
140157
Assert.Equal([true, false], overlay.DragActiveChanges);
141-
Assert.Equal(1, overlay.HideCount);
158+
Assert.Equal(2, overlay.HideCount);
142159
Assert.Equal(1, overlay.EndSessionCount);
143160
}
144161

@@ -301,6 +318,12 @@ public Task PressKeyAsync(string key, CancellationToken cancellationToken = defa
301318
return Task.CompletedTask;
302319
}
303320

321+
public Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default)
322+
{
323+
Calls.Add($"setKeyDown:{key}:{down}");
324+
return Task.CompletedTask;
325+
}
326+
304327
public Task PressShortcutAsync(IReadOnlyList<string> keys, CancellationToken cancellationToken = default)
305328
{
306329
Calls.Add($"pressShortcut:{string.Join("+", keys)}");

0 commit comments

Comments
 (0)