Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 8 additions & 2 deletions src/SwitchifyPc.App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
using SwitchifyPc.Windows.Bluetooth;
using SwitchifyPc.Windows.CursorOverlay;
using SwitchifyPc.Windows.Input;
using SwitchifyPc.Windows.ModifierOverlay;
using SwitchifyPc.Windows.Startup;
using SwitchifyPc.Windows.Updates;
using SwitchifyPc.Protocol;
Expand All @@ -44,6 +45,7 @@ public partial class App : System.Windows.Application
private DesktopCommandExecutor? commandExecutor;
private MouseRepeatController? mouseRepeatController;
private WindowsCursorOverlayNotifier? cursorOverlay;
private WindowsModifierKeyOverlayNotifier? modifierOverlay;
private DispatcherTimer? pairingExpiryTimer;
private AppThemeManager? themeManager;
private bool isQuitting;
Expand Down Expand Up @@ -117,6 +119,8 @@ protected override void OnExit(ExitEventArgs e)
bluetoothServer = null;
cursorOverlay?.Dispose();
cursorOverlay = null;
modifierOverlay?.Dispose();
modifierOverlay = null;
themeManager?.Dispose();
themeManager = null;
commandExecutor = null;
Expand Down Expand Up @@ -275,7 +279,8 @@ private async Task StartBluetoothAsync()
SendInputWindowsNativeInput nativeInput = new();
WindowsDesktopInputAdapter inputAdapter = new(nativeInput, pointerSettingsStore.Load());
cursorOverlay = new WindowsCursorOverlayNotifier(nativeInput, cursorOverlaySettingsStore);
commandExecutor = new DesktopCommandExecutor(inputAdapter, cursorOverlay);
modifierOverlay = new WindowsModifierKeyOverlayNotifier(nativeInput);
commandExecutor = new DesktopCommandExecutor(inputAdapter, cursorOverlay, modifierOverlay: modifierOverlay);
mouseRepeatController = new MouseRepeatController(commandExecutor, mouseRepeatSettingsStore);
ControlSession controlSession = new(
new CommandAuthValidator(pairingStore),
Expand Down Expand Up @@ -328,10 +333,11 @@ private void HandleBluetoothEvent(BluetoothHelperEvent helperEvent)

if (commandExecutor is not null)
{
await commandExecutor.ReleaseHeldMouseButtonsAsync();
await commandExecutor.ReleaseHeldInputsAsync();
}

cursorOverlay?.EndControlSession();
modifierOverlay?.EndControlSession();
}
break;
case BluetoothDiagnosticEvent diagnostic:
Expand Down
11 changes: 10 additions & 1 deletion src/SwitchifyPc.Core/Control/ControlSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,16 @@ public async Task<ControlSessionResult> ProcessMessageAsync(string rawMessage, C
await StopRepeatAsync(failedDeviceId!).ConfigureAwait(false);
}

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

