Lumi/companion/src/Lumi.Companion.App/ObsBridgeManager.cs
2026-07-24 23:39:14 +02:00

256 lines
13 KiB
C#

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<ObsBridgeStatus> 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<ObsBridgeStatus> 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<ObsBridgeManifest>(File.ReadAllBytes(path), ProtocolV1.JsonOptions); } catch { return null; } }
private ObsBridgePackage? LoadPackage()
{
var failures = new List<string>();
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<string> CandidatePackageRoots()
{
var bases = new List<string?>
{
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);