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 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; return new ObsBridgeStatus(packageAvailable, fileInstalled, valid, 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. Use Check now to repair the Companion package." : 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 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 roots = new[] { Path.Combine(Path.GetDirectoryName(Environment.ProcessPath) ?? string.Empty, "components", "obs-bridge"), Path.Combine(AppContext.BaseDirectory, "components", "obs-bridge") }.Distinct(StringComparer.OrdinalIgnoreCase); 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 = JsonSerializer.Deserialize(manifestBytes, ProtocolV1.JsonOptions); if (manifest is not null && HashBytes(dll) == manifest.Sha256) return new ObsBridgePackage(manifest, dll, File.Exists(Path.Combine(root, "en-US.ini")) ? File.ReadAllBytes(Path.Combine(root, "en-US.ini")) : null); } catch { } } try { var assembly = Assembly.GetExecutingAssembly(); var manifestBytes = ReadResource(assembly, "Lumi.Companion.ObsBridge.manifest.json"); var dll = ReadResource(assembly, "Lumi.Companion.ObsBridge.dll"); var manifest = JsonSerializer.Deserialize(manifestBytes, ProtocolV1.JsonOptions); return manifest is not null && HashBytes(dll) == manifest.Sha256 ? new ObsBridgePackage(manifest, dll, TryReadResource(assembly, "Lumi.Companion.ObsBridge.en-US.ini")) : null; } catch { return null; } } 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, string? Version, string Detail); internal sealed record ObsBridgePackage(ObsBridgeManifest Manifest, byte[] Dll, byte[]? Locale);