using System.Diagnostics; using System.ComponentModel; using System.Reflection; using System.Security.Cryptography; using System.Security.Principal; using System.Text.Json; using System.Text.Json.Serialization; using Lumi.Companion.Protocol; namespace Lumi.Companion.App; public sealed class ObsBridgeManager { private readonly string _installRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "obs-studio", "plugins", "lumi-obs-bridge"); private string? _packageFailure; private string InstalledDll => Path.Combine(_installRoot, "bin", "64bit", "lumi-obs-bridge.dll"); private string InstalledManifest => Path.Combine(_installRoot, "lumi-managed.json"); public ObsBridgeStatus Inspect() { var package = LoadPackage(); var installed = ReadManifest(InstalledManifest); var packageAvailable = package is not null; var fileInstalled = File.Exists(InstalledDll); var valid = packageAvailable && fileInstalled && HashFile(InstalledDll) == package!.Manifest.Sha256 && installed?.Version == package.Manifest.Version; var updateAvailable = packageAvailable && fileInstalled && installed is not null && installed.Version != package!.Manifest.Version; return new ObsBridgeStatus(packageAvailable, fileInstalled, valid, updateAvailable, installed?.Version, package?.Manifest.Version, valid ? $"OBS integration {package!.Manifest.Version} is installed. Restart OBS if it was open during the last repair." : !packageAvailable ? $"The bundled OBS integration could not be verified. {_packageFailure ?? "The packaged component was not found."}" : updateAvailable ? $"OBS Bridge {installed!.Version} is installed; Companion includes {package!.Manifest.Version}. Close OBS, then update the bridge." : fileInstalled ? "The OBS integration is outdated or damaged. Repair it while OBS is closed." : "The OBS integration is ready to install."); } public async Task InstallOrRepairAsync(CancellationToken cancellationToken = default) { EnsureObsClosed(); var status = Inspect(); if (!status.PackageAvailable) throw new InvalidOperationException(status.Detail); if (!IsElevated()) { await RunElevatedAsync("install", cancellationToken); var elevatedResult = Inspect(); if (!elevatedResult.Valid) throw new InvalidDataException("The OBS integration did not pass verification after installation."); return elevatedResult; } return await InstallDirectAsync(cancellationToken); } private async Task InstallDirectAsync(CancellationToken cancellationToken) { var package = LoadPackage() ?? throw new InvalidDataException("The bundled OBS integration could not be verified."); var manifest = package.Manifest; var bin = Path.GetDirectoryName(InstalledDll)!; var locale = Path.Combine(_installRoot, "data", "locale"); Directory.CreateDirectory(bin); Directory.CreateDirectory(locale); await WriteAtomicAsync(package.Dll, InstalledDll, cancellationToken); if (package.Locale is not null) await WriteAtomicAsync(package.Locale, Path.Combine(locale, "en-US.ini"), cancellationToken); var marker = JsonSerializer.SerializeToUtf8Bytes(manifest, ProtocolV1.JsonOptions); var temporary = $"{InstalledManifest}.{Environment.ProcessId}.tmp"; await File.WriteAllBytesAsync(temporary, marker, cancellationToken); File.Move(temporary, InstalledManifest, true); var result = Inspect(); if (!result.Valid) throw new InvalidDataException("The OBS integration did not pass verification after installation."); return result; } public Task RemoveAsync() { EnsureObsClosed(); return RemoveCoreAsync(); } private async Task RemoveCoreAsync() { if (!IsElevated()) await RunElevatedAsync("remove", CancellationToken.None); else if (Directory.Exists(_installRoot)) Directory.Delete(_installRoot, true); } public static bool IsMaintenanceRequest(string[] args) => args.Length is 2 or 3 && args[0].Equals("--manage-obs-bridge", StringComparison.OrdinalIgnoreCase); public static bool IsDiagnosticRequest(string[] args) => args.Length == 2 && args[0].Equals("--diagnose-obs-bridge", StringComparison.OrdinalIgnoreCase); public static int RunDiagnostic(string[] args) { if (!IsDiagnosticRequest(args)) return 10; try { var status = new ObsBridgeManager().Inspect(); File.WriteAllBytes(args[1], JsonSerializer.SerializeToUtf8Bytes(status, ProtocolV1.JsonOptions)); return status.PackageAvailable ? 0 : 9; } catch (Exception error) { try { File.WriteAllText(args[1], JsonSerializer.Serialize(new { packageAvailable = false, detail = error.Message }, ProtocolV1.JsonOptions)); } catch { } return 9; } } public static int RunMaintenance(string[] args) { if (!IsMaintenanceRequest(args) || !IsElevated()) return 6; try { var manager = new ObsBridgeManager(); EnsureObsClosed(); if (args[1].Equals("install", StringComparison.OrdinalIgnoreCase)) manager.InstallDirectAsync(CancellationToken.None).GetAwaiter().GetResult(); else if (args[1].Equals("remove", StringComparison.OrdinalIgnoreCase)) { if (Directory.Exists(manager._installRoot)) Directory.Delete(manager._installRoot, true); } else return 7; return 0; } catch (Exception error) { if (args.Length == 3) try { File.WriteAllText(args[2], error.Message); } catch { } return 8; } } private static bool IsElevated() { if (!OperatingSystem.IsWindows()) return false; using var identity = WindowsIdentity.GetCurrent(); return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); } private static async Task RunElevatedAsync(string action, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(Environment.ProcessPath)) throw new InvalidOperationException("The packaged Companion application is required to manage OBS integration."); var resultPath = Path.Combine(Path.GetTempPath(), $"lumi-obs-maintenance-{Guid.NewGuid():N}.txt"); var start = new ProcessStartInfo(Environment.ProcessPath) { UseShellExecute = true, Verb = "runas", WindowStyle = ProcessWindowStyle.Hidden }; start.ArgumentList.Add("--manage-obs-bridge"); start.ArgumentList.Add(action); start.ArgumentList.Add(resultPath); try { using var process = Process.Start(start) ?? throw new InvalidOperationException("The OBS integration maintenance process could not start."); await process.WaitForExitAsync(cancellationToken); if (process.ExitCode != 0) { string? detail = null; try { if (File.Exists(resultPath)) detail = File.ReadAllText(resultPath).Trim(); } catch { } throw new InvalidOperationException(string.IsNullOrWhiteSpace(detail) ? "OBS integration maintenance did not complete." : detail); } } catch (Win32Exception error) when (error.NativeErrorCode == 1223) { throw new InvalidOperationException("Administrator approval was cancelled. Lumi only requests it to manage the OBS plugin under ProgramData."); } finally { try { File.Delete(resultPath); } catch { } } } private static void EnsureObsClosed() { var processes = Process.GetProcessesByName("obs64"); foreach (var process in processes) process.Dispose(); if (processes.Length > 0) throw new InvalidOperationException("Close OBS before installing, repairing, or removing the managed integration. Companion will never modify a loaded OBS plugin."); } private static ObsBridgeManifest? ReadManifest(string path) { try { return JsonSerializer.Deserialize(File.ReadAllBytes(path), ProtocolV1.JsonOptions); } catch { return null; } } private ObsBridgePackage? LoadPackage() { var failures = new List(); var roots = CandidatePackageRoots(); foreach (var root in roots) { try { var manifestBytes = File.ReadAllBytes(Path.Combine(root, "manifest.json")); var dll = File.ReadAllBytes(Path.Combine(root, "lumi-obs-bridge.dll")); var manifest = ParseManifest(manifestBytes); var actualHash = HashBytes(dll); if (!actualHash.Equals(manifest.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException($"OBS bridge checksum mismatch (expected {manifest.Sha256}, got {actualHash})."); _packageFailure = null; return new ObsBridgePackage(manifest, dll, File.Exists(Path.Combine(root, "en-US.ini")) ? File.ReadAllBytes(Path.Combine(root, "en-US.ini")) : null); } catch (Exception error) { failures.Add($"{root}: {error.Message}"); } } try { var assembly = Assembly.GetExecutingAssembly(); var manifestBytes = ReadResource(assembly, "Lumi.Companion.ObsBridge.manifest.json"); var dll = ReadResource(assembly, "Lumi.Companion.ObsBridge.dll"); var manifest = ParseManifest(manifestBytes); var actualHash = HashBytes(dll); if (!actualHash.Equals(manifest.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException($"Embedded OBS bridge checksum mismatch (expected {manifest.Sha256}, got {actualHash})."); _packageFailure = null; return new ObsBridgePackage(manifest, dll, TryReadResource(assembly, "Lumi.Companion.ObsBridge.en-US.ini")); } catch (Exception error) { failures.Add($"embedded payload: {error.Message}"); _packageFailure = string.Join(" ", failures); return null; } } private static IReadOnlyList CandidatePackageRoots() { var bases = new List { Path.GetDirectoryName(Environment.ProcessPath), AppContext.BaseDirectory, Path.GetDirectoryName(Environment.GetCommandLineArgs().FirstOrDefault()), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Lumi Companion") }; try { bases.Add(Path.GetDirectoryName(Process.GetCurrentProcess().MainModule?.FileName)); } catch { } return bases.Where(value => !string.IsNullOrWhiteSpace(value)) .Select(value => Path.Combine(value!, "components", "obs-bridge")) .Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } private static ObsBridgeManifest ParseManifest(byte[] value) { var start = value.Length >= 3 && value[0] == 0xEF && value[1] == 0xBB && value[2] == 0xBF ? 3 : 0; using var document = JsonDocument.Parse(value.AsMemory(start)); var root = document.RootElement; var version = root.GetProperty("version").GetString(); var sha256 = root.GetProperty("sha256").GetString(); var minimum = root.GetProperty("obs_minimum_version").GetString(); if (string.IsNullOrWhiteSpace(version) || string.IsNullOrWhiteSpace(minimum) || sha256 is null || sha256.Length != 64 || !sha256.All(Uri.IsHexDigit)) throw new InvalidDataException("The OBS bridge manifest is invalid."); return new ObsBridgeManifest(version, sha256.ToLowerInvariant(), minimum); } private static byte[] ReadResource(Assembly assembly, string name) { using var stream = assembly.GetManifestResourceStream(name) ?? throw new FileNotFoundException($"Embedded resource {name} is missing."); using var body = new MemoryStream(); stream.CopyTo(body); return body.ToArray(); } private static byte[]? TryReadResource(Assembly assembly, string name) { try { return ReadResource(assembly, name); } catch { return null; } } private static string HashFile(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); } private static string HashBytes(byte[] value) => Convert.ToHexString(SHA256.HashData(value)).ToLowerInvariant(); private static async Task WriteAtomicAsync(byte[] value, string target, CancellationToken cancellationToken) { var temporary = $"{target}.{Environment.ProcessId}.tmp"; try { await File.WriteAllBytesAsync(temporary, value, cancellationToken); File.Move(temporary, target, true); } catch { try { File.Delete(temporary); } catch { } throw; } } } public sealed record ObsBridgeManifest( [property: JsonPropertyName("version")] string Version, [property: JsonPropertyName("sha256")] string Sha256, [property: JsonPropertyName("obs_minimum_version")] string ObsMinimumVersion); public sealed record ObsBridgeStatus( bool PackageAvailable, bool Installed, bool Valid, bool UpdateAvailable, string? InstalledVersion, string? Version, string Detail); internal sealed record ObsBridgePackage(ObsBridgeManifest Manifest, byte[] Dll, byte[]? Locale);