From 81cdc027e123ddc0d71acb02755f2331135b5aea Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 23 Sep 2026 06:41:53 +0200 Subject: [PATCH 1/2] build(codegen): write one newline per generated file --- ObsWebSocket.Codegen.Tasks/ProtocolCodegenRunner.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ObsWebSocket.Codegen.Tasks/ProtocolCodegenRunner.cs b/ObsWebSocket.Codegen.Tasks/ProtocolCodegenRunner.cs index a98673a..697f792 100644 --- a/ObsWebSocket.Codegen.Tasks/ProtocolCodegenRunner.cs +++ b/ObsWebSocket.Codegen.Tasks/ProtocolCodegenRunner.cs @@ -207,7 +207,17 @@ string existingFile in Directory.GetFiles( string normalizedRelativePath = NormalizeRelativePath(relativePath); string outputPath = Path.Combine(outputDirectory, normalizedRelativePath); _ = Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); - File.WriteAllText(outputPath, source, new UTF8Encoding(false)); + // One newline per file. StringBuilder.AppendLine writes the OS newline while protocol + // descriptions carry a bare "\n", which left mixed endings that showed as a change on + // every regeneration. The OS newline is what git checks out on that OS, and git stores + // LF either way. + File.WriteAllText( + outputPath, + source + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace("\n", Environment.NewLine, StringComparison.Ordinal), + new UTF8Encoding(false) + ); } } From 3ff3072d0f7cfd3f4d61567e9586f63dcbbec386 Mon Sep 17 00:00:00 2001 From: Agash Date: Wed, 23 Sep 2026 06:41:54 +0200 Subject: [PATCH 2/2] feat(core): typed access to free-form protocol fields --- .../Generation/Emitter.FreeFormFields.cs | 214 +++++++++++ .../Generation/ProtocolCodeGenerator.cs | 1 + .../ObsWebSocketClient.FreeFormFields.g.cs | 355 ++++++++++++++++++ ObsWebSocket.Core/Groups/ConfigGroup.cs | 91 ++++- ObsWebSocket.Core/Groups/FiltersGroup.cs | 34 +- ObsWebSocket.Core/Groups/GeneralGroup.cs | 83 ++++ ObsWebSocket.Core/Groups/InputsGroup.cs | 34 +- ObsWebSocket.Core/Groups/OutputsGroup.cs | 6 +- ObsWebSocket.Core/Groups/TransitionsGroup.cs | 6 +- .../ObsWebSocketClientOperations.cs | 31 ++ ObsWebSocket.Tests/FreeFormFieldTests.cs | 311 +++++++++++++++ ObsWebSocket.Tests/GroupHelperFailureTests.cs | 17 +- .../Integration/TypedSettingsHelperTests.cs | 37 ++ ObsWebSocket.Tests/ReadmeCompileCheck.cs | 13 + README.md | 35 +- 15 files changed, 1212 insertions(+), 56 deletions(-) create mode 100644 ObsWebSocket.Codegen.Tasks/Generation/Emitter.FreeFormFields.cs create mode 100644 ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.FreeFormFields.g.cs create mode 100644 ObsWebSocket.Tests/FreeFormFieldTests.cs diff --git a/ObsWebSocket.Codegen.Tasks/Generation/Emitter.FreeFormFields.cs b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.FreeFormFields.cs new file mode 100644 index 0000000..b0feb98 --- /dev/null +++ b/ObsWebSocket.Codegen.Tasks/Generation/Emitter.FreeFormFields.cs @@ -0,0 +1,214 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace ObsWebSocket.Codegen.Tasks.Generation; + +internal static partial class Emitter +{ + private const string FreeFormType = "System.Text.Json.JsonElement?"; + + /// + /// Emits typed readers for every field the protocol declares as Object or Any, + /// on every event payload and response that carries one. + /// + /// + /// Those fields are free-form by definition: an input's settings depend on its kind, a vendor's + /// response on the vendor. The property stays a JsonElement, and these readers are what + /// turns it into the caller's own type, the same way the group helpers do for requests. + /// + public static void GenerateFreeFormFieldReaders( + SourceProductionContext context, + ProtocolDefinition protocol + ) + { + StringBuilder builder = new(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine("using System;"); + builder.AppendLine("using System.Text.Json;"); + builder.AppendLine("using System.Text.Json.Serialization.Metadata;"); + builder.AppendLine(); + builder.AppendLine($"namespace {ExtensionsNamespace};"); + builder.AppendLine(); + builder.AppendLine("/// "); + builder.AppendLine( + "/// Typed readers for the free-form fields events and responses carry, such as an" + ); + builder.AppendLine("/// input's settings or a vendor's response data."); + builder.AppendLine("/// "); + AppendGeneratedAttributes(builder); + builder.AppendLine("public static class ObsWebSocketFreeFormFields"); + builder.AppendLine("{"); + + int emitted = 0; + + if (protocol.Events is not null) + { + foreach ( + OBSEvent definition in protocol.Events.OrderBy( + e => e.EventType, + StringComparer.Ordinal + ) + ) + { + string name = $"{SanitizeIdentifier(definition.EventType)}Payload"; + emitted += EmitFreeFormReaders( + context, + builder, + $"{GeneratedEventsNamespace}.{name}", + name, + "payload", + definition.DataFields + ); + } + } + + if (protocol.Requests is not null) + { + foreach ( + RequestDefinition request in protocol.Requests.OrderBy( + r => r.RequestType, + StringComparer.Ordinal + ) + ) + { + string name = $"{SanitizeIdentifier(request.RequestType)}ResponseData"; + emitted += EmitFreeFormReaders( + context, + builder, + $"{GeneratedResponsesNamespace}.{name}", + name, + "response", + request.ResponseFields + ); + } + } + + builder.AppendLine( + " private static T? Read(JsonElement? element, JsonTypeInfo typeInfo, string field, string owner)" + ); + builder.AppendLine(" {"); + builder.AppendLine(" ArgumentNullException.ThrowIfNull(typeInfo);"); + builder.AppendLine( + " if (element is not { } value || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)" + ); + builder.AppendLine(" {"); + builder.AppendLine(" return default;"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" try"); + builder.AppendLine(" {"); + builder.AppendLine(" return value.Deserialize(typeInfo);"); + builder.AppendLine(" }"); + builder.AppendLine( + " catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidOperationException)" + ); + builder.AppendLine(" {"); + builder.AppendLine(" throw new ObsWebSocketSerializationException("); + builder.AppendLine( + " $\"Could not read '{field}' of {owner} as {typeof(T).Name}.\"," + ); + builder.AppendLine(" ex"); + builder.AppendLine(" );"); + builder.AppendLine(" }"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + builder.AppendLine(); + builder.AppendLine($"// Free-form fields with typed readers: {emitted}"); + + context.AddSource( + "ObsWebSocketClient.FreeFormFields.g.cs", + SourceText.From(builder.ToString(), Encoding.UTF8) + ); + } + + /// + /// Emits one extension block for a payload, with a pair of readers per free-form field. + /// + private static int EmitFreeFormReaders( + SourceProductionContext context, + StringBuilder builder, + string payloadType, + string payloadName, + string parameterName, + IReadOnlyList? fields + ) + { + if (fields is null || fields.Count == 0) + { + return 0; + } + + List freeForm = + [ + .. fields.Where(field => + !field.ValueName.Contains('.') + && MapProtocolTypeToCSharp( + context, + field, + payloadName, + reportDiagnostics: false + ).CSharpType == FreeFormType + ), + ]; + + if (freeForm.Count == 0) + { + return 0; + } + + builder.AppendLine($" extension({payloadType} {parameterName})"); + builder.AppendLine(" {"); + foreach (FieldDefinition field in freeForm) + { + string wire = field.ValueName; + string property = SanitizeIdentifier(ToPascalCase(wire)); + string reader = $"Get{property}"; + string read = $"Read({parameterName}.{property}, {{0}}, \"{wire}\", \"{payloadName}\")"; + + builder.AppendLine( + $" /// Reads {wire} as , using metadata the caller supplies." + ); + builder.AppendLine( + " /// The shape to read the field as." + ); + builder.AppendLine( + " /// Metadata for , typically from your own JsonSerializerContext." + ); + builder.AppendLine( + " /// The value, or when OBS sent none." + ); + builder.AppendLine( + " /// Thrown when the field does not have that shape." + ); + builder.AppendLine( + $" public T? {reader}(JsonTypeInfo typeInfo) => {string.Format(System.Globalization.CultureInfo.InvariantCulture, read, "typeInfo")};" + ); + builder.AppendLine(); + builder.AppendLine( + $" /// Reads {wire} as a settings type this library registers." + ); + builder.AppendLine( + " /// A library-registered settings type." + ); + builder.AppendLine( + " /// The value, or when OBS sent none." + ); + builder.AppendLine( + " /// Thrown when is not registered, or the field does not have that shape." + ); + builder.AppendLine($" public T? {reader}()"); + builder.AppendLine(" where T : class =>"); + builder.AppendLine( + $" {string.Format(System.Globalization.CultureInfo.InvariantCulture, read, "ObsWebSocketClientOperations.GetRegisteredTypeInfo()")};" + ); + builder.AppendLine(); + } + + builder.AppendLine(" }"); + builder.AppendLine(); + return freeForm.Count; + } +} diff --git a/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs b/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs index 4b636c1..6f4a347 100644 --- a/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs +++ b/ObsWebSocket.Codegen.Tasks/Generation/ProtocolCodeGenerator.cs @@ -45,6 +45,7 @@ IReadOnlyList Diagnostics Emitter.GenerateClientExtensions(context, protocol); Emitter.GenerateHandleOverloads(context, protocol); Emitter.GeneratePayloadHandles(context, protocol); + Emitter.GenerateFreeFormFieldReaders(context, protocol); Emitter.GenerateEventPayloads(context, protocol); Emitter.GenerateEventArgs(context, protocol); Emitter.GenerateClientEventInfrastructure(context, protocol); diff --git a/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.FreeFormFields.g.cs b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.FreeFormFields.g.cs new file mode 100644 index 0000000..785d6a3 --- /dev/null +++ b/ObsWebSocket.Core/Generated/Client/ObsWebSocketClient.FreeFormFields.g.cs @@ -0,0 +1,355 @@ +// +#nullable enable + +using System; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace ObsWebSocket.Core; + +/// +/// Typed readers for the free-form fields events and responses carry, such as an +/// input's settings or a vendor's response data. +/// +[global::System.CodeDom.Compiler.GeneratedCode("ObsWebSocket.Codegen", "1.0.0.0")] +[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] +public static class ObsWebSocketFreeFormFields +{ + extension(ObsWebSocket.Core.Protocol.Events.CustomEventPayload payload) + { + /// Reads eventData as , using metadata the caller supplies. + /// The shape to read the field as. + /// Metadata for , typically from your own JsonSerializerContext. + /// The value, or when OBS sent none. + /// Thrown when the field does not have that shape. + public T? GetEventData(JsonTypeInfo typeInfo) => Read(payload.EventData, typeInfo, "eventData", "CustomEventPayload"); + + /// Reads eventData as a settings type this library registers. + /// A library-registered settings type. + /// The value, or when OBS sent none. + /// Thrown when is not registered, or the field does not have that shape. + public T? GetEventData() + where T : class => + Read(payload.EventData, ObsWebSocketClientOperations.GetRegisteredTypeInfo(), "eventData", "CustomEventPayload"); + + } + + extension(ObsWebSocket.Core.Protocol.Events.InputCreatedPayload payload) + { + /// Reads inputSettings as , using metadata the caller supplies. + /// The shape to read the field as. + /// Metadata for , typically from your own JsonSerializerContext. + /// The value, or when OBS sent none. + /// Thrown when the field does not have that shape. + public T? GetInputSettings(JsonTypeInfo typeInfo) => Read(payload.InputSettings, typeInfo, "inputSettings", "InputCreatedPayload"); + + /// Reads inputSettings as a settings type this library registers. + /// A library-registered settings type. + /// The value, or when OBS sent none. + /// Thrown when is not registered, or the field does not have that shape. + public T? GetInputSettings() + where T : class => + Read(payload.InputSettings, ObsWebSocketClientOperations.GetRegisteredTypeInfo(), "inputSettings", "InputCreatedPayload"); + + /// Reads defaultInputSettings as , using metadata the caller supplies. + /// The shape to read the field as. + /// Metadata for , typically from your own JsonSerializerContext. + /// The value, or when OBS sent none. + /// Thrown when the field does not have that shape. + public T? GetDefaultInputSettings(JsonTypeInfo typeInfo) => Read(payload.DefaultInputSettings, typeInfo, "defaultInputSettings", "InputCreatedPayload"); + + /// Reads defaultInputSettings as a settings type this library registers. + /// A library-registered settings type. + /// The value, or when OBS sent none. + /// Thrown when is not registered, or the field does not have that shape. + public T? GetDefaultInputSettings() + where T : class => + Read(payload.DefaultInputSettings, ObsWebSocketClientOperations.GetRegisteredTypeInfo(), "defaultInputSettings", "InputCreatedPayload"); + + } + + extension(ObsWebSocket.Core.Protocol.Events.InputSettingsChangedPayload payload) + { + /// Reads inputSettings as , using metadata the caller supplies. + /// The shape to read the field as. + /// Metadata for , typically from your own JsonSerializerContext. + /// The value, or when OBS sent none. + /// Thrown when the field does not have that shape. + public T? GetInputSettings(JsonTypeInfo typeInfo) => Read(payload.InputSettings, typeInfo, "inputSettings", "InputSettingsChangedPayload"); + + /// Reads inputSettings as a settings type this library registers. + /// A library-registered settings type. + /// The value, or when OBS sent none. + /// Thrown when is not registered, or the field does not have that shape. + public T? GetInputSettings() + where T : class => + Read(payload.InputSettings, ObsWebSocketClientOperations.GetRegisteredTypeInfo(), "inputSettings", "InputSettingsChangedPayload"); + + } + + extension(ObsWebSocket.Core.Protocol.Events.SourceFilterCreatedPayload payload) + { + /// Reads filterSettings as , using metadata the caller supplies. + /// The shape to read the field as. + /// Metadata for , typically from your own JsonSerializerContext. + /// The value, or when OBS sent none. + /// Thrown when the field does not have that shape. + public T? GetFilterSettings(JsonTypeInfo typeInfo) => Read(payload.FilterSettings, typeInfo, "filterSettings", "SourceFilterCreatedPayload"); + + /// Reads filterSettings as a settings type this library registers. + /// A library-registered settings type. + /// The value, or when OBS sent none. + /// Thrown when is not registered, or the field does not have that shape. + public T? GetFilterSettings() + where T : class => + Read(payload.FilterSettings, ObsWebSocketClientOperations.GetRegisteredTypeInfo(), "filterSettings", "SourceFilterCreatedPayload"); + + /// Reads defaultFilterSettings as , using metadata the caller supplies. + /// The shape to read the field as. + /// Metadata for , typically from your own JsonSerializerContext. + /// The value, or when OBS sent none. + /// Thrown when the field does not have that shape. + public T? GetDefaultFilterSettings(JsonTypeInfo typeInfo) => Read(payload.DefaultFilterSettings, typeInfo, "defaultFilterSettings", "SourceFilterCreatedPayload"); + + /// Reads defaultFilterSettings as a settings type this library registers. + /// A library-registered settings type. + /// The value, or when OBS sent none. + /// Thrown when is not registered, or the field does not have that shape. + public T? GetDefaultFilterSettings() + where T : class => + Read(payload.DefaultFilterSettings, ObsWebSocketClientOperations.GetRegisteredTypeInfo(), "defaultFilterSettings", "SourceFilterCreatedPayload"); + + } + + extension(ObsWebSocket.Core.Protocol.Events.SourceFilterSettingsChangedPayload payload) + { + /// Reads filterSettings as , using metadata the caller supplies. + /// The shape to read the field as. + /// Metadata for , typically from your own JsonSerializerContext. + /// The value, or when OBS sent none. + /// Thrown when the field does not have that shape. + public T? GetFilterSettings(JsonTypeInfo typeInfo) => Read(payload.FilterSettings, typeInfo, "filterSettings", "SourceFilterSettingsChangedPayload"); + + /// Reads filterSettings as a settings type this library registers. + /// A library-registered settings type. + /// The value, or when OBS sent none. + /// Thrown when is not registered, or the field does not have that shape. + public T? GetFilterSettings() + where T : class => + Read(payload.FilterSettings, ObsWebSocketClientOperations.GetRegisteredTypeInfo(), "filterSettings", "SourceFilterSettingsChangedPayload"); + + } + + extension(ObsWebSocket.Core.Protocol.Events.VendorEventPayload payload) + { + /// Reads eventData as , using metadata the caller supplies. + /// The shape to read the field as. + /// Metadata for , typically from your own JsonSerializerContext. + /// The value, or when OBS sent none. + /// Thrown when the field does not have that shape. + public T? GetEventData(JsonTypeInfo typeInfo) => Read(payload.EventData, typeInfo, "eventData", "VendorEventPayload"); + + /// Reads eventData as a settings type this library registers. + /// A library-registered settings type. + /// The value, or when OBS sent none. + /// Thrown when is not registered, or the field does not have that shape. + public T? GetEventData() + where T : class => + Read(payload.EventData, ObsWebSocketClientOperations.GetRegisteredTypeInfo(), "eventData", "VendorEventPayload"); + + } + + extension(ObsWebSocket.Core.Protocol.Responses.CallVendorRequestResponseData response) + { + /// Reads responseData as , using metadata the caller supplies. + /// The shape to read the field as. + /// Metadata for , typically from your own JsonSerializerContext. + /// The value, or when OBS sent none. + /// Thrown when the field does not have that shape. + public T? GetResponseData(JsonTypeInfo typeInfo) => Read(response.ResponseData, typeInfo, "responseData", "CallVendorRequestResponseData"); + + /// Reads responseData as a settings type this library registers. + /// A library-registered settings type. + /// The value, or when OBS sent none. + /// Thrown when is not registered, or the field does not have that shape. + public T? GetResponseData() + where T : class => + Read(response.ResponseData, ObsWebSocketClientOperations.GetRegisteredTypeInfo(), "responseData", "CallVendorRequestResponseData"); + + } + + extension(ObsWebSocket.Core.Protocol.Responses.GetCurrentSceneTransitionResponseData response) + { + /// Reads transitionSettings as , using metadata the caller supplies. + /// The shape to read the field as. + /// Metadata for , typically from your own JsonSerializerContext. + /// The value, or when OBS sent none. + /// Thrown when the field does not have that shape. + public T? GetTransitionSettings(JsonTypeInfo typeInfo) => Read(response.TransitionSettings, typeInfo, "transitionSettings", "GetCurrentSceneTransitionResponseData"); + + /// Reads transitionSettings as a settings type this library registers. + /// A library-registered settings type. + /// The value, or when OBS sent none. + /// Thrown when is not registered, or the field does not have that shape. + public T? GetTransitionSettings() + where T : class => + Read(response.TransitionSettings, ObsWebSocketClientOperations.GetRegisteredTypeInfo(), "transitionSettings", "GetCurrentSceneTransitionResponseData"); + + } + + extension(ObsWebSocket.Core.Protocol.Responses.GetInputDefaultSettingsResponseData response) + { + /// Reads defaultInputSettings as , using metadata the caller supplies. + /// The shape to read the field as. + /// Metadata for , typically from your own JsonSerializerContext. + /// The value, or when OBS sent none. + /// Thrown when the field does not have that shape. + public T? GetDefaultInputSettings(JsonTypeInfo typeInfo) => Read(response.DefaultInputSettings, typeInfo, "defaultInputSettings", "GetInputDefaultSettingsResponseData"); + + /// Reads defaultInputSettings as a settings type this library registers. + /// A library-registered settings type. + /// The value, or when OBS sent none. + /// Thrown when is not registered, or the field does not have that shape. + public T? GetDefaultInputSettings() + where T : class => + Read(response.DefaultInputSettings, ObsWebSocketClientOperations.GetRegisteredTypeInfo(), "defaultInputSettings", "GetInputDefaultSettingsResponseData"); + + } + + extension(ObsWebSocket.Core.Protocol.Responses.GetInputSettingsResponseData response) + { + /// Reads inputSettings as , using metadata the caller supplies. + /// The shape to read the field as. + /// Metadata for , typically from your own JsonSerializerContext. + /// The value, or when OBS sent none. + /// Thrown when the field does not have that shape. + public T? GetInputSettings(JsonTypeInfo typeInfo) => Read(response.InputSettings, typeInfo, "inputSettings", "GetInputSettingsResponseData"); + + /// Reads inputSettings as a settings type this library registers. + /// A library-registered settings type. + /// The value, or when OBS sent none. + /// Thrown when is not registered, or the field does not have that shape. + public T? GetInputSettings() + where T : class => + Read(response.InputSettings, ObsWebSocketClientOperations.GetRegisteredTypeInfo(), "inputSettings", "GetInputSettingsResponseData"); + + } + + extension(ObsWebSocket.Core.Protocol.Responses.GetOutputSettingsResponseData response) + { + /// Reads outputSettings as , using metadata the caller supplies. + /// The shape to read the field as. + /// Metadata for , typically from your own JsonSerializerContext. + /// The value, or when OBS sent none. + /// Thrown when the field does not have that shape. + public T? GetOutputSettings(JsonTypeInfo typeInfo) => Read(response.OutputSettings, typeInfo, "outputSettings", "GetOutputSettingsResponseData"); + + /// Reads outputSettings as a settings type this library registers. + /// A library-registered settings type. + /// The value, or when OBS sent none. + /// Thrown when is not registered, or the field does not have that shape. + public T? GetOutputSettings() + where T : class => + Read(response.OutputSettings, ObsWebSocketClientOperations.GetRegisteredTypeInfo(), "outputSettings", "GetOutputSettingsResponseData"); + + } + + extension(ObsWebSocket.Core.Protocol.Responses.GetPersistentDataResponseData response) + { + /// Reads slotValue as , using metadata the caller supplies. + /// The shape to read the field as. + /// Metadata for , typically from your own JsonSerializerContext. + /// The value, or when OBS sent none. + /// Thrown when the field does not have that shape. + public T? GetSlotValue(JsonTypeInfo typeInfo) => Read(response.SlotValue, typeInfo, "slotValue", "GetPersistentDataResponseData"); + + /// Reads slotValue as a settings type this library registers. + /// A library-registered settings type. + /// The value, or when OBS sent none. + /// Thrown when is not registered, or the field does not have that shape. + public T? GetSlotValue() + where T : class => + Read(response.SlotValue, ObsWebSocketClientOperations.GetRegisteredTypeInfo(), "slotValue", "GetPersistentDataResponseData"); + + } + + extension(ObsWebSocket.Core.Protocol.Responses.GetSourceFilterResponseData response) + { + /// Reads filterSettings as , using metadata the caller supplies. + /// The shape to read the field as. + /// Metadata for , typically from your own JsonSerializerContext. + /// The value, or when OBS sent none. + /// Thrown when the field does not have that shape. + public T? GetFilterSettings(JsonTypeInfo typeInfo) => Read(response.FilterSettings, typeInfo, "filterSettings", "GetSourceFilterResponseData"); + + /// Reads filterSettings as a settings type this library registers. + /// A library-registered settings type. + /// The value, or when OBS sent none. + /// Thrown when is not registered, or the field does not have that shape. + public T? GetFilterSettings() + where T : class => + Read(response.FilterSettings, ObsWebSocketClientOperations.GetRegisteredTypeInfo(), "filterSettings", "GetSourceFilterResponseData"); + + } + + extension(ObsWebSocket.Core.Protocol.Responses.GetSourceFilterDefaultSettingsResponseData response) + { + /// Reads defaultFilterSettings as , using metadata the caller supplies. + /// The shape to read the field as. + /// Metadata for , typically from your own JsonSerializerContext. + /// The value, or when OBS sent none. + /// Thrown when the field does not have that shape. + public T? GetDefaultFilterSettings(JsonTypeInfo typeInfo) => Read(response.DefaultFilterSettings, typeInfo, "defaultFilterSettings", "GetSourceFilterDefaultSettingsResponseData"); + + /// Reads defaultFilterSettings as a settings type this library registers. + /// A library-registered settings type. + /// The value, or when OBS sent none. + /// Thrown when is not registered, or the field does not have that shape. + public T? GetDefaultFilterSettings() + where T : class => + Read(response.DefaultFilterSettings, ObsWebSocketClientOperations.GetRegisteredTypeInfo(), "defaultFilterSettings", "GetSourceFilterDefaultSettingsResponseData"); + + } + + extension(ObsWebSocket.Core.Protocol.Responses.GetStreamServiceSettingsResponseData response) + { + /// Reads streamServiceSettings as , using metadata the caller supplies. + /// The shape to read the field as. + /// Metadata for , typically from your own JsonSerializerContext. + /// The value, or when OBS sent none. + /// Thrown when the field does not have that shape. + public T? GetStreamServiceSettings(JsonTypeInfo typeInfo) => Read(response.StreamServiceSettings, typeInfo, "streamServiceSettings", "GetStreamServiceSettingsResponseData"); + + /// Reads streamServiceSettings as a settings type this library registers. + /// A library-registered settings type. + /// The value, or when OBS sent none. + /// Thrown when is not registered, or the field does not have that shape. + public T? GetStreamServiceSettings() + where T : class => + Read(response.StreamServiceSettings, ObsWebSocketClientOperations.GetRegisteredTypeInfo(), "streamServiceSettings", "GetStreamServiceSettingsResponseData"); + + } + + private static T? Read(JsonElement? element, JsonTypeInfo typeInfo, string field, string owner) + { + ArgumentNullException.ThrowIfNull(typeInfo); + if (element is not { } value || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return default; + } + + try + { + return value.Deserialize(typeInfo); + } + catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidOperationException) + { + throw new ObsWebSocketSerializationException( + $"Could not read '{field}' of {owner} as {typeof(T).Name}.", + ex + ); + } + } +} + +// Free-form fields with typed readers: 17 diff --git a/ObsWebSocket.Core/Groups/ConfigGroup.cs b/ObsWebSocket.Core/Groups/ConfigGroup.cs index 05599ba..0853bf3 100644 --- a/ObsWebSocket.Core/Groups/ConfigGroup.cs +++ b/ObsWebSocket.Core/Groups/ConfigGroup.cs @@ -83,7 +83,11 @@ public async Task SetStreamServiceSettingsAsync( ArgumentNullException.ThrowIfNull(typeInfo); client.EnsureConnected(); - JsonElement settingsElement = JsonSerializer.SerializeToElement(settings, typeInfo); + JsonElement settingsElement = ObsWebSocketClientOperations.SerializeFreeForm( + settings, + typeInfo, + "streamServiceSettings" + ); await client .Config.SetStreamServiceSettingsAsync( @@ -232,4 +236,89 @@ or RequestStatusCode.InvalidRequestField } // Let other exceptions propagate } + + /// + /// Reads a persistent data slot as a caller-defined type. + /// + /// The type the slot holds. + /// + /// OBS_WEBSOCKET_DATA_REALM_GLOBAL, or OBS_WEBSOCKET_DATA_REALM_PROFILE for data + /// kept with the current profile. + /// + /// The slot to read. + /// Metadata for , typically from your own JsonSerializerContext. + /// A token to cancel the operation. + /// The slot's value, or when the slot is empty. + /// Thrown if OBS returns an error or the value does not have that shape. + /// Thrown if the client is not connected. + public async Task GetPersistentDataAsync( + string realm, + string slotName, + JsonTypeInfo typeInfo, + CancellationToken cancellationToken = default + ) + { + ArgumentException.ThrowIfNullOrEmpty(realm); + ArgumentException.ThrowIfNullOrEmpty(slotName); + ArgumentNullException.ThrowIfNull(typeInfo); + client.EnsureConnected(); + + GetPersistentDataResponseData response = await client + .Config.GetPersistentDataAsync( + new GetPersistentDataRequestData(realm: realm, slotName: slotName), + cancellationToken + ) + .ConfigureAwait(false); + + return response.GetSlotValue(typeInfo); + } + + /// + /// Writes a caller-defined value to a persistent data slot. + /// + /// + /// OBS treats a null value as a missing one and rejects the request, so a slot cannot be + /// cleared, only overwritten. + /// + /// The type to store. + /// + /// OBS_WEBSOCKET_DATA_REALM_GLOBAL, or OBS_WEBSOCKET_DATA_REALM_PROFILE for data + /// kept with the current profile. + /// + /// The slot to write. + /// The value to store. + /// Metadata for , typically from your own JsonSerializerContext. + /// A token to cancel the operation. + /// Thrown if OBS returns an error or serialization fails. + /// Thrown if the client is not connected. + public async Task SetPersistentDataAsync( + string realm, + string slotName, + T value, + JsonTypeInfo typeInfo, + CancellationToken cancellationToken = default + ) + { + ArgumentException.ThrowIfNullOrEmpty(realm); + ArgumentException.ThrowIfNullOrEmpty(slotName); + ArgumentNullException.ThrowIfNull(typeInfo); + client.EnsureConnected(); + + JsonElement slotValue = ObsWebSocketClientOperations.SerializeFreeForm( + value, + typeInfo, + "slotValue" + ); + + await client + .Config.SetPersistentDataAsync( + new SetPersistentDataRequestData( + realm: realm, + slotName: slotName, + slotValue: slotValue + ), + cancellationToken + ) + .ConfigureAwait(false); + } } diff --git a/ObsWebSocket.Core/Groups/FiltersGroup.cs b/ObsWebSocket.Core/Groups/FiltersGroup.cs index db46733..1685e07 100644 --- a/ObsWebSocket.Core/Groups/FiltersGroup.cs +++ b/ObsWebSocket.Core/Groups/FiltersGroup.cs @@ -136,18 +136,11 @@ public async Task SetSourceFilterSettingsAsync( ArgumentNullException.ThrowIfNull(typeInfo); client.EnsureConnected(); - JsonElement settingsElement; - try - { - settingsElement = JsonSerializer.SerializeToElement(settings, typeInfo); - } - catch (JsonException jsonEx) - { - throw new ObsWebSocketException( - $"Failed to serialize settings object of type '{typeof(T).Name}' for filter '{filterName}'.", - jsonEx - ); - } + JsonElement settingsElement = ObsWebSocketClientOperations.SerializeFreeForm( + settings, + typeInfo, + "filterSettings" + ); await client .Filters.SetSourceFilterSettingsAsync( @@ -223,18 +216,11 @@ public async Task CreateSourceFilterAsync( ArgumentNullException.ThrowIfNull(typeInfo); client.EnsureConnected(); - JsonElement settingsElement; - try - { - settingsElement = JsonSerializer.SerializeToElement(settings, typeInfo); - } - catch (JsonException jsonEx) - { - throw new ObsWebSocketException( - $"Failed to serialize settings object of type '{typeof(T).Name}' for filter '{filterName}'.", - jsonEx - ); - } + JsonElement settingsElement = ObsWebSocketClientOperations.SerializeFreeForm( + settings, + typeInfo, + "filterSettings" + ); await client .Filters.CreateSourceFilterAsync( diff --git a/ObsWebSocket.Core/Groups/GeneralGroup.cs b/ObsWebSocket.Core/Groups/GeneralGroup.cs index 02caebf..bf8b848 100644 --- a/ObsWebSocket.Core/Groups/GeneralGroup.cs +++ b/ObsWebSocket.Core/Groups/GeneralGroup.cs @@ -41,4 +41,87 @@ await client ) .ConfigureAwait(false); } + + /// + /// Calls a request a third-party plugin registered with obs-websocket, with typed data both + /// ways. + /// + /// The request data the vendor expects. + /// The response data the vendor returns. + /// The vendor the request belongs to. + /// The vendor's request type. + /// The data to send. + /// Metadata for . + /// Metadata for . + /// A token to cancel the operation. + /// The vendor's response data, or when it sent none. + /// Thrown if OBS or the vendor rejects the request, or the data cannot be serialized or read. + /// Thrown if the client is not connected. + public async Task CallVendorRequestAsync( + string vendorName, + string requestType, + TRequest requestData, + JsonTypeInfo requestTypeInfo, + JsonTypeInfo responseTypeInfo, + CancellationToken cancellationToken = default + ) + { + ArgumentException.ThrowIfNullOrEmpty(vendorName); + ArgumentException.ThrowIfNullOrEmpty(requestType); + ArgumentNullException.ThrowIfNull(requestTypeInfo); + ArgumentNullException.ThrowIfNull(responseTypeInfo); + client.EnsureConnected(); + + JsonElement data = ObsWebSocketClientOperations.SerializeFreeForm( + requestData, + requestTypeInfo, + "requestData" + ); + + CallVendorRequestResponseData response = await client + .General.CallVendorRequestAsync( + new CallVendorRequestRequestData( + vendorName: vendorName, + requestType: requestType, + requestData: data + ), + cancellationToken + ) + .ConfigureAwait(false); + + return response.GetResponseData(responseTypeInfo); + } + + /// + /// Broadcasts a CustomEvent carrying a caller-defined payload to every client + /// subscribed to general events. + /// + /// The payload's type. + /// The payload to send. + /// Metadata for , typically from your own JsonSerializerContext. + /// A token to cancel the operation. + /// Thrown if OBS returns an error or serialization fails. + /// Thrown if the client is not connected. + public async Task BroadcastCustomEventAsync( + T eventData, + JsonTypeInfo typeInfo, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(typeInfo); + client.EnsureConnected(); + + JsonElement data = ObsWebSocketClientOperations.SerializeFreeForm( + eventData, + typeInfo, + "eventData" + ); + + await client + .General.BroadcastCustomEventAsync( + new BroadcastCustomEventRequestData(eventData: data), + cancellationToken + ) + .ConfigureAwait(false); + } } diff --git a/ObsWebSocket.Core/Groups/InputsGroup.cs b/ObsWebSocket.Core/Groups/InputsGroup.cs index b5481ca..33fc1aa 100644 --- a/ObsWebSocket.Core/Groups/InputsGroup.cs +++ b/ObsWebSocket.Core/Groups/InputsGroup.cs @@ -209,18 +209,11 @@ public async Task SetInputSettingsAsync( ArgumentNullException.ThrowIfNull(typeInfo); client.EnsureConnected(); - JsonElement settingsElement; - try - { - settingsElement = JsonSerializer.SerializeToElement(settings, typeInfo); - } - catch (JsonException jsonEx) - { - throw new ObsWebSocketException( - $"Failed to serialize settings object of type '{typeof(T).Name}' for input '{inputName}'.", - jsonEx - ); - } + JsonElement settingsElement = ObsWebSocketClientOperations.SerializeFreeForm( + settings, + typeInfo, + "inputSettings" + ); await client .Inputs.SetInputSettingsAsync( @@ -296,18 +289,11 @@ public Task SetInputSettingsAsync( ArgumentNullException.ThrowIfNull(typeInfo); client.EnsureConnected(); - JsonElement settingsElement; - try - { - settingsElement = JsonSerializer.SerializeToElement(settings, typeInfo); - } - catch (JsonException jsonEx) - { - throw new ObsWebSocketException( - $"Failed to serialize settings object of type '{typeof(T).Name}' for input '{inputName}'.", - jsonEx - ); - } + JsonElement settingsElement = ObsWebSocketClientOperations.SerializeFreeForm( + settings, + typeInfo, + "inputSettings" + ); return await client .Inputs.CreateInputAsync( diff --git a/ObsWebSocket.Core/Groups/OutputsGroup.cs b/ObsWebSocket.Core/Groups/OutputsGroup.cs index 0fd8396..d943bfe 100644 --- a/ObsWebSocket.Core/Groups/OutputsGroup.cs +++ b/ObsWebSocket.Core/Groups/OutputsGroup.cs @@ -93,7 +93,11 @@ public async Task SetOutputSettingsAsync( ArgumentNullException.ThrowIfNull(typeInfo); client.EnsureConnected(); - JsonElement settingsElement = JsonSerializer.SerializeToElement(settings, typeInfo); + JsonElement settingsElement = ObsWebSocketClientOperations.SerializeFreeForm( + settings, + typeInfo, + "outputSettings" + ); await client .Outputs.SetOutputSettingsAsync( diff --git a/ObsWebSocket.Core/Groups/TransitionsGroup.cs b/ObsWebSocket.Core/Groups/TransitionsGroup.cs index 61238eb..d2e7558 100644 --- a/ObsWebSocket.Core/Groups/TransitionsGroup.cs +++ b/ObsWebSocket.Core/Groups/TransitionsGroup.cs @@ -87,7 +87,11 @@ public async Task SetCurrentSceneTransitionSettingsAsync( ArgumentNullException.ThrowIfNull(typeInfo); client.EnsureConnected(); - JsonElement settingsElement = JsonSerializer.SerializeToElement(settings, typeInfo); + JsonElement settingsElement = ObsWebSocketClientOperations.SerializeFreeForm( + settings, + typeInfo, + "transitionSettings" + ); await client .Transitions.SetCurrentSceneTransitionSettingsAsync( diff --git a/ObsWebSocket.Core/ObsWebSocketClientOperations.cs b/ObsWebSocket.Core/ObsWebSocketClientOperations.cs index 2dbafb7..3574045 100644 --- a/ObsWebSocket.Core/ObsWebSocketClientOperations.cs +++ b/ObsWebSocket.Core/ObsWebSocketClientOperations.cs @@ -46,6 +46,37 @@ internal static JsonTypeInfo GetRegisteredTypeInfo() return typeInfo ?? throw new ObsWebSocketException(NotRegistered()); } + /// + /// Serializes a caller's value for a free-form request field, reporting a failure as this + /// library's serialization exception rather than letting the serializer's own escape. + /// + /// The value's type. + /// The value to send. + /// Metadata for . + /// The protocol field the value is for, for the message. + /// Thrown when the value cannot be serialized. + internal static JsonElement SerializeFreeForm( + T value, + JsonTypeInfo typeInfo, + string field + ) + { + ArgumentNullException.ThrowIfNull(typeInfo); + + try + { + return JsonSerializer.SerializeToElement(value, typeInfo); + } + catch (Exception ex) + when (ex is JsonException or NotSupportedException or InvalidOperationException) + { + throw new ObsWebSocketSerializationException( + $"Could not serialize {typeof(T).Name} for '{field}'.", + ex + ); + } + } + private static string NotRegistered() => $"Type '{typeof(T).Name}' is not registered in ObsWebSocketJsonContext. " + "Pass an explicit JsonTypeInfo or use a library-registered settings type."; diff --git a/ObsWebSocket.Tests/FreeFormFieldTests.cs b/ObsWebSocket.Tests/FreeFormFieldTests.cs new file mode 100644 index 0000000..878cc6c --- /dev/null +++ b/ObsWebSocket.Tests/FreeFormFieldTests.cs @@ -0,0 +1,311 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using ObsWebSocket.Core; +using ObsWebSocket.Core.Protocol.Common.InputSettings; +using ObsWebSocket.Core.Protocol.Events; +using ObsWebSocket.Core.Protocol.Responses; +using ObsWebSocket.Tests.Fakes; + +namespace ObsWebSocket.Tests; + +/// +/// The fields the protocol leaves free-form: every one an event or response carries has a typed +/// reader, and every one a request carries has a typed helper. +/// +[TestClass] +public sealed class FreeFormFieldTests +{ + private const int TestTimeout = 30_000; + + private static IEnumerable<(Type Payload, PropertyInfo Field)> FreeFormFields() => + typeof(ObsWebSocketClient) + .Assembly.GetExportedTypes() + .Where(t => + t.Namespace + is "ObsWebSocket.Core.Protocol.Responses" + or "ObsWebSocket.Core.Protocol.Events" + ) + .SelectMany(t => + t.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.PropertyType == typeof(JsonElement?)) + .Select(p => (t, p)) + ); + + [TestMethod] + public void Readers_EveryInboundFreeFormField_HasBothOverloads() + { + MethodInfo[] readers = typeof(ObsWebSocketFreeFormFields).GetMethods( + BindingFlags.Public | BindingFlags.Static + ); + List<(Type Payload, PropertyInfo Field)> fields = [.. FreeFormFields()]; + + Assert.IsGreaterThan(10, fields.Count, "the protocol has many free-form fields"); + + foreach ((Type payload, PropertyInfo field) in fields) + { + MethodInfo[] forField = + [ + .. readers.Where(m => + m.Name == $"Get{field.Name}" + && m.IsGenericMethodDefinition + && m.GetParameters()[0].ParameterType == payload + ), + ]; + + Assert.HasCount( + 2, + forField, + $"{payload.Name}.{field.Name} needs a registered and an explicit reader" + ); + } + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task GetInputSettings_EventCarriesSettings_ReadsThemBothWays() + { + FakeObsServer server = new(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + TaskCompletionSource changed = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + fake.Client.InputSettingsChanged += (_, e) => changed.TrySetResult(e.EventData); + + server.RaiseEvent( + "InputSettingsChanged", + """{"inputName":"Web","inputUuid":"u","inputSettings":{"url":"https://example.com","css":"body{}"}}""" + ); + + InputSettingsChangedPayload payload = await changed.Task.WaitAsync( + TimeSpan.FromSeconds(10) + ); + + Assert.AreEqual( + "https://example.com", + payload.GetInputSettings()?.Url + ); + Assert.AreEqual( + "body{}", + payload.GetInputSettings(FreeFormContext.Default.OverlayCue)?.Css + ); + } + + [TestMethod] + public void GetInputSettings_Absent_ReturnsNull() + { + InputSettingsChangedPayload payload = new() + { + InputName = "Web", + InputUuid = "u", + InputSettings = null, + }; + + Assert.IsNull(payload.GetInputSettings()); + Assert.IsNull(payload.GetInputSettings(FreeFormContext.Default.OverlayCue)); + Assert.IsNull( + ( + payload with + { + InputSettings = JsonDocument.Parse("null").RootElement.Clone(), + } + ).GetInputSettings() + ); + } + + [TestMethod] + public void GetInputSettings_WrongShape_ThrowsSerialization() + { + InputSettingsChangedPayload payload = new() + { + InputName = "Web", + InputUuid = "u", + InputSettings = JsonDocument.Parse("""{"url":42}""").RootElement.Clone(), + }; + + ObsWebSocketSerializationException error = + Assert.ThrowsExactly(() => + payload.GetInputSettings() + ); + Assert.Contains("inputSettings", error.Message); + } + + [TestMethod] + public void GetInputSettings_UnregisteredTypeWithoutMetadata_Throws() + { + InputSettingsChangedPayload payload = new() + { + InputName = "Web", + InputUuid = "u", + InputSettings = JsonDocument.Parse("{}").RootElement.Clone(), + }; + + _ = Assert.ThrowsExactly(() => + payload.GetInputSettings() + ); + _ = Assert.ThrowsExactly(() => + payload.GetInputSettings(null!) + ); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task PersistentDataAsync_TypedValue_RoundTrips() + { + FakeObsServer server = new(); + string? stored = null; + _ = server.OnRequest( + "SetPersistentData", + data => + { + stored = data?.GetProperty("slotValue").GetRawText(); + return FakeObsServer.RequestOutcome.Success(); + } + ); + _ = server.OnRequest( + "GetPersistentData", + _ => FakeObsServer.RequestOutcome.Success($$"""{"slotValue":{{stored ?? "null"}}}""") + ); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + const string realm = "OBS_WEBSOCKET_DATA_REALM_GLOBAL"; + + Assert.IsNull( + await fake.Client.Config.GetPersistentDataAsync( + realm, + "cue", + FreeFormContext.Default.OverlayCue + ), + "an empty slot reads as nothing" + ); + + await fake.Client.Config.SetPersistentDataAsync( + realm, + "cue", + new OverlayCue("lights", "body{}"), + FreeFormContext.Default.OverlayCue + ); + OverlayCue? read = await fake.Client.Config.GetPersistentDataAsync( + realm, + "cue", + FreeFormContext.Default.OverlayCue + ); + + Assert.AreEqual(new OverlayCue("lights", "body{}"), read); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task CallVendorRequestAsync_TypedBothWays_SendsAndReads() + { + FakeObsServer server = new(); + string? sentName = null; + _ = server.OnRequest( + "CallVendorRequest", + data => + { + sentName = data?.GetProperty("requestData").GetProperty("name").GetString(); + return FakeObsServer.RequestOutcome.Success( + """{"vendorName":"cues","requestType":"Fire","responseData":{"name":"done","css":null}}""" + ); + } + ); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + OverlayCue? reply = await fake.Client.General.CallVendorRequestAsync( + "cues", + "Fire", + new OverlayCue("lights", null), + FreeFormContext.Default.OverlayCue, + FreeFormContext.Default.OverlayCue + ); + + Assert.AreEqual("lights", sentName); + Assert.AreEqual("done", reply?.Name); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task BroadcastCustomEventAsync_TypedPayload_ReachesSubscribers() + { + FakeObsServer server = new(); + _ = server.OnRequest( + "BroadcastCustomEvent", + data => + { + // OBS echoes the broadcast to every subscribed client, the sender included. + server.RaiseEvent("CustomEvent", data!.Value.GetProperty("eventData").GetRawText()); + return FakeObsServer.RequestOutcome.Success(null); + } + ); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + + TaskCompletionSource received = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + fake.Client.CustomEvent += (_, e) => + received.TrySetResult(e.EventData.GetEventData(FreeFormContext.Default.OverlayCue)); + + await fake.Client.General.BroadcastCustomEventAsync( + new OverlayCue("lights", null), + FreeFormContext.Default.OverlayCue + ); + + Assert.AreEqual("lights", (await received.Task.WaitAsync(TimeSpan.FromSeconds(10)))?.Name); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task FreeFormHelpers_Unserializable_ThrowBeforeSending() + { + FakeObsServer server = new(); + await using FakeObsClient fake = await FakeObsClient.ConnectAsync(server); + ObsWebSocketClient client = fake.Client; + BrokenSettings broken = new(new Unwritable()); + var typeInfo = BrokenSettingsContext.Default.BrokenSettings; + + _ = await Assert.ThrowsExactlyAsync(() => + client.Config.SetPersistentDataAsync( + "OBS_WEBSOCKET_DATA_REALM_GLOBAL", + "s", + broken, + typeInfo + ) + ); + _ = await Assert.ThrowsExactlyAsync(() => + client.General.CallVendorRequestAsync("v", "t", broken, typeInfo, typeInfo) + ); + _ = await Assert.ThrowsExactlyAsync(() => + client.General.BroadcastCustomEventAsync(broken, typeInfo) + ); + + Assert.IsEmpty(server.Requests); + } + + [TestMethod] + [Timeout(TestTimeout)] + public async Task FreeFormHelpers_NotConnected_Throw() + { + await using FakeObsClient fake = FakeObsClient.Build(new FakeObsServer()); + ObsWebSocketClient client = fake.Client; + var cue = FreeFormContext.Default.OverlayCue; + + _ = await Assert.ThrowsExactlyAsync(() => + client.Config.GetPersistentDataAsync("OBS_WEBSOCKET_DATA_REALM_GLOBAL", "s", cue) + ); + _ = await Assert.ThrowsExactlyAsync(() => + client.General.BroadcastCustomEventAsync(new OverlayCue(null, null), cue) + ); + } +} + +/// A payload shape a consumer would define. +/// What the cue is called. +/// Styling to apply. +internal sealed record OverlayCue( + [property: JsonPropertyName("name")] string? Name = null, + [property: JsonPropertyName("css")] string? Css = null +); + +[JsonSerializable(typeof(OverlayCue))] +internal sealed partial class FreeFormContext : JsonSerializerContext { } diff --git a/ObsWebSocket.Tests/GroupHelperFailureTests.cs b/ObsWebSocket.Tests/GroupHelperFailureTests.cs index 6e167f0..8afa18b 100644 --- a/ObsWebSocket.Tests/GroupHelperFailureTests.cs +++ b/ObsWebSocket.Tests/GroupHelperFailureTests.cs @@ -86,10 +86,10 @@ public async Task SetSettingsAsync_Unserializable_ThrowsBeforeSending() BrokenSettings broken = new(new Unwritable()); var typeInfo = BrokenSettingsContext.Default.BrokenSettings; - _ = await Assert.ThrowsExactlyAsync(() => + _ = await Assert.ThrowsExactlyAsync(() => client.Inputs.SetInputSettingsAsync("Web", broken, typeInfo) ); - _ = await Assert.ThrowsExactlyAsync(() => + _ = await Assert.ThrowsExactlyAsync(() => client.Inputs.CreateInputAsync( "browser_source", "Web", @@ -98,10 +98,10 @@ public async Task SetSettingsAsync_Unserializable_ThrowsBeforeSending() sceneName: "Live" ) ); - _ = await Assert.ThrowsExactlyAsync(() => + _ = await Assert.ThrowsExactlyAsync(() => client.Filters.SetSourceFilterSettingsAsync("Cam", "Grade", broken, typeInfo) ); - _ = await Assert.ThrowsExactlyAsync(() => + _ = await Assert.ThrowsExactlyAsync(() => client.Filters.CreateSourceFilterAsync( "Cam", "Grade", @@ -110,6 +110,15 @@ public async Task SetSettingsAsync_Unserializable_ThrowsBeforeSending() typeInfo ) ); + _ = await Assert.ThrowsExactlyAsync(() => + client.Outputs.SetOutputSettingsAsync("adv_file_output", broken, typeInfo) + ); + _ = await Assert.ThrowsExactlyAsync(() => + client.Config.SetStreamServiceSettingsAsync("rtmp_custom", broken, typeInfo) + ); + _ = await Assert.ThrowsExactlyAsync(() => + client.Transitions.SetCurrentSceneTransitionSettingsAsync(broken, typeInfo) + ); Assert.IsEmpty(server.Requests); } diff --git a/ObsWebSocket.Tests/Integration/TypedSettingsHelperTests.cs b/ObsWebSocket.Tests/Integration/TypedSettingsHelperTests.cs index 4ef1b1a..2532a0e 100644 --- a/ObsWebSocket.Tests/Integration/TypedSettingsHelperTests.cs +++ b/ObsWebSocket.Tests/Integration/TypedSettingsHelperTests.cs @@ -197,6 +197,43 @@ await live .ConfigureAwait(false); } } + + [TestMethod] + [Timeout(TimeoutMs, CooperativeCancellation = true)] + public async Task PersistentData_LiveObs_RoundTrip() + { + CancellationToken token = TestContext.CancellationToken; + await using LiveClient live = await LiveClient + .ConnectAsync(SerializationFormat.Json, TestContext) + .ConfigureAwait(false); + + // One fixed slot, overwritten each run: OBS treats a null value as a missing field, so a + // slot can never be cleared, and a fresh name per run would leave one behind every time. + const string realm = "OBS_WEBSOCKET_DATA_REALM_GLOBAL"; + const string slot = "obsws_test_persistent_data"; + SwipeSettings written = new($"run-{Guid.NewGuid():N}", SwipeIn: true); + + await live + .Client.Config.SetPersistentDataAsync( + realm, + slot, + written, + TransitionSettingsContext.Default.SwipeSettings, + token + ) + .ConfigureAwait(false); + + SwipeSettings? read = await live + .Client.Config.GetPersistentDataAsync( + realm, + slot, + TransitionSettingsContext.Default.SwipeSettings, + token + ) + .ConfigureAwait(false); + + Assert.AreEqual(written, read); + } } /// Settings for the swipe transition, as a consumer would model them. diff --git a/ObsWebSocket.Tests/ReadmeCompileCheck.cs b/ObsWebSocket.Tests/ReadmeCompileCheck.cs index f831396..817ab5f 100644 --- a/ObsWebSocket.Tests/ReadmeCompileCheck.cs +++ b/ObsWebSocket.Tests/ReadmeCompileCheck.cs @@ -610,4 +610,17 @@ format is SerializationFormat.MsgPack await client.ConnectAsync(); } + + internal static void FreeFormReaders(ObsWebSocketClient client) + { + client.InputSettingsChanged += (_, e) => + { + BrowserSourceSettings? browser = e.EventData.GetInputSettings(); + }; + + client.CustomEvent += (_, e) => + { + OverlaySettings? cue = e.EventData.GetEventData(MyContext.Default.OverlaySettings); + }; + } } diff --git a/README.md b/README.md index 6fbd373..83dc49e 100644 --- a/README.md +++ b/README.md @@ -210,8 +210,40 @@ Native AOT. | `Outputs.GetOutputSettingsAsync` / `SetOutputSettingsAsync` | Output settings | | `Config.GetStreamServiceSettingsAsync` / `SetStreamServiceSettingsAsync` | Stream service settings | +**Your own data** + +These carry data only you know the shape of, so they take a `JsonTypeInfo` for it. + +| Helper | Notes | +|---|---| +| `Config.GetPersistentDataAsync` / `SetPersistentDataAsync` | A persistent data slot; realm is `OBS_WEBSOCKET_DATA_REALM_GLOBAL` or `OBS_WEBSOCKET_DATA_REALM_PROFILE` | +| `General.CallVendorRequestAsync` | A request another plugin registered, typed both ways | +| `General.BroadcastCustomEventAsync` | A `CustomEvent` with your own payload | + Most take optional parameters before the cancellation token, so pass it as `cancellationToken: ct`. +**Free-form fields in events and responses** + +Fields the protocol leaves free-form, such as an input's settings or a vendor's reply, are +`JsonElement?` on the payload. Each has a pair of typed readers named after the field, one for a +library-registered type and one taking your `JsonTypeInfo`: + +```csharp +client.InputSettingsChanged += (_, e) => +{ + BrowserSourceSettings? browser = e.EventData.GetInputSettings(); +}; + +client.CustomEvent += (_, e) => +{ + OverlaySettings? cue = e.EventData.GetEventData(MyContext.Default.OverlaySettings); +}; +``` + +They work on responses too, including a batch result read with `GetRequiredData`. A reader returns +null when OBS sent nothing, and throws `ObsWebSocketSerializationException` when the field has a +different shape. + **Scenes and scene items** - `Scenes.SwitchProgramSceneAsync(scene, ct)` and `Scenes.SwitchPreviewSceneAsync(scene, ct)`. @@ -249,7 +281,8 @@ Most take optional parameters before the cancellation token, so pass it as `canc - `Record.SetRecordActiveAndWaitAsync(activate, timeout, ct)`, `Stream.SetStreamActiveAndWaitAsync(...)` and `Outputs.SetVirtualCamActiveAndWaitAsync(...)` start - or stop the output and wait for confirmation, returning the resulting `OutputState`. + or stop the output and wait for confirmation, returning the state the event reported, or null + when it does not arrive in time. - `Record.IsRecordActiveAsync(ct)`, `Stream.IsStreamActiveAsync(ct)` and `Outputs.IsVirtualCamActiveAsync(ct)` read current state.