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
5 changes: 5 additions & 0 deletions src/SwitchifyPc.Core/Control/ControlSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,11 @@ private static JsonObject PointerProfileResponse(string id, PointerMovementProfi
["stepPercent"] = profile.Capabilities.PointerSpeed.StepPercent,
["baseMoveDelta"] = profile.Capabilities.PointerSpeed.BaseMoveDelta,
["effectiveMoveDelta"] = profile.Capabilities.PointerSpeed.EffectiveMoveDelta
},
["displayNavigation"] = new JsonObject
{
["supported"] = profile.Capabilities.DisplayNavigation.Supported,
["displayCount"] = profile.Capabilities.DisplayNavigation.DisplayCount
}
}
},
Expand Down
15 changes: 14 additions & 1 deletion src/SwitchifyPc.Core/Input/DesktopCommandExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ public async Task<CommandExecutionResult> ExecuteAsync(JsonElement command, Canc
"mouse.doubleClick" => await DoubleClickMouseAsync(payload.GetProperty("button").GetString() ?? "", cancellationToken),
"mouse.rightClick" => await RightClickMouseAsync(cancellationToken),
"mouse.scroll" => await ScrollMouseAsync(payload, cancellationToken),
"pointer.display.move" => await MovePointerToDisplayAsync(payload.GetProperty("direction").GetString() ?? "", 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),
Expand Down Expand Up @@ -183,6 +184,18 @@ private async Task<CommandExecutionResult> ScrollMouseAsync(JsonElement payload,
return CommandExecutionResult.Success;
}

private async Task<CommandExecutionResult> MovePointerToDisplayAsync(string direction, CancellationToken cancellationToken)
{
if (activeDragButton is not null)
{
return CommandExecutionResult.Failure("drag_active", "End the active drag before moving to another monitor.");
}

await adapter.MovePointerToDisplayAsync(direction, cancellationToken);
cursorOverlay?.Show(new CursorOverlayEvent(CursorOverlayEventKind.Move));
return CommandExecutionResult.Success;
}

private async Task<CommandExecutionResult> PressKeyAsync(string key, CancellationToken cancellationToken)
{
await adapter.PressKeyAsync(key, cancellationToken);
Expand Down Expand Up @@ -378,7 +391,7 @@ private static void AssertBoundedNumber(double value, int maxAbsValue, string la

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";
return type is "mouse.move" or "mouse.click" or "mouse.doubleClick" or "mouse.rightClick" or "mouse.scroll" or "mouse.dragStart" or "mouse.dragEnd" or "pointer.display.move";
}

private async Task ReleaseHeldMouseButtonAsync(CancellationToken cancellationToken)
Expand Down
2 changes: 2 additions & 0 deletions src/SwitchifyPc.Core/Input/DesktopInputAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ public interface IDesktopInputAdapter
Task ClickMouseAsync(string button, CancellationToken cancellationToken = default);
Task DoubleClickMouseAsync(string button, CancellationToken cancellationToken = default);
Task ScrollMouseAsync(double dx, double dy, CancellationToken cancellationToken = default);
Task MovePointerToDisplayAsync(string direction, CancellationToken cancellationToken = default) =>
Task.FromException(new DesktopInputException("unsupported_command", "Display navigation is not available."));
Task PressKeyAsync(string key, CancellationToken cancellationToken = default);
Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default);
Task PressShortcutAsync(IReadOnlyList<string> keys, CancellationToken cancellationToken = default);
Expand Down
10 changes: 7 additions & 3 deletions src/SwitchifyPc.Core/Input/PointerProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ public sealed record PointerSpeedCapabilities(
double StepPercent,
int BaseMoveDelta,
int EffectiveMoveDelta);
public sealed record DisplayNavigationCapabilities(bool Supported, int DisplayCount);
public sealed record PointerCapabilities(
bool NoAckMouseMove,
IReadOnlyList<string> NoAckCommands,
IReadOnlyList<string> SupportedCommands,
MouseRepeatCapabilities MouseRepeat,
PointerSpeedCapabilities PointerSpeed);
PointerSpeedCapabilities PointerSpeed,
DisplayNavigationCapabilities DisplayNavigation);
public sealed record PointerMovementProfile(
string DisplayId,
double ScaleFactor,
Expand Down Expand Up @@ -78,7 +80,8 @@ public static PointerMovementProfile Create(PointerProfileInput input)
"keyboard.textStream.chunk",
"keyboard.textStream.close",
"connection.ping",
"pointer.profile"
"pointer.profile",
"pointer.display.move"
],
MouseRepeat: new MouseRepeatCapabilities(
Supported: true,
Expand All @@ -91,7 +94,8 @@ public static PointerMovementProfile Create(PointerProfileInput input)
AccelerationDurationMs: MouseRepeatSettingsModel.Default.AccelerationDurationMs,
AccelerationDurationOptionsMs: MouseRepeatSettingsModel.AccelerationDurationOptionsMs,
AccelerationInitialScalePercent: MouseRepeatSettingsModel.AccelerationInitialScalePercent),
PointerSpeed: PointerSpeedFor(PointerMovementSettingsModel.Default)));
PointerSpeed: PointerSpeedFor(PointerMovementSettingsModel.Default),
DisplayNavigation: new DisplayNavigationCapabilities(Supported: true, DisplayCount: 1)));
}

