diff --git a/src/SwitchifyPc.App/App.xaml.cs b/src/SwitchifyPc.App/App.xaml.cs
index 80c292a..5dd7331 100644
--- a/src/SwitchifyPc.App/App.xaml.cs
+++ b/src/SwitchifyPc.App/App.xaml.cs
@@ -9,6 +9,7 @@
using SwitchifyPc.Core.Input;
using SwitchifyPc.Core.Pairing;
using SwitchifyPc.Core.Settings;
+using SwitchifyPc.Core.SwitchControl;
using SwitchifyPc.Core.AppLifecycle;
using SwitchifyPc.Core.Diagnostics;
using SwitchifyPc.Core.Startup;
@@ -36,6 +37,7 @@ public partial class App : System.Windows.Application
private WindowsExistingInstanceSignal? existingInstanceSignal;
private NativeTrayIcon? trayIcon;
private SettingsWindow? settingsWindow;
+ private SwitchControlProfileWindow? switchControlProfileWindow;
private SetupGuideWindow? setupGuideWindow;
private MainWindowViewModel mainWindowViewModel = new();
private SetupGuideViewModel setupGuideViewModel = new();
@@ -45,6 +47,7 @@ public partial class App : System.Windows.Application
private WindowsBluetoothGattServer? bluetoothServer;
private BluetoothRemoteFrameProcessor? bluetoothFrameProcessor;
private DesktopCommandExecutor? commandExecutor;
+ private JsonSwitchControlProfileStore? switchControlProfileStore;
private MouseRepeatController? mouseRepeatController;
private WindowsCursorOverlayNotifier? cursorOverlay;
private WindowsModifierKeyOverlayNotifier? modifierOverlay;
@@ -154,6 +157,7 @@ protected override void OnExit(ExitEventArgs e)
themeManager?.Dispose();
themeManager = null;
commandExecutor = null;
+ switchControlProfileStore = null;
bluetoothFrameProcessor = null;
bluetoothStatusTracker = null;
pairingApprovalManager = null;
@@ -164,6 +168,7 @@ protected override void OnExit(ExitEventArgs e)
trayIcon?.Dispose();
trayIcon = null;
settingsWindow = null;
+ switchControlProfileWindow = null;
setupGuideWindow = null;
telemetryReporter?.Dispose();
telemetryReporter = null;
@@ -273,7 +278,7 @@ private SetupGuideWindow CreateSetupGuideWindow()
private SettingsWindow CreateSettingsWindow()
{
- SettingsWindow window = new(CreateSettingsController());
+ SettingsWindow window = new(CreateSettingsController(), ShowSwitchControlProfiles);
window.Closing += (_, eventArgs) =>
{
if (isQuitting) return;
@@ -284,6 +289,22 @@ private SettingsWindow CreateSettingsWindow()
return window;
}
+ private void ShowSwitchControlProfiles()
+ {
+ switchControlProfileStore ??= new JsonSwitchControlProfileStore(
+ Path.Combine(UserDataDirectory(), "switch-control-profiles.json"));
+ if (switchControlProfileWindow is null)
+ {
+ switchControlProfileWindow = new SwitchControlProfileWindow(
+ switchControlProfileStore,
+ () => null);
+ switchControlProfileWindow.Closed += (_, _) => switchControlProfileWindow = null;
+ }
+ switchControlProfileWindow.Owner = settingsWindow;
+ switchControlProfileWindow.Show();
+ switchControlProfileWindow.Activate();
+ }
+
private static void CenterWindow(Window window)
{
window.Left = SystemParameters.WorkArea.Left + (SystemParameters.WorkArea.Width - window.Width) / 2;
diff --git a/src/SwitchifyPc.App/SettingsWindow.xaml b/src/SwitchifyPc.App/SettingsWindow.xaml
index 7b99159..78751cc 100644
--- a/src/SwitchifyPc.App/SettingsWindow.xaml
+++ b/src/SwitchifyPc.App/SettingsWindow.xaml
@@ -368,6 +368,20 @@
+
+
+
+
+
+
+
diff --git a/src/SwitchifyPc.App/SettingsWindow.xaml.cs b/src/SwitchifyPc.App/SettingsWindow.xaml.cs
index 915f617..bf78405 100644
--- a/src/SwitchifyPc.App/SettingsWindow.xaml.cs
+++ b/src/SwitchifyPc.App/SettingsWindow.xaml.cs
@@ -15,10 +15,12 @@ public partial class SettingsWindow : Window
private bool isApplyingSettings;
private bool settingsLoaded;
private bool isInstallingUpdate;
+ private readonly Action? manageSwitchProfiles;
- public SettingsWindow(SettingsController controller) : this(controller.ViewModel)
+ public SettingsWindow(SettingsController controller, Action? manageSwitchProfiles = null) : this(controller.ViewModel)
{
this.controller = controller;
+ this.manageSwitchProfiles = manageSwitchProfiles;
}
public SettingsWindow(SettingsViewModel? viewModel = null)
@@ -129,6 +131,8 @@ private void OpenPrivacyPolicy_Click(object sender, RoutedEventArgs e)
}
}
+ private void ManageSwitchProfiles_Click(object sender, RoutedEventArgs e) => manageSwitchProfiles?.Invoke();
+
private async void ForgetPairedDevice_Click(object sender, RoutedEventArgs e)
{
if (controller is null || sender is not WpfButton { Tag: string deviceId }) return;
diff --git a/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml b/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml
new file mode 100644
index 0000000..7410107
--- /dev/null
+++ b/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml.cs b/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml.cs
new file mode 100644
index 0000000..cfe741c
--- /dev/null
+++ b/src/SwitchifyPc.App/SwitchControlProfileWindow.xaml.cs
@@ -0,0 +1,323 @@
+using System.ComponentModel;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Windows;
+using System.Windows.Automation;
+using System.Windows.Controls;
+using System.Windows.Media;
+using SwitchifyPc.Core.SwitchControl;
+using WpfMessageBox = System.Windows.MessageBox;
+
+namespace SwitchifyPc.App;
+
+public partial class SwitchControlProfileWindow : Window
+{
+ private readonly ISwitchControlProfileStore store;
+ private readonly Func activeProfileId;
+ private IReadOnlyList profiles = [];
+ private SwitchControlProfile? selected;
+ private readonly BindingRowViewModel[] rows =
+ Enumerable.Range(1, 8).Select(id => new BindingRowViewModel(id)).ToArray();
+
+ public SwitchControlProfileWindow(
+ ISwitchControlProfileStore store,
+ Func activeProfileId)
+ {
+ this.store = store;
+ this.activeProfileId = activeProfileId;
+ InitializeComponent();
+ BindingRows.ItemsSource = rows;
+ Reload();
+ }
+
+ private void Reload(string? selectId = null)
+ {
+ profiles = store.Load();
+ ProfilesList.ItemsSource = profiles;
+ ProfilesList.SelectedItem = profiles.FirstOrDefault(profile => profile.Id == selectId) ?? profiles.FirstOrDefault();
+ }
+
+ private void ProfilesList_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (ProfilesList.SelectedItem is not SwitchControlProfile profile) return;
+ selected = profile;
+ bool editable = !profile.IsBuiltIn && profile.Id != activeProfileId();
+ ProfileName.Text = profile.Name;
+ ProfileName.IsEnabled = editable;
+ ReadOnlyMessage.Text = profile.IsBuiltIn
+ ? "Built-in profiles are read-only. Duplicate this profile to customize it."
+ : editable
+ ? ""
+ : "This profile is active and cannot be changed until PC Switch Control stops.";
+ foreach ((BindingRowViewModel row, SwitchControlBinding binding) in rows.Zip(profile.Bindings))
+ {
+ row.Load(binding, editable);
+ }
+ SaveButton.IsEnabled = editable;
+ CancelButton.IsEnabled = editable;
+ DeleteButton.IsEnabled = editable;
+ ValidationMessage.Text = "";
+ }
+
+ private void New_Click(object sender, RoutedEventArgs e)
+ {
+ EditUnsaved(new SwitchControlProfile(
+ Guid.NewGuid().ToString(),
+ 1,
+ UniqueName("New profile"),
+ SwitchControlProviderKind.Mapped,
+ Enumerable.Range(1, 8).Select(id => new SwitchControlBinding(id, SwitchBindingType.None)).ToArray()));
+ }
+
+ private void Duplicate_Click(object sender, RoutedEventArgs e)
+ {
+ if (selected is null) return;
+ EditUnsaved(selected with
+ {
+ Id = Guid.NewGuid().ToString(),
+ Version = 1,
+ Name = UniqueName($"{selected.Name} copy"),
+ IsBuiltIn = false,
+ Kind = SwitchControlProviderKind.Mapped,
+ Bindings = selected.Kind == SwitchControlProviderKind.Grid3
+ ? Enumerable.Range(1, 8).Select(id => new SwitchControlBinding(id, SwitchBindingType.None)).ToArray()
+ : selected.Bindings.Select(binding => binding with { }).ToArray()
+ });
+ }
+
+ private void EditUnsaved(SwitchControlProfile profile)
+ {
+ profiles = [.. profiles, profile];
+ ProfilesList.ItemsSource = profiles;
+ ProfilesList.SelectedItem = profile;
+ ProfilesList.ScrollIntoView(profile);
+ }
+
+ private void Save_Click(object sender, RoutedEventArgs e)
+ {
+ if (selected is null || selected.IsBuiltIn || selected.Id == activeProfileId()) return;
+ BindingRowViewModel? invalidRow = rows.FirstOrDefault(row => !row.IsLocallyValid());
+ if (invalidRow is not null)
+ {
+ ValidationMessage.Text = $"Switch {invalidRow.SwitchId}: {invalidRow.ValueHelp}";
+ FocusBindingValue(invalidRow);
+ return;
+ }
+ try
+ {
+ SwitchControlProfile saved = selected with
+ {
+ Name = ProfileName.Text,
+ Version = store.Load().Any(profile => profile.Id == selected.Id)
+ ? selected.Version + 1
+ : 1,
+ Bindings = rows.Select(row => row.ToBinding()).ToArray()
+ };
+ IReadOnlyList custom = profiles
+ .Where(profile => !profile.IsBuiltIn && profile.Id != selected.Id)
+ .Append(saved)
+ .ToArray();
+ store.Save(custom);
+ Reload(saved.Id);
+ }
+ catch (Exception error) when (error is InvalidDataException or IOException or UnauthorizedAccessException)
+ {
+ ValidationMessage.Text = error.Message;
+ ProfileName.Focus();
+ }
+ }
+
+ private void Cancel_Click(object sender, RoutedEventArgs e) => Reload(selected?.Id);
+
+ private void Delete_Click(object sender, RoutedEventArgs e)
+ {
+ if (selected is null || selected.IsBuiltIn || selected.Id == activeProfileId()) return;
+ MessageBoxResult answer = WpfMessageBox.Show(
+ $"Delete “{selected.Name}”?",
+ "Delete PC Switch Control profile",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning);
+ if (answer != MessageBoxResult.Yes) return;
+ store.Save(profiles.Where(profile => !profile.IsBuiltIn && profile.Id != selected.Id).ToArray());
+ Reload();
+ }
+
+ private void Close_Click(object sender, RoutedEventArgs e) => Close();
+
+ private string UniqueName(string proposed)
+ {
+ string name = proposed;
+ int suffix = 2;
+ while (profiles.Any(profile => string.Equals(profile.Name, name, StringComparison.OrdinalIgnoreCase)))
+ {
+ name = $"{proposed} {suffix++}";
+ }
+ return name;
+ }
+
+ private void FocusBindingValue(BindingRowViewModel row)
+ {
+ BindingRows.UpdateLayout();
+ if (BindingRows.ItemContainerGenerator.ContainerFromItem(row) is not DependencyObject container)
+ {
+ return;
+ }
+ FindDescendants(container)
+ .FirstOrDefault(control =>
+ AutomationProperties.GetName(control) == row.ValueAutomationName)
+ ?.Focus();
+ }
+
+ private static IEnumerable FindDescendants(DependencyObject parent)
+ where T : DependencyObject
+ {
+ for (int index = 0; index < VisualTreeHelper.GetChildrenCount(parent); index++)
+ {
+ DependencyObject child = VisualTreeHelper.GetChild(parent, index);
+ if (child is T match) yield return match;
+ foreach (T descendant in FindDescendants(child)) yield return descendant;
+ }
+ }
+
+ private sealed class BindingRowViewModel : INotifyPropertyChanged
+ {
+ private SwitchBindingType selectedType;
+ private string value = "";
+ private bool isEditable;
+
+ public BindingRowViewModel(int switchId)
+ {
+ SwitchId = switchId;
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ public int SwitchId { get; }
+ public string SwitchLabel => $"Switch {SwitchId}";
+ public string TypeAutomationName => $"Switch {SwitchId} action type";
+ public string ValueAutomationName => $"Switch {SwitchId} action value";
+ public IReadOnlyList Types { get; } = Enum.GetValues();
+
+ public SwitchBindingType SelectedType
+ {
+ get => selectedType;
+ set
+ {
+ selectedType = value;
+ Changed();
+ Changed(nameof(ValueOptions));
+ Changed(nameof(ValueHelp));
+ }
+ }
+
+ public string Value
+ {
+ get => value;
+ set { this.value = value; Changed(); }
+ }
+
+ public bool IsEditable
+ {
+ get => isEditable;
+ private set { isEditable = value; Changed(); }
+ }
+
+ public IReadOnlyList ValueOptions => SelectedType switch
+ {
+ SwitchBindingType.Key => KeyValues,
+ SwitchBindingType.MouseButton => ["left", "right", "middle"],
+ SwitchBindingType.Shortcut => ["Ctrl + C", "Ctrl + V", "Alt + Tab", "Ctrl + Shift + Escape"],
+ SwitchBindingType.MouseClick => ["left", "left:2", "right", "right:2", "middle", "middle:2"],
+ SwitchBindingType.Scroll => ["up", "down", "left", "right"],
+ SwitchBindingType.Media => ["playPause", "nextTrack", "previousTrack", "volumeUp", "volumeDown", "mute"],
+ _ => []
+ };
+
+ public string ValueHelp => SelectedType switch
+ {
+ SwitchBindingType.None => "No value is required.",
+ SwitchBindingType.Key => "Choose one keyboard key.",
+ SwitchBindingType.MouseButton => "Choose left, right, or middle.",
+ SwitchBindingType.Shortcut => "Enter one to four unique keys separated by +, including a non-modifier.",
+ SwitchBindingType.MouseClick => "Choose a button, with :2 for a double click.",
+ SwitchBindingType.Scroll => "Choose up, down, left, or right.",
+ SwitchBindingType.Media => "Choose play/pause, track, volume, or mute.",
+ _ => "Choose a valid value."
+ };
+
+ public bool IsLocallyValid()
+ {
+ string trimmed = Value.Trim();
+ return SelectedType switch
+ {
+ SwitchBindingType.None => true,
+ SwitchBindingType.Key => KeyValues.Contains(trimmed, StringComparer.OrdinalIgnoreCase),
+ SwitchBindingType.MouseButton =>
+ new[] { "left", "right", "middle" }.Contains(trimmed, StringComparer.Ordinal),
+ SwitchBindingType.Shortcut => IsValidShortcut(trimmed),
+ SwitchBindingType.MouseClick =>
+ new[] { "left", "left:2", "right", "right:2", "middle", "middle:2" }
+ .Contains(trimmed, StringComparer.Ordinal),
+ SwitchBindingType.Scroll =>
+ new[] { "up", "down", "left", "right" }.Contains(trimmed, StringComparer.Ordinal),
+ SwitchBindingType.Media =>
+ new[] { "playPause", "nextTrack", "previousTrack", "volumeUp", "volumeDown", "mute" }
+ .Contains(trimmed, StringComparer.Ordinal),
+ _ => false
+ };
+ }
+
+ public void Load(SwitchControlBinding binding, bool editable)
+ {
+ SelectedType = binding.Type;
+ Value = binding.Type == SwitchBindingType.Shortcut
+ ? string.Join(" + ", binding.Keys ?? [])
+ : binding.Type == SwitchBindingType.MouseClick
+ ? $"{binding.Value}:{binding.ClickCount}"
+ : binding.Value ?? "";
+ IsEditable = editable;
+ }
+
+ public SwitchControlBinding ToBinding()
+ {
+ string trimmed = Value.Trim();
+ if (SelectedType == SwitchBindingType.Shortcut)
+ {
+ return new(SwitchId, SelectedType, Keys: trimmed.Split(
+ '+',
+ StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
+ }
+ if (SelectedType == SwitchBindingType.MouseClick)
+ {
+ string[] parts = trimmed.Split(':', StringSplitOptions.TrimEntries);
+ int clicks = parts.Length == 2 && int.TryParse(parts[1], out int parsed) ? parsed : 1;
+ return new(SwitchId, SelectedType, parts[0], ClickCount: clicks);
+ }
+ return new(SwitchId, SelectedType, string.IsNullOrEmpty(trimmed) ? null : trimmed);
+ }
+
+ private void Changed([CallerMemberName] string? propertyName = null) =>
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+
+ private static bool IsValidShortcut(string value)
+ {
+ string[] keys = value.Split(
+ '+',
+ StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+ return keys.Length is >= 1 and <= 4 &&
+ keys.Distinct(StringComparer.OrdinalIgnoreCase).Count() == keys.Length &&
+ keys.All(key => KeyValues.Contains(key, StringComparer.OrdinalIgnoreCase)) &&
+ keys.Any(key => !ModifierValues.Contains(key, StringComparer.OrdinalIgnoreCase));
+ }
+
+ private static readonly string[] ModifierValues = ["Ctrl", "Alt", "Shift", "Meta"];
+ private static readonly string[] KeyValues =
+ [
+ .. Enumerable.Range('A', 26).Select(value => ((char)value).ToString()),
+ .. Enumerable.Range(0, 10).Select(value => value.ToString()),
+ .. Enumerable.Range(1, 12).Select(value => $"F{value}"),
+ "Space", "Enter", "Escape", "Tab", "Backspace", "Delete",
+ "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
+ "Home", "End", "PageUp", "PageDown", "Ctrl", "Alt", "Shift", "Meta"
+ ];
+ }
+}
diff --git a/src/SwitchifyPc.Core/SwitchControl/SwitchControlProfile.cs b/src/SwitchifyPc.Core/SwitchControl/SwitchControlProfile.cs
new file mode 100644
index 0000000..2c5dd1b
--- /dev/null
+++ b/src/SwitchifyPc.Core/SwitchControl/SwitchControlProfile.cs
@@ -0,0 +1,139 @@
+namespace SwitchifyPc.Core.SwitchControl;
+
+public enum SwitchControlProviderKind
+{
+ Grid3,
+ Mapped
+}
+
+public enum SwitchBindingType
+{
+ None,
+ Key,
+ MouseButton,
+ Shortcut,
+ MouseClick,
+ Scroll,
+ Media
+}
+
+public enum SwitchBindingBehavior
+{
+ Unassigned,
+ Stateful,
+ Pulse
+}
+
+public sealed record SwitchControlBinding(
+ int SwitchId,
+ SwitchBindingType Type,
+ string? Value = null,
+ IReadOnlyList? Keys = null,
+ int ClickCount = 1)
+{
+ public SwitchBindingBehavior Behavior => Type switch
+ {
+ SwitchBindingType.Key or SwitchBindingType.MouseButton => SwitchBindingBehavior.Stateful,
+ SwitchBindingType.None => SwitchBindingBehavior.Unassigned,
+ _ => SwitchBindingBehavior.Pulse
+ };
+
+ public string Label => Type switch
+ {
+ SwitchBindingType.None => "Unassigned",
+ SwitchBindingType.Key => Value ?? "Key",
+ SwitchBindingType.MouseButton => $"{Title(Value)} mouse button",
+ SwitchBindingType.Shortcut => string.Join(" + ", Keys ?? []),
+ SwitchBindingType.MouseClick => $"{(ClickCount == 2 ? "Double " : "")}{Title(Value)} click",
+ SwitchBindingType.Scroll => $"Scroll {Value}",
+ SwitchBindingType.Media => MediaLabel(Value),
+ _ => "Unassigned"
+ };
+
+ private static string Title(string? value) =>
+ string.IsNullOrWhiteSpace(value) ? "" : char.ToUpperInvariant(value[0]) + value[1..];
+
+ private static string MediaLabel(string? value) => value switch
+ {
+ "playPause" => "Play / pause",
+ "nextTrack" => "Next track",
+ "previousTrack" => "Previous track",
+ "volumeUp" => "Volume up",
+ "volumeDown" => "Volume down",
+ "mute" => "Mute",
+ _ => "Media"
+ };
+}
+
+public sealed record SwitchControlProfile(
+ string Id,
+ int Version,
+ string Name,
+ SwitchControlProviderKind Kind,
+ IReadOnlyList Bindings,
+ bool IsBuiltIn = false);
+
+public sealed record SwitchControlBindingSummary(
+ int SwitchId,
+ string Label,
+ string Behavior);
+
+public sealed record SwitchControlProfileSummary(
+ string Id,
+ int Version,
+ string Name,
+ string Kind,
+ IReadOnlyList Bindings);
+
+public sealed record SwitchControlProfileCatalog(
+ int CatalogRevision,
+ IReadOnlyList Profiles);
+
+public static class SwitchControlProfiles
+{
+ public const string Grid3Id = "builtin.grid3";
+ public const string GenericKeyboardId = "builtin.keyboard";
+ public const int MaximumCustomProfiles = 32;
+
+ public static IReadOnlyList BuiltIns { get; } =
+ [
+ new(
+ Grid3Id,
+ 1,
+ "Grid 3",
+ SwitchControlProviderKind.Grid3,
+ Enumerable.Range(1, 8)
+ .Select(id => new SwitchControlBinding(id, SwitchBindingType.None, $"Grid switch {id}"))
+ .ToArray(),
+ true),
+ new(
+ GenericKeyboardId,
+ 1,
+ "Generic keyboard",
+ SwitchControlProviderKind.Mapped,
+ [
+ new(1, SwitchBindingType.Key, "Space"),
+ new(2, SwitchBindingType.Key, "Enter"),
+ .. Enumerable.Range(3, 6).Select(id => new SwitchControlBinding(id, SwitchBindingType.None))
+ ],
+ true)
+ ];
+
+ public static SwitchControlProfileSummary Summarize(SwitchControlProfile profile) =>
+ new(
+ profile.Id,
+ profile.Version,
+ profile.Name,
+ profile.Kind == SwitchControlProviderKind.Grid3 ? "grid3" : "mapped",
+ profile.Bindings.Select(binding => new SwitchControlBindingSummary(
+ binding.SwitchId,
+ profile.Kind == SwitchControlProviderKind.Grid3 ? $"Grid switch {binding.SwitchId}" : binding.Label,
+ profile.Kind == SwitchControlProviderKind.Grid3 ? "stateful" : BehaviorValue(binding.Behavior))).ToArray());
+
+ private static string BehaviorValue(SwitchBindingBehavior behavior) => behavior switch
+ {
+ SwitchBindingBehavior.Stateful => "stateful",
+ SwitchBindingBehavior.Pulse => "pulse",
+ _ => "unassigned"
+ };
+}
diff --git a/src/SwitchifyPc.Core/SwitchControl/SwitchControlProfileStore.cs b/src/SwitchifyPc.Core/SwitchControl/SwitchControlProfileStore.cs
new file mode 100644
index 0000000..eb92d91
--- /dev/null
+++ b/src/SwitchifyPc.Core/SwitchControl/SwitchControlProfileStore.cs
@@ -0,0 +1,171 @@
+using System.Text.Json;
+using SwitchifyPc.Core.Storage;
+
+namespace SwitchifyPc.Core.SwitchControl;
+
+public interface ISwitchControlProfileStore
+{
+ IReadOnlyList Load();
+ IReadOnlyList Save(IReadOnlyList customProfiles);
+}
+
+public sealed class JsonSwitchControlProfileStore : ISwitchControlProfileStore
+{
+ private const int SchemaVersion = 1;
+ private readonly string filePath;
+ private readonly Action warn;
+
+ public JsonSwitchControlProfileStore(string filePath, Action? warn = null)
+ {
+ this.filePath = filePath;
+ this.warn = warn ?? Console.WriteLine;
+ }
+
+ public IReadOnlyList Load()
+ {
+ if (!File.Exists(filePath))
+ {
+ return SwitchControlProfiles.BuiltIns;
+ }
+
+ try
+ {
+ StoredProfiles stored = JsonSerializer.Deserialize(
+ File.ReadAllText(filePath),
+ JsonOptions) ?? throw new JsonException();
+ if (stored.SchemaVersion != SchemaVersion)
+ {
+ throw new JsonException();
+ }
+
+ return [.. SwitchControlProfiles.BuiltIns, .. NormalizeCustom(stored.Profiles)];
+ }
+ catch
+ {
+ warn("Switchify PC Switch Control profiles could not be loaded. Built-in profiles will be used.");
+ return SwitchControlProfiles.BuiltIns;
+ }
+ }
+
+ public IReadOnlyList Save(IReadOnlyList customProfiles)
+ {
+ IReadOnlyList normalized = NormalizeCustom(customProfiles);
+ string json = JsonSerializer.Serialize(new StoredProfiles(SchemaVersion, normalized), JsonOptions) + "\n";
+ JsonFileStore.WriteJsonFileAtomicSync(filePath, json);
+ return [.. SwitchControlProfiles.BuiltIns, .. normalized];
+ }
+
+ public static IReadOnlyList NormalizeCustom(IReadOnlyList profiles)
+ {
+ if (profiles.Count > SwitchControlProfiles.MaximumCustomProfiles)
+ {
+ throw new InvalidDataException("Too many custom profiles.");
+ }
+
+ var names = new HashSet(
+ SwitchControlProfiles.BuiltIns.Select(profile => profile.Name),
+ StringComparer.OrdinalIgnoreCase);
+ var ids = new HashSet(SwitchControlProfiles.BuiltIns.Select(profile => profile.Id), StringComparer.Ordinal);
+ var normalized = new List(profiles.Count);
+ foreach (SwitchControlProfile profile in profiles)
+ {
+ string name = profile.Name.Trim();
+ if (name.Length is < 1 or > 50 || !names.Add(name))
+ {
+ throw new InvalidDataException("Profile names must be unique and contain 1 to 50 characters.");
+ }
+
+ if (!Guid.TryParse(profile.Id, out _) || !ids.Add(profile.Id) || profile.Version < 1)
+ {
+ throw new InvalidDataException("Custom profile metadata is invalid.");
+ }
+
+ if (profile.Kind != SwitchControlProviderKind.Mapped || profile.Bindings.Count != 8)
+ {
+ throw new InvalidDataException("Custom profiles must contain eight mapped bindings.");
+ }
+
+ SwitchControlBinding[] bindings = profile.Bindings
+ .OrderBy(binding => binding.SwitchId)
+ .Select(NormalizeBinding)
+ .ToArray();
+ if (!bindings.Select(binding => binding.SwitchId).SequenceEqual(Enumerable.Range(1, 8)))
+ {
+ throw new InvalidDataException("Switch IDs must be 1 through 8.");
+ }
+
+ normalized.Add(profile with { Name = name, Bindings = bindings, IsBuiltIn = false });
+ }
+
+ return normalized;
+ }
+
+ private static SwitchControlBinding NormalizeBinding(SwitchControlBinding binding)
+ {
+ HashSet keys = AllowedKeys;
+ return binding.Type switch
+ {
+ SwitchBindingType.None => new(binding.SwitchId, SwitchBindingType.None),
+ SwitchBindingType.Key when binding.Value is not null && keys.Contains(binding.Value) =>
+ new(binding.SwitchId, binding.Type, CanonicalKey(binding.Value)),
+ SwitchBindingType.MouseButton when AllowedMouseButtons.Contains(binding.Value ?? "") =>
+ new(binding.SwitchId, binding.Type, binding.Value),
+ SwitchBindingType.Shortcut => NormalizeShortcut(binding, keys),
+ SwitchBindingType.MouseClick when AllowedMouseButtons.Contains(binding.Value ?? "") && binding.ClickCount is 1 or 2 =>
+ new(binding.SwitchId, binding.Type, binding.Value, ClickCount: binding.ClickCount),
+ SwitchBindingType.Scroll when AllowedScrollDirections.Contains(binding.Value ?? "") =>
+ new(binding.SwitchId, binding.Type, binding.Value),
+ SwitchBindingType.Media when AllowedMediaActions.Contains(binding.Value ?? "") =>
+ new(binding.SwitchId, binding.Type, binding.Value),
+ _ => throw new InvalidDataException("A profile binding is invalid.")
+ };
+ }
+
+ private static SwitchControlBinding NormalizeShortcut(SwitchControlBinding binding, HashSet allowedKeys)
+ {
+ string[] suppliedKeys = (binding.Keys ?? []).ToArray();
+ if (suppliedKeys.Length is < 1 or > 4 ||
+ suppliedKeys.Distinct(StringComparer.OrdinalIgnoreCase).Count() != suppliedKeys.Length ||
+ suppliedKeys.Any(key => !allowedKeys.Contains(key)) ||
+ suppliedKeys.All(ModifierKeys.Contains))
+ {
+ throw new InvalidDataException("A shortcut binding is invalid.");
+ }
+
+ string[] keys = suppliedKeys.Select(CanonicalKey).ToArray();
+ return new(binding.SwitchId, SwitchBindingType.Shortcut, Keys: keys);
+ }
+
+ private static string CanonicalKey(string key) =>
+ AllowedKeys.First(allowed => string.Equals(allowed, key, StringComparison.OrdinalIgnoreCase));
+
+ private static readonly HashSet ModifierKeys = new(
+ ["Ctrl", "Alt", "Shift", "Meta"],
+ StringComparer.OrdinalIgnoreCase);
+
+ private static readonly HashSet AllowedKeys = new(
+ [
+ .. Enumerable.Range('A', 26).Select(value => ((char)value).ToString()),
+ .. Enumerable.Range(0, 10).Select(value => value.ToString()),
+ .. Enumerable.Range(1, 12).Select(value => $"F{value}"),
+ "Space", "Enter", "Escape", "Tab", "Backspace", "Delete",
+ "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown",
+ "Ctrl", "Alt", "Shift", "Meta"
+ ],
+ StringComparer.OrdinalIgnoreCase);
+
+ private static readonly HashSet AllowedMouseButtons = new(["left", "right", "middle"], StringComparer.Ordinal);
+ private static readonly HashSet AllowedScrollDirections = new(["up", "down", "left", "right"], StringComparer.Ordinal);
+ private static readonly HashSet AllowedMediaActions = new(
+ ["playPause", "nextTrack", "previousTrack", "volumeUp", "volumeDown", "mute"],
+ StringComparer.Ordinal);
+
+ private sealed record StoredProfiles(int SchemaVersion, IReadOnlyList Profiles);
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ WriteIndented = true
+ };
+}
diff --git a/src/SwitchifyPc.Core/SwitchControl/SwitchControlSessionManager.cs b/src/SwitchifyPc.Core/SwitchControl/SwitchControlSessionManager.cs
new file mode 100644
index 0000000..c85a694
--- /dev/null
+++ b/src/SwitchifyPc.Core/SwitchControl/SwitchControlSessionManager.cs
@@ -0,0 +1,305 @@
+using SwitchifyPc.Core.Input;
+
+namespace SwitchifyPc.Core.SwitchControl;
+
+public sealed record SwitchSessionResult(bool Ok, string? Code = null, string? Message = null)
+{
+ public static SwitchSessionResult Success { get; } = new(true);
+ public static SwitchSessionResult Failure(string code, string message) => new(false, code, message);
+}
+
+public sealed class SwitchControlSessionManager
+{
+ private readonly ISwitchControlProfileStore profiles;
+ private readonly ISwitchOutputSessionFactory outputs;
+ private readonly SemaphoreSlim gate = new(1, 1);
+ private ActiveSession? active;
+ private long catalogRevision = 1;
+
+ public SwitchControlSessionManager(
+ ISwitchControlProfileStore profiles,
+ ISwitchOutputSessionFactory outputs)
+ {
+ this.profiles = profiles;
+ this.outputs = outputs;
+ }
+
+ public bool IsActive => active is not null;
+ public string? ActiveProfileId => active?.Profile.Id;
+
+ public SwitchControlProfileCatalog GetCatalog()
+ {
+ IReadOnlyList loaded = profiles.Load();
+ return new SwitchControlProfileCatalog(
+ checked((int)catalogRevision),
+ loaded.Select(SwitchControlProfiles.Summarize).ToArray());
+ }
+
+ public async Task StartAsync(
+ string deviceId,
+ string sessionId,
+ string profileId,
+ int profileVersion,
+ CancellationToken token = default)
+ {
+ if (!Guid.TryParse(sessionId, out _))
+ {
+ return SwitchSessionResult.Failure("invalid_payload", "Session ID must be a UUID.");
+ }
+
+ SwitchControlProfile? profile = profiles.Load().FirstOrDefault(candidate => candidate.Id == profileId);
+ if (profile is null || profile.Version != profileVersion)
+ {
+ return SwitchSessionResult.Failure("profile_changed", "The selected profile changed or is no longer available.");
+ }
+
+ await gate.WaitAsync(token).ConfigureAwait(false);
+ try
+ {
+ await StopLockedAsync(token).ConfigureAwait(false);
+ ISwitchOutputSession output;
+ try
+ {
+ output = outputs.Create(profile);
+ }
+ catch (DesktopInputException error)
+ {
+ return SwitchSessionResult.Failure(error.Code, error.Message);
+ }
+
+ active = new ActiveSession(deviceId, sessionId, profile, output);
+ return SwitchSessionResult.Success;
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ public Task ApplyEdgeAsync(
+ string deviceId,
+ string sessionId,
+ long sequence,
+ int switchId,
+ bool pressed,
+ CancellationToken token = default) =>
+ ExecuteSequencedAsync(deviceId, sessionId, sequence, output => output.ApplyEdgeAsync(switchId, pressed, token), token);
+
+ public Task SynchronizeAsync(
+ string deviceId,
+ string sessionId,
+ long sequence,
+ IReadOnlySet pressedSwitchIds,
+ CancellationToken token = default) =>
+ ExecuteSequencedAsync(deviceId, sessionId, sequence, output => output.SynchronizeAsync(pressedSwitchIds, token), token);
+
+ public async Task StopAsync(
+ string deviceId,
+ string sessionId,
+ long sequence,
+ CancellationToken token = default)
+ {
+ await gate.WaitAsync(token).ConfigureAwait(false);
+ try
+ {
+ if (active is null || active.DeviceId != deviceId || active.SessionId != sessionId)
+ {
+ return SwitchSessionResult.Success;
+ }
+ if (sequence <= active.LastSequence)
+ {
+ return SwitchSessionResult.Success;
+ }
+
+ await StopLockedAsync(token).ConfigureAwait(false);
+ return SwitchSessionResult.Success;
+ }
+ catch
+ {
+ active = null;
+ return SwitchSessionResult.Failure("output_failure", "The active switch output could not be released.");
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ public async Task ApplyLegacyGridEdgeAsync(
+ string deviceId,
+ string? sessionId,
+ long? sequence,
+ int switchId,
+ bool pressed,
+ CancellationToken token = default)
+ {
+ await gate.WaitAsync(token).ConfigureAwait(false);
+ try
+ {
+ SwitchSessionResult ready = await EnsureLegacyGridSessionLockedAsync(deviceId, sessionId, token).ConfigureAwait(false);
+ if (!ready.Ok) return ready;
+ long effectiveSequence = sequence ?? active!.LastSequence + 1;
+ return await ExecuteActiveLockedAsync(effectiveSequence, output =>
+ output.ApplyEdgeAsync(switchId, pressed, token)).ConfigureAwait(false);
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ public async Task SynchronizeLegacyGridAsync(
+ string deviceId,
+ string sessionId,
+ long sequence,
+ IReadOnlySet pressedSwitchIds,
+ CancellationToken token = default)
+ {
+ await gate.WaitAsync(token).ConfigureAwait(false);
+ try
+ {
+ SwitchSessionResult ready = await EnsureLegacyGridSessionLockedAsync(deviceId, sessionId, token).ConfigureAwait(false);
+ return ready.Ok
+ ? await ExecuteActiveLockedAsync(sequence, output =>
+ output.SynchronizeAsync(pressedSwitchIds, token)).ConfigureAwait(false)
+ : ready;
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ public async Task StopForDeviceAsync(string deviceId, CancellationToken token = default)
+ {
+ await gate.WaitAsync(token).ConfigureAwait(false);
+ try
+ {
+ if (active?.DeviceId == deviceId)
+ {
+ await StopLockedAsync(token).ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ public async Task StopAllAsync(CancellationToken token = default)
+ {
+ await gate.WaitAsync(token).ConfigureAwait(false);
+ try
+ {
+ await StopLockedAsync(token).ConfigureAwait(false);
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ private async Task ExecuteSequencedAsync(
+ string deviceId,
+ string sessionId,
+ long sequence,
+ Func operation,
+ CancellationToken token)
+ {
+ if (sequence < 1)
+ {
+ return SwitchSessionResult.Failure("invalid_payload", "Sequence must be positive.");
+ }
+
+ await gate.WaitAsync(token).ConfigureAwait(false);
+ try
+ {
+ if (active is null || active.DeviceId != deviceId || active.SessionId != sessionId)
+ {
+ return SwitchSessionResult.Failure("session_not_active", "The switch-control session is not active.");
+ }
+ return await ExecuteActiveLockedAsync(sequence, operation).ConfigureAwait(false);
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ private async Task EnsureLegacyGridSessionLockedAsync(
+ string deviceId,
+ string? sessionId,
+ CancellationToken token)
+ {
+ string effectiveSessionId = sessionId ?? $"legacy:{deviceId}";
+ if (active is not null &&
+ active.DeviceId == deviceId &&
+ active.SessionId == effectiveSessionId &&
+ active.Profile.Id == SwitchControlProfiles.Grid3Id)
+ {
+ return SwitchSessionResult.Success;
+ }
+
+ await StopLockedAsync(token).ConfigureAwait(false);
+ SwitchControlProfile profile = SwitchControlProfiles.BuiltIns[0];
+ try
+ {
+ active = new ActiveSession(deviceId, effectiveSessionId, profile, outputs.Create(profile));
+ return SwitchSessionResult.Success;
+ }
+ catch (DesktopInputException error)
+ {
+ return SwitchSessionResult.Failure(error.Code, error.Message);
+ }
+ }
+
+ private async Task ExecuteActiveLockedAsync(
+ long sequence,
+ Func operation)
+ {
+ if (active!.Faulted)
+ {
+ return SwitchSessionResult.Failure("output_failure", "The switch output provider has faulted.");
+ }
+ if (sequence <= active.LastSequence)
+ {
+ return SwitchSessionResult.Success;
+ }
+
+ try
+ {
+ await operation(active.Output).ConfigureAwait(false);
+ active.LastSequence = sequence;
+ return SwitchSessionResult.Success;
+ }
+ catch
+ {
+ active.Faulted = true;
+ return SwitchSessionResult.Failure("output_failure", "The switch output operation failed.");
+ }
+ }
+
+ private async Task StopLockedAsync(CancellationToken token)
+ {
+ ActiveSession? previous = active;
+ active = null;
+ if (previous is not null)
+ {
+ await previous.Output.StopAsync(token).ConfigureAwait(false);
+ }
+ }
+
+ private sealed class ActiveSession(
+ string deviceId,
+ string sessionId,
+ SwitchControlProfile profile,
+ ISwitchOutputSession output)
+ {
+ public string DeviceId { get; } = deviceId;
+ public string SessionId { get; } = sessionId;
+ public SwitchControlProfile Profile { get; } = profile;
+ public ISwitchOutputSession Output { get; } = output;
+ public long LastSequence { get; set; }
+ public bool Faulted { get; set; }
+ }
+}
diff --git a/src/SwitchifyPc.Core/SwitchControl/SwitchOutputSessions.cs b/src/SwitchifyPc.Core/SwitchControl/SwitchOutputSessions.cs
new file mode 100644
index 0000000..b4d1438
--- /dev/null
+++ b/src/SwitchifyPc.Core/SwitchControl/SwitchOutputSessions.cs
@@ -0,0 +1,258 @@
+using SwitchifyPc.Core.Input;
+
+namespace SwitchifyPc.Core.SwitchControl;
+
+public interface ISwitchOutputSession
+{
+ Task ApplyEdgeAsync(int switchId, bool pressed, CancellationToken token);
+ Task SynchronizeAsync(IReadOnlySet pressedSwitchIds, CancellationToken token);
+ Task StopAsync(CancellationToken token);
+}
+
+public interface ISwitchOutputSessionFactory
+{
+ ISwitchOutputSession Create(SwitchControlProfile profile);
+}
+
+public sealed class SwitchOutputSessionFactory(
+ IDesktopInputAdapter desktopInput,
+ IGridSwitchBroadcaster? gridSwitchBroadcaster) : ISwitchOutputSessionFactory
+{
+ public ISwitchOutputSession Create(SwitchControlProfile profile) => profile.Kind switch
+ {
+ SwitchControlProviderKind.Grid3 when gridSwitchBroadcaster is not null =>
+ new Grid3SwitchOutputSession(gridSwitchBroadcaster),
+ SwitchControlProviderKind.Grid3 =>
+ throw new DesktopInputException("output_unavailable", "Grid 3 output is unavailable."),
+ _ => new MappedDesktopSwitchOutputSession(desktopInput, profile.Bindings)
+ };
+}
+
+public sealed class Grid3SwitchOutputSession(IGridSwitchBroadcaster broadcaster) : ISwitchOutputSession
+{
+ private readonly HashSet pressed = [];
+
+ public async Task ApplyEdgeAsync(int switchId, bool isPressed, CancellationToken token)
+ {
+ if (isPressed ? pressed.Contains(switchId) : !pressed.Contains(switchId))
+ {
+ return;
+ }
+
+ await broadcaster.SetSwitchStateAsync(switchId, isPressed, token).ConfigureAwait(false);
+ if (isPressed) pressed.Add(switchId); else pressed.Remove(switchId);
+ }
+
+ public async Task SynchronizeAsync(IReadOnlySet pressedSwitchIds, CancellationToken token)
+ {
+ foreach (int switchId in pressed.Except(pressedSwitchIds).Order())
+ {
+ await ApplyEdgeAsync(switchId, false, token).ConfigureAwait(false);
+ }
+ foreach (int switchId in pressedSwitchIds.Except(pressed).Order())
+ {
+ await ApplyEdgeAsync(switchId, true, token).ConfigureAwait(false);
+ }
+ }
+
+ public Task StopAsync(CancellationToken token) => SynchronizeAsync(new HashSet(), token);
+}
+
+public sealed class MappedDesktopSwitchOutputSession : ISwitchOutputSession
+{
+ private readonly IDesktopInputAdapter input;
+ private readonly IReadOnlyDictionary bindings;
+ private readonly HashSet pressedSources = [];
+ private readonly Dictionary heldOutputCounts = new(StringComparer.OrdinalIgnoreCase);
+ private readonly List outputAcquisitionOrder = [];
+ private readonly List temporaryShortcutKeys = [];
+
+ public MappedDesktopSwitchOutputSession(
+ IDesktopInputAdapter input,
+ IReadOnlyList bindings)
+ {
+ this.input = input;
+ this.bindings = bindings.ToDictionary(binding => binding.SwitchId);
+ }
+
+ public async Task ApplyEdgeAsync(int switchId, bool pressed, CancellationToken token)
+ {
+ bool changed = pressed ? pressedSources.Add(switchId) : pressedSources.Remove(switchId);
+ if (!changed || !bindings.TryGetValue(switchId, out SwitchControlBinding? binding))
+ {
+ return;
+ }
+
+ try
+ {
+ if (binding.Behavior == SwitchBindingBehavior.Stateful)
+ {
+ await SetStatefulBindingAsync(binding, pressed, token).ConfigureAwait(false);
+ }
+ else if (pressed && binding.Behavior == SwitchBindingBehavior.Pulse)
+ {
+ await ExecutePulseAsync(binding, token).ConfigureAwait(false);
+ }
+ }
+ catch
+ {
+ if (pressed) pressedSources.Remove(switchId); else pressedSources.Add(switchId);
+ throw;
+ }
+ }
+
+ public async Task SynchronizeAsync(IReadOnlySet pressedSwitchIds, CancellationToken token)
+ {
+ foreach (int switchId in pressedSources.Except(pressedSwitchIds).Order().ToArray())
+ {
+ if (bindings.TryGetValue(switchId, out SwitchControlBinding? binding) &&
+ binding.Behavior == SwitchBindingBehavior.Stateful)
+ {
+ await ApplyEdgeAsync(switchId, false, token).ConfigureAwait(false);
+ }
+ else
+ {
+ pressedSources.Remove(switchId);
+ }
+ }
+
+ foreach (int switchId in pressedSwitchIds.Except(pressedSources).Order())
+ {
+ if (bindings.TryGetValue(switchId, out SwitchControlBinding? binding) &&
+ binding.Behavior == SwitchBindingBehavior.Stateful)
+ {
+ await ApplyEdgeAsync(switchId, true, token).ConfigureAwait(false);
+ }
+ else
+ {
+ pressedSources.Add(switchId);
+ }
+ }
+ }
+
+ public async Task StopAsync(CancellationToken token)
+ {
+ string[] outputs = outputAcquisitionOrder
+ .Concat(temporaryShortcutKeys.Select(key => $"key:{key}"))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(output => IsModifier(output) ? 1 : 0)
+ .ThenByDescending(output => outputAcquisitionOrder.LastIndexOf(output))
+ .ToArray();
+ Exception? firstError = null;
+ foreach (string output in outputs)
+ {
+ try
+ {
+ await SetOutputAsync(output, false, token).ConfigureAwait(false);
+ heldOutputCounts.Remove(output);
+ outputAcquisitionOrder.RemoveAll(item =>
+ string.Equals(item, output, StringComparison.OrdinalIgnoreCase));
+ if (output.StartsWith("key:", StringComparison.Ordinal))
+ {
+ string key = output["key:".Length..];
+ temporaryShortcutKeys.RemoveAll(item =>
+ string.Equals(item, key, StringComparison.OrdinalIgnoreCase));
+ }
+ }
+ catch (Exception error)
+ {
+ firstError ??= error;
+ }
+ }
+ if (firstError is not null) throw firstError;
+ pressedSources.Clear();
+ }
+
+ private async Task SetStatefulBindingAsync(SwitchControlBinding binding, bool pressed, CancellationToken token)
+ {
+ string output = binding.Type == SwitchBindingType.Key
+ ? $"key:{binding.Value}"
+ : $"mouse:{binding.Value}";
+ int count = heldOutputCounts.GetValueOrDefault(output);
+ int next = pressed ? count + 1 : Math.Max(0, count - 1);
+ if (count == 0 && next == 1)
+ {
+ await SetOutputAsync(output, true, token).ConfigureAwait(false);
+ outputAcquisitionOrder.Add(output);
+ }
+ else if (count == 1 && next == 0)
+ {
+ await SetOutputAsync(output, false, token).ConfigureAwait(false);
+ }
+
+ if (next == 0) heldOutputCounts.Remove(output); else heldOutputCounts[output] = next;
+ }
+
+ private async Task ExecutePulseAsync(SwitchControlBinding binding, CancellationToken token)
+ {
+ switch (binding.Type)
+ {
+ case SwitchBindingType.Shortcut:
+ await ExecuteShortcutAsync(binding.Keys ?? [], token).ConfigureAwait(false);
+ break;
+ case SwitchBindingType.MouseClick when binding.ClickCount == 2:
+ await input.DoubleClickMouseAsync(binding.Value ?? "", token).ConfigureAwait(false);
+ break;
+ case SwitchBindingType.MouseClick:
+ await input.ClickMouseAsync(binding.Value ?? "", token).ConfigureAwait(false);
+ break;
+ case SwitchBindingType.Scroll:
+ (double dx, double dy) = binding.Value switch
+ {
+ "up" => (0, 1),
+ "down" => (0, -1),
+ "left" => (-1, 0),
+ _ => (1, 0)
+ };
+ await input.ScrollMouseAsync(dx, dy, token).ConfigureAwait(false);
+ break;
+ case SwitchBindingType.Media:
+ await input.MediaControlAsync(binding.Value ?? "", token).ConfigureAwait(false);
+ break;
+ }
+ }
+
+ private async Task ExecuteShortcutAsync(IReadOnlyList keys, CancellationToken token)
+ {
+ Exception? firstError = null;
+ try
+ {
+ foreach (string key in keys)
+ {
+ string output = $"key:{key}";
+ if (heldOutputCounts.ContainsKey(output)) continue;
+ await input.SetKeyDownAsync(key, true, token).ConfigureAwait(false);
+ temporaryShortcutKeys.Add(key);
+ }
+ }
+ catch (Exception error)
+ {
+ firstError = error;
+ }
+
+ foreach (string key in temporaryShortcutKeys.AsEnumerable().Reverse().ToArray())
+ {
+ try
+ {
+ await input.SetKeyDownAsync(key, false, CancellationToken.None).ConfigureAwait(false);
+ temporaryShortcutKeys.Remove(key);
+ }
+ catch (Exception error)
+ {
+ firstError ??= error;
+ }
+ }
+ if (firstError is not null) throw firstError;
+ }
+
+ private Task SetOutputAsync(string output, bool down, CancellationToken token)
+ {
+ string value = output[(output.IndexOf(':') + 1)..];
+ return output.StartsWith("mouse:", StringComparison.Ordinal)
+ ? input.SetMouseButtonDownAsync(value, down, token)
+ : input.SetKeyDownAsync(value, down, token);
+ }
+
+ private static bool IsModifier(string output) =>
+ output is "key:Ctrl" or "key:Alt" or "key:Shift" or "key:Meta";
+}
diff --git a/src/SwitchifyPc.Tests/SwitchControlProfileTests.cs b/src/SwitchifyPc.Tests/SwitchControlProfileTests.cs
new file mode 100644
index 0000000..15ff093
--- /dev/null
+++ b/src/SwitchifyPc.Tests/SwitchControlProfileTests.cs
@@ -0,0 +1,116 @@
+using SwitchifyPc.Core.SwitchControl;
+
+namespace SwitchifyPc.Tests;
+
+public sealed class SwitchControlProfileTests
+{
+ [Fact]
+ public void BuiltInsHaveStableIdentityAndMappings()
+ {
+ SwitchControlProfile grid = SwitchControlProfiles.BuiltIns[0];
+ SwitchControlProfile keyboard = SwitchControlProfiles.BuiltIns[1];
+
+ Assert.Equal("builtin.grid3", grid.Id);
+ Assert.Equal("Grid 3", grid.Name);
+ Assert.Equal(1, grid.Version);
+ Assert.Equal("builtin.keyboard", keyboard.Id);
+ Assert.Equal("Generic keyboard", keyboard.Name);
+ Assert.Equal(SwitchBindingType.Key, keyboard.Bindings[0].Type);
+ Assert.Equal("Space", keyboard.Bindings[0].Value);
+ Assert.Equal("Enter", keyboard.Bindings[1].Value);
+ Assert.All(keyboard.Bindings.Skip(2), binding => Assert.Equal(SwitchBindingType.None, binding.Type));
+ }
+
+ [Fact]
+ public void StoreRoundTripsValidatedCustomProfiles()
+ {
+ string path = Path.Combine(Path.GetTempPath(), $"switch-control-{Guid.NewGuid():N}.json");
+ try
+ {
+ var store = new JsonSwitchControlProfileStore(path);
+ SwitchControlProfile custom = CustomProfile("My profile");
+
+ IReadOnlyList saved = store.Save([custom]);
+ IReadOnlyList loaded = store.Load();
+
+ Assert.Equal(3, saved.Count);
+ Assert.Equal(custom.Id, loaded[2].Id);
+ Assert.Equal(custom.Name, loaded[2].Name);
+ Assert.Equal(custom.Version, loaded[2].Version);
+ Assert.Equal(custom.Bindings, loaded[2].Bindings);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void StorePreservesMalformedFileAndLoadsBuiltIns()
+ {
+ string path = Path.Combine(Path.GetTempPath(), $"switch-control-{Guid.NewGuid():N}.json");
+ File.WriteAllText(path, "{broken");
+ try
+ {
+ var warnings = new List();
+ var store = new JsonSwitchControlProfileStore(path, warnings.Add);
+
+ IReadOnlyList loaded = store.Load();
+
+ Assert.Equal(2, loaded.Count);
+ Assert.Equal("{broken", File.ReadAllText(path));
+ Assert.Single(warnings);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void DuplicateNamesAreRejectedCaseInsensitively()
+ {
+ SwitchControlProfile first = CustomProfile("Writing");
+ SwitchControlProfile second = CustomProfile(" writing ");
+
+ Assert.Throws(() =>
+ JsonSwitchControlProfileStore.NormalizeCustom([first, second]));
+ }
+
+ [Fact]
+ public void ShortcutRequiresUniqueKeysAndNonModifier()
+ {
+ SwitchControlProfile invalid = CustomProfile(
+ "Shortcut",
+ new(1, SwitchBindingType.Shortcut, Keys: ["Ctrl", "Alt"]));
+
+ Assert.Throws(() =>
+ JsonSwitchControlProfileStore.NormalizeCustom([invalid]));
+ }
+
+ [Fact]
+ public void KeyTokensAreCanonicalizedBeforePersistence()
+ {
+ SwitchControlProfile profile = CustomProfile(
+ "Canonical",
+ new(1, SwitchBindingType.Shortcut, Keys: ["ctrl", "a"]));
+
+ SwitchControlProfile normalized =
+ Assert.Single(JsonSwitchControlProfileStore.NormalizeCustom([profile]));
+
+ Assert.Equal(["Ctrl", "A"], normalized.Bindings[0].Keys);
+ }
+
+ private static SwitchControlProfile CustomProfile(
+ string name,
+ SwitchControlBinding? firstBinding = null) =>
+ new(
+ Guid.NewGuid().ToString(),
+ 1,
+ name,
+ SwitchControlProviderKind.Mapped,
+ [
+ firstBinding ?? new(1, SwitchBindingType.Key, "Space"),
+ .. Enumerable.Range(2, 7).Select(id => new SwitchControlBinding(id, SwitchBindingType.None))
+ ]);
+}
diff --git a/src/SwitchifyPc.Tests/SwitchControlSessionManagerTests.cs b/src/SwitchifyPc.Tests/SwitchControlSessionManagerTests.cs
new file mode 100644
index 0000000..ac61473
--- /dev/null
+++ b/src/SwitchifyPc.Tests/SwitchControlSessionManagerTests.cs
@@ -0,0 +1,94 @@
+using SwitchifyPc.Core.SwitchControl;
+
+namespace SwitchifyPc.Tests;
+
+public sealed class SwitchControlSessionManagerTests
+{
+ [Fact]
+ public async Task StaleSequencesAreIdempotent()
+ {
+ SwitchControlProfile profile = SwitchControlProfiles.BuiltIns[1];
+ var output = new RecordingOutputSession();
+ var manager = Manager(profile, output);
+ string sessionId = Guid.NewGuid().ToString();
+ await manager.StartAsync("device", sessionId, profile.Id, profile.Version);
+
+ await manager.ApplyEdgeAsync("device", sessionId, 2, 1, true);
+ await manager.ApplyEdgeAsync("device", sessionId, 1, 1, false);
+
+ Assert.Equal(["1:down"], output.Events);
+ }
+
+ [Fact]
+ public async Task ReplacementStopsOldOutputBeforeStartingNewSession()
+ {
+ SwitchControlProfile profile = SwitchControlProfiles.BuiltIns[1];
+ var first = new RecordingOutputSession();
+ var second = new RecordingOutputSession();
+ var factory = new QueueOutputFactory(first, second);
+ var manager = new SwitchControlSessionManager(new StaticProfileStore(profile), factory);
+
+ await manager.StartAsync("one", Guid.NewGuid().ToString(), profile.Id, profile.Version);
+ await manager.StartAsync("two", Guid.NewGuid().ToString(), profile.Id, profile.Version);
+
+ Assert.Equal(1, first.StopCount);
+ Assert.Equal(0, second.StopCount);
+ }
+
+ [Fact]
+ public async Task OutputFailureFaultsSessionAndDoesNotAdvanceSequence()
+ {
+ SwitchControlProfile profile = SwitchControlProfiles.BuiltIns[1];
+ var output = new RecordingOutputSession { Fail = true };
+ var manager = Manager(profile, output);
+ string sessionId = Guid.NewGuid().ToString();
+ await manager.StartAsync("device", sessionId, profile.Id, profile.Version);
+
+ SwitchSessionResult first = await manager.ApplyEdgeAsync("device", sessionId, 1, 1, true);
+ output.Fail = false;
+ SwitchSessionResult second = await manager.ApplyEdgeAsync("device", sessionId, 1, 1, true);
+
+ Assert.Equal("output_failure", first.Code);
+ Assert.Equal("output_failure", second.Code);
+ Assert.Empty(output.Events);
+ }
+
+ private static SwitchControlSessionManager Manager(
+ SwitchControlProfile profile,
+ RecordingOutputSession output) =>
+ new(new StaticProfileStore(profile), new QueueOutputFactory(output));
+
+ private sealed class StaticProfileStore(params SwitchControlProfile[] profiles) : ISwitchControlProfileStore
+ {
+ public IReadOnlyList Load() => profiles;
+ public IReadOnlyList Save(IReadOnlyList customProfiles) => customProfiles;
+ }
+
+ private sealed class QueueOutputFactory(params RecordingOutputSession[] outputs) : ISwitchOutputSessionFactory
+ {
+ private readonly Queue queue = new(outputs);
+ public ISwitchOutputSession Create(SwitchControlProfile profile) => queue.Dequeue();
+ }
+
+ private sealed class RecordingOutputSession : ISwitchOutputSession
+ {
+ public List Events { get; } = [];
+ public int StopCount { get; private set; }
+ public bool Fail { get; set; }
+
+ public Task ApplyEdgeAsync(int switchId, bool pressed, CancellationToken token)
+ {
+ if (Fail) throw new InvalidOperationException();
+ Events.Add($"{switchId}:{(pressed ? "down" : "up")}");
+ return Task.CompletedTask;
+ }
+
+ public Task SynchronizeAsync(IReadOnlySet pressedSwitchIds, CancellationToken token) => Task.CompletedTask;
+
+ public Task StopAsync(CancellationToken token)
+ {
+ StopCount++;
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/src/SwitchifyPc.Tests/SwitchOutputSessionTests.cs b/src/SwitchifyPc.Tests/SwitchOutputSessionTests.cs
new file mode 100644
index 0000000..c09a019
--- /dev/null
+++ b/src/SwitchifyPc.Tests/SwitchOutputSessionTests.cs
@@ -0,0 +1,157 @@
+using SwitchifyPc.Core.Input;
+using SwitchifyPc.Core.SwitchControl;
+
+namespace SwitchifyPc.Tests;
+
+public sealed class SwitchOutputSessionTests
+{
+ [Fact]
+ public async Task SharedStatefulOutputUsesReferenceCounting()
+ {
+ var input = new RecordingInputAdapter();
+ var session = new MappedDesktopSwitchOutputSession(input,
+ [
+ new(1, SwitchBindingType.Key, "Space"),
+ new(2, SwitchBindingType.Key, "Space")
+ ]);
+
+ await session.ApplyEdgeAsync(1, true, default);
+ await session.ApplyEdgeAsync(2, true, default);
+ await session.ApplyEdgeAsync(1, false, default);
+ await session.ApplyEdgeAsync(2, false, default);
+
+ Assert.Equal(["key:Space:down", "key:Space:up"], input.Events);
+ }
+
+ [Fact]
+ public async Task PulseRunsOnceAndNeverFromSnapshot()
+ {
+ var input = new RecordingInputAdapter();
+ var session = new MappedDesktopSwitchOutputSession(input,
+ [
+ new(1, SwitchBindingType.MouseClick, "left")
+ ]);
+
+ await session.ApplyEdgeAsync(1, true, default);
+ await session.ApplyEdgeAsync(1, true, default);
+ await session.SynchronizeAsync(new HashSet { 1 }, default);
+
+ Assert.Equal(["click:left"], input.Events);
+ }
+
+ [Fact]
+ public async Task SnapshotReleasesBeforePresses()
+ {
+ var input = new RecordingInputAdapter();
+ var session = new MappedDesktopSwitchOutputSession(input,
+ [
+ new(1, SwitchBindingType.Key, "Space"),
+ new(2, SwitchBindingType.Key, "Enter")
+ ]);
+ await session.ApplyEdgeAsync(1, true, default);
+
+ await session.SynchronizeAsync(new HashSet { 2 }, default);
+
+ Assert.Equal(["key:Space:down", "key:Space:up", "key:Enter:down"], input.Events);
+ }
+
+ [Fact]
+ public async Task ShortcutDoesNotReleaseAlreadyHeldKey()
+ {
+ var input = new RecordingInputAdapter();
+ var session = new MappedDesktopSwitchOutputSession(input,
+ [
+ new(1, SwitchBindingType.Key, "Ctrl"),
+ new(2, SwitchBindingType.Shortcut, Keys: ["Ctrl", "C"])
+ ]);
+
+ await session.ApplyEdgeAsync(1, true, default);
+ await session.ApplyEdgeAsync(2, true, default);
+
+ Assert.Equal(["key:Ctrl:down", "key:C:down", "key:C:up"], input.Events);
+ }
+
+ [Fact]
+ public async Task FailedDownDoesNotMutateTrackedOutput()
+ {
+ var input = new RecordingInputAdapter { FailNext = true };
+ var session = new MappedDesktopSwitchOutputSession(input,
+ [
+ new(1, SwitchBindingType.Key, "Space")
+ ]);
+
+ await Assert.ThrowsAsync(() => session.ApplyEdgeAsync(1, true, default));
+ await session.ApplyEdgeAsync(1, true, default);
+
+ Assert.Equal(["key:Space:down"], input.Events);
+ }
+
+ [Fact]
+ public async Task FailedStopRetainsOutputForRetry()
+ {
+ var input = new RecordingInputAdapter();
+ var session = new MappedDesktopSwitchOutputSession(input,
+ [
+ new(1, SwitchBindingType.Key, "Space")
+ ]);
+ await session.ApplyEdgeAsync(1, true, default);
+ input.FailValueOnce = "key:Space:up";
+
+ await Assert.ThrowsAsync(() => session.StopAsync(default));
+ await session.StopAsync(default);
+
+ Assert.Equal(["key:Space:down", "key:Space:up"], input.Events);
+ }
+
+ [Fact]
+ public async Task ShortcutCleanupContinuesAndRetainsFailedKeyForStop()
+ {
+ var input = new RecordingInputAdapter { FailValueOnce = "key:C:up" };
+ var session = new MappedDesktopSwitchOutputSession(input,
+ [
+ new(1, SwitchBindingType.Shortcut, Keys: ["Ctrl", "C"])
+ ]);
+
+ await Assert.ThrowsAsync(
+ () => session.ApplyEdgeAsync(1, true, default));
+ await session.StopAsync(default);
+
+ Assert.Equal(
+ ["key:Ctrl:down", "key:C:down", "key:Ctrl:up", "key:C:up"],
+ input.Events);
+ }
+
+ private sealed class RecordingInputAdapter : IDesktopInputAdapter
+ {
+ public List Events { get; } = [];
+ public bool FailNext { get; set; }
+ public string? FailValueOnce { get; set; }
+
+ public Task SetKeyDownAsync(string key, bool down, CancellationToken cancellationToken = default) =>
+ Record($"key:{key}:{(down ? "down" : "up")}");
+ public Task SetMouseButtonDownAsync(string button, bool down, CancellationToken cancellationToken = default) =>
+ Record($"mouse:{button}:{(down ? "down" : "up")}");
+ public Task ClickMouseAsync(string button, CancellationToken cancellationToken = default) => Record($"click:{button}");
+ public Task DoubleClickMouseAsync(string button, CancellationToken cancellationToken = default) => Record($"double:{button}");
+ public Task ScrollMouseAsync(double dx, double dy, CancellationToken cancellationToken = default) => Record($"scroll:{dx}:{dy}");
+ public Task MediaControlAsync(string action, CancellationToken cancellationToken = default) => Record($"media:{action}");
+ public Task MoveMouseByAsync(double dx, double dy, CancellationToken cancellationToken = default) => Task.CompletedTask;
+ public Task PressKeyAsync(string key, CancellationToken cancellationToken = default) => Task.CompletedTask;
+ public Task PressShortcutAsync(IReadOnlyList keys, CancellationToken cancellationToken = default) => Task.CompletedTask;
+ public Task TypeTextAsync(string text, CancellationToken cancellationToken = default) => Task.CompletedTask;
+ public Task TypeCharacterAsync(string text, CancellationToken cancellationToken = default) => Task.CompletedTask;
+ public Task ControlWindowAsync(string action, CancellationToken cancellationToken = default) => Task.CompletedTask;
+
+ private Task Record(string value)
+ {
+ if (FailNext || value == FailValueOnce)
+ {
+ FailNext = false;
+ FailValueOnce = null;
+ throw new DesktopInputException("adapter_failure", "Failed.");
+ }
+ Events.Add(value);
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/src/SwitchifyPc.Windows/Input/WindowsInputMapper.cs b/src/SwitchifyPc.Windows/Input/WindowsInputMapper.cs
index 7084595..357f49d 100644
--- a/src/SwitchifyPc.Windows/Input/WindowsInputMapper.cs
+++ b/src/SwitchifyPc.Windows/Input/WindowsInputMapper.cs
@@ -66,6 +66,7 @@ public static ushort KeyboardVirtualKey(string key)
"Shift" => VkShift,
"Meta" => VkLeftWindows,
_ when IsUppercaseLetterKey(key) => key[0],
+ _ when IsDigitKey(key) => key[0],
_ when IsFunctionKey(key, out ushort virtualKey) => virtualKey,
_ => throw new ArgumentOutOfRangeException(nameof(key), key, null)
};
@@ -111,4 +112,9 @@ private static bool IsUppercaseLetterKey(string key)
{
return key.Length == 1 && key[0] is >= 'A' and <= 'Z';
}
+
+ private static bool IsDigitKey(string key)
+ {
+ return key.Length == 1 && key[0] is >= '0' and <= '9';
+ }
}