From b616722af7ff2677e3d8ee456d355cbddbdddcb4 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Wed, 29 Jul 2026 09:21:05 +0100 Subject: [PATCH] Refactor BLE runtime orchestration --- src/SwitchifyPc.App/App.xaml.cs | 251 +++-------------- src/SwitchifyPc.App/BluetoothRuntime.cs | 264 ++++++++++++++++++ .../BluetoothRemoteFrameProcessor.cs | 18 +- .../Bluetooth/IBluetoothTransportServer.cs | 12 + .../BluetoothRuntimeTests.cs | 160 +++++++++++ .../Bluetooth/WindowsBluetoothGattServer.cs | 7 +- 6 files changed, 502 insertions(+), 210 deletions(-) create mode 100644 src/SwitchifyPc.App/BluetoothRuntime.cs create mode 100644 src/SwitchifyPc.Core/Bluetooth/IBluetoothTransportServer.cs create mode 100644 src/SwitchifyPc.Tests/BluetoothRuntimeTests.cs diff --git a/src/SwitchifyPc.App/App.xaml.cs b/src/SwitchifyPc.App/App.xaml.cs index d249532..b93928d 100644 --- a/src/SwitchifyPc.App/App.xaml.cs +++ b/src/SwitchifyPc.App/App.xaml.cs @@ -23,7 +23,6 @@ using SwitchifyPc.Windows.ModifierOverlay; using SwitchifyPc.Windows.Startup; using SwitchifyPc.Windows.Updates; -using SwitchifyPc.Protocol; namespace SwitchifyPc.App; @@ -44,19 +43,13 @@ public partial class App : System.Windows.Application private UpdateService? updateService; private PairingApprovalManager? pairingApprovalManager; private BluetoothStatusTracker? bluetoothStatusTracker; - private WindowsBluetoothGattServer? bluetoothServer; - private BluetoothRemoteFrameProcessor? bluetoothFrameProcessor; + private BluetoothRuntime? bluetoothRuntime; private DesktopCommandExecutor? commandExecutor; private JsonSwitchControlProfileStore? switchControlProfileStore; private SwitchControlSessionManager? switchControlSessionManager; private MouseRepeatController? mouseRepeatController; private WindowsCursorOverlayNotifier? cursorOverlay; private WindowsModifierKeyOverlayNotifier? modifierOverlay; - private readonly SemaphoreSlim bluetoothMessageProcessing = new(1, 1); - private readonly SemaphoreSlim bluetoothOutputProcessing = new(1, 1); - private readonly object bluetoothConnectionSync = new(); - private readonly HashSet authenticatedBluetoothConnections = new(StringComparer.Ordinal); - private DispatcherTimer? pairingExpiryTimer; private DispatcherTimer? telemetryFlushTimer; private AppThemeManager? themeManager; private JsonTelemetrySettingsStore? telemetrySettingsStore; @@ -106,7 +99,6 @@ protected override void OnStartup(StartupEventArgs e) bluetoothStatusTracker = new BluetoothStatusTracker(onStatusChanged: UpdateBluetoothState); RefreshPairingApprovals(); updateService.StartAutomaticUpdateChecks(); - StartPairingExpiryTimer(); StartTelemetryFlushTimer(); _ = telemetryReporter?.FlushAsync(); _ = StartBluetoothAsync(); @@ -137,8 +129,6 @@ protected override void OnExit(ExitEventArgs e) existingInstanceSignal = null; updateService?.StopAutomaticUpdateChecks(); updateService = null; - pairingExpiryTimer?.Stop(); - pairingExpiryTimer = null; telemetryFlushTimer?.Stop(); telemetryFlushTimer = null; mouseRepeatController?.StopAllAsync().GetAwaiter().GetResult(); @@ -151,8 +141,8 @@ protected override void OnExit(ExitEventArgs e) catch { } - bluetoothServer?.Dispose(); - bluetoothServer = null; + bluetoothRuntime?.Dispose(); + bluetoothRuntime = null; cursorOverlay?.Dispose(); cursorOverlay = null; modifierOverlay?.Dispose(); @@ -162,13 +152,8 @@ protected override void OnExit(ExitEventArgs e) commandExecutor = null; switchControlSessionManager = null; switchControlProfileStore = null; - bluetoothFrameProcessor = null; bluetoothStatusTracker = null; pairingApprovalManager = null; - lock (bluetoothConnectionSync) - { - authenticatedBluetoothConnections.Clear(); - } trayIcon?.Dispose(); trayIcon = null; settingsWindow = null; @@ -222,18 +207,18 @@ private void QuitApplication() private string TrayStatusText() { - BluetoothStatus status = bluetoothStatusTracker?.Status ?? BluetoothStatusModel.DefaultStatus; + BluetoothStatus status = bluetoothRuntime?.Status ?? bluetoothStatusTracker?.Status ?? BluetoothStatusModel.DefaultStatus; return $"Status: {MainWindowCopy.BluetoothStatusLabel(status)}"; } private bool CanDisconnectBluetoothDevices() { - return bluetoothStatusTracker?.Status.ConnectedClientCount > 0; + return (bluetoothRuntime?.Status ?? bluetoothStatusTracker?.Status)?.ConnectedClientCount > 0; } private void DisconnectBluetoothDevices() { - bluetoothServer?.DisconnectAll(); + bluetoothRuntime?.DisconnectAll(); } private void ShowSettingsWindow() @@ -425,10 +410,16 @@ private async Task StartBluetoothAsync() pairingApprovalManager, controlSession, () => Dispatcher.BeginInvoke(RefreshPairingApprovals)); - bluetoothFrameProcessor = new BluetoothRemoteFrameProcessor(remoteSession); - bluetoothServer = new WindowsBluetoothGattServer(HandleBluetoothEvent); - await bluetoothServer.StartAsync(WindowsBluetoothGattServerOptions.CreateDefault("Switchify PC", desktopId)); - RecordRuntimeDiagnostic("bluetooth.start.completed"); + bluetoothRuntime = new BluetoothRuntime( + pairingApprovalManager, + bluetoothStatusTracker ?? new BluetoothStatusTracker(onStatusChanged: UpdateBluetoothState), + new BluetoothRemoteFrameProcessor(remoteSession), + emit => new WindowsBluetoothGattServer(emit), + DispatchBluetoothAsync, + EndBluetoothControlSessionAsync, + (eventName, status, reason) => RecordRuntimeDiagnostic(eventName, status, reason), + RefreshPairingApprovals); + await bluetoothRuntime.StartAsync("Switchify PC", desktopId); } catch (Exception error) { @@ -438,148 +429,11 @@ private async Task StartBluetoothAsync() } } - private void HandleBluetoothEvent(BluetoothHelperEvent helperEvent) - { - if (helperEvent is BluetoothMessageEvent message) - { - _ = HandleBluetoothMessageSerializedAsync(message); - return; - } - - Dispatcher.BeginInvoke(async () => - { - if (bluetoothStatusTracker is null) return; - - switch (helperEvent) - { - case BluetoothReadyEvent: - bluetoothStatusTracker.SetReady(); - RecordRuntimeDiagnostic("bluetooth.ready", status: "ready"); - break; - case BluetoothUnavailableEvent unavailable: - bluetoothStatusTracker.SetUnavailable(unavailable.Reason); - RecordRuntimeDiagnostic("bluetooth.unavailable", status: "unavailable", reason: unavailable.Reason); - break; - case BluetoothConnectedEvent: - bluetoothStatusTracker.RecordDiagnostic("transport_connected"); - RecordRuntimeDiagnostic("bluetooth.connected", status: "connected"); - break; - case BluetoothDisconnectedEvent disconnected: - BluetoothStatus status = bluetoothStatusTracker.RemoveConnection(disconnected.ConnectionId, disconnected.Reason); - RecordRuntimeDiagnostic("bluetooth.disconnected", status: status.Status, reason: disconnected.Reason); - lock (bluetoothConnectionSync) - { - authenticatedBluetoothConnections.Remove(disconnected.ConnectionId); - } - bluetoothFrameProcessor?.RemoveConnection(disconnected.ConnectionId); - if (status.ConnectedClientCount == 0) - { - if (switchControlSessionManager is not null) - { - await switchControlSessionManager.StopAllAsync(); - } - - if (mouseRepeatController is not null) - { - await mouseRepeatController.StopAllAsync(); - } - - if (commandExecutor is not null) - { - await commandExecutor.ReleaseHeldInputsAsync(); - } - - cursorOverlay?.EndControlSession(); - modifierOverlay?.EndControlSession(); - } - break; - case BluetoothDiagnosticEvent diagnostic: - bluetoothStatusTracker.RecordDiagnostic(diagnostic.Event); - RecordRuntimeDiagnostic("bluetooth.diagnostic", reason: diagnostic.Event); - break; - case BluetoothSystemStatusEvent systemStatus: - bluetoothStatusTracker.SetSystemStatus(systemStatus); - break; - case BluetoothErrorEvent error: - bluetoothStatusTracker.SetError(error.Reason); - RecordRuntimeDiagnostic("bluetooth.error", status: "error", reason: error.Reason); - break; - } - }); - } - - private async Task HandleBluetoothMessageSerializedAsync(BluetoothMessageEvent message) - { - if (bluetoothFrameProcessor is null || bluetoothServer is null) return; - - BluetoothRemoteFrameResult result; - await bluetoothMessageProcessing.WaitAsync(); - try - { - result = await bluetoothFrameProcessor.AcceptAsync(message.ConnectionId, message.Frame).ConfigureAwait(false); - } - finally - { - bluetoothMessageProcessing.Release(); - } - await HandleBluetoothMessageResultAsync(result).ConfigureAwait(false); - } - - private async Task HandleBluetoothMessageResultAsync(BluetoothRemoteFrameResult result) - { - if (!result.MessageComplete) return; - if (result.ErrorReason is not null) - { - RecordRuntimeDiagnostic("bluetooth.message.error", status: "error", reason: result.ErrorReason); - await Dispatcher.BeginInvoke(() => bluetoothStatusTracker?.SetError(result.ErrorReason)); - return; - } - - await SendBluetoothOutputsAsync(result.OutgoingMessages); - if (result.AuthenticatedConnectionId is not null) - { - string connectionId = result.AuthenticatedConnectionId; - bool firstAuthenticatedMessage; - lock (bluetoothConnectionSync) - { - firstAuthenticatedMessage = authenticatedBluetoothConnections.Add(connectionId); - } - - if (firstAuthenticatedMessage) - { - RecordRuntimeDiagnostic("bluetooth.authenticated", status: "connected"); - await Dispatcher.BeginInvoke(() => bluetoothStatusTracker?.AddConnection(connectionId)); - } - } - else if (result.AuthFailureReason is not null) - { - RecordRuntimeDiagnostic("bluetooth.auth.rejected", reason: result.AuthFailureReason); - await Dispatcher.BeginInvoke(() => bluetoothStatusTracker?.RecordDiagnostic("unauthenticated_command_rejected")); - string? authFailureMessage = AuthFailureMessage(result.AuthFailureReason); - if (authFailureMessage is not null) - { - await Dispatcher.BeginInvoke(() => bluetoothStatusTracker?.SetError(authFailureMessage)); - } - } - - } - - private static string? AuthFailureMessage(string reason) - { - return reason switch - { - "unknown_device" => "Bluetooth device is not approved in Switchify. Open Switchify on Android and request access.", - "invalid_auth" => "Switchify access expired. Request access again from Android.", - "expired_timestamp" => "Switchify command timestamp was stale. Check the device time and reconnect.", - _ => null - }; - } - private async Task AcceptPairingApprovalAsync(string requestId) { - if (bluetoothFrameProcessor is not null && bluetoothServer is not null) + if (bluetoothRuntime is not null) { - await SendBluetoothOutputsAsync(await bluetoothFrameProcessor.AcceptPairingRequestAsync(requestId)); + await bluetoothRuntime.AcceptPairingRequestAsync(requestId); } else { @@ -593,9 +447,9 @@ private async Task AcceptPairingApprovalAsync(string requestId) private void RejectPairingApproval(string requestId) { - if (bluetoothFrameProcessor is not null && bluetoothServer is not null) + if (bluetoothRuntime is not null) { - _ = SendBluetoothOutputsAsync(bluetoothFrameProcessor.RejectPairingRequest(requestId)); + bluetoothRuntime.RejectPairingRequest(requestId); } else { @@ -611,7 +465,7 @@ private async Task RefreshSetupGuideStateAsync(bool allowAutoOpen) PairingState pairingState = await new JsonPairingStore(Path.Combine(UserDataDirectory(), "pairing-state.json")).LoadAsync(); SystemStartupSettings startupSettings = await CreateStartupService().GetSettingsAsync(); IReadOnlyList approvals = pairingApprovalManager?.ListPendingRequestViews() ?? []; - BluetoothStatus bluetoothStatus = bluetoothStatusTracker?.Status ?? BluetoothStatusModel.DefaultStatus; + BluetoothStatus bluetoothStatus = bluetoothRuntime?.Status ?? bluetoothStatusTracker?.Status ?? BluetoothStatusModel.DefaultStatus; bool shouldAutoOpen = allowAutoOpen && SetupGuidePrompt.ShouldAutoOpen(settings, pairingState.PairedDevices.Count > 0); await Dispatcher.BeginInvoke(() => @@ -736,56 +590,37 @@ private void StartTelemetryFlushTimer() telemetryFlushTimer.Start(); } - private void StartPairingExpiryTimer() + private void RefreshPairingApprovals() { - pairingExpiryTimer = new DispatcherTimer - { - Interval = TimeSpan.FromSeconds(5) - }; - pairingExpiryTimer.Tick += async (_, _) => - { - if (bluetoothFrameProcessor is not null) - { - await SendBluetoothOutputsAsync(bluetoothFrameProcessor.ExpirePendingPairingRequests()); - bluetoothFrameProcessor.ClearExpiredPartials(); - } - - RefreshPairingApprovals(); - }; - pairingExpiryTimer.Start(); + IReadOnlyList approvals = + bluetoothRuntime?.PendingPairingApprovals ?? + pairingApprovalManager?.ListPendingRequestViews() ?? + []; + mainWindowViewModel.SetPairingApprovals(approvals); + setupGuideViewModel.SetPairingApprovals(approvals); } - private async Task SendBluetoothOutputsAsync(IReadOnlyList outputs) + private async Task DispatchBluetoothAsync(Func action) { - if (bluetoothServer is null) return; + await (await Dispatcher.InvokeAsync(action)); + } - await bluetoothOutputProcessing.WaitAsync(); - try + private async Task EndBluetoothControlSessionAsync() + { + if (switchControlSessionManager is not null) { - foreach (BluetoothRemoteFrameOutput output in outputs) - { - foreach (BluetoothFrame frame in output.ResponseFrames) - { - await bluetoothServer.SendAsync(output.ConnectionId, frame); - } - - if (output.CloseConnection) - { - bluetoothServer.Disconnect(output.ConnectionId); - } - } + await switchControlSessionManager.StopAllAsync(); } - finally + if (mouseRepeatController is not null) { - bluetoothOutputProcessing.Release(); + await mouseRepeatController.StopAllAsync(); } - } - - private void RefreshPairingApprovals() - { - IReadOnlyList approvals = pairingApprovalManager?.ListPendingRequestViews() ?? []; - mainWindowViewModel.SetPairingApprovals(approvals); - setupGuideViewModel.SetPairingApprovals(approvals); + if (commandExecutor is not null) + { + await commandExecutor.ReleaseHeldInputsAsync(); + } + cursorOverlay?.EndControlSession(); + modifierOverlay?.EndControlSession(); } private static string UserDataDirectory() diff --git a/src/SwitchifyPc.App/BluetoothRuntime.cs b/src/SwitchifyPc.App/BluetoothRuntime.cs new file mode 100644 index 0000000..7f6ed92 --- /dev/null +++ b/src/SwitchifyPc.App/BluetoothRuntime.cs @@ -0,0 +1,264 @@ +using System.Windows.Threading; +using SwitchifyPc.Core.Bluetooth; +using SwitchifyPc.Core.Pairing; + +namespace SwitchifyPc.App; + +internal sealed class BluetoothRuntime : IDisposable +{ + private readonly PairingApprovalManager pairingApprovalManager; + private readonly BluetoothStatusTracker statusTracker; + private readonly IBluetoothRemoteFrameProcessor frameProcessor; + private readonly IBluetoothTransportServer server; + private readonly Func, Task> dispatchAsync; + private readonly Func endControlSessionAsync; + private readonly Action recordDiagnostic; + private readonly Action pairingApprovalsChanged; + private readonly SemaphoreSlim messageProcessing = new(1, 1); + private readonly SemaphoreSlim outputProcessing = new(1, 1); + private readonly object connectionSync = new(); + private readonly HashSet authenticatedConnections = new(StringComparer.Ordinal); + private DispatcherTimer? pairingExpiryTimer; + private bool disposed; + + public BluetoothRuntime( + PairingApprovalManager pairingApprovalManager, + BluetoothStatusTracker statusTracker, + IBluetoothRemoteFrameProcessor frameProcessor, + Func, IBluetoothTransportServer> serverFactory, + Func, Task> dispatchAsync, + Func endControlSessionAsync, + Action recordDiagnostic, + Action pairingApprovalsChanged) + { + this.pairingApprovalManager = pairingApprovalManager; + this.statusTracker = statusTracker; + this.frameProcessor = frameProcessor; + this.dispatchAsync = dispatchAsync; + this.endControlSessionAsync = endControlSessionAsync; + this.recordDiagnostic = recordDiagnostic; + this.pairingApprovalsChanged = pairingApprovalsChanged; + server = serverFactory(HandleTransportEvent); + } + + public BluetoothStatus Status => statusTracker.Status; + + public IReadOnlyList PendingPairingApprovals => + pairingApprovalManager.ListPendingRequestViews(); + + public async Task StartAsync(string displayName, string desktopId) + { + ObjectDisposedException.ThrowIf(disposed, this); + statusTracker.SetStarting(); + recordDiagnostic("bluetooth.starting", null, null); + StartPairingExpiryTimer(); + await server.StartAsync(displayName, desktopId).ConfigureAwait(false); + recordDiagnostic("bluetooth.start.completed", null, null); + } + + public void DisconnectAll() + { + server.DisconnectAll(); + } + + public async Task AcceptPairingRequestAsync(string requestId) + { + await SendOutputsAsync(await frameProcessor.AcceptPairingRequestAsync(requestId).ConfigureAwait(false)) + .ConfigureAwait(false); + pairingApprovalsChanged(); + } + + public void RejectPairingRequest(string requestId) + { + _ = SendOutputsAsync(frameProcessor.RejectPairingRequest(requestId)); + pairingApprovalsChanged(); + } + + public async Task ExpirePendingPairingRequestsAsync() + { + await SendOutputsAsync(frameProcessor.ExpirePendingPairingRequests()).ConfigureAwait(false); + frameProcessor.ClearExpiredPartials(); + pairingApprovalsChanged(); + } + + public void Dispose() + { + if (disposed) return; + disposed = true; + pairingExpiryTimer?.Stop(); + pairingExpiryTimer = null; + server.Dispose(); + messageProcessing.Dispose(); + outputProcessing.Dispose(); + lock (connectionSync) + { + authenticatedConnections.Clear(); + } + } + + private void HandleTransportEvent(BluetoothHelperEvent transportEvent) + { + if (transportEvent is BluetoothMessageEvent message) + { + _ = ProcessMessageAsync(message); + return; + } + + _ = dispatchAsync(() => ProcessTransportEventAsync(transportEvent)); + } + + private async Task ProcessTransportEventAsync(BluetoothHelperEvent transportEvent) + { + switch (transportEvent) + { + case BluetoothReadyEvent: + statusTracker.SetReady(); + recordDiagnostic("bluetooth.ready", "ready", null); + break; + case BluetoothUnavailableEvent unavailable: + statusTracker.SetUnavailable(unavailable.Reason); + recordDiagnostic("bluetooth.unavailable", "unavailable", unavailable.Reason); + break; + case BluetoothConnectedEvent: + statusTracker.RecordDiagnostic("transport_connected"); + recordDiagnostic("bluetooth.connected", "connected", null); + break; + case BluetoothDisconnectedEvent disconnected: + await HandleDisconnectedAsync(disconnected).ConfigureAwait(false); + break; + case BluetoothDiagnosticEvent diagnostic: + statusTracker.RecordDiagnostic(diagnostic.Event); + recordDiagnostic("bluetooth.diagnostic", null, diagnostic.Event); + break; + case BluetoothSystemStatusEvent systemStatus: + statusTracker.SetSystemStatus(systemStatus); + break; + case BluetoothErrorEvent error: + statusTracker.SetError(error.Reason); + recordDiagnostic("bluetooth.error", "error", error.Reason); + break; + } + } + + private async Task HandleDisconnectedAsync(BluetoothDisconnectedEvent disconnected) + { + BluetoothStatus status = statusTracker.RemoveConnection(disconnected.ConnectionId, disconnected.Reason); + recordDiagnostic("bluetooth.disconnected", status.Status, disconnected.Reason); + lock (connectionSync) + { + authenticatedConnections.Remove(disconnected.ConnectionId); + } + frameProcessor.RemoveConnection(disconnected.ConnectionId); + if (status.ConnectedClientCount == 0) + { + await endControlSessionAsync().ConfigureAwait(false); + } + } + + private async Task ProcessMessageAsync(BluetoothMessageEvent message) + { + BluetoothRemoteFrameResult result; + await messageProcessing.WaitAsync().ConfigureAwait(false); + try + { + result = await frameProcessor.AcceptAsync(message.ConnectionId, message.Frame).ConfigureAwait(false); + } + finally + { + messageProcessing.Release(); + } + + if (!result.MessageComplete) return; + if (result.ErrorReason is not null) + { + recordDiagnostic("bluetooth.message.error", "error", result.ErrorReason); + await dispatchAsync(() => + { + statusTracker.SetError(result.ErrorReason); + return Task.CompletedTask; + }).ConfigureAwait(false); + return; + } + + await SendOutputsAsync(result.OutgoingMessages).ConfigureAwait(false); + await UpdateAuthenticationStateAsync(result).ConfigureAwait(false); + } + + private async Task UpdateAuthenticationStateAsync(BluetoothRemoteFrameResult result) + { + if (result.AuthenticatedConnectionId is not null) + { + string connectionId = result.AuthenticatedConnectionId; + bool firstAuthenticatedMessage; + lock (connectionSync) + { + firstAuthenticatedMessage = authenticatedConnections.Add(connectionId); + } + + if (firstAuthenticatedMessage) + { + recordDiagnostic("bluetooth.authenticated", "connected", null); + await dispatchAsync(() => + { + statusTracker.AddConnection(connectionId); + return Task.CompletedTask; + }).ConfigureAwait(false); + } + return; + } + + if (result.AuthFailureReason is null) return; + recordDiagnostic("bluetooth.auth.rejected", null, result.AuthFailureReason); + await dispatchAsync(() => + { + statusTracker.RecordDiagnostic("unauthenticated_command_rejected"); + string? message = AuthFailureMessage(result.AuthFailureReason); + if (message is not null) + { + statusTracker.SetError(message); + } + return Task.CompletedTask; + }).ConfigureAwait(false); + } + + private async Task SendOutputsAsync(IReadOnlyList outputs) + { + await outputProcessing.WaitAsync().ConfigureAwait(false); + try + { + foreach (BluetoothRemoteFrameOutput output in outputs) + { + foreach (var frame in output.ResponseFrames) + { + await server.SendAsync(output.ConnectionId, frame).ConfigureAwait(false); + } + if (output.CloseConnection) + { + server.Disconnect(output.ConnectionId); + } + } + } + finally + { + outputProcessing.Release(); + } + } + + private void StartPairingExpiryTimer() + { + pairingExpiryTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) }; + pairingExpiryTimer.Tick += async (_, _) => await ExpirePendingPairingRequestsAsync(); + pairingExpiryTimer.Start(); + } + + internal static string? AuthFailureMessage(string reason) + { + return reason switch + { + "unknown_device" => "Bluetooth device is not approved in Switchify. Open Switchify on Android and request access.", + "invalid_auth" => "Switchify access expired. Request access again from Android.", + "expired_timestamp" => "Switchify command timestamp was stale. Check the device time and reconnect.", + _ => null + }; + } +} diff --git a/src/SwitchifyPc.Core/Bluetooth/BluetoothRemoteFrameProcessor.cs b/src/SwitchifyPc.Core/Bluetooth/BluetoothRemoteFrameProcessor.cs index f756790..a0adc93 100644 --- a/src/SwitchifyPc.Core/Bluetooth/BluetoothRemoteFrameProcessor.cs +++ b/src/SwitchifyPc.Core/Bluetooth/BluetoothRemoteFrameProcessor.cs @@ -28,7 +28,23 @@ public static BluetoothRemoteFrameResult Complete( public static BluetoothRemoteFrameResult Error(string reason) => new(true, reason, []); } -public sealed class BluetoothRemoteFrameProcessor +public interface IBluetoothRemoteFrameProcessor +{ + Task AcceptAsync( + string connectionId, + BluetoothFrame frame, + string? remoteAddress = null, + CancellationToken cancellationToken = default); + Task> AcceptPairingRequestAsync( + string requestId, + CancellationToken cancellationToken = default); + IReadOnlyList RejectPairingRequest(string requestId); + IReadOnlyList ExpirePendingPairingRequests(); + void RemoveConnection(string connectionId); + int ClearExpiredPartials(); +} + +public sealed class BluetoothRemoteFrameProcessor : IBluetoothRemoteFrameProcessor { private readonly RemoteControlSession remoteSession; private readonly int maxResponseFramePayloadBytes; diff --git a/src/SwitchifyPc.Core/Bluetooth/IBluetoothTransportServer.cs b/src/SwitchifyPc.Core/Bluetooth/IBluetoothTransportServer.cs new file mode 100644 index 0000000..535be0c --- /dev/null +++ b/src/SwitchifyPc.Core/Bluetooth/IBluetoothTransportServer.cs @@ -0,0 +1,12 @@ +using SwitchifyPc.Protocol; + +namespace SwitchifyPc.Core.Bluetooth; + +public interface IBluetoothTransportServer : IDisposable +{ + Task StartAsync(string displayName, string desktopId); + Task SendAsync(string connectionId, BluetoothFrame frame); + void Disconnect(string connectionId); + void DisconnectAll(); + void Stop(); +} diff --git a/src/SwitchifyPc.Tests/BluetoothRuntimeTests.cs b/src/SwitchifyPc.Tests/BluetoothRuntimeTests.cs new file mode 100644 index 0000000..336400a --- /dev/null +++ b/src/SwitchifyPc.Tests/BluetoothRuntimeTests.cs @@ -0,0 +1,160 @@ +using SwitchifyPc.App; +using SwitchifyPc.Core.Bluetooth; +using SwitchifyPc.Core.Pairing; +using SwitchifyPc.Protocol; + +namespace SwitchifyPc.Tests; + +public sealed class BluetoothRuntimeTests +{ + [Fact] + public async Task StartAndDisconnectDelegateToTransport() + { + TestContext context = CreateContext(); + + await context.Runtime.StartAsync("Switchify PC", "desktop-1"); + context.Runtime.DisconnectAll(); + + Assert.Equal(("Switchify PC", "desktop-1"), context.Server.StartArguments); + Assert.Equal(1, context.Server.DisconnectAllCalls); + Assert.Equal("starting", context.Tracker.Status.Status); + Assert.Contains(context.Diagnostics, item => item.EventName == "bluetooth.starting"); + Assert.Contains(context.Diagnostics, item => item.EventName == "bluetooth.start.completed"); + } + + [Fact] + public async Task ReadyAndUnavailableEventsUpdateTrackedStatus() + { + TestContext context = CreateContext(); + await context.Runtime.StartAsync("Switchify PC", "desktop-1"); + + context.Server.Emit(new BluetoothReadyEvent()); + Assert.Equal("ready", context.Tracker.Status.Status); + + context.Server.Emit(new BluetoothUnavailableEvent("adapter_off")); + Assert.Equal("unavailable", context.Tracker.Status.Status); + Assert.Equal("adapter_off", context.Tracker.Status.Reason); + } + + [Fact] + public async Task ExpirySendsFramesAndRefreshesApprovals() + { + TestContext context = CreateContext(); + BluetoothFrame frame = new(1, "response-1", 0, true, 0, ""); + context.Processor.ExpiredOutputs = + [ + new BluetoothRemoteFrameOutput("ble", [frame], CloseConnection: true) + ]; + + await context.Runtime.ExpirePendingPairingRequestsAsync(); + + Assert.Equal([("ble", frame)], context.Server.Sent); + Assert.Equal(["ble"], context.Server.Disconnected); + Assert.Equal(1, context.Processor.ClearExpiredCalls); + Assert.Equal(1, context.ApprovalsChanged); + } + + [Theory] + [InlineData("unknown_device", "Bluetooth device is not approved")] + [InlineData("invalid_auth", "Switchify access expired")] + [InlineData("expired_timestamp", "timestamp was stale")] + public void AuthFailureMessagesRemainUserFriendly(string reason, string expected) + { + Assert.Contains(expected, BluetoothRuntime.AuthFailureMessage(reason)); + } + + [Fact] + public void UnknownAuthFailureHasNoUserMessage() + { + Assert.Null(BluetoothRuntime.AuthFailureMessage("other")); + } + + private static TestContext CreateContext() + { + MemoryPairingStore store = new(new PairingState("desktop-1", [])); + PairingApprovalManager approvals = new(store); + BluetoothStatusTracker tracker = new(now: () => 100); + FakeFrameProcessor processor = new(); + FakeTransportServer server = new(); + List diagnostics = []; + int approvalsChanged = 0; + BluetoothRuntime runtime = new( + approvals, + tracker, + processor, + emit => + { + server.SetEmitter(emit); + return server; + }, + async action => await action(), + () => Task.CompletedTask, + (eventName, status, reason) => diagnostics.Add(new Diagnostic(eventName, status, reason)), + () => approvalsChanged += 1); + return new TestContext(runtime, tracker, processor, server, diagnostics, () => approvalsChanged); + } + + private sealed record Diagnostic(string EventName, string? Status, string? Reason); + + private sealed record TestContext( + BluetoothRuntime Runtime, + BluetoothStatusTracker Tracker, + FakeFrameProcessor Processor, + FakeTransportServer Server, + List Diagnostics, + Func GetApprovalsChanged) + { + public int ApprovalsChanged => GetApprovalsChanged(); + } + + private sealed class FakeTransportServer : IBluetoothTransportServer + { + private Action? emit; + public (string DisplayName, string DesktopId)? StartArguments { get; private set; } + public int DisconnectAllCalls { get; private set; } + public List<(string ConnectionId, BluetoothFrame Frame)> Sent { get; } = []; + public List Disconnected { get; } = []; + + public void SetEmitter(Action emitter) => emit = emitter; + public void Emit(BluetoothHelperEvent transportEvent) => emit!(transportEvent); + public Task StartAsync(string displayName, string desktopId) + { + StartArguments = (displayName, desktopId); + return Task.CompletedTask; + } + public Task SendAsync(string connectionId, BluetoothFrame frame) + { + Sent.Add((connectionId, frame)); + return Task.CompletedTask; + } + public void Disconnect(string connectionId) => Disconnected.Add(connectionId); + public void DisconnectAll() => DisconnectAllCalls += 1; + public void Stop() { } + public void Dispose() { } + } + + private sealed class FakeFrameProcessor : IBluetoothRemoteFrameProcessor + { + public IReadOnlyList ExpiredOutputs { get; set; } = []; + public int ClearExpiredCalls { get; private set; } + + public Task AcceptAsync( + string connectionId, + BluetoothFrame frame, + string? remoteAddress = null, + CancellationToken cancellationToken = default) => + Task.FromResult(BluetoothRemoteFrameResult.Incomplete()); + public Task> AcceptPairingRequestAsync( + string requestId, + CancellationToken cancellationToken = default) => + Task.FromResult>([]); + public IReadOnlyList RejectPairingRequest(string requestId) => []; + public IReadOnlyList ExpirePendingPairingRequests() => ExpiredOutputs; + public void RemoveConnection(string connectionId) { } + public int ClearExpiredPartials() + { + ClearExpiredCalls += 1; + return 0; + } + } +} diff --git a/src/SwitchifyPc.Windows/Bluetooth/WindowsBluetoothGattServer.cs b/src/SwitchifyPc.Windows/Bluetooth/WindowsBluetoothGattServer.cs index e70845d..d66dfb0 100644 --- a/src/SwitchifyPc.Windows/Bluetooth/WindowsBluetoothGattServer.cs +++ b/src/SwitchifyPc.Windows/Bluetooth/WindowsBluetoothGattServer.cs @@ -28,7 +28,7 @@ public static WindowsBluetoothGattServerOptions CreateDefault(string displayName BluetoothHelperProtocol.StatusCharacteristicUuid); } -public sealed class WindowsBluetoothGattServer : IDisposable +public sealed class WindowsBluetoothGattServer : IBluetoothTransportServer { private const string ConnectionId = "ble"; private static readonly TimeSpan DisconnectGracePeriod = TimeSpan.FromSeconds(10); @@ -84,6 +84,11 @@ public async Task StartAsync(WindowsBluetoothGattServerOptions options) await StartAdvertisingAsync(options, restarted: false).ConfigureAwait(false); } + public Task StartAsync(string displayName, string desktopId) + { + return StartAsync(WindowsBluetoothGattServerOptions.CreateDefault(displayName, desktopId)); + } + public void Stop() { StopSystemStatusMonitoring();