private static Bounds NormalizeBounds(Bounds bounds)
Expand Down
9 changes: 9 additions & 0 deletions src/SwitchifyPc.Protocol/ProtocolConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public static class ProtocolConstants
"media.control",
"window.control",
"pointer.profile",
"pointer.display.move",
"pointer.speed.set",
"connection.ping",
"connection.disconnecting"
Expand Down Expand Up @@ -138,6 +139,14 @@ public static class ProtocolConstants
"maximizeFocused"
};

public static readonly IReadOnlySet<string> DisplayNavigationDirections = new HashSet<string>(StringComparer.Ordinal)
{
"left",
"right",
"up",
"down"
};

public static readonly IReadOnlySet<string> CommandResponseModes = new HashSet<string>(StringComparer.Ordinal)
{
"ack",
Expand Down
20 changes: 20 additions & 0 deletions src/SwitchifyPc.Protocol/ProtocolValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ private static ProtocolValidationResult ValidateCommandPayload(string type, Json
TryGetPositiveFiniteNumber(payload, "scalePercent", out _) && ObjectPropertyCount(payload) == 1
? Valid(payload)
: Invalid("invalid_payload", "Pointer speed is invalid."),
"pointer.display.move" =>
TryGetString(payload, "direction", out string? direction) &&
ProtocolConstants.DisplayNavigationDirections.Contains(direction) &&
ObjectPropertyCount(payload) == 1
? Valid(payload)
: Invalid("invalid_payload", "Display navigation direction is invalid."),
"keyboard.key" =>
TryGetString(payload, "key", out string? key) && ProtocolConstants.KeyboardKeys.Contains(key)
? Valid(payload)
Expand Down Expand Up @@ -361,11 +367,25 @@ private static ProtocolValidationResult ValidatePointerProfilePayload(JsonElemen
{
return Invalid("invalid_payload", "Pointer speed capability is invalid.");
}

if (capabilities.TryGetProperty("displayNavigation", out JsonElement displayNavigation) && !ValidateDisplayNavigationCapability(displayNavigation))
{
return Invalid("invalid_payload", "Display navigation capability is invalid.");
}
}

return Valid(payload);
}

private static bool ValidateDisplayNavigationCapability(JsonElement displayNavigation)
{
return IsObject(displayNavigation) &&
ObjectPropertyCount(displayNavigation) == 2 &&
TryGetBoolean(displayNavigation, "supported", out _) &&
TryGetInteger(displayNavigation, "displayCount", out int displayCount) &&
displayCount is >= 1 and <= 64;
}

private static ProtocolValidationResult ValidateMouseRepeatStartPayload(JsonElement payload)
{
if (ObjectPropertyCount(payload) != 1 ||
Expand Down
3 changes: 2 additions & 1 deletion src/SwitchifyPc.Tests/BluetoothControlFrameProcessorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ private static PointerCapabilities TestPointerCapabilities()
ProtocolConstants.NoAckControlCommandTypes.ToArray(),
ProtocolConstants.CommandTypes.ToArray(),
new MouseRepeatCapabilities(true, true, 250, 250, 250, 100, 2000),
PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default));
PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default),
new DisplayNavigationCapabilities(true, 2));
}

private static string SignedCommand(string type, object payload, string id = "request-1", string? responseMode = null)
Expand Down
3 changes: 2 additions & 1 deletion src/SwitchifyPc.Tests/BluetoothRemoteFrameProcessorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,8 @@ private static PointerCapabilities TestPointerCapabilities()
ProtocolConstants.NoAckControlCommandTypes.ToArray(),
ProtocolConstants.CommandTypes.ToArray(),
new MouseRepeatCapabilities(true, true, 250, 250, 250, 100, 2000),
PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default));
PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default),
new DisplayNavigationCapabilities(true, 2));
}

