Fix installed OBS integration discovery

This commit is contained in:
Franz Rolfsvaag 2026-07-22 21:18:19 +02:00
parent 739e96eee6
commit eb127eadeb
12 changed files with 112 additions and 29 deletions

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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"

View File

@ -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

View File

@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Version>0.1.0-experimental.7</Version>
<Version>0.1.0-experimental.8</Version>
<AssemblyVersion>0.1.0.0</AssemblyVersion>
</PropertyGroup>
<ItemGroup>

View File

@ -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<bool>(this);
}
finally { RenderState(_runtime.State); }
}
private async Task SaveSettingsAsync()
{
SaveSettingsButton.IsEnabled = false;

View File

@ -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<ObsBridgeManifest>(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<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 = JsonSerializer.Deserialize<ObsBridgeManifest>(manifestBytes, ProtocolV1.JsonOptions);
if (manifest is not null && HashBytes(dll) == manifest.Sha256)
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<ObsBridgeManifest>(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<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; } }

View File

@ -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);

View File

@ -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"
}
]

View File

@ -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",

View File

@ -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);