Lumi/companion/src/Lumi.Companion.App/CompanionRuntime.cs
2026-07-22 15:47:56 +02:00

536 lines
28 KiB
C#

using System.Diagnostics;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Lumi.Companion.Core;
using Lumi.Companion.Protocol;
using Lumi.Companion.Transcription;
using Microsoft.Win32;
namespace Lumi.Companion.App;
public sealed class CompanionRuntime : IAsyncDisposable
{
private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
private readonly CompanionPaths _paths;
private readonly CompanionSettingsStore _settings;
private readonly SecureCredentialStore _credentials;
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
private readonly UpdateService _updates;
private readonly ObsBridgeManager _bridgeManager = new();
private readonly CancellationTokenSource _maintenanceLifetime = new();
private CompanionSocket? _socket;
private ObsBridgePipe? _obsBridge;
private TaskCompletionSource<bool>? _audioSignal;
private TaskCompletionSource<bool>? _captionSignal;
private bool _disposed;
private CompanionUpdate? _availableUpdate;
public CompanionRuntime(CompanionPaths paths, CompanionSettingsStore settings)
{
_paths = paths;
_settings = settings;
_credentials = new SecureCredentialStore(paths.Root);
_updates = new UpdateService(_http, paths);
State = new CompanionState();
TestStages = CreateInitialTestStages();
ObsSources = [];
}
public CompanionState State { get; private set; }
public IReadOnlyList<TestStage> TestStages { get; private set; }
public IReadOnlyList<ObsSource> ObsSources { get; private set; }
public event Action<CompanionState>? StateChanged;
public event Action<IReadOnlyList<TestStage>>? TestStagesChanged;
public event Action<IReadOnlyList<ObsSource>>? ObsSourcesChanged;
public event Action<string, bool>? CaptionReceived;
public event Action<string>? LogAdded;
public event Func<Task>? UpdateRestartRequested;
public async Task InitializeAsync()
{
try { await InitializeCoreAsync(); }
catch (Exception error)
{
SetState(State with { Health = TrayHealth.Failed, Detail = $"Companion startup could not finish. {Friendly(error)}" });
await WriteLogAsync("startup_failed", error.Message);
}
}
private async Task InitializeCoreAsync()
{
await _settings.LoadAsync();
StartObsBridgeBoundary();
_ = RunUpdateChecksAsync(_maintenanceLifetime.Token);
ApplyAutoStart(_settings.Current.AutoStartWithWindows);
DeviceCredential? credential;
try { credential = _credentials.Load(); }
catch (Exception error)
{
SetState(State with { Health = TrayHealth.Failed, Detail = "The saved device credential could not be opened. Pair this computer again." });
await WriteLogAsync("credential_load_failed", error.Message);
return;
}
if (credential is null)
{
var bundledPairing = FindBundledPairingPackage();
if (bundledPairing is not null)
{
await PairAsync(bundledPairing);
try { File.Delete(bundledPairing); } catch { }
return;
}
SetState(WithBridgeState(State with { Detail = "Download a pairing package from Lumi, then open it here." }));
return;
}
SetState(WithBridgeState(State with { Paired = true, DeviceName = Environment.MachineName, Host = credential.Host, Detail = "Connecting securely to Lumi…" }));
await ConnectAsync(credential);
}
public async Task PairAsync(string packagePath, CancellationToken cancellationToken = default)
{
SetState(State with { Health = TrayHealth.Degraded, Detail = "Validating the pairing package…" });
var client = new PairingClient(_http);
try
{
var credential = await client.PairAsync(packagePath, new
{
install_id = _credentials.GetOrCreateInstallId(),
name = Environment.MachineName,
companion_version = Version
}, cancellationToken);
_credentials.Save(credential);
SetState(State with { Paired = true, DeviceName = Environment.MachineName, Host = credential.Host, Detail = "Pairing complete. Connecting to Lumi…" });
await WriteLogAsync("paired", $"Paired {Environment.MachineName} with {credential.Host}.");
await ConnectAsync(credential, cancellationToken);
}
catch (Exception error)
{
SetState(State with { Health = TrayHealth.Failed, Detail = Friendly(error) });
await WriteLogAsync("pairing_failed", error.Message);
throw;
}
}
public async Task RetryConnectionAsync(CancellationToken cancellationToken = default)
{
var credential = _credentials.Load() ?? throw new InvalidOperationException("Pair this computer before reconnecting.");
await ConnectAsync(credential, cancellationToken);
}
private async Task ConnectAsync(DeviceCredential credential, CancellationToken cancellationToken = default)
{
if (_socket is not null) await _socket.DisposeAsync();
_socket = new CompanionSocket();
_socket.MessageReceived += OnServerMessageAsync;
_socket.Disconnected += error =>
{
if (_disposed) return;
SetState(State with { Connected = false, Health = TrayHealth.Degraded, Detail = "The secure Lumi connection closed. Retry when the host is available." });
_ = WriteLogAsync("disconnected", error?.Message ?? "Connection closed.");
};
SetState(State with { Connected = false, Detail = "Connecting securely to Lumi…" });
try
{
await _socket.ConnectAsync(credential, Version, "0.1.0", null, cancellationToken);
var bridgeInstalled = State.ObsBridgeInstalled || DetectBridgeInstallation();
SetState(State with
{
Paired = true,
Connected = true,
ObsBridgeInstalled = bridgeInstalled,
Health = bridgeInstalled ? TrayHealth.Ready : TrayHealth.Degraded,
Detail = bridgeInstalled ? "Lumi is connected. Waiting for OBS." : "Lumi is connected. Install the managed OBS integration to continue setup.",
Host = credential.Host,
LastConnectedAt = DateTimeOffset.Now
});
foreach (var source in ObsSources) await SendSourceUpdateAsync(source);
if (State.ObsConnected)
await _socket.SendAsync("obs_state", new { streaming = State.ObsStreaming, recording = State.ObsRecording, auto_start = _settings.Current.StartWithObs }, _socket.SessionId, cancellationToken);
await WriteLogAsync("connected", $"Connected to {credential.Host}.");
await CheckForUpdatesAsync(cancellationToken);
}
catch (Exception error)
{
SetState(State with { Paired = true, Connected = false, Health = TrayHealth.Degraded, Detail = $"Lumi could not be reached. {Friendly(error)}" });
await WriteLogAsync("connection_failed", error.Message);
}
}
public async Task RunTestAsync(CancellationToken cancellationToken = default)
{
if (State.TestRunning) return;
SetState(State with { TestRunning = true, Detail = "Running the transcription path check…" });
var stages = CreateInitialTestStages().ToArray();
TestStages = stages;
TestStagesChanged?.Invoke(TestStages);
try
{
await EvaluateStageAsync(stages, 0, State.ObsBridgeInstalled, "The managed OBS integration is installed.", "Install or repair the OBS integration before testing.", cancellationToken);
if (!State.ObsBridgeInstalled) return;
await EvaluateStageAsync(stages, 1, State.ObsConnected, "OBS reported a healthy local connection.", "Open OBS 31 or newer and retry.", cancellationToken);
if (!State.ObsConnected) return;
var selected = ObsSources.FirstOrDefault(source => source.Uuid == _settings.Current.PrimarySourceUuid);
await EvaluateStageAsync(stages, 2, selected is { Missing: false, Active: true }, "The selected microphone is available and active in Program.", selected is null ? "Choose a microphone on the Transcription page." : selected.Missing ? "The selected microphone is missing from OBS." : "Put the selected microphone in the active Program scene, then retry.", cancellationToken);
if (selected is not { Missing: false, Active: true }) return;
await EvaluateStageAsync(stages, 4, State.Connected, "The secure Lumi transport is ready.", "Reconnect to Lumi before testing.", cancellationToken);
if (!State.Connected) return;
_audioSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
_captionSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
await _socket!.SendAsync("start", new { mode = "test" }, _socket.SessionId, cancellationToken);
MarkStage(stages, 3, TestStageState.Running, "Speak normally into the selected microphone…");
if (!await WaitForAsync(_audioSignal.Task, TimeSpan.FromSeconds(10), cancellationToken))
{
MarkStage(stages, 3, TestStageState.Blocked, "No active microphone audio reached the companion within 10 seconds.");
return;
}
MarkStage(stages, 3, TestStageState.Passed, "Live microphone audio reached the companion.");
MarkStage(stages, 5, TestStageState.Running, "Waiting for server-hosted speech recognition…");
if (!await WaitForAsync(_captionSignal.Task, TimeSpan.FromSeconds(30), cancellationToken))
{
MarkStage(stages, 5, TestStageState.Blocked, "No caption returned within 30 seconds. Check the loaded model and host diagnostics.");
return;
}
MarkStage(stages, 5, TestStageState.Passed, "Speech recognition returned a stable result.");
MarkStage(stages, 6, TestStageState.Passed, "A caption returned over the secure connection.");
MarkStage(stages, 7, TestStageState.Passed, "The delivery adapter stayed in safe simulation mode.");
MarkStage(stages, 8, TestStageState.Passed, "The exact outgoing caption was rendered locally, not sent to Twitch.");
SetState(State with { Detail = "The safe end-to-end transcription test passed.", Health = TrayHealth.Ready });
}
finally
{
if (_socket is not null && State.Connected)
try { await _socket.SendAsync("stop", new { reason = "test_complete" }, _socket.SessionId, CancellationToken.None); } catch { }
_audioSignal = null;
_captionSignal = null;
SetState(State with { TestRunning = false, Detail = TestStages.Any(stage => stage.State == TestStageState.Blocked) ? "The test stopped at the first unavailable real boundary." : State.Detail });
}
}
private async Task EvaluateStageAsync(TestStage[] stages, int index, bool passed, string success, string blocked, CancellationToken cancellationToken)
{
MarkStage(stages, index, TestStageState.Running, "Checking…");
await Task.Delay(180, cancellationToken);
MarkStage(stages, index, passed ? TestStageState.Passed : TestStageState.Blocked, passed ? success : blocked);
}
private void MarkStage(TestStage[] stages, int index, TestStageState state, string detail)
{
stages[index] = stages[index] with { State = state, Detail = detail };
TestStages = stages.ToArray();
TestStagesChanged?.Invoke(TestStages);
}
public async Task SaveSettingsAsync(CompanionSettings value, CancellationToken cancellationToken = default)
{
await _settings.SaveAsync(value, cancellationToken);
ApplyAutoStart(value.AutoStartWithWindows);
await WriteLogAsync("settings_saved", "Local companion preferences updated.");
}
public async Task CheckForUpdatesAsync(CancellationToken cancellationToken = default)
{
var credential = _credentials.Load();
if (credential is null) { SetState(State with { UpdateDetail = "Pair this computer before checking for updates." }); return; }
try
{
_availableUpdate = await _updates.CheckAsync(credential, Version, cancellationToken);
SetState(State with
{
UpdateAvailable = _availableUpdate is not null,
AvailableVersion = _availableUpdate?.Version,
UpdateDetail = _availableUpdate is null ? $"Lumi Companion {Version} is current." : $"Lumi Companion {_availableUpdate.Version} is ready to install when OBS is idle."
});
}
catch (Exception error)
{
SetState(State with { UpdateDetail = $"Update check could not finish. {Friendly(error)}" });
await WriteLogAsync("update_check_failed", error.Message);
}
}
public async Task ApplyUpdateAsync(CancellationToken cancellationToken = default)
{
if (State.ObsStreaming || State.ObsRecording) throw new InvalidOperationException("Stop streaming and recording before updating Lumi Companion.");
var update = _availableUpdate ?? throw new InvalidOperationException("No Companion update is ready to install.");
try
{
SetState(State with { UpdateDetail = $"Downloading and verifying {update.Version}…" });
var staged = await _updates.StageAsync(update, cancellationToken);
if (State.ObsStreaming || State.ObsRecording) throw new InvalidOperationException("OBS started output while the update was downloading. Stop streaming and recording, then try again.");
_updates.LaunchApplier(staged);
SetState(State with { UpdateDetail = "Update verified. Restarting Lumi Companion…" });
await WriteLogAsync("update_staged", $"Verified Companion {update.Version}; restarting to apply it.");
if (UpdateRestartRequested is { } restart) await restart();
}
catch (Exception error)
{
SetState(State with { UpdateDetail = $"Update could not finish. {Friendly(error)}" });
await WriteLogAsync("update_failed", error.Message);
throw;
}
}
public async Task InstallOrRepairObsBridgeAsync(CancellationToken cancellationToken = default)
{
try
{
var result = await _bridgeManager.InstallOrRepairAsync(cancellationToken);
SetState(WithBridgeState(State with { Detail = "OBS integration installed. Start or restart OBS to connect it to Companion." }));
await WriteLogAsync("obs_bridge_installed", $"Installed managed OBS integration {result.Version}.");
}
catch (Exception error)
{
SetState(State with { ObsBridgeDetail = $"OBS integration maintenance could not finish. {Friendly(error)}" });
await WriteLogAsync("obs_bridge_install_failed", error.Message);
throw;
}
}
public async Task RemoveObsBridgeAsync()
{
try
{
await _bridgeManager.RemoveAsync();
SetState(WithBridgeState(State with { ObsConnected = false, Detail = "OBS integration removed. Other Companion features remain installed." }));
await WriteLogAsync("obs_bridge_removed", "Removed the managed OBS integration.");
}
catch (Exception error)
{
SetState(State with { ObsBridgeDetail = $"OBS integration removal could not finish. {Friendly(error)}" });
await WriteLogAsync("obs_bridge_remove_failed", error.Message);
throw;
}
}
private async Task RunUpdateChecksAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(6));
try { while (await timer.WaitForNextTickAsync(cancellationToken)) await CheckForUpdatesAsync(cancellationToken); }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
}
public async Task SelectSourceAsync(ObsSource source, CancellationToken cancellationToken = default)
{
await _settings.SaveAsync(_settings.Current with { PrimarySourceUuid = source.Uuid, PrimarySourceName = source.Name }, cancellationToken);
await SyncBridgeSelectionAsync(cancellationToken);
foreach (var item in ObsSources) await SendSourceUpdateAsync(item);
await WriteLogAsync("source_selected", $"Selected OBS source {source.Name}.");
}
public void OpenLumiWebUi()
{
if (!Uri.TryCreate(State.Host, UriKind.Absolute, out var uri)) return;
Process.Start(new ProcessStartInfo(uri.ToString()) { UseShellExecute = true });
}
public void OpenLogsDirectory()
{
Directory.CreateDirectory(_paths.LogsDirectory);
Process.Start(new ProcessStartInfo(_paths.LogsDirectory) { UseShellExecute = true });
}
public async Task ForgetDeviceAsync()
{
if (_socket is not null) { await _socket.DisposeAsync(); _socket = null; }
_credentials.Remove();
SetState(WithBridgeState(new CompanionState(Detail: "Device removed. Pair this computer to reconnect.")));
await WriteLogAsync("device_removed", "Saved device credential removed locally.");
}
private Task OnServerMessageAsync(ServerEnvelope message)
{
if (message.Type == "caption")
{
var text = message.Payload.TryGetProperty("stable_text", out var stable) ? stable.GetString() : null;
var simulated = message.Payload.TryGetProperty("delivery", out var delivery) && delivery.TryGetProperty("disposition", out var disposition) && disposition.GetString() == "simulated";
if (!string.IsNullOrWhiteSpace(text))
{
if (simulated) _captionSignal?.TrySetResult(true);
CaptionReceived?.Invoke(text, simulated);
if (!simulated) _ = _obsBridge?.SendAsync(new { type = "caption", payload = message.Payload });
}
}
if (message.Type == "error")
{
var serverMessage = message.Payload.TryGetProperty("message", out var value) ? value.GetString() : "Lumi reported an error.";
SetState(State with { Health = TrayHealth.Degraded, Detail = serverMessage ?? "Lumi reported an error." });
}
return Task.CompletedTask;
}
private void StartObsBridgeBoundary()
{
if (_obsBridge is not null) return;
var userKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{Environment.UserDomainName}\\{Environment.UserName}"))).Substring(0, 16);
_obsBridge = new ObsBridgePipe(userKey, OnObsMessageAsync, OnObsAudioAsync);
_obsBridge.ConnectionChanged += connected =>
{
var health = connected && State.Connected ? TrayHealth.Ready : State.Health;
SetState(State with { ObsBridgeInstalled = connected || State.ObsBridgeInstalled, ObsConnected = connected, Health = health, Detail = connected ? "OBS and Lumi are connected. Choose a microphone and run a safe test." : State.Detail });
_ = WriteLogAsync("obs_connection", connected ? "OBS bridge connected." : "OBS bridge disconnected.");
if (connected) _ = SyncBridgeSelectionAsync();
};
_obsBridge.Start();
}
private async Task OnObsMessageAsync(JsonElement message)
{
var type = message.GetProperty("type").GetString();
if (type == "obs_state")
{
var streaming = ReadBoolean(message, "streaming");
var recording = ReadBoolean(message, "recording");
SetState(State with { ObsConnected = true, ObsStreaming = streaming, ObsRecording = recording, Health = streaming ? TrayHealth.Operating : TrayHealth.Ready, Detail = streaming ? "OBS is live and companion services are active." : "OBS is connected and ready." });
if (_socket is not null && State.Connected)
await _socket.SendAsync("obs_state", new { streaming, recording, version = ReadString(message, "version"), auto_start = _settings.Current.StartWithObs }, _socket.SessionId, _lifetimeToken());
}
else if (type == "source_list" && message.TryGetProperty("sources", out var sources) && sources.ValueKind == JsonValueKind.Array)
{
ObsSources = sources.EnumerateArray().Select(source => new ObsSource(
ReadString(source, "source_uuid") ?? ReadString(source, "uuid") ?? string.Empty,
ReadString(source, "display_name") ?? ReadString(source, "name") ?? "OBS source",
ReadBoolean(source, "program_active") || ReadBoolean(source, "active"),
ReadBoolean(source, "source_missing") || ReadBoolean(source, "missing")))
.Where(source => Guid.TryParse(source.Uuid, out _)).ToArray();
ObsSourcesChanged?.Invoke(ObsSources);
foreach (var source in ObsSources) await SendSourceUpdateAsync(source);
}
else if (type == "source_state")
{
var source = new ObsSource(ReadString(message, "source_uuid") ?? string.Empty, ReadString(message, "display_name") ?? "OBS source", ReadBoolean(message, "program_active"), ReadBoolean(message, "source_missing"));
if (Guid.TryParse(source.Uuid, out _))
{
ObsSources = ObsSources.Where(item => item.Uuid != source.Uuid).Append(source).OrderBy(item => item.Name).ToArray();
ObsSourcesChanged?.Invoke(ObsSources);
await SendSourceUpdateAsync(source);
}
}
}
private Task OnObsAudioAsync(ReadOnlyMemory<byte> frame)
{
_audioSignal?.TrySetResult(true);
if (_socket is not null && State.Connected && !_socket.QueueBridgeAudio(frame))
_ = WriteLogAsync("audio_dropped", "An obsolete audio frame was dropped before network delivery.");
return Task.CompletedTask;
}
private async Task SendSourceUpdateAsync(ObsSource source)
{
if (_socket is null || !State.Connected) return;
await _socket.SendAsync("source_update", new
{
source_uuid = source.Uuid,
display_name = source.Name,
enabled = source.Uuid == _settings.Current.PrimarySourceUuid,
primary = source.Uuid == _settings.Current.PrimarySourceUuid,
program_active = source.Active,
source_missing = source.Missing,
single_speaker = true,
delivery_enabled = true
}, _socket.SessionId, _lifetimeToken());
}
private Task<bool> SyncBridgeSelectionAsync(CancellationToken cancellationToken = default) => _obsBridge?.SendAsync(new
{
type = "select_sources",
protocol_version = 1,
source_uuids = string.IsNullOrWhiteSpace(_settings.Current.PrimarySourceUuid) ? Array.Empty<string>() : new[] { _settings.Current.PrimarySourceUuid },
primary_source_uuid = _settings.Current.PrimarySourceUuid
}, cancellationToken) ?? Task.FromResult(false);
private static bool ReadBoolean(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.True;
private static string? ReadString(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String ? property.GetString() : null;
private CancellationToken _lifetimeToken() => _disposed ? new CancellationToken(true) : CancellationToken.None;
private static async Task<bool> WaitForAsync(Task task, TimeSpan timeout, CancellationToken cancellationToken)
{
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var delay = Task.Delay(timeout, timeoutSource.Token);
if (await Task.WhenAny(task, delay) != task) return false;
timeoutSource.Cancel();
await task;
return true;
}
private bool DetectBridgeInstallation() => _bridgeManager.Inspect().Valid;
private CompanionState WithBridgeState(CompanionState state) { var bridge = _bridgeManager.Inspect(); return state with { ObsBridgeInstalled = bridge.Valid, ObsBridgeRepairNeeded = bridge.Installed && !bridge.Valid, ObsBridgePackageAvailable = bridge.PackageAvailable, ObsBridgeDetail = bridge.Detail }; }
private static string? FindBundledPairingPackage()
{
try
{
return Directory.EnumerateFiles(AppContext.BaseDirectory, "*.lumi-pairing.json", SearchOption.TopDirectoryOnly).Take(2).SingleOrDefault();
}
catch { return null; }
}
private void ApplyAutoStart(bool enabled)
{
if (!OperatingSystem.IsWindows()) return;
try
{
using var key = Registry.CurrentUser.CreateSubKey(RunKey);
if (enabled) key.SetValue("Lumi Companion", $"\"{Environment.ProcessPath}\" --background");
else key.DeleteValue("Lumi Companion", false);
}
catch (Exception error) { _ = WriteLogAsync("autostart_failed", error.Message); }
}
private async Task WriteLogAsync(string kind, string message)
{
try
{
Directory.CreateDirectory(_paths.LogsDirectory);
PruneLogs();
var path = Path.Combine(_paths.LogsDirectory, $"companion-{DateTime.UtcNow:yyyy-MM-dd}.jsonl");
var line = JsonSerializer.Serialize(new { timestamp = DateTimeOffset.UtcNow, kind, message }, ProtocolV1.JsonOptions);
await File.AppendAllTextAsync(path, line + Environment.NewLine);
LogAdded?.Invoke($"{DateTime.Now:HH:mm:ss} {message}");
}
catch { }
}
private void PruneLogs()
{
var files = new DirectoryInfo(_paths.LogsDirectory).GetFiles("*.jsonl").OrderBy(file => file.CreationTimeUtc).ToList();
foreach (var file in files.Where(file => file.CreationTimeUtc < DateTime.UtcNow.AddDays(-7))) file.Delete();
const long cap = 256L * 1024 * 1024;
var total = files.Where(file => file.Exists).Sum(file => file.Length);
foreach (var file in files.Where(file => file.Exists)) { if (total <= cap) break; total -= file.Length; file.Delete(); }
}
private void SetState(CompanionState state) { State = state; StateChanged?.Invoke(state); }
private static string Friendly(Exception error) => error switch
{
InvalidDataException => error.Message,
HttpRequestException => "Check the Lumi address, TLS certificate, and network connection.",
TaskCanceledException => "The connection timed out. Check that Lumi is reachable.",
_ => error.Message
};
private static TestStage[] CreateInitialTestStages() =>
[
new("OBS integration", "Waiting to check the managed bridge.", TestStageState.Waiting),
new("OBS connection", "Waiting for OBS.", TestStageState.Waiting),
new("Microphone", "Waiting for a selected source.", TestStageState.Waiting),
new("Audio capture", "Waiting for live microphone audio.", TestStageState.Waiting),
new("Lumi connection", "Waiting to verify secure transport.", TestStageState.Waiting),
new("Speech recognition", "Waiting for real server-hosted inference.", TestStageState.Waiting),
new("Caption return", "Waiting for a stable caption.", TestStageState.Waiting),
new("Delivery adapter", "Waiting for safe simulation mode.", TestStageState.Waiting),
new("Simulated output", "Nothing is sent to Twitch during this test.", TestStageState.Waiting)
];
public static string Version => (Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0.1.0").Split('+')[0];
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
_maintenanceLifetime.Cancel();
if (_socket is not null) await _socket.DisposeAsync();
if (_obsBridge is not null) await _obsBridge.DisposeAsync();
_http.Dispose(); _maintenanceLifetime.Dispose();
}
}