private static JsonDocument Reassemble(IReadOnlyList<BluetoothFrame> frames)
Expand Down
30 changes: 29 additions & 1 deletion src/SwitchifyPc.Tests/ControlSessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,28 @@ public async Task ReturnsPointerProfileResponse()
Assert.Equal(1000, mouseRepeat.GetProperty("accelerationDurationMs").GetInt32());
Assert.Equal([0, 500, 1000, 2000], mouseRepeat.GetProperty("accelerationDurationOptionsMs").EnumerateArray().Select(value => value.GetInt32()));
Assert.Equal(25, mouseRepeat.GetProperty("accelerationInitialScalePercent").GetInt32());
JsonElement displayNavigation = payload.GetProperty("capabilities").GetProperty("displayNavigation");
Assert.True(displayNavigation.GetProperty("supported").GetBoolean());
Assert.Equal(2, displayNavigation.GetProperty("displayCount").GetInt32());
Assert.True(ProtocolValidator.ValidateProtocolResponse(response.RootElement).Ok);
}

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

ControlSessionResult rejected = await session.ProcessMessageAsync(
SignedCommand("pointer.display.move", new { direction = "right" }, authOverride: "bad-proof"));
ControlSessionResult accepted = await session.ProcessMessageAsync(
SignedCommand("pointer.display.move", new { direction = "right" }));

AssertError(rejected, "request-1", "invalid_auth");
Assert.True(accepted.HasResponse);
Assert.Equal(["right"], adapter.DisplayMoves);
}

[Fact]
public async Task AuthenticatedPointerSpeedSetSavesAndAppliesLiveSettings()
{
Expand Down Expand Up @@ -254,7 +273,8 @@ private static PointerCapabilities TestPointerCapabilities()
ProtocolConstants.NoAckControlCommandTypes.ToArray(),
ProtocolConstants.CommandTypes.ToArray(),
new MouseRepeatCapabilities(true, true, 250, 250, 250, 100, 2000),
PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default));
PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default),
new DisplayNavigationCapabilities(true, 2));
}

private sealed class FakeMouseRepeatSettings(MouseRepeatSettings settings) : IMouseRepeatSettingsStore
Expand Down Expand Up @@ -337,6 +357,14 @@ private static void AssertError(ControlSessionResult result, string? id, string

private sealed class FakeInputAdapter : IDesktopInputAdapter
{
public List<string> DisplayMoves { get; } = [];

public Task MovePointerToDisplayAsync(string direction, CancellationToken cancellationToken = default)
{
DisplayMoves.Add(direction);
return Task.CompletedTask;
}

public List<string> Calls { get; } = [];
public bool ThrowOnPressKey { get; init; }

Expand Down
54 changes: 54 additions & 0 deletions src/SwitchifyPc.Tests/DesktopCommandExecutorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,48 @@ public async Task LeavesServerOwnedCommandsUnsupported()
Assert.Equal(2, overlay.HideCount);
}

[Fact]
public async Task MovesPointerToDisplayAndShowsCursorOverlay()
{
FakeInputAdapter adapter = new();
FakeCursorOverlay overlay = new();
DesktopCommandExecutor executor = new(adapter, overlay);

CommandExecutionResult result = await executor.ExecuteAsync(Command("pointer.display.move", new { direction = "right" }));

Assert.True(result.Ok);
Assert.Contains("movePointerToDisplay:right", adapter.Calls);
Assert.Equal(CursorOverlayEventKind.Move, Assert.Single(overlay.Events).Kind);
Assert.Equal(1, overlay.ActiveCount);
}

[Fact]
public async Task RejectsDisplayNavigationDuringActiveDrag()
{
FakeInputAdapter adapter = new();
DesktopCommandExecutor executor = new(adapter);
await executor.ExecuteAsync(Command("mouse.dragStart", new { button = "left" }));

CommandExecutionResult result = await executor.ExecuteAsync(Command("pointer.display.move", new { direction = "right" }));

Assert.False(result.Ok);
Assert.Equal("drag_active", result.Code);
Assert.DoesNotContain("movePointerToDisplay:right", adapter.Calls);
}

[Fact]
public async Task ConvertsDisplayNavigationAdapterFailureToStructuredResult()
{
FakeInputAdapter adapter = new() { ThrowOnMoveToDisplay = true };
DesktopCommandExecutor executor = new(adapter);

CommandExecutionResult result = await executor.ExecuteAsync(Command("pointer.display.move", new { direction = "up" }));

Assert.False(result.Ok);
Assert.Equal("adapter_failure", result.Code);
Assert.Equal("Monitor move failed.", result.Message);
}

[Fact]
public void EndControlSessionHidesCursorOverlaySession()
{
Expand Down Expand Up @@ -460,6 +502,7 @@ private sealed class FakeInputAdapter : IDesktopInputAdapter
{
public List<string> Calls { get; } = [];
public bool ThrowOnSetKeyDown { get; set; }
public bool ThrowOnMoveToDisplay { get; set; }

public Task MoveMouseByAsync(double dx, double dy, CancellationToken cancellationToken = default)
{
Expand Down Expand Up @@ -491,6 +534,17 @@ public Task ScrollMouseAsync(double dx, double dy, CancellationToken cancellatio
return Task.CompletedTask;
}

public Task MovePointerToDisplayAsync(string direction, CancellationToken cancellationToken = default)
{
if (ThrowOnMoveToDisplay)
{
throw new DesktopInputException("adapter_failure", "Monitor move failed.");
}

Calls.Add($"movePointerToDisplay:{direction}");
return Task.CompletedTask;
}

public Task PressKeyAsync(string key, CancellationToken cancellationToken = default)
{
Calls.Add($"pressKey:{key}");
Expand Down
3 changes: 3 additions & 0 deletions src/SwitchifyPc.Tests/PointerProfileTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ public void CreatesStableBaselineDeltasForScaledDisplay()
Assert.Equal(5, profile.Capabilities.PointerSpeed.StepPercent);
Assert.Equal(128, profile.Capabilities.PointerSpeed.BaseMoveDelta);
Assert.Equal(128, profile.Capabilities.PointerSpeed.EffectiveMoveDelta);
Assert.True(profile.Capabilities.DisplayNavigation.Supported);
Assert.Equal(1, profile.Capabilities.DisplayNavigation.DisplayCount);
Assert.Contains("pointer.display.move", profile.Capabilities.SupportedCommands);
}

[Fact]
Expand Down
1 change: 1 addition & 0 deletions src/SwitchifyPc.Tests/ProtocolConstantsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public void IncludesCurrentCommandTypes()
"keyboard.textStream.close",
"media.control",
"window.control",
"pointer.display.move",
"pointer.profile",
"pointer.speed.set",
"connection.ping",
Expand Down
37 changes: 37 additions & 0 deletions src/SwitchifyPc.Tests/ProtocolValidatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public void AcceptsCurrentCommandPayloads()
new { type = "media.control", payload = new { action = "playPause" } },
new { type = "window.control", payload = new { action = "switchNext" } },
new { type = "pointer.profile", payload = new { } },
new { type = "pointer.display.move", payload = new { direction = "left" } },
new { type = "pointer.speed.set", payload = new { scalePercent = 125 } },
new { type = "connection.ping", payload = new { } },
new { type = "connection.disconnecting", payload = new { } }
Expand Down Expand Up @@ -159,6 +160,9 @@ public void RejectsUnsafePayloads()
BaseCommand("keyboard.textStream.key", new { streamId = "stream-1", seq = 0, key = "F13" }),
BaseCommand("keyboard.textStream.close", new { streamId = "stream-1", expectedCount = -1 }),
BaseCommand("pointer.profile", new { includeDisplays = true }),
BaseCommand("pointer.display.move", new { }),
BaseCommand("pointer.display.move", new { direction = "forward" }),
BaseCommand("pointer.display.move", new { direction = "left", extra = true }),
BaseCommand("pointer.speed.set", new { }),
BaseCommand("pointer.speed.set", new { scalePercent = "125" }),
BaseCommand("pointer.speed.set", new { scalePercent = -1 }),
Expand Down Expand Up @@ -230,6 +234,11 @@ public void ValidatesPointerProfileResponses()
stepPercent = 5,
baseMoveDelta = 128,
effectiveMoveDelta = 128
},
displayNavigation = new
{
supported = true,
displayCount = 3
}
}
},
Expand Down Expand Up @@ -411,6 +420,34 @@ public void RejectsMalformedPointerProfileResponses()
Assert.Equal("invalid_payload", result.Error);
}

