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? _audioSignal; private TaskCompletionSource? _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 TestStages { get; private set; } public IReadOnlyList ObsSources { get; private set; } public event Action? StateChanged; public event Action>? TestStagesChanged; public event Action>? ObsSourcesChanged; public event Action? CaptionReceived; public event Action? LogAdded; public event Func? 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 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 SyncBridgeSelectionAsync(CancellationToken cancellationToken = default) => _obsBridge?.SendAsync(new { type = "select_sources", protocol_version = 1, source_uuids = string.IsNullOrWhiteSpace(_settings.Current.PrimarySourceUuid) ? Array.Empty() : 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 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()?.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(); } }