From 8823806cb70277691cf36a686e8b7767c655bba5 Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 23 Sep 2026 11:05:07 +0200 Subject: [PATCH 1/3] style: format with csharpier --- .config/dotnet-tools.json | 13 + .editorconfig | 45 + .github/workflows/build.yml | 5 + samples/WaveLink.Console/Program.cs | 25 +- .../WaveLink.Console/WaveLink.Console.csproj | 7 +- src/WaveLink.Client/JsonRpcModels.cs | 2 +- src/WaveLink.Client/Models.cs | 290 ++++-- src/WaveLink.Client/Params.cs | 122 ++- src/WaveLink.Client/SubscriptionResults.cs | 24 +- src/WaveLink.Client/WaveLink.Client.csproj | 80 +- src/WaveLink.Client/WaveLinkClient.cs | 834 ++++++++++++------ src/WaveLink.Client/WaveLinkClientOptions.cs | 2 +- src/WaveLink.Client/WaveLinkException.cs | 9 +- src/WaveLink.Client/WaveLinkJsonContext.cs | 5 +- .../FakeWaveLinkServer.cs | 51 +- .../PortDiscoveryTests.cs | 124 ++- .../WaveLink.Client.Tests.csproj | 40 +- .../WaveLinkClientTests.cs | 191 ++-- .../WaveLinkJsonContextTests.cs | 30 +- 19 files changed, 1253 insertions(+), 646 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 .editorconfig diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..9e028ef --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "csharpier": { + "version": "1.3.0", + "commands": [ + "csharpier" + ], + "rollForward": false + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5e95c9a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,45 @@ +root = true + + +[*.cs] +# Formatting follows CSharpier; `dotnet csharpier check` enforces it in CI. These keys keep the IDE +# formatter consistent with it, and IDE0055 is off because some layouts, such as a multi-line +# constructor initializer before an empty body, cannot be expressed to it. +dotnet_diagnostic.IDE0055.severity = none +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = false +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = do_not_ignore +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1bf5d8a..6461076 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,6 +49,11 @@ jobs: - name: Restore dependencies run: dotnet restore WaveLink.Client.slnx + - name: Check formatting + run: | + dotnet tool restore + dotnet csharpier check . + - name: Clean solution run: dotnet clean WaveLink.Client.slnx --configuration Release diff --git a/samples/WaveLink.Console/Program.cs b/samples/WaveLink.Console/Program.cs index 84b86c2..7b9fe9a 100644 --- a/samples/WaveLink.Console/Program.cs +++ b/samples/WaveLink.Console/Program.cs @@ -3,8 +3,10 @@ WaveLinkClient client = new(); client.Disconnected += (_, _) => Console.WriteLine("Disconnected."); -client.FocusedAppChanged += (_, e) => Console.WriteLine($"Focused app: {e.Name} ({e.Id}) -> channel {e.Channel?.Id}"); -client.LevelMeterChanged += (_, e) => Console.WriteLine($"Level meters update: channels={e.Channels?.Count ?? 0}"); +client.FocusedAppChanged += (_, e) => + Console.WriteLine($"Focused app: {e.Name} ({e.Id}) -> channel {e.Channel?.Id}"); +client.LevelMeterChanged += (_, e) => + Console.WriteLine($"Level meters update: channels={e.Channels?.Count ?? 0}"); await client.ConnectAsync(); @@ -17,13 +19,20 @@ await client.GetMixesAsync(); // Subscribe to focused app changes and all channel meters -await client.SetSubscriptionAsync(new SetSubscriptionParams -{ - FocusedAppChanged = new SubscriptionToggle { IsEnabled = true }, - LevelMeterChanged = new LevelMeterSubscription { IsEnabled = true, Type = "channel", Id = "all" } -}); +await client.SetSubscriptionAsync( + new SetSubscriptionParams + { + FocusedAppChanged = new SubscriptionToggle { IsEnabled = true }, + LevelMeterChanged = new LevelMeterSubscription + { + IsEnabled = true, + Type = "channel", + Id = "all", + }, + } +); Console.WriteLine("Press Enter to exit."); Console.ReadLine(); -await client.DisposeAsync(); \ No newline at end of file +await client.DisposeAsync(); diff --git a/samples/WaveLink.Console/WaveLink.Console.csproj b/samples/WaveLink.Console/WaveLink.Console.csproj index 5553f33..818d48e 100644 --- a/samples/WaveLink.Console/WaveLink.Console.csproj +++ b/samples/WaveLink.Console/WaveLink.Console.csproj @@ -4,12 +4,13 @@ net10.0 - 10.0.11 + 10.0.11 preview enable enable - true - false + true + false diff --git a/src/WaveLink.Client/JsonRpcModels.cs b/src/WaveLink.Client/JsonRpcModels.cs index 8eb6fac..40b9e7f 100644 --- a/src/WaveLink.Client/JsonRpcModels.cs +++ b/src/WaveLink.Client/JsonRpcModels.cs @@ -73,4 +73,4 @@ public sealed record JsonRpcNotification /// The parameters for the method call, or null for no parameters. [JsonPropertyName("params")] public JsonElement? Params { get; init; } -} \ No newline at end of file +} diff --git a/src/WaveLink.Client/Models.cs b/src/WaveLink.Client/Models.cs index 08bd5b3..4bed471 100644 --- a/src/WaveLink.Client/Models.cs +++ b/src/WaveLink.Client/Models.cs @@ -8,16 +8,20 @@ namespace WaveLink.Client; public sealed record ApplicationInfo { /// The application ID. - [JsonPropertyName("appID")] public required string AppId { get; set; } + [JsonPropertyName("appID")] + public required string AppId { get; set; } /// The application name. - [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("name")] + public string? Name { get; set; } /// The interface revision supported by the server. - [JsonPropertyName("interfaceRevision")] public required int InterfaceRevision { get; set; } + [JsonPropertyName("interfaceRevision")] + public required int InterfaceRevision { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } // Input devices @@ -25,105 +29,132 @@ public sealed record ApplicationInfo public sealed record InputDevicesResult { /// List of input devices. - [JsonPropertyName("inputDevices")] public required List InputDevices { get; set; } + [JsonPropertyName("inputDevices")] + public required List InputDevices { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Represents an input device and its inputs. public sealed record InputDevice { /// Unique identifier for this input device. - [JsonPropertyName("id")] public required string Id { get; set; } + [JsonPropertyName("id")] + public required string Id { get; set; } /// Display name of the input device. - [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("name")] + public string? Name { get; set; } /// Indicates whether this is a Wave Link device. - [JsonPropertyName("isWaveDevice")] public bool? IsWaveDevice { get; set; } + [JsonPropertyName("isWaveDevice")] + public bool? IsWaveDevice { get; set; } /// List of inputs on this device. - [JsonPropertyName("inputs")] public required List Inputs { get; set; } + [JsonPropertyName("inputs")] + public required List Inputs { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Represents a single input on an input device. public sealed record Input { /// Unique identifier for this input. - [JsonPropertyName("id")] public required string Id { get; set; } + [JsonPropertyName("id")] + public required string Id { get; set; } /// Display name of the input. - [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("name")] + public string? Name { get; set; } /// Indicates whether the input is muted. - [JsonPropertyName("isMuted")] public bool? IsMuted { get; set; } + [JsonPropertyName("isMuted")] + public bool? IsMuted { get; set; } /// Indicates whether the hardware gain lock is on (if supported). - [JsonPropertyName("isGainLockOn")] public bool? IsGainLockOn { get; set; } + [JsonPropertyName("isGainLockOn")] + public bool? IsGainLockOn { get; set; } /// Gain settings for this input. - [JsonPropertyName("gain")] public Gain? Gain { get; set; } + [JsonPropertyName("gain")] + public Gain? Gain { get; set; } /// Microphone to PC audio mix settings for this input. - [JsonPropertyName("micPcMix")] public MicPcMix? MicPcMix { get; set; } + [JsonPropertyName("micPcMix")] + public MicPcMix? MicPcMix { get; set; } /// List of software effects applied to this input. - [JsonPropertyName("effects")] public List? Effects { get; set; } + [JsonPropertyName("effects")] + public List? Effects { get; set; } /// List of hardware DSP effects applied to this input. - [JsonPropertyName("dspEffects")] public List? DspEffects { get; set; } + [JsonPropertyName("dspEffects")] + public List? DspEffects { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Represents gain information for an input. public sealed record Gain { /// The gain value normalized to the range 0..1. - [JsonPropertyName("value")] public required double Value { get; set; } + [JsonPropertyName("value")] + public required double Value { get; set; } /// The maximum gain value in dB. - [JsonPropertyName("max")] public double? Max { get; set; } + [JsonPropertyName("max")] + public double? Max { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Represents mic/PC mix value and related metadata. public sealed record MicPcMix { /// The mic/PC mix value normalized to the range 0..1. - [JsonPropertyName("value")] public required double Value { get; set; } + [JsonPropertyName("value")] + public required double Value { get; set; } /// Indicates whether the mix direction is inverted. - [JsonPropertyName("isInverted")] public bool? IsInverted { get; set; } + [JsonPropertyName("isInverted")] + public bool? IsInverted { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Represents an audio effect available on an input. public sealed record Effect { /// Unique identifier for this effect. - [JsonPropertyName("id")] public required string Id { get; set; } + [JsonPropertyName("id")] + public required string Id { get; set; } /// Display name of the effect. - [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("name")] + public string? Name { get; set; } /// Indicates whether the effect is currently enabled. - [JsonPropertyName("isEnabled")] public bool? IsEnabled { get; set; } + [JsonPropertyName("isEnabled")] + public bool? IsEnabled { get; set; } /// Indicates whether the effect is supported by the device. - [JsonPropertyName("isSupported")] public bool? IsSupported { get; set; } + [JsonPropertyName("isSupported")] + public bool? IsSupported { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } // Output devices @@ -131,64 +162,80 @@ public sealed record Effect public sealed record OutputDevicesResult { /// The current main output selection. - [JsonPropertyName("mainOutput")] public required MainOutput MainOutput { get; set; } + [JsonPropertyName("mainOutput")] + public required MainOutput MainOutput { get; set; } /// List of output devices. - [JsonPropertyName("outputDevices")] public required List OutputDevices { get; set; } + [JsonPropertyName("outputDevices")] + public required List OutputDevices { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Identifies the selected main output. public sealed record MainOutput { /// The ID of the output device containing the main output. - [JsonPropertyName("outputDeviceId")] public required string OutputDeviceId { get; set; } + [JsonPropertyName("outputDeviceId")] + public required string OutputDeviceId { get; set; } /// The ID of the main output on the device. - [JsonPropertyName("outputId")] public required string OutputId { get; set; } + [JsonPropertyName("outputId")] + public required string OutputId { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Represents an output device and its outputs. public sealed record OutputDevice { /// Unique identifier for this output device. - [JsonPropertyName("id")] public required string Id { get; set; } + [JsonPropertyName("id")] + public required string Id { get; set; } /// Display name of the output device. - [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("name")] + public string? Name { get; set; } /// Indicates whether this is a Wave Link device. - [JsonPropertyName("isWaveDevice")] public bool? IsWaveDevice { get; set; } + [JsonPropertyName("isWaveDevice")] + public bool? IsWaveDevice { get; set; } /// List of outputs on this device. - [JsonPropertyName("outputs")] public required List Outputs { get; set; } + [JsonPropertyName("outputs")] + public required List Outputs { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Represents a single output on an output device. public sealed record Output { /// Unique identifier for this output. - [JsonPropertyName("id")] public required string Id { get; set; } + [JsonPropertyName("id")] + public required string Id { get; set; } /// Display name of the output. - [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("name")] + public string? Name { get; set; } /// Indicates whether the output is muted. - [JsonPropertyName("isMuted")] public bool? IsMuted { get; set; } + [JsonPropertyName("isMuted")] + public bool? IsMuted { get; set; } /// The output level normalized to the range 0..1. - [JsonPropertyName("level")] public double? Level { get; set; } + [JsonPropertyName("level")] + public double? Level { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } // Channels & mixes @@ -196,118 +243,148 @@ public sealed record Output public sealed record ChannelsResult { /// List of channels. - [JsonPropertyName("channels")] public required List Channels { get; set; } + [JsonPropertyName("channels")] + public required List Channels { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Represents an image definition for a channel or mix. public sealed record ChannelImage { /// The base64 image data or path. - [JsonPropertyName("imgData")] public string? ImgData { get; init; } + [JsonPropertyName("imgData")] + public string? ImgData { get; init; } /// Indicates whether the image acts as an app icon. - [JsonPropertyName("isAppIcon")] public bool? IsAppIcon { get; init; } + [JsonPropertyName("isAppIcon")] + public bool? IsAppIcon { get; init; } /// The name of the image. - [JsonPropertyName("name")] public string? Name { get; init; } + [JsonPropertyName("name")] + public string? Name { get; init; } } /// Represents a channel which can contain apps and mixes. public sealed record Channel { /// Unique identifier for this channel. - [JsonPropertyName("id")] public required string Id { get; set; } + [JsonPropertyName("id")] + public required string Id { get; set; } /// Display name of the channel. - [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("name")] + public string? Name { get; set; } /// The type of channel. - [JsonPropertyName("type")] public string? Type { get; set; } + [JsonPropertyName("type")] + public string? Type { get; set; } /// Indicates whether the entire channel is muted. - [JsonPropertyName("isMuted")] public bool? IsMuted { get; set; } + [JsonPropertyName("isMuted")] + public bool? IsMuted { get; set; } /// The overall level of the channel normalized 0..1. - [JsonPropertyName("level")] public double? Level { get; set; } + [JsonPropertyName("level")] + public double? Level { get; set; } /// The image properties assigned to the channel. - [JsonPropertyName("image")] public ChannelImage? Image { get; set; } + [JsonPropertyName("image")] + public ChannelImage? Image { get; set; } /// List of applications assigned to this channel. - [JsonPropertyName("apps")] public List? Apps { get; set; } + [JsonPropertyName("apps")] + public List? Apps { get; set; } /// List of mixes in this channel. - [JsonPropertyName("mixes")] public List? Mixes { get; set; } + [JsonPropertyName("mixes")] + public List? Mixes { get; set; } /// List of software effects on this channel. - [JsonPropertyName("effects")] public List? Effects { get; set; } + [JsonPropertyName("effects")] + public List? Effects { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Reference to an application that can be attached to a channel. public sealed record AppRef { /// Unique identifier for the application. - [JsonPropertyName("id")] public required string Id { get; set; } + [JsonPropertyName("id")] + public required string Id { get; set; } /// Display name of the application. - [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("name")] + public string? Name { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Represents a mix entry within a channel. public sealed record ChannelMix { /// The ID of the mix. - [JsonPropertyName("mixId")] public required string MixId { get; set; } + [JsonPropertyName("mixId")] + public required string MixId { get; set; } /// Indicates whether the mix is muted. - [JsonPropertyName("isMuted")] public bool? IsMuted { get; set; } + [JsonPropertyName("isMuted")] + public bool? IsMuted { get; set; } /// The mix level normalized to the range 0..1. - [JsonPropertyName("level")] public double? Level { get; set; } + [JsonPropertyName("level")] + public double? Level { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Result returned by the server for the mixes list. public sealed record MixesResult { /// List of mixes. - [JsonPropertyName("mixes")] public required List Mixes { get; set; } + [JsonPropertyName("mixes")] + public required List Mixes { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Represents a mix (bus) in the Wave Link system. public sealed record Mix { /// Unique identifier for this mix. - [JsonPropertyName("id")] public required string Id { get; set; } + [JsonPropertyName("id")] + public required string Id { get; set; } /// Display name of the mix. - [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("name")] + public string? Name { get; set; } /// Indicates whether the mix is muted. - [JsonPropertyName("isMuted")] public bool? IsMuted { get; set; } + [JsonPropertyName("isMuted")] + public bool? IsMuted { get; set; } /// The mix level. - [JsonPropertyName("level")] public double? Level { get; set; } + [JsonPropertyName("level")] + public double? Level { get; set; } /// The image properties assigned to the mix. - [JsonPropertyName("image")] public ChannelImage? Image { get; set; } + [JsonPropertyName("image")] + public ChannelImage? Image { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } // Notifications @@ -315,64 +392,80 @@ public sealed record Mix public sealed record FocusedAppChangedParams { /// Unique identifier for the focused application. - [JsonPropertyName("id")] public required string Id { get; set; } + [JsonPropertyName("id")] + public required string Id { get; set; } /// Display name of the focused application. - [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("name")] + public string? Name { get; set; } /// The channel associated with the focused application. - [JsonPropertyName("channel")] public FocusedAppChannel? Channel { get; set; } + [JsonPropertyName("channel")] + public FocusedAppChannel? Channel { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Represents the channel associated with a focused app. public sealed record FocusedAppChannel { /// The channel ID. - [JsonPropertyName("id")] public required string Id { get; set; } + [JsonPropertyName("id")] + public required string Id { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Notification payload for level meter updates. public sealed record LevelMeterChangedParams { /// Level meter entries for input devices. - [JsonPropertyName("inputDevices")] public List? InputDevices { get; set; } + [JsonPropertyName("inputDevices")] + public List? InputDevices { get; set; } /// Level meter entries for output devices. - [JsonPropertyName("outputDevices")] public List? OutputDevices { get; set; } + [JsonPropertyName("outputDevices")] + public List? OutputDevices { get; set; } /// Level meter entries for channels. - [JsonPropertyName("channels")] public List? Channels { get; set; } + [JsonPropertyName("channels")] + public List? Channels { get; set; } /// Level meter entries for mixes. - [JsonPropertyName("mixes")] public List? Mixes { get; set; } + [JsonPropertyName("mixes")] + public List? Mixes { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Entry describing left/right level percentages for an entity. public sealed record MeterEntry { /// The unique identifier of the metered entity. - [JsonPropertyName("id")] public required string Id { get; set; } + [JsonPropertyName("id")] + public required string Id { get; set; } /// Sub-identifier for specific mix associations. - [JsonPropertyName("subId")] public string? SubId { get; set; } + [JsonPropertyName("subId")] + public string? SubId { get; set; } /// The left channel level as a percentage (0-100). - [JsonPropertyName("levelLeftPercentage")] public double? LevelLeftPercentage { get; set; } + [JsonPropertyName("levelLeftPercentage")] + public double? LevelLeftPercentage { get; set; } /// The right channel level as a percentage (0-100). - [JsonPropertyName("levelRightPercentage")] public double? LevelRightPercentage { get; set; } + [JsonPropertyName("levelRightPercentage")] + public double? LevelRightPercentage { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } // Other notifications @@ -380,11 +473,14 @@ public sealed record MeterEntry public sealed record CreateProfileRequestedParams { /// The type of device for which to create a profile. - [JsonPropertyName("deviceType")] public string? DeviceType { get; set; } + [JsonPropertyName("deviceType")] + public string? DeviceType { get; set; } /// List of mix IDs to include in the profile. - [JsonPropertyName("mixes")] public List? Mixes { get; set; } + [JsonPropertyName("mixes")] + public List? Mixes { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } -} \ No newline at end of file + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } +} diff --git a/src/WaveLink.Client/Params.cs b/src/WaveLink.Client/Params.cs index bb7c32a..71b6210 100644 --- a/src/WaveLink.Client/Params.cs +++ b/src/WaveLink.Client/Params.cs @@ -8,176 +8,216 @@ namespace WaveLink.Client; public sealed record SetInputDeviceParams { /// The input device ID. - [JsonPropertyName("id")] public required string Id { get; set; } + [JsonPropertyName("id")] + public required string Id { get; set; } /// List of input changes to apply. - [JsonPropertyName("inputs")] public required List Inputs { get; set; } + [JsonPropertyName("inputs")] + public required List Inputs { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Parameters describing changes to a single input. public sealed record SetInputParams { /// The input ID. - [JsonPropertyName("id")] public required string Id { get; set; } + [JsonPropertyName("id")] + public required string Id { get; set; } /// Mute state to apply, or null to leave unchanged. - [JsonPropertyName("isMuted")] public bool? IsMuted { get; set; } + [JsonPropertyName("isMuted")] + public bool? IsMuted { get; set; } /// Hardware gain lock state to apply, or null to leave unchanged. - [JsonPropertyName("isGainLockOn")] public bool? IsGainLockOn { get; set; } + [JsonPropertyName("isGainLockOn")] + public bool? IsGainLockOn { get; set; } /// Gain value to apply, or null to leave unchanged. - [JsonPropertyName("gain")] public GainValue? Gain { get; set; } + [JsonPropertyName("gain")] + public GainValue? Gain { get; set; } /// Mic/PC mix value to apply, or null to leave unchanged. - [JsonPropertyName("micPcMix")] public MicPcMixValue? MicPcMix { get; set; } + [JsonPropertyName("micPcMix")] + public MicPcMixValue? MicPcMix { get; set; } /// List of software effect state changes to apply. - [JsonPropertyName("effects")] public List? Effects { get; set; } + [JsonPropertyName("effects")] + public List? Effects { get; set; } /// List of hardware DSP effect state changes to apply. - [JsonPropertyName("dspEffects")] public List? DspEffects { get; set; } + [JsonPropertyName("dspEffects")] + public List? DspEffects { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Represents a normalized gain value. public sealed record GainValue { /// The gain value normalized to the range 0..1. - [JsonPropertyName("value")] public required double Value { get; init; } + [JsonPropertyName("value")] + public required double Value { get; init; } } /// Represents a normalized mic/pc mix value. public sealed record MicPcMixValue { /// The mic/PC mix value normalized to the range 0..1. - [JsonPropertyName("value")] public required double Value { get; init; } + [JsonPropertyName("value")] + public required double Value { get; init; } } /// Toggle state for an effect. public sealed record EffectToggle { /// The ID of the effect to toggle. - [JsonPropertyName("id")] public required string Id { get; init; } + [JsonPropertyName("id")] + public required string Id { get; init; } /// Whether the effect should be enabled. - [JsonPropertyName("isEnabled")] public required bool IsEnabled { get; init; } + [JsonPropertyName("isEnabled")] + public required bool IsEnabled { get; init; } } /// Parameters for the setOutputDevice RPC. public sealed record SetOutputDeviceParams { /// Output device update, or null to skip updating output devices. - [JsonPropertyName("outputDevice")] public OutputDeviceUpdate? OutputDevice { get; set; } + [JsonPropertyName("outputDevice")] + public OutputDeviceUpdate? OutputDevice { get; set; } /// Main output selection, or null to skip changing the main output. - [JsonPropertyName("mainOutput")] public MainOutput? MainOutput { get; set; } + [JsonPropertyName("mainOutput")] + public MainOutput? MainOutput { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Describes updates to an output device. public sealed record OutputDeviceUpdate { /// The output device ID. - [JsonPropertyName("id")] public required string Id { get; init; } + [JsonPropertyName("id")] + public required string Id { get; init; } /// List of output changes to apply. - [JsonPropertyName("outputs")] public required List Outputs { get; init; } + [JsonPropertyName("outputs")] + public required List Outputs { get; init; } } /// Describes updates to a single output. public sealed record OutputUpdate { /// The output ID. - [JsonPropertyName("id")] public required string Id { get; init; } + [JsonPropertyName("id")] + public required string Id { get; init; } /// Mute state to apply, or null to leave unchanged. - [JsonPropertyName("isMuted")] public bool? IsMuted { get; init; } + [JsonPropertyName("isMuted")] + public bool? IsMuted { get; init; } /// Level value to apply, or null to leave unchanged. - [JsonPropertyName("level")] public double? Level { get; init; } + [JsonPropertyName("level")] + public double? Level { get; init; } } /// Parameters for the setChannel RPC. public sealed record SetChannelParams { /// The channel ID. - [JsonPropertyName("id")] public required string Id { get; set; } + [JsonPropertyName("id")] + public required string Id { get; set; } /// List of mix changes to apply to this channel. - [JsonPropertyName("mixes")] public List? Mixes { get; set; } + [JsonPropertyName("mixes")] + public List? Mixes { get; set; } /// List of effect changes to apply to this channel. - [JsonPropertyName("effects")] public List? Effects { get; set; } + [JsonPropertyName("effects")] + public List? Effects { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Describes updates to a channel mix entry. public sealed record ChannelMixUpdate { /// The mix ID. - [JsonPropertyName("mixId")] public required string MixId { get; init; } + [JsonPropertyName("mixId")] + public required string MixId { get; init; } /// Mute state to apply, or null to leave unchanged. - [JsonPropertyName("isMuted")] public bool? IsMuted { get; init; } + [JsonPropertyName("isMuted")] + public bool? IsMuted { get; init; } /// Level value to apply, or null to leave unchanged. - [JsonPropertyName("level")] public double? Level { get; init; } + [JsonPropertyName("level")] + public double? Level { get; init; } } /// Parameters for the setMix RPC. public sealed record SetMixParams { /// The mix ID. - [JsonPropertyName("id")] public required string Id { get; set; } + [JsonPropertyName("id")] + public required string Id { get; set; } /// Mute state to apply, or null to leave unchanged. - [JsonPropertyName("isMuted")] public bool? IsMuted { get; set; } + [JsonPropertyName("isMuted")] + public bool? IsMuted { get; set; } /// Level value to apply, or null to leave unchanged. - [JsonPropertyName("level")] public double? Level { get; set; } + [JsonPropertyName("level")] + public double? Level { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Parameters to add an app to a channel. public sealed record AddToChannelParams { /// The application ID. - [JsonPropertyName("appId")] public required string AppId { get; init; } + [JsonPropertyName("appId")] + public required string AppId { get; init; } /// The channel ID. - [JsonPropertyName("channelId")] public required string ChannelId { get; init; } + [JsonPropertyName("channelId")] + public required string ChannelId { get; init; } } /// Parameters to configure server notification subscriptions. public sealed record SetSubscriptionParams { /// Subscription configuration for focused app change notifications. - [JsonPropertyName("focusedAppChanged")] public SubscriptionToggle? FocusedAppChanged { get; set; } + [JsonPropertyName("focusedAppChanged")] + public SubscriptionToggle? FocusedAppChanged { get; set; } /// Subscription configuration for level meter change notifications. - [JsonPropertyName("levelMeterChanged")] public LevelMeterSubscription? LevelMeterChanged { get; set; } + [JsonPropertyName("levelMeterChanged")] + public LevelMeterSubscription? LevelMeterChanged { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Toggle to enable or disable a subscription. public sealed record SubscriptionToggle { /// Whether the subscription is enabled. - [JsonPropertyName("isEnabled")] public required bool IsEnabled { get; init; } + [JsonPropertyName("isEnabled")] + public required bool IsEnabled { get; init; } } /// Parameters for subscribing to level meter notifications. @@ -198,4 +238,4 @@ public sealed record LevelMeterSubscription /// The sub-ID filter, used by some servers for additional filtering. [JsonPropertyName("subId")] public string? SubId { get; init; } -} \ No newline at end of file +} diff --git a/src/WaveLink.Client/SubscriptionResults.cs b/src/WaveLink.Client/SubscriptionResults.cs index 3e4cdb7..2c96d9e 100644 --- a/src/WaveLink.Client/SubscriptionResults.cs +++ b/src/WaveLink.Client/SubscriptionResults.cs @@ -7,30 +7,38 @@ namespace WaveLink.Client; public sealed record SetSubscriptionResult { /// Acknowledgment for the focused app changed subscription. - [JsonPropertyName("focusedAppChanged")] public SubscriptionAck? FocusedAppChanged { get; set; } + [JsonPropertyName("focusedAppChanged")] + public SubscriptionAck? FocusedAppChanged { get; set; } /// Acknowledgment for the level meter changed subscription. - [JsonPropertyName("levelMeterChanged")] public SubscriptionAck? LevelMeterChanged { get; set; } + [JsonPropertyName("levelMeterChanged")] + public SubscriptionAck? LevelMeterChanged { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } /// Acknowledgment for a subscription configuration. public sealed record SubscriptionAck { /// Whether the subscription is enabled. - [JsonPropertyName("isEnabled")] public bool? IsEnabled { get; set; } + [JsonPropertyName("isEnabled")] + public bool? IsEnabled { get; set; } /// Type of the subscription target. - [JsonPropertyName("type")] public string? Type { get; set; } + [JsonPropertyName("type")] + public string? Type { get; set; } /// ID of the subscription target. - [JsonPropertyName("id")] public string? Id { get; set; } + [JsonPropertyName("id")] + public string? Id { get; set; } /// Sub-ID of the subscription target if applicable. - [JsonPropertyName("subId")] public string? SubId { get; set; } + [JsonPropertyName("subId")] + public string? SubId { get; set; } /// Additional properties returned by the server. - [JsonExtensionData] public Dictionary? ExtensionData { get; set; } + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } } diff --git a/src/WaveLink.Client/WaveLink.Client.csproj b/src/WaveLink.Client/WaveLink.Client.csproj index 540fde6..f5ebfcb 100644 --- a/src/WaveLink.Client/WaveLink.Client.csproj +++ b/src/WaveLink.Client/WaveLink.Client.csproj @@ -1,49 +1,47 @@  - - - - net9.0;net10.0;net11.0 - preview - enable - enable - true - - - true - true - - - true - true - - - A local WebSocket JSON-RPC client for Elgato Wave Link 3.x, optimized for Native AOT and modern .NET. - Agash - WaveLink.Client - MIT - Elgato;WaveLink;Wave;Link;StreamDeck;Audio;Mixer;WebSocket;JSON-RPC;NativeAOT;AOT;Microphone;DSP - https://github.com/Agash/WaveLink.Client - git - https://github.com/Agash/WaveLink.Client - README.md - - v - - - - - + true + true + + + true + true + + + A local WebSocket JSON-RPC client for Elgato Wave Link 3.x, optimized for Native AOT and modern .NET. + Agash + WaveLink.Client + MIT + Elgato;WaveLink;Wave;Link;StreamDeck;Audio;Mixer;WebSocket;JSON-RPC;NativeAOT;AOT;Microphone;DSP + https://github.com/Agash/WaveLink.Client + git + https://github.com/Agash/WaveLink.Client + README.md + + v + + + + + - - - - - - + + - \ No newline at end of file + + + + diff --git a/src/WaveLink.Client/WaveLinkClient.cs b/src/WaveLink.Client/WaveLinkClient.cs index ad62a83..41fa1c0 100644 --- a/src/WaveLink.Client/WaveLinkClient.cs +++ b/src/WaveLink.Client/WaveLinkClient.cs @@ -22,7 +22,8 @@ public sealed class WaveLinkClient(WaveLinkClientOptions? options = null) : IAsy private readonly WaveLinkClientOptions _options = options ?? new WaveLinkClientOptions(); private readonly ClientWebSocket _ws = new(); private readonly CancellationTokenSource _cts = new(); - private readonly ConcurrentDictionary> _pending = new(); + private readonly ConcurrentDictionary> _pending = + new(); private Task? _recvLoop; private int _nextId; @@ -69,7 +70,10 @@ public sealed class WaveLinkClient(WaveLinkClientOptions? options = null) : IAsy public event EventHandler? InputDeviceChanged; /// Raised when the main output or output devices list changes. - public event EventHandler<(MainOutput mainOutput, IReadOnlyList outputDevices)>? OutputDevicesChanged; + public event EventHandler<( + MainOutput mainOutput, + IReadOnlyList outputDevices + )>? OutputDevicesChanged; /// Raised when a single output device is added or updated. public event EventHandler? OutputDeviceChanged; @@ -106,10 +110,14 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) return; } - int port = _options.PortOverride ?? await DiscoverPortAsync(cancellationToken).ConfigureAwait(false); + int port = + _options.PortOverride + ?? await DiscoverPortAsync(cancellationToken).ConfigureAwait(false); Uri uri = new($"ws://127.0.0.1:{port}"); - using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken + ); timeoutCts.CancelAfter(_options.ConnectTimeout); _ws.Options.SetRequestHeader("Origin", _options.OriginHeader); @@ -126,17 +134,22 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) _recvLoop = Task.Run(() => ReceiveLoopAsync(_cts.Token), CancellationToken.None); // Basic validation handshake - ApplicationInfo info = await GetApplicationInfoAsync(cancellationToken).ConfigureAwait(false); + ApplicationInfo info = await GetApplicationInfoAsync(cancellationToken) + .ConfigureAwait(false); ApplicationInfo = info; if (!string.Equals(info.AppId, "EWL", StringComparison.Ordinal)) { - throw new WaveLinkException($"Connected server returned unexpected appID '{info.AppId}'."); + throw new WaveLinkException( + $"Connected server returned unexpected appID '{info.AppId}'." + ); } if (info.InterfaceRevision < 1) { - throw new WaveLinkException($"Connected server returned unsupported interfaceRevision {info.InterfaceRevision}."); + throw new WaveLinkException( + $"Connected server returned unsupported interfaceRevision {info.InterfaceRevision}." + ); } } @@ -148,8 +161,18 @@ public async ValueTask DisposeAsync() await _cts.CancelAsync().ConfigureAwait(false); if (_ws.State is WebSocketState.Open or WebSocketState.CloseReceived) { - try { await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Dispose", CancellationToken.None).ConfigureAwait(false); } - catch { /* ignore */ } + try + { + await _ws.CloseAsync( + WebSocketCloseStatus.NormalClosure, + "Dispose", + CancellationToken.None + ) + .ConfigureAwait(false); + } + catch + { /* ignore */ + } } } finally @@ -170,13 +193,23 @@ public Task GetApplicationInfoAsync(CancellationToken ct = defa /// Send plugin information to the server. public Task SetPluginInfoAsync(SetPluginInfoParams info, CancellationToken ct = default) { - return CallVoidAsync("setPluginInfo", info, WaveLinkJsonContext.Default.SetPluginInfoParams, ct); + return CallVoidAsync( + "setPluginInfo", + info, + WaveLinkJsonContext.Default.SetPluginInfoParams, + ct + ); } /// Retrieve the list of input devices and update the cached state. public async Task GetInputDevicesAsync(CancellationToken ct = default) { - InputDevicesResult result = await CallAsync("getInputDevices", WaveLinkJsonContext.Default.InputDevicesResult, ct).ConfigureAwait(false); + InputDevicesResult result = await CallAsync( + "getInputDevices", + WaveLinkJsonContext.Default.InputDevicesResult, + ct + ) + .ConfigureAwait(false); _inputDevices = result.InputDevices ?? []; InputDevicesChanged?.Invoke(this, _inputDevices); return result; @@ -185,13 +218,23 @@ public async Task GetInputDevicesAsync(CancellationToken ct /// Update an input device configuration. public Task SetInputDeviceAsync(SetInputDeviceParams p, CancellationToken ct = default) { - return CallVoidAsync("setInputDevice", p, WaveLinkJsonContext.Default.SetInputDeviceParams, ct); + return CallVoidAsync( + "setInputDevice", + p, + WaveLinkJsonContext.Default.SetInputDeviceParams, + ct + ); } /// Retrieve the list of output devices and update the cached state. public async Task GetOutputDevicesAsync(CancellationToken ct = default) { - OutputDevicesResult result = await CallAsync("getOutputDevices", WaveLinkJsonContext.Default.OutputDevicesResult, ct).ConfigureAwait(false); + OutputDevicesResult result = await CallAsync( + "getOutputDevices", + WaveLinkJsonContext.Default.OutputDevicesResult, + ct + ) + .ConfigureAwait(false); MainOutput = result.MainOutput; _outputDevices = result.OutputDevices ?? []; OutputDevicesChanged?.Invoke(this, (result.MainOutput, _outputDevices)); @@ -201,13 +244,23 @@ public async Task GetOutputDevicesAsync(CancellationToken c /// Update an output device configuration. public Task SetOutputDeviceAsync(SetOutputDeviceParams p, CancellationToken ct = default) { - return CallVoidAsync("setOutputDevice", p, WaveLinkJsonContext.Default.SetOutputDeviceParams, ct); + return CallVoidAsync( + "setOutputDevice", + p, + WaveLinkJsonContext.Default.SetOutputDeviceParams, + ct + ); } /// Retrieve the list of channels and update the cached state. public async Task GetChannelsAsync(CancellationToken ct = default) { - ChannelsResult result = await CallAsync("getChannels", WaveLinkJsonContext.Default.ChannelsResult, ct).ConfigureAwait(false); + ChannelsResult result = await CallAsync( + "getChannels", + WaveLinkJsonContext.Default.ChannelsResult, + ct + ) + .ConfigureAwait(false); _channels = result.Channels ?? []; ChannelsChanged?.Invoke(this, _channels); return result; @@ -228,7 +281,12 @@ public Task AddToChannelAsync(AddToChannelParams p, CancellationToken ct = defau /// Retrieve the list of mixes and update the cached state. public async Task GetMixesAsync(CancellationToken ct = default) { - MixesResult result = await CallAsync("getMixes", WaveLinkJsonContext.Default.MixesResult, ct).ConfigureAwait(false); + MixesResult result = await CallAsync( + "getMixes", + WaveLinkJsonContext.Default.MixesResult, + ct + ) + .ConfigureAwait(false); _mixes = result.Mixes ?? []; MixesChanged?.Invoke(this, _mixes); return result; @@ -241,9 +299,18 @@ public Task SetMixAsync(SetMixParams p, CancellationToken ct = default) } /// Subscribe or unsubscribe to server notifications. - public Task SetSubscriptionAsync(SetSubscriptionParams p, CancellationToken ct = default) + public Task SetSubscriptionAsync( + SetSubscriptionParams p, + CancellationToken ct = default + ) { - return CallAsync("setSubscription", p, WaveLinkJsonContext.Default.SetSubscriptionParams, WaveLinkJsonContext.Default.SetSubscriptionResult, ct); + return CallAsync( + "setSubscription", + p, + WaveLinkJsonContext.Default.SetSubscriptionParams, + WaveLinkJsonContext.Default.SetSubscriptionResult, + ct + ); } #endregion @@ -251,9 +318,12 @@ public Task SetSubscriptionAsync(SetSubscriptionParams p, #region Streams (IAsyncEnumerable) /// Streams real-time updates for input devices. - public async IAsyncEnumerable StreamInputDeviceChangesAsync([EnumeratorCancellation] CancellationToken ct = default) + public async IAsyncEnumerable StreamInputDeviceChangesAsync( + [EnumeratorCancellation] CancellationToken ct = default + ) { - Channel channel = System.Threading.Channels.Channel.CreateUnbounded(); + Channel channel = + System.Threading.Channels.Channel.CreateUnbounded(); void handler(object? _, InputDevice e) { _ = channel.Writer.TryWrite(e); @@ -262,7 +332,9 @@ void handler(object? _, InputDevice e) InputDeviceChanged += handler; try { - await foreach (InputDevice? item in channel.Reader.ReadAllAsync(ct).ConfigureAwait(false)) + await foreach ( + InputDevice? item in channel.Reader.ReadAllAsync(ct).ConfigureAwait(false) + ) { yield return item; } @@ -274,9 +346,12 @@ void handler(object? _, InputDevice e) } /// Streams real-time updates for level meters. - public async IAsyncEnumerable StreamLevelMetersAsync([EnumeratorCancellation] CancellationToken ct = default) + public async IAsyncEnumerable StreamLevelMetersAsync( + [EnumeratorCancellation] CancellationToken ct = default + ) { - Channel channel = System.Threading.Channels.Channel.CreateUnbounded(); + Channel channel = + System.Threading.Channels.Channel.CreateUnbounded(); void handler(object? _, LevelMeterChangedParams e) { _ = channel.Writer.TryWrite(e); @@ -285,7 +360,11 @@ void handler(object? _, LevelMeterChangedParams e) LevelMeterChanged += handler; try { - await foreach (LevelMeterChangedParams? item in channel.Reader.ReadAllAsync(ct).ConfigureAwait(false)) + await foreach ( + LevelMeterChangedParams? item in channel + .Reader.ReadAllAsync(ct) + .ConfigureAwait(false) + ) { yield return item; } @@ -297,9 +376,12 @@ void handler(object? _, LevelMeterChangedParams e) } /// Streams real-time updates for focused app changes. - public async IAsyncEnumerable StreamFocusedAppChangesAsync([EnumeratorCancellation] CancellationToken ct = default) + public async IAsyncEnumerable StreamFocusedAppChangesAsync( + [EnumeratorCancellation] CancellationToken ct = default + ) { - Channel channel = System.Threading.Channels.Channel.CreateUnbounded(); + Channel channel = + System.Threading.Channels.Channel.CreateUnbounded(); void handler(object? _, FocusedAppChangedParams e) { _ = channel.Writer.TryWrite(e); @@ -308,7 +390,11 @@ void handler(object? _, FocusedAppChangedParams e) FocusedAppChanged += handler; try { - await foreach (FocusedAppChangedParams? item in channel.Reader.ReadAllAsync(ct).ConfigureAwait(false)) + await foreach ( + FocusedAppChangedParams? item in channel + .Reader.ReadAllAsync(ct) + .ConfigureAwait(false) + ) { yield return item; } @@ -324,68 +410,133 @@ void handler(object? _, FocusedAppChangedParams e) #region Convenience Helpers /// Convenience: set the level for a specific output on a device. - public Task SetOutputLevelAsync(string outputDeviceId, string outputId, double level0to1, CancellationToken ct = default) + public Task SetOutputLevelAsync( + string outputDeviceId, + string outputId, + double level0to1, + CancellationToken ct = default + ) { - return SetOutputDeviceAsync(new SetOutputDeviceParams - { - OutputDevice = new OutputDeviceUpdate + return SetOutputDeviceAsync( + new SetOutputDeviceParams { - Id = outputDeviceId, - Outputs = [new() { Id = outputId, Level = Clamp01(level0to1) }] - } - }, ct); + OutputDevice = new OutputDeviceUpdate + { + Id = outputDeviceId, + Outputs = [new() { Id = outputId, Level = Clamp01(level0to1) }], + }, + }, + ct + ); } /// Convenience: set mute state for a specific output on a device. - public Task SetOutputMuteAsync(string outputDeviceId, string outputId, bool isMuted, CancellationToken ct = default) + public Task SetOutputMuteAsync( + string outputDeviceId, + string outputId, + bool isMuted, + CancellationToken ct = default + ) { - return SetOutputDeviceAsync(new SetOutputDeviceParams - { - OutputDevice = new OutputDeviceUpdate + return SetOutputDeviceAsync( + new SetOutputDeviceParams { - Id = outputDeviceId, - Outputs = [new() { Id = outputId, IsMuted = isMuted }] - } - }, ct); + OutputDevice = new OutputDeviceUpdate + { + Id = outputDeviceId, + Outputs = [new() { Id = outputId, IsMuted = isMuted }], + }, + }, + ct + ); } /// Convenience: set the main output. - public Task SetMainOutputAsync(string outputDeviceId, string outputId, CancellationToken ct = default) + public Task SetMainOutputAsync( + string outputDeviceId, + string outputId, + CancellationToken ct = default + ) { - return SetOutputDeviceAsync(new SetOutputDeviceParams - { - MainOutput = new MainOutput { OutputDeviceId = outputDeviceId, OutputId = outputId } - }, ct); + return SetOutputDeviceAsync( + new SetOutputDeviceParams + { + MainOutput = new MainOutput + { + OutputDeviceId = outputDeviceId, + OutputId = outputId, + }, + }, + ct + ); } /// Convenience: set mute state for a specific input on a device. - public Task SetInputMuteAsync(string inputDeviceId, string inputId, bool isMuted, CancellationToken ct = default) + public Task SetInputMuteAsync( + string inputDeviceId, + string inputId, + bool isMuted, + CancellationToken ct = default + ) { - return SetInputDeviceAsync(new SetInputDeviceParams - { - Id = inputDeviceId, - Inputs = [new() { Id = inputId, IsMuted = isMuted }] - }, ct); + return SetInputDeviceAsync( + new SetInputDeviceParams + { + Id = inputDeviceId, + Inputs = [new() { Id = inputId, IsMuted = isMuted }], + }, + ct + ); } /// Convenience: set normalized gain for a specific input. - public Task SetInputGainNormalizedAsync(string inputDeviceId, string inputId, double value0to1, CancellationToken ct = default) + public Task SetInputGainNormalizedAsync( + string inputDeviceId, + string inputId, + double value0to1, + CancellationToken ct = default + ) { - return SetInputDeviceAsync(new SetInputDeviceParams - { - Id = inputDeviceId, - Inputs = [new() { Id = inputId, Gain = new GainValue { Value = Clamp01(value0to1) } }] - }, ct); + return SetInputDeviceAsync( + new SetInputDeviceParams + { + Id = inputDeviceId, + Inputs = + [ + new() + { + Id = inputId, + Gain = new GainValue { Value = Clamp01(value0to1) }, + }, + ], + }, + ct + ); } /// Convenience: set normalized mic/PC mix for a specific input. - public Task SetMicPcMixNormalizedAsync(string inputDeviceId, string inputId, double value0to1, CancellationToken ct = default) + public Task SetMicPcMixNormalizedAsync( + string inputDeviceId, + string inputId, + double value0to1, + CancellationToken ct = default + ) { - return SetInputDeviceAsync(new SetInputDeviceParams - { - Id = inputDeviceId, - Inputs = [new() { Id = inputId, MicPcMix = new MicPcMixValue { Value = Clamp01(value0to1) } }] - }, ct); + return SetInputDeviceAsync( + new SetInputDeviceParams + { + Id = inputDeviceId, + Inputs = + [ + new() + { + Id = inputId, + MicPcMix = new MicPcMixValue { Value = Clamp01(value0to1) }, + }, + ], + }, + ct + ); } /// Clamp a value to the range [0,1]. @@ -421,7 +572,9 @@ private async Task DiscoverPortAsync(CancellationToken ct) } } - throw new WaveLinkException($"Unable to discover Wave Link WebSocket port (ws-info.json missing and scan {_options.MinPort}-{_options.MaxPort} failed)."); + throw new WaveLinkException( + $"Unable to discover Wave Link WebSocket port (ws-info.json missing and scan {_options.MinPort}-{_options.MaxPort} failed)." + ); } private static string? GetDefaultWsInfoFilePath() @@ -437,16 +590,31 @@ private async Task DiscoverPortAsync(CancellationToken ct) if (appData.EndsWith("Roaming", StringComparison.OrdinalIgnoreCase)) { string baseDir = appData[..^"Roaming".Length].TrimEnd('\\', '/'); - return Path.Combine(baseDir, "Local", "Packages", "Elgato.WaveLink_g54w8ztgkx496", "LocalState", "ws-info.json"); + return Path.Combine( + baseDir, + "Local", + "Packages", + "Elgato.WaveLink_g54w8ztgkx496", + "LocalState", + "ws-info.json" + ); } string? localAppData = Environment.GetEnvironmentVariable("LOCALAPPDATA"); if (!string.IsNullOrWhiteSpace(localAppData)) { - return Path.Combine(localAppData, "Packages", "Elgato.WaveLink_g54w8ztgkx496", "LocalState", "ws-info.json"); + return Path.Combine( + localAppData, + "Packages", + "Elgato.WaveLink_g54w8ztgkx496", + "LocalState", + "ws-info.json" + ); } } - catch { /* ignored */ } + catch + { /* ignored */ + } return null; } @@ -460,13 +628,21 @@ private async Task DiscoverPortAsync(CancellationToken ct) } using FileStream stream = File.OpenRead(path); - using JsonDocument doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct).ConfigureAwait(false); - if (doc.RootElement.TryGetProperty("port", out JsonElement portEl) && portEl.ValueKind == JsonValueKind.Number && portEl.TryGetInt32(out int port)) + using JsonDocument doc = await JsonDocument + .ParseAsync(stream, cancellationToken: ct) + .ConfigureAwait(false); + if ( + doc.RootElement.TryGetProperty("port", out JsonElement portEl) + && portEl.ValueKind == JsonValueKind.Number + && portEl.TryGetInt32(out int port) + ) { return port; } } - catch { /* ignored */ } + catch + { /* ignored */ + } return null; } @@ -475,13 +651,17 @@ private async Task CanConnectAsync(int port, CancellationToken ct) using ClientWebSocket ws = new(); ws.Options.SetRequestHeader("Origin", _options.OriginHeader); - using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource( + ct + ); timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(700)); try { - await ws.ConnectAsync(new Uri($"ws://127.0.0.1:{port}"), timeoutCts.Token).ConfigureAwait(false); - await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "probe", CancellationToken.None).ConfigureAwait(false); + await ws.ConnectAsync(new Uri($"ws://127.0.0.1:{port}"), timeoutCts.Token) + .ConfigureAwait(false); + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "probe", CancellationToken.None) + .ConfigureAwait(false); return true; } catch @@ -491,28 +671,52 @@ private async Task CanConnectAsync(int port, CancellationToken ct) } // AOT-safe typed calls - private async Task CallAsync(string method, JsonTypeInfo responseTypeInfo, CancellationToken ct) + private async Task CallAsync( + string method, + JsonTypeInfo responseTypeInfo, + CancellationToken ct + ) { - JsonElement? resultEl = await CallCoreAsync(method, null, null, ct).ConfigureAwait(false); + JsonElement? resultEl = await CallCoreAsync(method, null, null, ct) + .ConfigureAwait(false); return resultEl is null ? throw new WaveLinkException($"RPC '{method}' returned null result.") - : resultEl.Value.Deserialize(responseTypeInfo) ?? throw new WaveLinkException($"RPC '{method}' result could not be deserialized."); + : resultEl.Value.Deserialize(responseTypeInfo) + ?? throw new WaveLinkException($"RPC '{method}' result could not be deserialized."); } - private async Task CallAsync(string method, TParams p, JsonTypeInfo paramsTypeInfo, JsonTypeInfo responseTypeInfo, CancellationToken ct) + private async Task CallAsync( + string method, + TParams p, + JsonTypeInfo paramsTypeInfo, + JsonTypeInfo responseTypeInfo, + CancellationToken ct + ) { - JsonElement? resultEl = await CallCoreAsync(method, p, paramsTypeInfo, ct).ConfigureAwait(false); + JsonElement? resultEl = await CallCoreAsync(method, p, paramsTypeInfo, ct) + .ConfigureAwait(false); return resultEl is null ? throw new WaveLinkException($"RPC '{method}' returned null result.") - : resultEl.Value.Deserialize(responseTypeInfo) ?? throw new WaveLinkException($"RPC '{method}' result could not be deserialized."); + : resultEl.Value.Deserialize(responseTypeInfo) + ?? throw new WaveLinkException($"RPC '{method}' result could not be deserialized."); } - private Task CallVoidAsync(string method, TParams p, JsonTypeInfo paramsTypeInfo, CancellationToken ct) + private Task CallVoidAsync( + string method, + TParams p, + JsonTypeInfo paramsTypeInfo, + CancellationToken ct + ) { return CallCoreAsync(method, p, paramsTypeInfo, ct); } - private async Task CallCoreAsync(string method, TParams? paramsObject, JsonTypeInfo? paramsTypeInfo, CancellationToken ct) + private async Task CallCoreAsync( + string method, + TParams? paramsObject, + JsonTypeInfo? paramsTypeInfo, + CancellationToken ct + ) { if (_ws.State != WebSocketState.Open) { @@ -520,7 +724,9 @@ private async Task CallAsync(string method, TPara } int id = Interlocked.Increment(ref _nextId); - TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource tcs = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); if (!_pending.TryAdd(id, tcs)) { throw new WaveLinkException("Failed to register pending RPC."); @@ -534,17 +740,26 @@ private async Task CallAsync(string method, TPara paramsEl = doc.RootElement.Clone(); } - JsonRpcRequest req = new() { Id = id, Method = method, Params = paramsEl }; + JsonRpcRequest req = new() + { + Id = id, + Method = method, + Params = paramsEl, + }; string payload = JsonSerializer.Serialize(req, WaveLinkJsonContext.Default.JsonRpcRequest); await SendTextAsync(payload, ct).ConfigureAwait(false); - using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource( + ct + ); timeoutCts.CancelAfter(_options.RequestTimeout); try { - await using CancellationTokenRegistration _ = timeoutCts.Token.Register(() => tcs.TrySetCanceled(timeoutCts.Token)); + await using CancellationTokenRegistration _ = timeoutCts.Token.Register(() => + tcs.TrySetCanceled(timeoutCts.Token) + ); JsonRpcResponse resp = await tcs.Task.ConfigureAwait(false); if (resp.Error is not null) @@ -564,7 +779,13 @@ private async Task CallAsync(string method, TPara private async Task SendTextAsync(string text, CancellationToken ct) { byte[] bytes = Encoding.UTF8.GetBytes(text); - await _ws.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken: ct).ConfigureAwait(false); + await _ws.SendAsync( + bytes, + WebSocketMessageType.Text, + endOfMessage: true, + cancellationToken: ct + ) + .ConfigureAwait(false); } private async Task ReceiveLoopAsync(CancellationToken ct) @@ -588,8 +809,7 @@ private async Task ReceiveLoopAsync(CancellationToken ct) } _ = sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count)); - } - while (!result.EndOfMessage); + } while (!result.EndOfMessage); if (result.MessageType == WebSocketMessageType.Close) { @@ -618,10 +838,19 @@ private void HandleIncoming(string json) using JsonDocument doc = JsonDocument.Parse(json); JsonElement root = doc.RootElement; - if (root.TryGetProperty("id", out JsonElement idEl) && idEl.ValueKind == JsonValueKind.Number && idEl.TryGetInt32(out int id)) + if ( + root.TryGetProperty("id", out JsonElement idEl) + && idEl.ValueKind == JsonValueKind.Number + && idEl.TryGetInt32(out int id) + ) { - JsonRpcResponse? resp = root.Deserialize(WaveLinkJsonContext.Default.JsonRpcResponse); - if (resp is not null && _pending.TryGetValue(id, out TaskCompletionSource? tcs)) + JsonRpcResponse? resp = root.Deserialize( + WaveLinkJsonContext.Default.JsonRpcResponse + ); + if ( + resp is not null + && _pending.TryGetValue(id, out TaskCompletionSource? tcs) + ) { _ = tcs.TrySetResult(resp); } @@ -629,10 +858,15 @@ private void HandleIncoming(string json) return; } - if (root.TryGetProperty("method", out JsonElement methodEl) && methodEl.ValueKind == JsonValueKind.String) + if ( + root.TryGetProperty("method", out JsonElement methodEl) + && methodEl.ValueKind == JsonValueKind.String + ) { string method = methodEl.GetString() ?? string.Empty; - JsonElement? p = root.TryGetProperty("params", out JsonElement paramsEl) ? paramsEl.Clone() : null; + JsonElement? p = root.TryGetProperty("params", out JsonElement paramsEl) + ? paramsEl.Clone() + : null; DispatchNotification(method, p); } } @@ -649,251 +883,271 @@ private void DispatchNotification(string method, JsonElement? p) switch (method) { case "inputDevicesChanged": + { + InputDevicesResult? parsed = p?.Deserialize( + WaveLinkJsonContext.Default.InputDevicesResult + ); + if (parsed?.InputDevices is not null) { - InputDevicesResult? parsed = p?.Deserialize(WaveLinkJsonContext.Default.InputDevicesResult); - if (parsed?.InputDevices is not null) - { - _inputDevices = parsed.InputDevices; - InputDevicesChanged?.Invoke(this, _inputDevices); - } - break; + _inputDevices = parsed.InputDevices; + InputDevicesChanged?.Invoke(this, _inputDevices); } + break; + } case "inputDeviceChanged": + { + if ( + p is null + || !p.Value.TryGetProperty("id", out JsonElement idEl) + || idEl.ValueKind != JsonValueKind.String + ) { - if (p is null || !p.Value.TryGetProperty("id", out JsonElement idEl) || idEl.ValueKind != JsonValueKind.String) - { - break; - } + break; + } - string devId = idEl.GetString()!; - InputDevice? update = p.Value.Deserialize(WaveLinkJsonContext.Default.InputDevice); - if (update is null) - { - break; - } + string devId = idEl.GetString()!; + InputDevice? update = p.Value.Deserialize( + WaveLinkJsonContext.Default.InputDevice + ); + if (update is null) + { + break; + } - int idx = _inputDevices.FindIndex(d => d.Id == devId); - if (idx >= 0) + int idx = _inputDevices.FindIndex(d => d.Id == devId); + if (idx >= 0) + { + InputDevice existing = _inputDevices[idx]; + List inputs = [.. existing.Inputs]; + foreach (Input inUpd in update.Inputs) { - InputDevice existing = _inputDevices[idx]; - List inputs = [.. existing.Inputs]; - foreach (Input inUpd in update.Inputs) + int iidx = inputs.FindIndex(x => x.Id == inUpd.Id); + if (iidx >= 0) { - int iidx = inputs.FindIndex(x => x.Id == inUpd.Id); - if (iidx >= 0) + Input old = inputs[iidx]; + inputs[iidx] = old with { - Input old = inputs[iidx]; - inputs[iidx] = old with - { - Name = inUpd.Name ?? old.Name, - IsMuted = inUpd.IsMuted ?? old.IsMuted, - IsGainLockOn = inUpd.IsGainLockOn ?? old.IsGainLockOn, - Gain = inUpd.Gain ?? old.Gain, - MicPcMix = inUpd.MicPcMix ?? old.MicPcMix, - Effects = inUpd.Effects ?? old.Effects, - DspEffects = inUpd.DspEffects ?? old.DspEffects, - ExtensionData = inUpd.ExtensionData ?? old.ExtensionData - }; - } - else - { - inputs.Add(inUpd); - } + Name = inUpd.Name ?? old.Name, + IsMuted = inUpd.IsMuted ?? old.IsMuted, + IsGainLockOn = inUpd.IsGainLockOn ?? old.IsGainLockOn, + Gain = inUpd.Gain ?? old.Gain, + MicPcMix = inUpd.MicPcMix ?? old.MicPcMix, + Effects = inUpd.Effects ?? old.Effects, + DspEffects = inUpd.DspEffects ?? old.DspEffects, + ExtensionData = inUpd.ExtensionData ?? old.ExtensionData, + }; + } + else + { + inputs.Add(inUpd); } - InputDevice merged = existing with { Inputs = inputs }; - _inputDevices[idx] = merged; - InputDeviceChanged?.Invoke(this, merged); - } - else - { - _inputDevices.Add(update); - InputDeviceChanged?.Invoke(this, update); } - break; + InputDevice merged = existing with { Inputs = inputs }; + _inputDevices[idx] = merged; + InputDeviceChanged?.Invoke(this, merged); + } + else + { + _inputDevices.Add(update); + InputDeviceChanged?.Invoke(this, update); } + break; + } case "outputDevicesChanged": + { + OutputDevicesResult? parsed = p?.Deserialize( + WaveLinkJsonContext.Default.OutputDevicesResult + ); + if (parsed is not null) { - OutputDevicesResult? parsed = p?.Deserialize(WaveLinkJsonContext.Default.OutputDevicesResult); - if (parsed is not null) - { - MainOutput = parsed.MainOutput; - _outputDevices = parsed.OutputDevices ?? []; - OutputDevicesChanged?.Invoke(this, (parsed.MainOutput, _outputDevices)); - } - break; + MainOutput = parsed.MainOutput; + _outputDevices = parsed.OutputDevices ?? []; + OutputDevicesChanged?.Invoke(this, (parsed.MainOutput, _outputDevices)); } + break; + } case "outputDeviceChanged": + { + if (p is null) { - if (p is null) - { - break; - } + break; + } - OutputDevice? update = p.Value.Deserialize(WaveLinkJsonContext.Default.OutputDevice); - if (update is null) - { - break; - } + OutputDevice? update = p.Value.Deserialize( + WaveLinkJsonContext.Default.OutputDevice + ); + if (update is null) + { + break; + } - int idx = _outputDevices.FindIndex(d => d.Id == update.Id); - if (idx >= 0) + int idx = _outputDevices.FindIndex(d => d.Id == update.Id); + if (idx >= 0) + { + OutputDevice existing = _outputDevices[idx]; + List outputs = [.. existing.Outputs]; + foreach (Output oUpd in update.Outputs) { - OutputDevice existing = _outputDevices[idx]; - List outputs = [.. existing.Outputs]; - foreach (Output oUpd in update.Outputs) + int oidx = outputs.FindIndex(x => x.Id == oUpd.Id); + if (oidx >= 0) { - int oidx = outputs.FindIndex(x => x.Id == oUpd.Id); - if (oidx >= 0) + Output old = outputs[oidx]; + outputs[oidx] = old with { - Output old = outputs[oidx]; - outputs[oidx] = old with - { - Name = oUpd.Name ?? old.Name, - IsMuted = oUpd.IsMuted ?? old.IsMuted, - Level = oUpd.Level ?? old.Level, - ExtensionData = oUpd.ExtensionData ?? old.ExtensionData - }; - } - else - { - outputs.Add(oUpd); - } + Name = oUpd.Name ?? old.Name, + IsMuted = oUpd.IsMuted ?? old.IsMuted, + Level = oUpd.Level ?? old.Level, + ExtensionData = oUpd.ExtensionData ?? old.ExtensionData, + }; + } + else + { + outputs.Add(oUpd); } - OutputDevice merged = existing with { Outputs = outputs }; - _outputDevices[idx] = merged; - OutputDeviceChanged?.Invoke(this, merged); - } - else - { - _outputDevices.Add(update); - OutputDeviceChanged?.Invoke(this, update); } - break; + OutputDevice merged = existing with { Outputs = outputs }; + _outputDevices[idx] = merged; + OutputDeviceChanged?.Invoke(this, merged); } + else + { + _outputDevices.Add(update); + OutputDeviceChanged?.Invoke(this, update); + } + break; + } case "channelsChanged": + { + ChannelsResult? parsed = p?.Deserialize( + WaveLinkJsonContext.Default.ChannelsResult + ); + if (parsed?.Channels is not null) { - ChannelsResult? parsed = p?.Deserialize(WaveLinkJsonContext.Default.ChannelsResult); - if (parsed?.Channels is not null) - { - _channels = parsed.Channels; - ChannelsChanged?.Invoke(this, _channels); - } - break; + _channels = parsed.Channels; + ChannelsChanged?.Invoke(this, _channels); } + break; + } case "channelChanged": + { + if (p is null) { - if (p is null) - { - break; - } + break; + } - Channel? update = p.Value.Deserialize(WaveLinkJsonContext.Default.Channel); - if (update is null) - { - break; - } + Channel? update = p.Value.Deserialize(WaveLinkJsonContext.Default.Channel); + if (update is null) + { + break; + } - int idx = _channels.FindIndex(c => c.Id == update.Id); - if (idx >= 0) - { - Channel existing = _channels[idx]; - Channel merged = existing with - { - Name = update.Name ?? existing.Name, - Type = update.Type ?? existing.Type, - IsMuted = update.IsMuted ?? existing.IsMuted, - Level = update.Level ?? existing.Level, - Image = update.Image ?? existing.Image, - Apps = update.Apps ?? existing.Apps, - Mixes = update.Mixes ?? existing.Mixes, - Effects = update.Effects ?? existing.Effects, - ExtensionData = update.ExtensionData ?? existing.ExtensionData - }; - _channels[idx] = merged; - ChannelChanged?.Invoke(this, merged); - } - else + int idx = _channels.FindIndex(c => c.Id == update.Id); + if (idx >= 0) + { + Channel existing = _channels[idx]; + Channel merged = existing with { - _channels.Add(update); - ChannelChanged?.Invoke(this, update); - } - break; + Name = update.Name ?? existing.Name, + Type = update.Type ?? existing.Type, + IsMuted = update.IsMuted ?? existing.IsMuted, + Level = update.Level ?? existing.Level, + Image = update.Image ?? existing.Image, + Apps = update.Apps ?? existing.Apps, + Mixes = update.Mixes ?? existing.Mixes, + Effects = update.Effects ?? existing.Effects, + ExtensionData = update.ExtensionData ?? existing.ExtensionData, + }; + _channels[idx] = merged; + ChannelChanged?.Invoke(this, merged); } + else + { + _channels.Add(update); + ChannelChanged?.Invoke(this, update); + } + break; + } case "mixesChanged": + { + MixesResult? parsed = p?.Deserialize(WaveLinkJsonContext.Default.MixesResult); + if (parsed?.Mixes is not null) { - MixesResult? parsed = p?.Deserialize(WaveLinkJsonContext.Default.MixesResult); - if (parsed?.Mixes is not null) - { - _mixes = parsed.Mixes; - MixesChanged?.Invoke(this, _mixes); - } - break; + _mixes = parsed.Mixes; + MixesChanged?.Invoke(this, _mixes); } + break; + } case "mixChanged": + { + if (p is null) { - if (p is null) - { - break; - } + break; + } - Mix? update = p.Value.Deserialize(WaveLinkJsonContext.Default.Mix); - if (update is null) - { - break; - } + Mix? update = p.Value.Deserialize(WaveLinkJsonContext.Default.Mix); + if (update is null) + { + break; + } - int idx = _mixes.FindIndex(m => m.Id == update.Id); - if (idx >= 0) - { - Mix existing = _mixes[idx]; - Mix merged = existing with - { - Name = update.Name ?? existing.Name, - IsMuted = update.IsMuted ?? existing.IsMuted, - Level = update.Level ?? existing.Level, - Image = update.Image ?? existing.Image, - ExtensionData = update.ExtensionData ?? existing.ExtensionData - }; - _mixes[idx] = merged; - MixChanged?.Invoke(this, merged); - } - else + int idx = _mixes.FindIndex(m => m.Id == update.Id); + if (idx >= 0) + { + Mix existing = _mixes[idx]; + Mix merged = existing with { - _mixes.Add(update); - MixChanged?.Invoke(this, update); - } - break; + Name = update.Name ?? existing.Name, + IsMuted = update.IsMuted ?? existing.IsMuted, + Level = update.Level ?? existing.Level, + Image = update.Image ?? existing.Image, + ExtensionData = update.ExtensionData ?? existing.ExtensionData, + }; + _mixes[idx] = merged; + MixChanged?.Invoke(this, merged); } + else + { + _mixes.Add(update); + MixChanged?.Invoke(this, update); + } + break; + } case "levelMeterChanged": + { + LevelMeterChangedParams? parsed = p?.Deserialize( + WaveLinkJsonContext.Default.LevelMeterChangedParams + ); + if (parsed is not null) { - LevelMeterChangedParams? parsed = p?.Deserialize(WaveLinkJsonContext.Default.LevelMeterChangedParams); - if (parsed is not null) - { - LevelMeters = parsed; - LevelMeterChanged?.Invoke(this, parsed); - } - break; + LevelMeters = parsed; + LevelMeterChanged?.Invoke(this, parsed); } + break; + } case "createProfileRequested": + { + CreateProfileRequestedParams? parsed = p?.Deserialize( + WaveLinkJsonContext.Default.CreateProfileRequestedParams + ); + if (parsed is not null) { - CreateProfileRequestedParams? parsed = p?.Deserialize(WaveLinkJsonContext.Default.CreateProfileRequestedParams); - if (parsed is not null) - { - CreateProfileRequested?.Invoke(this, parsed); - } - - break; + CreateProfileRequested?.Invoke(this, parsed); } + + break; + } case "focusedAppChanged": + { + FocusedAppChangedParams? parsed = p?.Deserialize( + WaveLinkJsonContext.Default.FocusedAppChangedParams + ); + if (parsed is not null) { - FocusedAppChangedParams? parsed = p?.Deserialize(WaveLinkJsonContext.Default.FocusedAppChangedParams); - if (parsed is not null) - { - FocusedApp = parsed; - FocusedAppChanged?.Invoke(this, parsed); - } - break; + FocusedApp = parsed; + FocusedAppChanged?.Invoke(this, parsed); } + break; + } } } catch @@ -902,4 +1156,4 @@ private void DispatchNotification(string method, JsonElement? p) } } #endregion -} \ No newline at end of file +} diff --git a/src/WaveLink.Client/WaveLinkClientOptions.cs b/src/WaveLink.Client/WaveLinkClientOptions.cs index cdafda8..ca130b8 100644 --- a/src/WaveLink.Client/WaveLinkClientOptions.cs +++ b/src/WaveLink.Client/WaveLinkClientOptions.cs @@ -25,4 +25,4 @@ public sealed record WaveLinkClientOptions /// Origin header used by the official Stream Deck plugin to authenticate. public string OriginHeader { get; init; } = "streamdeck://"; -} \ No newline at end of file +} diff --git a/src/WaveLink.Client/WaveLinkException.cs b/src/WaveLink.Client/WaveLinkException.cs index 26e3122..b0cd0fd 100644 --- a/src/WaveLink.Client/WaveLinkException.cs +++ b/src/WaveLink.Client/WaveLinkException.cs @@ -5,12 +5,14 @@ public class WaveLinkException : Exception { /// Initializes a new instance of the class with a message. /// The error message. - public WaveLinkException(string message) : base(message) { } + public WaveLinkException(string message) + : base(message) { } /// Initializes a new instance of the class with a message and inner exception. /// The error message. /// The inner exception. - public WaveLinkException(string message, Exception inner) : base(message, inner) { } + public WaveLinkException(string message, Exception inner) + : base(message, inner) { } } /// Exception representing a JSON-RPC error returned by the server. @@ -18,7 +20,8 @@ public WaveLinkException(string message, Exception inner) : base(message, inner) /// The JSON-RPC error code. /// The error message. /// Optional error data payload from the server. -public sealed class WaveLinkRpcException(int code, string message, object? data = null) : WaveLinkException(message) +public sealed class WaveLinkRpcException(int code, string message, object? data = null) + : WaveLinkException(message) { /// Gets the JSON-RPC error code. public int Code { get; } = code; diff --git a/src/WaveLink.Client/WaveLinkJsonContext.cs b/src/WaveLink.Client/WaveLinkJsonContext.cs index 2d53103..a72d255 100644 --- a/src/WaveLink.Client/WaveLinkJsonContext.cs +++ b/src/WaveLink.Client/WaveLinkJsonContext.cs @@ -24,7 +24,8 @@ namespace WaveLink.Client; GenerationMode = JsonSourceGenerationMode.Metadata, PropertyNamingPolicy = JsonKnownNamingPolicy.Unspecified, WriteIndented = false, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull +)] [JsonSerializable(typeof(JsonElement))] [JsonSerializable(typeof(JsonRpcRequest))] [JsonSerializable(typeof(JsonRpcResponse))] @@ -70,4 +71,4 @@ namespace WaveLink.Client; [JsonSerializable(typeof(SetSubscriptionResult))] [JsonSerializable(typeof(SubscriptionAck))] [JsonSerializable(typeof(CreateProfileRequestedParams))] -public partial class WaveLinkJsonContext : JsonSerializerContext; \ No newline at end of file +public partial class WaveLinkJsonContext : JsonSerializerContext; diff --git a/tests/WaveLink.Client.Tests/FakeWaveLinkServer.cs b/tests/WaveLink.Client.Tests/FakeWaveLinkServer.cs index f1a649a..7693005 100644 --- a/tests/WaveLink.Client.Tests/FakeWaveLinkServer.cs +++ b/tests/WaveLink.Client.Tests/FakeWaveLinkServer.cs @@ -17,7 +17,9 @@ internal sealed class FakeWaveLinkServer : IAsyncDisposable private readonly HttpListener _listener = new(); private readonly CancellationTokenSource _cts = new(); private readonly Func _respond; - private readonly TaskCompletionSource _clientConnected = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _clientConnected = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); private WebSocket? _socket; private Task? _acceptLoop; private bool _disposed; @@ -73,7 +75,9 @@ public async Task PushAsync(string json) { await ClientConnected.ConfigureAwait(false); byte[] bytes = Encoding.UTF8.GetBytes(json); - await _socket!.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, _cts.Token).ConfigureAwait(false); + await _socket! + .SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, _cts.Token) + .ConfigureAwait(false); } private static int GetFreePort() @@ -91,7 +95,9 @@ private async Task AcceptAsync() { while (!_cts.IsCancellationRequested) { - HttpListenerContext context = await _listener.GetContextAsync().ConfigureAwait(false); + HttpListenerContext context = await _listener + .GetContextAsync() + .ConfigureAwait(false); if (!context.Request.IsWebSocketRequest) { context.Response.StatusCode = 400; @@ -99,13 +105,20 @@ private async Task AcceptAsync() continue; } - HttpListenerWebSocketContext wsContext = await context.AcceptWebSocketAsync(subProtocol: null).ConfigureAwait(false); + HttpListenerWebSocketContext wsContext = await context + .AcceptWebSocketAsync(subProtocol: null) + .ConfigureAwait(false); _socket = wsContext.WebSocket; _ = _clientConnected.TrySetResult(); await ServeAsync(wsContext.WebSocket).ConfigureAwait(false); } } - catch (Exception ex) when (ex is HttpListenerException or ObjectDisposedException or OperationCanceledException) + catch (Exception ex) + when (ex + is HttpListenerException + or ObjectDisposedException + or OperationCanceledException + ) { // Expected on shutdown: disposing the listener is how this loop is stopped. } @@ -133,7 +146,12 @@ private async Task ServeAsync(WebSocket socket) // hangs every test at disposal rather than failing one. try { - await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None) + await socket + .CloseOutputAsync( + WebSocketCloseStatus.NormalClosure, + "bye", + CancellationToken.None + ) .ConfigureAwait(false); } catch (WebSocketException) @@ -151,7 +169,13 @@ await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "bye", Cancell continue; } - await socket.SendAsync(Encoding.UTF8.GetBytes(reply), WebSocketMessageType.Text, endOfMessage: true, _cts.Token) + await socket + .SendAsync( + Encoding.UTF8.GetBytes(reply), + WebSocketMessageType.Text, + endOfMessage: true, + _cts.Token + ) .ConfigureAwait(false); } } @@ -175,7 +199,12 @@ public async ValueTask DisposeAsync() { await _acceptLoop.ConfigureAwait(false); } - catch (Exception ex) when (ex is OperationCanceledException or HttpListenerException or ObjectDisposedException) + catch (Exception ex) + when (ex + is OperationCanceledException + or HttpListenerException + or ObjectDisposedException + ) { // Expected: the loop is torn down by closing the listener out from under it. } @@ -189,6 +218,10 @@ public async ValueTask DisposeAsync() /// The reply Wave Link gives to the handshake the client performs on connect. Anything else /// makes ConnectAsync throw, so most tests need this to succeed before reaching their subject. /// - public static string ApplicationInfoReply(int id, string appId = "EWL", int interfaceRevision = 3) => + public static string ApplicationInfoReply( + int id, + string appId = "EWL", + int interfaceRevision = 3 + ) => $$$"""{"jsonrpc":"2.0","id":{{{id}}},"result":{"appID":"{{{appId}}}","name":"Wave Link","interfaceRevision":{{{interfaceRevision}}}}}"""; } diff --git a/tests/WaveLink.Client.Tests/PortDiscoveryTests.cs b/tests/WaveLink.Client.Tests/PortDiscoveryTests.cs index f873725..f7ce585 100644 --- a/tests/WaveLink.Client.Tests/PortDiscoveryTests.cs +++ b/tests/WaveLink.Client.Tests/PortDiscoveryTests.cs @@ -10,11 +10,15 @@ namespace WaveLink.Client.Tests; [TestClass] public sealed class PortDiscoveryTests { - private static int IdOf(System.Text.Json.JsonElement request) => request.GetProperty("id").GetInt32(); + private static int IdOf(System.Text.Json.JsonElement request) => + request.GetProperty("id").GetInt32(); private string WriteWsInfo(string contents) { - string path = Path.Combine(TestContext.TestRunResultsDirectory ?? Path.GetTempPath(), $"ws-info-{Guid.NewGuid():N}.json"); + string path = Path.Combine( + TestContext.TestRunResultsDirectory ?? Path.GetTempPath(), + $"ws-info-{Guid.NewGuid():N}.json" + ); Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, contents); return path; @@ -23,19 +27,22 @@ private string WriteWsInfo(string contents) [TestMethod] public async Task ConnectAsync_WhenWsInfoNamesThePort_UsesIt() { - await using FakeWaveLinkServer server = FakeWaveLinkServer.Start( - request => FakeWaveLinkServer.ApplicationInfoReply(IdOf(request))); + await using FakeWaveLinkServer server = FakeWaveLinkServer.Start(request => + FakeWaveLinkServer.ApplicationInfoReply(IdOf(request)) + ); // The scan range is set past the server's port, so connecting can only succeed by way of // the file. Otherwise this test would pass even if the file were ignored entirely. - await using WaveLinkClient client = new(new WaveLinkClientOptions - { - WsInfoFilePathOverride = WriteWsInfo($$$"""{"port":{{{server.Port}}}}"""), - MinPort = 1, - MaxPort = 1, - ConnectTimeout = TimeSpan.FromSeconds(10), - RequestTimeout = TimeSpan.FromSeconds(10), - }); + await using WaveLinkClient client = new( + new WaveLinkClientOptions + { + WsInfoFilePathOverride = WriteWsInfo($$$"""{"port":{{{server.Port}}}}"""), + MinPort = 1, + MaxPort = 1, + ConnectTimeout = TimeSpan.FromSeconds(10), + RequestTimeout = TimeSpan.FromSeconds(10), + } + ); await client.ConnectAsync(TestContext.CancellationTokenSource.Token); @@ -45,19 +52,22 @@ public async Task ConnectAsync_WhenWsInfoNamesThePort_UsesIt() [TestMethod] public async Task ConnectAsync_WhenWsInfoIsUnusable_FallsBackToScanning() { - await using FakeWaveLinkServer server = FakeWaveLinkServer.Start( - request => FakeWaveLinkServer.ApplicationInfoReply(IdOf(request))); - - await using WaveLinkClient client = new(new WaveLinkClientOptions - { - // Wave Link leaves this file behind after it exits, so a stale or truncated one is the - // normal case rather than an exotic one. It must not stop discovery. - WsInfoFilePathOverride = WriteWsInfo("{ this is not valid json"), - MinPort = server.Port, - MaxPort = server.Port, - ConnectTimeout = TimeSpan.FromSeconds(10), - RequestTimeout = TimeSpan.FromSeconds(10), - }); + await using FakeWaveLinkServer server = FakeWaveLinkServer.Start(request => + FakeWaveLinkServer.ApplicationInfoReply(IdOf(request)) + ); + + await using WaveLinkClient client = new( + new WaveLinkClientOptions + { + // Wave Link leaves this file behind after it exits, so a stale or truncated one is the + // normal case rather than an exotic one. It must not stop discovery. + WsInfoFilePathOverride = WriteWsInfo("{ this is not valid json"), + MinPort = server.Port, + MaxPort = server.Port, + ConnectTimeout = TimeSpan.FromSeconds(10), + RequestTimeout = TimeSpan.FromSeconds(10), + } + ); await client.ConnectAsync(TestContext.CancellationTokenSource.Token); @@ -67,17 +77,23 @@ public async Task ConnectAsync_WhenWsInfoIsUnusable_FallsBackToScanning() [TestMethod] public async Task ConnectAsync_WhenWsInfoIsMissing_FallsBackToScanning() { - await using FakeWaveLinkServer server = FakeWaveLinkServer.Start( - request => FakeWaveLinkServer.ApplicationInfoReply(IdOf(request))); - - await using WaveLinkClient client = new(new WaveLinkClientOptions - { - WsInfoFilePathOverride = Path.Combine(Path.GetTempPath(), $"absent-{Guid.NewGuid():N}.json"), - MinPort = server.Port, - MaxPort = server.Port, - ConnectTimeout = TimeSpan.FromSeconds(10), - RequestTimeout = TimeSpan.FromSeconds(10), - }); + await using FakeWaveLinkServer server = FakeWaveLinkServer.Start(request => + FakeWaveLinkServer.ApplicationInfoReply(IdOf(request)) + ); + + await using WaveLinkClient client = new( + new WaveLinkClientOptions + { + WsInfoFilePathOverride = Path.Combine( + Path.GetTempPath(), + $"absent-{Guid.NewGuid():N}.json" + ), + MinPort = server.Port, + MaxPort = server.Port, + ConnectTimeout = TimeSpan.FromSeconds(10), + RequestTimeout = TimeSpan.FromSeconds(10), + } + ); await client.ConnectAsync(TestContext.CancellationTokenSource.Token); @@ -88,22 +104,32 @@ public async Task ConnectAsync_WhenWsInfoIsMissing_FallsBackToScanning() public async Task ConnectAsync_WhenNothingAnswersInTheRange_ReportsTheRangeItTried() { // Bind a port and immediately release it, so the range is real but certainly empty. - await using FakeWaveLinkServer server = FakeWaveLinkServer.Start( - request => FakeWaveLinkServer.ApplicationInfoReply(IdOf(request))); + await using FakeWaveLinkServer server = FakeWaveLinkServer.Start(request => + FakeWaveLinkServer.ApplicationInfoReply(IdOf(request)) + ); int emptyPort = server.Port; await server.DisposeAsync(); - await using WaveLinkClient client = new(new WaveLinkClientOptions - { - WsInfoFilePathOverride = Path.Combine(Path.GetTempPath(), $"absent-{Guid.NewGuid():N}.json"), - MinPort = emptyPort, - MaxPort = emptyPort, - }); - - WaveLinkException ex = await Assert.ThrowsAsync( - () => client.ConnectAsync(TestContext.CancellationTokenSource.Token)); - - Assert.IsTrue(ex.Message.Contains($"{emptyPort}-{emptyPort}", StringComparison.Ordinal), ex.Message); + await using WaveLinkClient client = new( + new WaveLinkClientOptions + { + WsInfoFilePathOverride = Path.Combine( + Path.GetTempPath(), + $"absent-{Guid.NewGuid():N}.json" + ), + MinPort = emptyPort, + MaxPort = emptyPort, + } + ); + + WaveLinkException ex = await Assert.ThrowsAsync(() => + client.ConnectAsync(TestContext.CancellationTokenSource.Token) + ); + + Assert.IsTrue( + ex.Message.Contains($"{emptyPort}-{emptyPort}", StringComparison.Ordinal), + ex.Message + ); } [TestMethod] diff --git a/tests/WaveLink.Client.Tests/WaveLink.Client.Tests.csproj b/tests/WaveLink.Client.Tests/WaveLink.Client.Tests.csproj index 1a782d7..bae8811 100644 --- a/tests/WaveLink.Client.Tests/WaveLink.Client.Tests.csproj +++ b/tests/WaveLink.Client.Tests/WaveLink.Client.Tests.csproj @@ -1,27 +1,25 @@  - - - net9.0;net10.0;net11.0 - preview - enable - enable - false - Exe - true - false - - $(NoWarn);IDE0005;CA1707 - - - - - - - - - + $(NoWarn);IDE0005;CA1707 + + + + + + + + diff --git a/tests/WaveLink.Client.Tests/WaveLinkClientTests.cs b/tests/WaveLink.Client.Tests/WaveLinkClientTests.cs index 37c25a6..93e4996 100644 --- a/tests/WaveLink.Client.Tests/WaveLinkClientTests.cs +++ b/tests/WaveLink.Client.Tests/WaveLinkClientTests.cs @@ -9,23 +9,27 @@ namespace WaveLink.Client.Tests; [TestClass] public sealed class WaveLinkClientTests { - private static WaveLinkClientOptions OptionsFor(FakeWaveLinkServer server) => new() - { - PortOverride = server.Port, - ConnectTimeout = TimeSpan.FromSeconds(10), - RequestTimeout = TimeSpan.FromSeconds(10), - }; + private static WaveLinkClientOptions OptionsFor(FakeWaveLinkServer server) => + new() + { + PortOverride = server.Port, + ConnectTimeout = TimeSpan.FromSeconds(10), + RequestTimeout = TimeSpan.FromSeconds(10), + }; private static string MethodOf(JsonElement request) => - request.TryGetProperty("method", out JsonElement method) ? method.GetString() ?? string.Empty : string.Empty; + request.TryGetProperty("method", out JsonElement method) + ? method.GetString() ?? string.Empty + : string.Empty; private static int IdOf(JsonElement request) => request.GetProperty("id").GetInt32(); [TestMethod] public async Task ConnectAsync_WhenServerCompletesHandshake_ExposesApplicationInfo() { - await using FakeWaveLinkServer server = FakeWaveLinkServer.Start( - request => FakeWaveLinkServer.ApplicationInfoReply(IdOf(request))); + await using FakeWaveLinkServer server = FakeWaveLinkServer.Start(request => + FakeWaveLinkServer.ApplicationInfoReply(IdOf(request)) + ); await using WaveLinkClient client = new(OptionsFor(server)); await client.ConnectAsync(TestContext.CancellationTokenSource.Token); @@ -40,12 +44,14 @@ public async Task ConnectAsync_WhenServerIsNotWaveLink_Fails() { // Something else on the port answering JSON-RPC must not be mistaken for Wave Link: the // client scans a port range, so it will meet unrelated servers. - await using FakeWaveLinkServer server = FakeWaveLinkServer.Start( - request => FakeWaveLinkServer.ApplicationInfoReply(IdOf(request), appId: "SOMETHING-ELSE")); + await using FakeWaveLinkServer server = FakeWaveLinkServer.Start(request => + FakeWaveLinkServer.ApplicationInfoReply(IdOf(request), appId: "SOMETHING-ELSE") + ); await using WaveLinkClient client = new(OptionsFor(server)); - WaveLinkException ex = await Assert.ThrowsAsync( - () => client.ConnectAsync(TestContext.CancellationTokenSource.Token)); + WaveLinkException ex = await Assert.ThrowsAsync(() => + client.ConnectAsync(TestContext.CancellationTokenSource.Token) + ); Assert.IsTrue(ex.Message.Contains("SOMETHING-ELSE", StringComparison.Ordinal), ex.Message); } @@ -53,32 +59,41 @@ public async Task ConnectAsync_WhenServerIsNotWaveLink_Fails() [TestMethod] public async Task ConnectAsync_WhenInterfaceRevisionIsUnsupported_Fails() { - await using FakeWaveLinkServer server = FakeWaveLinkServer.Start( - request => FakeWaveLinkServer.ApplicationInfoReply(IdOf(request), interfaceRevision: 0)); + await using FakeWaveLinkServer server = FakeWaveLinkServer.Start(request => + FakeWaveLinkServer.ApplicationInfoReply(IdOf(request), interfaceRevision: 0) + ); await using WaveLinkClient client = new(OptionsFor(server)); - WaveLinkException ex = await Assert.ThrowsAsync( - () => client.ConnectAsync(TestContext.CancellationTokenSource.Token)); + WaveLinkException ex = await Assert.ThrowsAsync(() => + client.ConnectAsync(TestContext.CancellationTokenSource.Token) + ); - Assert.IsTrue(ex.Message.Contains("interfaceRevision", StringComparison.Ordinal), ex.Message); + Assert.IsTrue( + ex.Message.Contains("interfaceRevision", StringComparison.Ordinal), + ex.Message + ); } [TestMethod] public async Task ConnectAsync_WhenNothingIsListening_Fails() { - await using FakeWaveLinkServer server = FakeWaveLinkServer.Start( - request => FakeWaveLinkServer.ApplicationInfoReply(IdOf(request))); + await using FakeWaveLinkServer server = FakeWaveLinkServer.Start(request => + FakeWaveLinkServer.ApplicationInfoReply(IdOf(request)) + ); int deadPort = server.Port; await server.DisposeAsync(); - await using WaveLinkClient client = new(new WaveLinkClientOptions - { - PortOverride = deadPort, - ConnectTimeout = TimeSpan.FromSeconds(2), - }); + await using WaveLinkClient client = new( + new WaveLinkClientOptions + { + PortOverride = deadPort, + ConnectTimeout = TimeSpan.FromSeconds(2), + } + ); - _ = await Assert.ThrowsAsync( - () => client.ConnectAsync(TestContext.CancellationTokenSource.Token)); + _ = await Assert.ThrowsAsync(() => + client.ConnectAsync(TestContext.CancellationTokenSource.Token) + ); } [TestMethod] @@ -87,7 +102,9 @@ public async Task CallAsync_WhenServerRepliesOutOfOrder_MatchesEachResultToItsRe // The client multiplexes over one socket and correlates by id. Answering the second request // first is the case a naive "next reply wins" implementation gets wrong. List<(int Id, string Method)> seen = []; - TaskCompletionSource deferred = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource deferred = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); await using FakeWaveLinkServer server = FakeWaveLinkServer.Start(request => { @@ -114,9 +131,11 @@ string HandleInputDevices(int id) int callNumber = seen.Count(entry => entry.Method == "getInputDevices"); if (callNumber == 1) { - deferred.SetResult($$$""" + deferred.SetResult( + $$$""" {"jsonrpc":"2.0","id":{{{id}}},"result":{"inputDevices":[{"id":"first","inputs":[]}]}} - """); + """ + ); return string.Empty; } } @@ -127,8 +146,12 @@ string HandleInputDevices(int id) await using WaveLinkClient client = new(OptionsFor(server)); await client.ConnectAsync(TestContext.CancellationTokenSource.Token); - Task first = client.GetInputDevicesAsync(TestContext.CancellationTokenSource.Token); - InputDevicesResult second = await client.GetInputDevicesAsync(TestContext.CancellationTokenSource.Token); + Task first = client.GetInputDevicesAsync( + TestContext.CancellationTokenSource.Token + ); + InputDevicesResult second = await client.GetInputDevicesAsync( + TestContext.CancellationTokenSource.Token + ); Assert.AreEqual("second", second.InputDevices[0].Id); @@ -151,8 +174,9 @@ public async Task CallAsync_WhenServerReturnsAnError_ThrowsWithCodeAndMessage() await using WaveLinkClient client = new(OptionsFor(server)); await client.ConnectAsync(TestContext.CancellationTokenSource.Token); - WaveLinkRpcException ex = await Assert.ThrowsAsync( - () => client.GetInputDevicesAsync(TestContext.CancellationTokenSource.Token)); + WaveLinkRpcException ex = await Assert.ThrowsAsync(() => + client.GetInputDevicesAsync(TestContext.CancellationTokenSource.Token) + ); Assert.AreEqual(-32602, ex.Code); Assert.AreEqual("Invalid params", ex.Message); @@ -162,20 +186,26 @@ public async Task CallAsync_WhenServerReturnsAnError_ThrowsWithCodeAndMessage() public async Task CallAsync_WhenServerNeverReplies_GivesUpAfterTheRequestTimeout() { await using FakeWaveLinkServer server = FakeWaveLinkServer.Start(request => - MethodOf(request) == "getApplicationInfo" ? FakeWaveLinkServer.ApplicationInfoReply(IdOf(request)) : null); + MethodOf(request) == "getApplicationInfo" + ? FakeWaveLinkServer.ApplicationInfoReply(IdOf(request)) + : null + ); - await using WaveLinkClient client = new(new WaveLinkClientOptions - { - PortOverride = server.Port, - ConnectTimeout = TimeSpan.FromSeconds(10), - RequestTimeout = TimeSpan.FromMilliseconds(250), - }); + await using WaveLinkClient client = new( + new WaveLinkClientOptions + { + PortOverride = server.Port, + ConnectTimeout = TimeSpan.FromSeconds(10), + RequestTimeout = TimeSpan.FromMilliseconds(250), + } + ); await client.ConnectAsync(TestContext.CancellationTokenSource.Token); // A silent server must not hang the caller forever; the per-call timeout is the only thing // standing between that and a deadlocked application. - _ = await Assert.ThrowsAsync( - () => client.GetInputDevicesAsync(TestContext.CancellationTokenSource.Token)); + _ = await Assert.ThrowsAsync(() => + client.GetInputDevicesAsync(TestContext.CancellationTokenSource.Token) + ); } [TestMethod] @@ -183,29 +213,41 @@ public async Task CallAsync_BeforeConnect_Fails() { await using WaveLinkClient client = new(new WaveLinkClientOptions { PortOverride = 1 }); - WaveLinkException ex = await Assert.ThrowsAsync( - () => client.GetInputDevicesAsync(TestContext.CancellationTokenSource.Token)); + WaveLinkException ex = await Assert.ThrowsAsync(() => + client.GetInputDevicesAsync(TestContext.CancellationTokenSource.Token) + ); - Assert.IsTrue(ex.Message.Contains("not connected", StringComparison.OrdinalIgnoreCase), ex.Message); + Assert.IsTrue( + ex.Message.Contains("not connected", StringComparison.OrdinalIgnoreCase), + ex.Message + ); } [TestMethod] public async Task Notification_WhenServerPushesLevelMeters_RaisesTheEvent() { - await using FakeWaveLinkServer server = FakeWaveLinkServer.Start( - request => FakeWaveLinkServer.ApplicationInfoReply(IdOf(request))); + await using FakeWaveLinkServer server = FakeWaveLinkServer.Start(request => + FakeWaveLinkServer.ApplicationInfoReply(IdOf(request)) + ); await using WaveLinkClient client = new(OptionsFor(server)); - TaskCompletionSource raised = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource raised = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); client.LevelMeterChanged += (_, e) => raised.TrySetResult(e); await client.ConnectAsync(TestContext.CancellationTokenSource.Token); - await server.PushAsync(""" + await server.PushAsync( + """ {"jsonrpc":"2.0","method":"levelMeterChanged","params":{"inputDevices":[ {"id":"in-1","levelLeftPercentage":42.5,"levelRightPercentage":43.5}]}} - """); + """ + ); - LevelMeterChangedParams received = await raised.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.CancellationTokenSource.Token); + LevelMeterChangedParams received = await raised.Task.WaitAsync( + TimeSpan.FromSeconds(10), + TestContext.CancellationTokenSource.Token + ); Assert.IsNotNull(received.InputDevices); Assert.AreEqual("in-1", received.InputDevices[0].Id); Assert.AreEqual(42.5, received.InputDevices[0].LevelLeftPercentage); @@ -214,19 +256,27 @@ await server.PushAsync(""" [TestMethod] public async Task Notification_WhenServerPushesInputDevices_UpdatesTheCachedState() { - await using FakeWaveLinkServer server = FakeWaveLinkServer.Start( - request => FakeWaveLinkServer.ApplicationInfoReply(IdOf(request))); + await using FakeWaveLinkServer server = FakeWaveLinkServer.Start(request => + FakeWaveLinkServer.ApplicationInfoReply(IdOf(request)) + ); await using WaveLinkClient client = new(OptionsFor(server)); - TaskCompletionSource> raised = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource> raised = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); client.InputDevicesChanged += (_, e) => raised.TrySetResult(e); await client.ConnectAsync(TestContext.CancellationTokenSource.Token); - await server.PushAsync(""" + await server.PushAsync( + """ {"jsonrpc":"2.0","method":"inputDevicesChanged","params":{"inputDevices":[{"id":"dev-9","inputs":[]}]}} - """); + """ + ); - IReadOnlyList devices = await raised.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.CancellationTokenSource.Token); + IReadOnlyList devices = await raised.Task.WaitAsync( + TimeSpan.FromSeconds(10), + TestContext.CancellationTokenSource.Token + ); Assert.AreEqual(1, devices.Count); Assert.AreEqual("dev-9", devices[0].Id); } @@ -234,23 +284,31 @@ await server.PushAsync(""" [TestMethod] public async Task Notification_WhenMessageIsMalformed_DoesNotTearDownTheConnection() { - await using FakeWaveLinkServer server = FakeWaveLinkServer.Start( - request => FakeWaveLinkServer.ApplicationInfoReply(IdOf(request))); + await using FakeWaveLinkServer server = FakeWaveLinkServer.Start(request => + FakeWaveLinkServer.ApplicationInfoReply(IdOf(request)) + ); await using WaveLinkClient client = new(OptionsFor(server)); await client.ConnectAsync(TestContext.CancellationTokenSource.Token); await server.PushAsync("this is not json"); - TaskCompletionSource raised = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource raised = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); client.LevelMeterChanged += (_, e) => raised.TrySetResult(e); // The receive loop has to survive garbage on the wire, or one bad frame silently ends every // subsequent notification for the life of the process. - await server.PushAsync(""" + await server.PushAsync( + """ {"jsonrpc":"2.0","method":"levelMeterChanged","params":{"inputDevices":[{"id":"still-alive"}]}} - """); + """ + ); - LevelMeterChangedParams received = await raised.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.CancellationTokenSource.Token); + LevelMeterChangedParams received = await raised.Task.WaitAsync( + TimeSpan.FromSeconds(10), + TestContext.CancellationTokenSource.Token + ); Assert.IsNotNull(received.InputDevices); Assert.AreEqual("still-alive", received.InputDevices[0].Id); } @@ -258,8 +316,9 @@ await server.PushAsync(""" [TestMethod] public async Task ConnectAsync_WhenCalledTwice_IsANoOp() { - await using FakeWaveLinkServer server = FakeWaveLinkServer.Start( - request => FakeWaveLinkServer.ApplicationInfoReply(IdOf(request))); + await using FakeWaveLinkServer server = FakeWaveLinkServer.Start(request => + FakeWaveLinkServer.ApplicationInfoReply(IdOf(request)) + ); await using WaveLinkClient client = new(OptionsFor(server)); await client.ConnectAsync(TestContext.CancellationTokenSource.Token); diff --git a/tests/WaveLink.Client.Tests/WaveLinkJsonContextTests.cs b/tests/WaveLink.Client.Tests/WaveLinkJsonContextTests.cs index 6a088c7..c97fabc 100644 --- a/tests/WaveLink.Client.Tests/WaveLinkJsonContextTests.cs +++ b/tests/WaveLink.Client.Tests/WaveLinkJsonContextTests.cs @@ -41,7 +41,10 @@ public void Response_WhenServerReturnsAnError_RoundTripsCodeAndMessage() {"jsonrpc":"2.0","id":4,"error":{"code":-32601,"message":"Method not found"}} """; - JsonRpcResponse? response = JsonSerializer.Deserialize(payload, WaveLinkJsonContext.Default.JsonRpcResponse); + JsonRpcResponse? response = JsonSerializer.Deserialize( + payload, + WaveLinkJsonContext.Default.JsonRpcResponse + ); Assert.IsNotNull(response); Assert.AreEqual(4, response.Id); @@ -58,7 +61,10 @@ public void Notification_WhenDeserialized_ExposesMethodAndRawParams() {"jsonrpc":"2.0","method":"focusedAppChanged","params":{"appID":"obs64.exe"}} """; - JsonRpcNotification? notification = JsonSerializer.Deserialize(payload, WaveLinkJsonContext.Default.JsonRpcNotification); + JsonRpcNotification? notification = JsonSerializer.Deserialize( + payload, + WaveLinkJsonContext.Default.JsonRpcNotification + ); Assert.IsNotNull(notification); Assert.AreEqual("focusedAppChanged", notification.Method); @@ -74,7 +80,10 @@ public void InputDevices_WhenDeserialized_MapsNestedInputsAndGain() "inputs":[{"id":"in-1","name":"Mic","isMuted":false,"gain":{"value":0.75}}]}]} """; - InputDevicesResult? result = JsonSerializer.Deserialize(payload, WaveLinkJsonContext.Default.InputDevicesResult); + InputDevicesResult? result = JsonSerializer.Deserialize( + payload, + WaveLinkJsonContext.Default.InputDevicesResult + ); Assert.IsNotNull(result); Assert.AreEqual(1, result.InputDevices.Count); @@ -95,7 +104,10 @@ public void UnknownServerProperties_WhenDeserialized_AreKeptAsExtensionData() {"appID":"wavelink","interfaceRevision":3,"someFutureField":42} """; - ApplicationInfo? info = JsonSerializer.Deserialize(payload, WaveLinkJsonContext.Default.ApplicationInfo); + ApplicationInfo? info = JsonSerializer.Deserialize( + payload, + WaveLinkJsonContext.Default.ApplicationInfo + ); Assert.IsNotNull(info); Assert.AreEqual("wavelink", info.AppId); @@ -113,7 +125,10 @@ public void SetInputDeviceParams_WhenSerialized_OmitsUnchangedFields() Inputs = [new SetInputParams { Id = "in-1", IsMuted = true }], }; - string json = JsonSerializer.Serialize(parameters, WaveLinkJsonContext.Default.SetInputDeviceParams); + string json = JsonSerializer.Serialize( + parameters, + WaveLinkJsonContext.Default.SetInputDeviceParams + ); // Only the field being changed is sent. Sending nulls for the rest would ask the server to // reinterpret every other property of the input on each call. @@ -127,7 +142,10 @@ public void SubscriptionResult_WhenDeserialized_ReportsPerTopicAcknowledgement() {"levelMeterChanged":{"isEnabled":true,"type":"input","id":"in-1"}} """; - SetSubscriptionResult? result = JsonSerializer.Deserialize(payload, WaveLinkJsonContext.Default.SetSubscriptionResult); + SetSubscriptionResult? result = JsonSerializer.Deserialize( + payload, + WaveLinkJsonContext.Default.SetSubscriptionResult + ); Assert.IsNotNull(result); Assert.IsNull(result.FocusedAppChanged); From 12a93f2debab53e551c95de30cfe5883011893b5 Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 23 Sep 2026 11:05:31 +0200 Subject: [PATCH 2/3] build: move to the .NET 11 RC1 SDK --- .github/workflows/build.yml | 2 +- global.json | 2 +- samples/WaveLink.Console/WaveLink.Console.csproj | 7 ++----- tests/WaveLink.Client.Tests/WaveLink.Client.Tests.csproj | 7 +++---- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6461076..6a32a2d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,7 +44,7 @@ jobs: dotnet-version: | 9.0.x 10.0.x - 11.0.100-preview.7.26381.103 + 11.0.100-rc.1.26425.128 - name: Restore dependencies run: dotnet restore WaveLink.Client.slnx diff --git a/global.json b/global.json index 6827f37..0f28551 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "11.0.100-preview.7.26381.103", + "version": "11.0.100-rc.1.26425.128", "allowPrerelease": true, "rollForward": "latestPreview" }, diff --git a/samples/WaveLink.Console/WaveLink.Console.csproj b/samples/WaveLink.Console/WaveLink.Console.csproj index 818d48e..0f40b4e 100644 --- a/samples/WaveLink.Console/WaveLink.Console.csproj +++ b/samples/WaveLink.Console/WaveLink.Console.csproj @@ -1,11 +1,8 @@ Exe - net10.0 - - 10.0.11 + net11.0 + true preview enable enable diff --git a/tests/WaveLink.Client.Tests/WaveLink.Client.Tests.csproj b/tests/WaveLink.Client.Tests/WaveLink.Client.Tests.csproj index bae8811..8798084 100644 --- a/tests/WaveLink.Client.Tests/WaveLink.Client.Tests.csproj +++ b/tests/WaveLink.Client.Tests/WaveLink.Client.Tests.csproj @@ -7,12 +7,11 @@ false Exe true - false - - $(NoWarn);IDE0005;CA1707 + $(NoWarn);CA1707;CS1591 From 149ae3e0e6d45100780927da1554fbb6ef75e22e Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 23 Sep 2026 11:27:18 +0200 Subject: [PATCH 3/3] ci: pass test targets to dotnet test explicitly --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6a32a2d..51589a2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -62,7 +62,7 @@ jobs: - name: Run tests run: > - dotnet test WaveLink.Client.slnx + dotnet test --solution WaveLink.Client.slnx --configuration Release --no-build -- --coverage --coverage-settings coverage.settings.xml --coverage-output-format cobertura --coverage-output coverage.cobertura.xml