if (type == "connection.disconnecting")
{
await StopRepeatAsync(auth.DeviceId ?? "").ConfigureAwait(false);
await commandExecutor.ReleaseHeldMouseButtonsAsync(cancellationToken).ConfigureAwait(false);
await commandExecutor.ReleaseHeldInputsAsync(cancellationToken).ConfigureAwait(false);
commandExecutor.EndControlSession();
return WithAuth(AckOrNoResponse(request), auth);
}
Expand Down Expand Up @@ -147,6 +149,13 @@ public async Task<ControlSessionResult> ProcessMessageAsync(string rawMessage, C

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

public async Task EndControlSessionAsync(CancellationToken cancellationToken = default)
{
await StopAllRepeatsAsync().ConfigureAwait(false);
await commandExecutor.ReleaseHeldInputsAsync(cancellationToken).ConfigureAwait(false);
commandExecutor.EndControlSession();
}

private static ControlSessionResult WithAuth(ControlSessionResult result, AuthValidationResult auth)
{
return result with
Expand Down
2 changes: 1 addition & 1 deletion src/SwitchifyPc.Core/Control/RemoteControlSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ public RemoteSessionResult ExpirePendingPairingRequests()

public void RemoveConnection(string connectionId)
{
_ = commandSession.StopAllRepeatsAsync();
_ = commandSession.EndControlSessionAsync();
string[] requestIds = pendingConnectionsByRequestId
.Where(entry => entry.Value.ConnectionId == connectionId)
.Select(entry => entry.Key)
Expand Down
107 changes: 100 additions & 7 deletions src/SwitchifyPc.Core/Input/DesktopCommandExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,21 @@ public sealed class DesktopCommandExecutor

private readonly IDesktopInputAdapter adapter;
private readonly ICursorOverlayNotifier? cursorOverlay;
private readonly IModifierKeyOverlayNotifier? modifierOverlay;
private readonly Func<double> now;
private readonly Dictionary<string, TextInputStreamState> textInputStreams = new(StringComparer.Ordinal);
private readonly HashSet<string> activeModifierKeys = new(StringComparer.Ordinal);
private string? activeDragButton;

public DesktopCommandExecutor(IDesktopInputAdapter adapter, ICursorOverlayNotifier? cursorOverlay = null, Func<double>? now = null)
public DesktopCommandExecutor(
IDesktopInputAdapter adapter,
ICursorOverlayNotifier? cursorOverlay = null,
Func<double>? now = null,
IModifierKeyOverlayNotifier? modifierOverlay = null)
{
this.adapter = adapter;
this.cursorOverlay = cursorOverlay;
this.modifierOverlay = modifierOverlay;
this.now = now ?? (() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
}

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

public async Task ReleaseHeldMouseButtonsAsync(CancellationToken cancellationToken = default)
{
if (activeDragButton is null) return;
string button = activeDragButton;
await adapter.SetMouseButtonDownAsync(button, false, cancellationToken);
activeDragButton = null;
cursorOverlay?.SetDragActive(false);
cursorOverlay?.Hide();
await ReleaseHeldInputsAsync(cancellationToken).ConfigureAwait(false);
}

public async Task ReleaseHeldInputsAsync(CancellationToken cancellationToken = default)
{
await ReleaseHeldMouseButtonAsync(cancellationToken).ConfigureAwait(false);
await ReleaseHeldModifiersAsync(cancellationToken).ConfigureAwait(false);
}

public void EndControlSession()
{
activeDragButton = null;
activeModifierKeys.Clear();
cursorOverlay?.EndControlSession();
modifierOverlay?.EndControlSession();
}

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

private async Task<CommandExecutionResult> SetModifierAsync(string key, bool down, CancellationToken cancellationToken)
{
if (down)
{
if (activeModifierKeys.Contains(key))
{
return CommandExecutionResult.Success;
}

await adapter.SetKeyDownAsync(key, true, cancellationToken);
activeModifierKeys.Add(key);
UpdateModifierOverlay();
return CommandExecutionResult.Success;
}

if (!activeModifierKeys.Remove(key))
{
return CommandExecutionResult.Success;
}

try
{
await adapter.SetKeyDownAsync(key, false, cancellationToken);
return CommandExecutionResult.Success;
}
finally
{
UpdateModifierOverlay();
}
}

private async Task<CommandExecutionResult> PressShortcutAsync(JsonElement keysElement, CancellationToken cancellationToken)
{
string[] keys = keysElement.EnumerateArray().Select(key => key.GetString() ?? "").ToArray();
Expand Down Expand Up @@ -310,6 +353,56 @@ private static bool IsMouseCommand(string type)
return type is "mouse.move" or "mouse.click" or "mouse.doubleClick" or "mouse.rightClick" or "mouse.scroll" or "mouse.dragStart" or "mouse.dragEnd";
}

private async Task ReleaseHeldMouseButtonAsync(CancellationToken cancellationToken)
{
if (activeDragButton is null) return;
string button = activeDragButton;
activeDragButton = null;
await adapter.SetMouseButtonDownAsync(button, false, cancellationToken);
cursorOverlay?.SetDragActive(false);
cursorOverlay?.Hide();
}

private async Task ReleaseHeldModifiersAsync(CancellationToken cancellationToken)
{
foreach (string key in new[] { "Meta", "Shift", "Alt", "Ctrl" })
{
if (!activeModifierKeys.Remove(key)) continue;
try
{
await adapter.SetKeyDownAsync(key, false, cancellationToken);
}
finally
{
UpdateModifierOverlay();
}
}
}

private void UpdateModifierOverlay()
{
modifierOverlay?.SetActiveModifiers(ActiveModifierLabels());
}

private IReadOnlyList<string> ActiveModifierLabels()
{
List<string> labels = [];
foreach (string key in new[] { "Ctrl", "Alt", "Shift", "Meta" })
{
if (activeModifierKeys.Contains(key))
{
labels.Add(DisplayModifierLabel(key));
}
}

return labels;
}

private static string DisplayModifierLabel(string key)
{
return key == "Meta" ? "Start" : key;
}

private static string TextInputStreamKey(string deviceId, string streamId)
{
return $"{deviceId}:{streamId}";
Expand Down
7 changes: 7 additions & 0 deletions src/SwitchifyPc.Core/Input/DesktopInputAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public interface IDesktopInputAdapter
Task DoubleClickMouseAsync(string button, CancellationToken cancellationToken = default);
Task ScrollMouseAsync(double dx, double dy, CancellationToken cancellationToken = default);
Task PressKeyAsync(string key, CancellationToken cancellationToken = default);
Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default);
Task PressShortcutAsync(IReadOnlyList<string> keys, CancellationToken cancellationToken = default);
Task TypeTextAsync(string text, CancellationToken cancellationToken = default);
Task TypeCharacterAsync(string text, CancellationToken cancellationToken = default);
Expand All @@ -33,3 +34,9 @@ public interface ICursorOverlayNotifier
void MarkControlActive();
void SetDragActive(bool active);
}

public interface IModifierKeyOverlayNotifier
{
void SetActiveModifiers(IReadOnlyCollection<string> activeModifiers);
void EndControlSession();
}
12 changes: 12 additions & 0 deletions src/SwitchifyPc.Protocol/ProtocolConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public static class ProtocolConstants
"mouse.dragStart",
"mouse.dragEnd",
"keyboard.key",
"keyboard.modifierDown",
"keyboard.modifierUp",
"keyboard.shortcut",
"keyboard.typeText",
"keyboard.textStream.open",
Expand Down Expand Up @@ -53,6 +55,8 @@ public static class ProtocolConstants
"mouse.dragStart",
"mouse.dragEnd",
"keyboard.key",
"keyboard.modifierDown",
"keyboard.modifierUp",
"keyboard.shortcut",
"keyboard.typeText",
"keyboard.textStream.char",
Expand Down Expand Up @@ -103,6 +107,14 @@ public static class ProtocolConstants
KeyboardKeys.Concat(["Ctrl", "Alt", "Shift", "Meta", "A", "C", "V", "X", "Z", "Y"]),
StringComparer.Ordinal);

public static readonly IReadOnlySet<string> ModifierKeys = new HashSet<string>(StringComparer.Ordinal)
{
"Ctrl",
"Alt",
"Shift",
"Meta"
};

public static readonly IReadOnlySet<string> MediaActions = new HashSet<string>(StringComparer.Ordinal)
{
"playPause",
Expand Down
4 changes: 4 additions & 0 deletions src/SwitchifyPc.Protocol/ProtocolValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ private static ProtocolValidationResult ValidateCommandPayload(string type, Json
TryGetString(payload, "key", out string? key) && ProtocolConstants.KeyboardKeys.Contains(key)
? Valid(payload)
: Invalid("invalid_payload", "Keyboard key is invalid."),
"keyboard.modifierDown" or "keyboard.modifierUp" =>
TryGetString(payload, "key", out string? modifierKey) && ProtocolConstants.ModifierKeys.Contains(modifierKey)
? Valid(payload)
: Invalid("invalid_payload", "Modifier key is invalid."),
"keyboard.shortcut" => ValidateShortcutPayload(payload),
"keyboard.typeText" =>
TryGetString(payload, "text", out string? text) && IsSafeTextPayload(text)
Expand Down
6 changes: 6 additions & 0 deletions src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,12 @@ public Task PressKeyAsync(string key, CancellationToken cancellationToken = defa
return Task.CompletedTask;
}

public Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default)
{
Calls.Add($"setKeyDown:{key}:{down}");
return Task.CompletedTask;
}

public Task PressShortcutAsync(IReadOnlyList<string> keys, CancellationToken cancellationToken = default)
{
Calls.Add($"pressShortcut:{string.Join("+", keys)}");
Expand Down
6 changes: 6 additions & 0 deletions src/SwitchifyPc.Tests/BluetoothRemoteFrameProcessorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,12 @@ public Task PressKeyAsync(string key, CancellationToken cancellationToken = defa
return Task.CompletedTask;
}

public Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default)
{
Calls.Add($"setKeyDown:{key}:{down}");
return Task.CompletedTask;
}

public Task PressShortcutAsync(IReadOnlyList<string> keys, CancellationToken cancellationToken = default)
{
Calls.Add($"pressShortcut:{string.Join("+", keys)}");
Expand Down
31 changes: 27 additions & 4 deletions src/SwitchifyPc.Tests/ControlSessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,20 @@ public async Task SuppressesResponseForNoAckCommands()
Assert.Equal(["moveMouseBy:10,-2"], adapter.Calls);
}

[Fact]
public async Task AuthenticatedModifierCommandsRouteToExecutor()
{
FakeInputAdapter adapter = new();
ControlSession session = CreateSession(adapter);

ControlSessionResult down = await session.ProcessMessageAsync(SignedCommand("keyboard.modifierDown", new { key = "Ctrl" }));
ControlSessionResult up = await session.ProcessMessageAsync(SignedCommand("keyboard.modifierUp", new { key = "Ctrl" }, id: "request-2"));

Assert.True(down.HasResponse);
Assert.True(up.HasResponse);
Assert.Equal(["setKeyDown:Ctrl:True", "setKeyDown:Ctrl:False"], adapter.Calls);
}

[Fact]
public async Task RejectsInvalidJsonAndMalformedPayloads()
{
Expand Down Expand Up @@ -121,24 +135,27 @@ public async Task AuthenticatedRepeatStartExecutesInitialCommandAndStopAcknowled
}

[Fact]
public async Task DisconnectingReleasesHeldMouseButtons()
public async Task DisconnectingReleasesHeldInputs()
{
FakeInputAdapter adapter = new();
FakeCursorOverlay overlay = new();
ControlSession session = CreateSession(adapter, overlay);

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

Assert.True(result.HasResponse);
Assert.Equal(
[
"setMouseButtonDown:left:True",
"setMouseButtonDown:left:False"
"setKeyDown:Ctrl:True",
"setMouseButtonDown:left:False",
"setKeyDown:Ctrl:False"
],
adapter.Calls);
Assert.Equal([true, false], overlay.DragActiveChanges);
Assert.Equal(1, overlay.HideCount);
Assert.Equal(2, overlay.HideCount);
Assert.Equal(1, overlay.EndSessionCount);
}

Expand Down Expand Up @@ -301,6 +318,12 @@ public Task PressKeyAsync(string key, CancellationToken cancellationToken = defa
return Task.CompletedTask;
}

public Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default)
{
Calls.Add($"setKeyDown:{key}:{down}");
return Task.CompletedTask;
}

public Task PressShortcutAsync(IReadOnlyList<string> keys, CancellationToken cancellationToken = default)
{
Calls.Add($"pressShortcut:{string.Join("+", keys)}");
Expand Down
Loading