[Fact]
public void RejectsMalformedDisplayNavigationCapability()
{
ProtocolValidationResult result = ProtocolValidator.ValidateProtocolResponse(Json(new
{
version = ProtocolConstants.ProtocolVersion,
id = "profile-1",
type = "pointer.profile",
ok = true,
payload = new
{
displayId = "display-1",
scaleFactor = 1,
bounds = new { x = 0, y = 0, width = 1920, height = 1080 },
maxDelta = ProtocolConstants.MaxPointerDelta,
recommendedDeltas = new { small = 50, medium = 130, large = 252 },
capabilities = new
{
displayNavigation = new { supported = true, displayCount = 0 }
}
},
error = (object?)null
}));

Assert.False(result.Ok);
Assert.Equal("invalid_payload", result.Error);
}

private static object BaseCommand(string type, object payload, string? responseMode = null)
{
Dictionary<string, object?> command = new(StringComparer.Ordinal)
Expand Down
3 changes: 2 additions & 1 deletion src/SwitchifyPc.Tests/RemoteControlSessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,8 @@ private static PointerCapabilities TestPointerCapabilities()
ProtocolConstants.NoAckControlCommandTypes.ToArray(),
ProtocolConstants.CommandTypes.ToArray(),
new MouseRepeatCapabilities(true, true, 250, 250, 250, 100, 2000),
PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default));
PointerProfile.PointerSpeedFor(PointerMovementSettingsModel.Default),
new DisplayNavigationCapabilities(true, 2));
}

private static string PairingRequest(
Expand Down
Loading