256 lines
13 KiB
C#
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);
|