From eb127eadeb7d5e908e8607ca8a8031a0a21458f3 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Wed, 22 Jul 2026 21:18:19 +0200 Subject: [PATCH] Fix installed OBS integration discovery --- TODO.md | 4 + companion/README.md | 2 +- companion/installer/Lumi.Companion.iss | 2 +- companion/scripts/build-obs-bridge.ps1 | 3 +- companion/scripts/publish-companion.ps1 | 11 ++- .../Lumi.Companion.App.csproj | 2 +- .../Lumi.Companion.App/MainWindow.axaml.cs | 17 +++- .../Lumi.Companion.App/ObsBridgeManager.cs | 80 +++++++++++++++---- companion/src/Lumi.Companion.App/Program.cs | 1 + .../companion_manifest.json | 16 ++-- plugins/lumi_transcription/plugin.json | 2 +- plugins/lumi_transcription/tests/verify.js | 1 + 12 files changed, 112 insertions(+), 29 deletions(-) diff --git a/TODO.md b/TODO.md index d53658a..a9a761c 100644 --- a/TODO.md +++ b/TODO.md @@ -50,6 +50,10 @@ Windows installer while preserving the verified in-place updater and DPAPI data. It also makes OBS bridge repair recoverable with fresh validation and actionable elevated-process errors, and adds timed permanent device revocation in the WebUI. +Experimental.8 makes the installed single-file build discover and verify its OBS +payload deterministically, adds an executable-level package diagnostic, and shows +OBS maintenance failures directly instead of leaving them only in diagnostics. + Release-blocking work remains: - Package the native whisper.cpp worker as the Windows CUDA runtime, then benchmark diff --git a/companion/README.md b/companion/README.md index 6fbc2f5..0f0e79f 100644 --- a/companion/README.md +++ b/companion/README.md @@ -22,6 +22,6 @@ companion/scripts/publish-companion.ps1 This verifies both official OBS archives by SHA-256, builds the native module with Visual Studio 2022/CMake, and places the bridge beside the published app as a managed component. In Companion, **Install/Repair** requests Windows administrator approval only to copy that verified component into OBS's shared ProgramData plugin directory. OBS must be closed; Companion refuses plugin maintenance while `obs64.exe` is running. -Experimental.3 checks for updates after connecting and every six hours. Updates remain user-approved, are checksum-verified, refuse to run while OBS is streaming or recording, replace the installed app and bundled components, then restart Companion. Pairing credentials and settings remain in the per-user data directory. Experimental.7 adds the durable per-user installer; existing portable users install it once, after which updates stay in the stable install directory. Code signing, rollback policy, and target-machine OBS/Twitch acceptance are still required. +Experimental.3 checks for updates after connecting and every six hours. Updates remain user-approved, are checksum-verified, refuse to run while OBS is streaming or recording, replace the installed app and bundled components, then restart Companion. Pairing credentials and settings remain in the per-user data directory. Experimental.7 adds the durable per-user installer; existing portable users install it once, after which updates stay in the stable install directory. Experimental.8 fixes packaged OBS integration discovery and exposes a standalone payload diagnostic. Code signing, rollback policy, and target-machine OBS/Twitch acceptance are still required. The Admin **Download Companion** action distributes a private ZIP containing a checksum-pinned Windows x64 installer plus the one-time pairing package. It is intentionally marked experimental and is not code-signed yet. diff --git a/companion/installer/Lumi.Companion.iss b/companion/installer/Lumi.Companion.iss index 461e88f..d09d8e6 100644 --- a/companion/installer/Lumi.Companion.iss +++ b/companion/installer/Lumi.Companion.iss @@ -1,5 +1,5 @@ #ifndef AppVersion - #define AppVersion "0.1.0-experimental.7" + #define AppVersion "0.1.0-experimental.8" #endif #ifndef SourceRoot #error SourceRoot must point at the self-contained Companion publish directory. diff --git a/companion/scripts/build-obs-bridge.ps1 b/companion/scripts/build-obs-bridge.ps1 index 6eeacff..b085c9f 100644 --- a/companion/scripts/build-obs-bridge.ps1 +++ b/companion/scripts/build-obs-bridge.ps1 @@ -94,5 +94,6 @@ $manifest = [ordered]@{ sha256 = (Get-FileHash $bridgeDll -Algorithm SHA256).Hash.ToLowerInvariant() obs_minimum_version = "31.0.0" } -$manifest | ConvertTo-Json | Set-Content -Encoding utf8 (Join-Path $componentRoot "manifest.json") +$manifestJson = $manifest | ConvertTo-Json +[IO.File]::WriteAllText((Join-Path $componentRoot "manifest.json"), $manifestJson, [Text.UTF8Encoding]::new($false)) Write-Host "Built OBS bridge $BridgeVersion at $componentRoot" diff --git a/companion/scripts/publish-companion.ps1 b/companion/scripts/publish-companion.ps1 index 0d35436..dbff2cc 100644 --- a/companion/scripts/publish-companion.ps1 +++ b/companion/scripts/publish-companion.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "0.1.0-experimental.7", + [string]$Version = "0.1.0-experimental.8", [string]$BridgeVersion = "0.1.0-experimental.5" ) @@ -22,6 +22,15 @@ $publishArguments = @("publish", "`"$project`"", "-c", "Release", "-r", "win-x64 $published = Start-Process -FilePath $dotnet -ArgumentList $publishArguments -NoNewWindow -Wait -PassThru if ($published.ExitCode) { throw "Companion publish failed." } +$bridgeDiagnostic = Join-Path $outputRoot "obs-bridge-package-diagnostic.json" +Remove-Item $bridgeDiagnostic -Force -ErrorAction SilentlyContinue +$diagnosed = Start-Process -FilePath (Join-Path $publishRoot "Lumi.Companion.App.exe") -ArgumentList @("--diagnose-obs-bridge", "`"$bridgeDiagnostic`"") -Wait -PassThru +if ($diagnosed.ExitCode -ne 0) { + $detail = if (Test-Path $bridgeDiagnostic) { Get-Content $bridgeDiagnostic -Raw } else { "No diagnostic result was written." } + throw "Published OBS integration payload failed its executable-level check: $detail" +} +Remove-Item $bridgeDiagnostic -Force -ErrorAction SilentlyContinue + Copy-Item (Join-Path $publishRoot "Lumi.Companion.App.exe") $stageRoot Copy-Item (Join-Path $publishRoot "components") $stageRoot -Recurse Remove-Item $archive -Force -ErrorAction SilentlyContinue diff --git a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj index 79b4f37..ffc86bd 100644 --- a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj +++ b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj @@ -5,7 +5,7 @@ enable enable app.manifest - 0.1.0-experimental.7 + 0.1.0-experimental.8 0.1.0.0 diff --git a/companion/src/Lumi.Companion.App/MainWindow.axaml.cs b/companion/src/Lumi.Companion.App/MainWindow.axaml.cs index 83c5517..67770a4 100644 --- a/companion/src/Lumi.Companion.App/MainWindow.axaml.cs +++ b/companion/src/Lumi.Companion.App/MainWindow.axaml.cs @@ -57,7 +57,7 @@ public partial class MainWindow : Window SaveSettingsButton.Click += async (_, _) => await SaveSettingsAsync(); CheckUpdateButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.CheckForUpdatesAsync(), CheckUpdateButton); ApplyUpdateButton.Click += async (_, _) => await ApplyUpdateAsync(); - InstallBridgeButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.InstallOrRepairObsBridgeAsync(), InstallBridgeButton); + InstallBridgeButton.Click += async (_, _) => await InstallBridgeAsync(); RemoveBridgeButton.Click += async (_, _) => await RemoveBridgeAsync(); AdvancedToggle.IsCheckedChanged += (_, _) => AdvancedPanel.IsVisible = AdvancedToggle.IsChecked == true; SourcePicker.SelectionChanged += async (_, _) => @@ -115,6 +115,21 @@ public partial class MainWindow : Window finally { RenderState(_runtime.State); } } + private async Task InstallBridgeAsync() + { + InstallBridgeButton.IsEnabled = false; + BridgeStatusText.Text = "Checking the packaged integration and requesting Windows approval…"; + try { await _runtime.InstallOrRepairObsBridgeAsync(); } + catch (Exception error) + { + AddLog($"{DateTime.Now:HH:mm:ss} {error.Message}"); + RenderState(_runtime.State); + var dialog = new DecisionWindow("OBS integration could not be installed", error.Message, "Close"); + await dialog.ShowDialog(this); + } + finally { RenderState(_runtime.State); } + } + private async Task SaveSettingsAsync() { SaveSettingsButton.IsEnabled = false; diff --git a/companion/src/Lumi.Companion.App/ObsBridgeManager.cs b/companion/src/Lumi.Companion.App/ObsBridgeManager.cs index 65b5240..eabc857 100644 --- a/companion/src/Lumi.Companion.App/ObsBridgeManager.cs +++ b/companion/src/Lumi.Companion.App/ObsBridgeManager.cs @@ -12,6 +12,7 @@ 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"); @@ -24,7 +25,7 @@ public sealed class ObsBridgeManager 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." : + !packageAvailable ? $"The bundled OBS integration could not be verified. {_packageFailure ?? "The packaged component was not found."}" : fileInstalled ? "The OBS integration is outdated or damaged. Repair it while OBS is closed." : "The OBS integration is ready to install."); } @@ -75,6 +76,23 @@ public sealed class ObsBridgeManager } 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) { @@ -138,33 +156,67 @@ public sealed class ObsBridgeManager 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); + 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 = 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); + 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 { } + 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 = 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; + 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 { return null; } + 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; } } diff --git a/companion/src/Lumi.Companion.App/Program.cs b/companion/src/Lumi.Companion.App/Program.cs index a3264d0..0975696 100644 --- a/companion/src/Lumi.Companion.App/Program.cs +++ b/companion/src/Lumi.Companion.App/Program.cs @@ -17,6 +17,7 @@ internal static class Program } if (UpdateApplier.IsApplyRequest(args)) return UpdateApplier.Apply(args); + if (ObsBridgeManager.IsDiagnosticRequest(args)) return ObsBridgeManager.RunDiagnostic(args); if (ObsBridgeManager.IsMaintenanceRequest(args)) return ObsBridgeManager.RunMaintenance(args); LaunchInBackground = args.Contains("--background", StringComparer.OrdinalIgnoreCase); diff --git a/plugins/lumi_transcription/companion_manifest.json b/plugins/lumi_transcription/companion_manifest.json index 6495a79..53620d4 100644 --- a/plugins/lumi_transcription/companion_manifest.json +++ b/plugins/lumi_transcription/companion_manifest.json @@ -1,17 +1,17 @@ { "schema_version": 1, - "version": "0.1.0-experimental.7", + "version": "0.1.0-experimental.8", "signed": false, - "release_notes": "Adds a durable per-user Windows installer, recoverable OBS integration repair with detailed errors, and permanent device revocation through Lumi's timed confirmation flow.", + "release_notes": "Fixes OBS integration package discovery in the installed single-file app, validates the packaged payload directly, and displays actionable installation errors.", "installer": { "id": "windows-x64-installer", "platform": "win32", "architecture": "x64", "label": "Windows x64 per-user installer", "filename": "Lumi.Companion-Setup.exe", - "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.7/Lumi.Companion-Setup.exe", - "sha256": "a06319ecbda2369e0f756fb4429d95583a58a1c802ae3537cd44faf0032c3ded", - "bytes": 32037289 + "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.8/Lumi.Companion-Setup.exe", + "sha256": "075fb4dd9a84ab10655d75899a6b4944269c9fe6371573778740d80b16ca19d0", + "bytes": 32041200 }, "artifacts": [ { @@ -20,9 +20,9 @@ "architecture": "x64", "label": "Windows x64 self-contained", "filename": "Lumi.Companion-win-x64.zip", - "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.7/Lumi.Companion-win-x64.zip", - "sha256": "5578b61aafbcf40b84cfa846aa31f2e2ff49700f62692e00c8d0587eac0cb414", - "bytes": 41757402, + "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.8/Lumi.Companion-win-x64.zip", + "sha256": "46c58b044a213e328fa96fe86e48b9ea0b67e327439fed14fc485c711f03eb3e", + "bytes": 41757425, "entrypoint": "Lumi.Companion.App.exe" } ] diff --git a/plugins/lumi_transcription/plugin.json b/plugins/lumi_transcription/plugin.json index 1637185..ad0e339 100644 --- a/plugins/lumi_transcription/plugin.json +++ b/plugins/lumi_transcription/plugin.json @@ -1,7 +1,7 @@ { "id": "lumi_transcription", "name": "Lumi Transcription", - "version": "0.1.0-experimental.7", + "version": "0.1.0-experimental.8", "description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.", "main": "index.js", "channel": "experimental", diff --git a/plugins/lumi_transcription/tests/verify.js b/plugins/lumi_transcription/tests/verify.js index b72f604..408a951 100644 --- a/plugins/lumi_transcription/tests/verify.js +++ b/plugins/lumi_transcription/tests/verify.js @@ -117,6 +117,7 @@ function verifyLocalhostTransportPolicy() { } function verifyCompanionVersionOrdering() { + assert.equal(plugin.compareVersions("0.1.0-experimental.8", "0.1.0-experimental.7"), 1); assert.equal(plugin.compareVersions("0.1.0-experimental.7", "0.1.0-experimental.6"), 1); assert.equal(plugin.compareVersions("0.1.0-experimental.6", "0.1.0-experimental.5"), 1); assert.equal(plugin.compareVersions("0.1.0-experimental.5", "0.1.0-experimental.4"), 1);