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