Install Companion and manage paired access

This commit is contained in:
Franz Rolfsvaag 2026-07-22 20:57:50 +02:00
parent 2e6de64e61
commit 7c571364c4
16 changed files with 234 additions and 43 deletions

View File

@ -45,12 +45,17 @@ adds a live dBFS meter to the dedicated speech test, and fixes reusable benchmar
sessions so failed starts cannot leave worker activity behind. Companion now also sessions so failed starts cannot leave worker activity behind. Companion now also
reports verified OBS integration and path readiness to the WebUI setup progress. reports verified OBS integration and path readiness to the WebUI setup progress.
Experimental.7 replaces the portable paired app download with a durable per-user
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.
Release-blocking work remains: Release-blocking work remains:
- Package the native whisper.cpp worker as the Windows CUDA runtime, then benchmark - Package the native whisper.cpp worker as the Windows CUDA runtime, then benchmark
`small.en`, quantized `small.en`, and `base.en` on the RTX 3060. `small.en`, quantized `small.en`, and `base.en` on the RTX 3060.
- Complete the signed companion installer and DPAPI migration UX; add a rollback - Sign the companion installer and add a rollback policy after the experimental
policy after the experimental updater has target-machine acceptance evidence. updater has target-machine acceptance evidence.
- Complete target-machine bridge acceptance and source rename/missing recovery. - Complete target-machine bridge acceptance and source rename/missing recovery.
- Run the OBS 31+/Twitch compatibility spike and prove toggleable closed captions; - Run the OBS 31+/Twitch compatibility spike and prove toggleable closed captions;
tune replacement/display duration from player behavior without open-caption fallback. tune replacement/display duration from player behavior without open-caption fallback.

View File

@ -4,7 +4,7 @@ This directory is the single Lumi Companion product boundary. The current milest
Build prerequisites: the current .NET SDK with the .NET 8 targeting pack on Windows x64. The app targets .NET 8; .NET SDK 10 is recommended for Avalonia 12 source-generator compatibility. The native bridge additionally requires CMake, Visual Studio C++ tools, and the OBS 31+ SDK. Build prerequisites: the current .NET SDK with the .NET 8 targeting pack on Windows x64. The app targets .NET 8; .NET SDK 10 is recommended for Avalonia 12 source-generator compatibility. The native bridge additionally requires CMake, Visual Studio C++ tools, and the OBS 31+ SDK.
The app accepts a `.lumi-pairing.json` bootstrap package. A paired ZIP downloaded from Lumi includes one beside the executable; the app detects it automatically, exchanges the embedded token once, stores the returned device credential with Windows DPAPI, and removes the pairing file after success. Do not commit bootstrap packages, credentials, generated installers, build output, or logs. The app accepts a `.lumi-pairing.json` bootstrap package. A paired ZIP downloaded from Lumi contains the per-user Windows setup executable and one bootstrap file. Setup installs Companion under `%LocalAppData%\Programs\Lumi Companion`, imports the adjacent package, and launches the installed app. Companion exchanges the embedded token once, stores the returned device credential with Windows DPAPI, and removes the imported pairing file after success. Do not commit bootstrap packages, credentials, generated installers, build output, or logs.
The companion never performs ASR in this MVP. Audio is normalized to 16 kHz mono signed 16-bit PCM, held in bounded memory, and sent to the paired Lumi host over TLS WebSockets. HTTP/WS works only for a package generated from the exact matching loopback Lumi origin. The OBS bridge talks only to the companion over a same-user named pipe. The companion never performs ASR in this MVP. Audio is normalized to 16 kHz mono signed 16-bit PCM, held in bounded memory, and sent to the paired Lumi host over TLS WebSockets. HTTP/WS works only for a package generated from the exact matching loopback Lumi origin. The OBS bridge talks only to the companion over a same-user named pipe.
@ -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. 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 app and bundled components, then restart Companion. Pairing credentials and settings remain in the per-user data directory. Experimental.2 requires one final manual download to bootstrap this updater. A signed installer, rollback policy, and target-machine OBS/Twitch acceptance run 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. Code signing, rollback policy, and target-machine OBS/Twitch acceptance are still required.
The Admin **Download Companion** action distributes a checksum-pinned, self-contained Windows x64 ZIP. It is intentionally marked experimental and is not code-signed yet. 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

@ -0,0 +1,72 @@
#ifndef AppVersion
#define AppVersion "0.1.0-experimental.7"
#endif
#ifndef SourceRoot
#error SourceRoot must point at the self-contained Companion publish directory.
#endif
#ifndef OutputRoot
#error OutputRoot must point at the installer output directory.
#endif
[Setup]
AppId={{EDEB45E4-41CB-4D7A-A479-4073A1A08B93}
AppName=Lumi Companion
AppVersion={#AppVersion}
AppVerName=Lumi Companion {#AppVersion}
AppPublisher=Lumi
DefaultDirName={localappdata}\Programs\Lumi Companion
DefaultGroupName=Lumi Companion
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
OutputDir={#OutputRoot}
OutputBaseFilename=Lumi.Companion-Setup
Compression=lzma2/ultra64
SolidCompression=yes
WizardStyle=modern
SetupLogging=yes
CloseApplications=yes
CloseApplicationsFilter=Lumi.Companion.App.exe
RestartApplications=no
UninstallDisplayName=Lumi Companion
UninstallDisplayIcon={app}\Lumi.Companion.App.exe
VersionInfoDescription=Lumi Companion installer
VersionInfoProductName=Lumi Companion
VersionInfoProductTextVersion={#AppVersion}
[Files]
Source: "{#SourceRoot}\Lumi.Companion.App.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#SourceRoot}\components\*"; DestDir: "{app}\components"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
Name: "{autoprograms}\Lumi Companion"; Filename: "{app}\Lumi.Companion.App.exe"
[Run]
Filename: "{app}\Lumi.Companion.App.exe"; Description: "Open Lumi Companion"; Flags: nowait postinstall skipifsilent
[Code]
procedure ImportPairingPackage;
var
FindRec: TFindRec;
SourcePath: String;
TargetPath: String;
begin
if FindFirst(ExpandConstant('{src}\*.lumi-pairing.json'), FindRec) then
begin
try
SourcePath := AddBackslash(ExpandConstant('{src}')) + FindRec.Name;
TargetPath := AddBackslash(ExpandConstant('{app}')) + FindRec.Name;
if not CopyFile(SourcePath, TargetPath, False) then
MsgBox('Lumi Companion was installed, but its one-time pairing package could not be imported. Open Companion and choose the pairing file manually.', mbError, MB_OK);
finally
FindClose(FindRec);
end;
end;
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssPostInstall then
ImportPairingPackage;
end;

View File

@ -1,5 +1,5 @@
param( param(
[string]$Version = "0.1.0-experimental.6", [string]$Version = "0.1.0-experimental.7",
[string]$BridgeVersion = "0.1.0-experimental.5" [string]$BridgeVersion = "0.1.0-experimental.5"
) )
@ -10,6 +10,7 @@ $outputRoot = Join-Path $repoRoot "companion\installer\output"
$publishRoot = Join-Path $outputRoot "publish" $publishRoot = Join-Path $outputRoot "publish"
$stageRoot = Join-Path $outputRoot "package" $stageRoot = Join-Path $outputRoot "package"
$archive = Join-Path $outputRoot "Lumi.Companion-win-x64.zip" $archive = Join-Path $outputRoot "Lumi.Companion-win-x64.zip"
$installer = Join-Path $outputRoot "Lumi.Companion-Setup.exe"
& (Join-Path $PSScriptRoot "build-obs-bridge.ps1") -BridgeVersion $BridgeVersion & (Join-Path $PSScriptRoot "build-obs-bridge.ps1") -BridgeVersion $BridgeVersion
Remove-Item $publishRoot, $stageRoot -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $publishRoot, $stageRoot -Recurse -Force -ErrorAction SilentlyContinue
@ -25,9 +26,32 @@ Copy-Item (Join-Path $publishRoot "Lumi.Companion.App.exe") $stageRoot
Copy-Item (Join-Path $publishRoot "components") $stageRoot -Recurse Copy-Item (Join-Path $publishRoot "components") $stageRoot -Recurse
Remove-Item $archive -Force -ErrorAction SilentlyContinue Remove-Item $archive -Force -ErrorAction SilentlyContinue
Compress-Archive -Path (Join-Path $stageRoot "*") -DestinationPath $archive -CompressionLevel Optimal Compress-Archive -Path (Join-Path $stageRoot "*") -DestinationPath $archive -CompressionLevel Optimal
$isccCandidates = @(
(Join-Path $env:LOCALAPPDATA "Programs\Inno Setup 6\ISCC.exe"),
(Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe"),
(Join-Path $env:ProgramFiles "Inno Setup 6\ISCC.exe")
)
$iscc = $isccCandidates | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
if (-not $iscc) { throw "Inno Setup 6 is required to build the durable per-user Companion installer." }
Remove-Item $installer -Force -ErrorAction SilentlyContinue
$compileArguments = @(
"`"/DAppVersion=$Version`"",
"`"/DSourceRoot=$publishRoot`"",
"`"/DOutputRoot=$outputRoot`"",
"`"$(Join-Path $repoRoot "companion\installer\Lumi.Companion.iss")`""
)
$compiled = Start-Process -FilePath $iscc -ArgumentList $compileArguments -NoNewWindow -Wait -PassThru
if ($compiled.ExitCode -ne 0 -or -not (Test-Path $installer)) { throw "Companion installer compilation failed." }
$file = Get-Item $archive $file = Get-Item $archive
$sha = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() $sha = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant()
$setupFile = Get-Item $installer
$setupSha = (Get-FileHash $installer -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Host "Published Lumi Companion $Version" Write-Host "Published Lumi Companion $Version"
Write-Host "Artifact: $archive" Write-Host "Artifact: $archive"
Write-Host "Bytes: $($file.Length)" Write-Host "Bytes: $($file.Length)"
Write-Host "SHA256: $sha" Write-Host "SHA256: $sha"
Write-Host "Installer: $installer"
Write-Host "Installer bytes: $($setupFile.Length)"
Write-Host "Installer SHA256: $setupSha"

View File

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

View File

@ -191,7 +191,9 @@ public partial class MainWindow : Window
UpdateStatusText.Text = state.UpdateDetail; UpdateStatusText.Text = state.UpdateDetail;
BridgeStatusText.Text = state.ObsBridgeDetail; BridgeStatusText.Text = state.ObsBridgeDetail;
InstallBridgeButton.Content = state.ObsBridgeRepairNeeded ? "Repair integration" : state.ObsBridgeInstalled ? "Reinstall integration" : "Install integration"; InstallBridgeButton.Content = state.ObsBridgeRepairNeeded ? "Repair integration" : state.ObsBridgeInstalled ? "Reinstall integration" : "Install integration";
InstallBridgeButton.IsEnabled = state.ObsBridgePackageAvailable && !state.ObsConnected; // The maintenance action performs a fresh package/process check and reports the
// actual blocker, so a stale OBS connection signal must never strand repair.
InstallBridgeButton.IsEnabled = !state.TestRunning && !state.BenchmarkRunning;
RemoveBridgeButton.IsEnabled = state.ObsBridgeInstalled || state.ObsBridgeRepairNeeded; RemoveBridgeButton.IsEnabled = state.ObsBridgeInstalled || state.ObsBridgeRepairNeeded;
if (!state.Paired) if (!state.Paired)

View File

@ -11,16 +11,13 @@ namespace Lumi.Companion.App;
public sealed class ObsBridgeManager public sealed class ObsBridgeManager
{ {
private readonly Lazy<ObsBridgePackage?> _package;
private readonly string _installRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "obs-studio", "plugins", "lumi-obs-bridge"); private readonly string _installRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "obs-studio", "plugins", "lumi-obs-bridge");
private string InstalledDll => Path.Combine(_installRoot, "bin", "64bit", "lumi-obs-bridge.dll"); private string InstalledDll => Path.Combine(_installRoot, "bin", "64bit", "lumi-obs-bridge.dll");
private string InstalledManifest => Path.Combine(_installRoot, "lumi-managed.json"); private string InstalledManifest => Path.Combine(_installRoot, "lumi-managed.json");
public ObsBridgeManager() => _package = new Lazy<ObsBridgePackage?>(LoadPackage);
public ObsBridgeStatus Inspect() public ObsBridgeStatus Inspect()
{ {
var package = _package.Value; var package = LoadPackage();
var installed = ReadManifest(InstalledManifest); var installed = ReadManifest(InstalledManifest);
var packageAvailable = package is not null; var packageAvailable = package is not null;
var fileInstalled = File.Exists(InstalledDll); var fileInstalled = File.Exists(InstalledDll);
@ -48,7 +45,7 @@ public sealed class ObsBridgeManager
private async Task<ObsBridgeStatus> InstallDirectAsync(CancellationToken cancellationToken) private async Task<ObsBridgeStatus> InstallDirectAsync(CancellationToken cancellationToken)
{ {
var package = _package.Value ?? throw new InvalidDataException("The bundled OBS integration could not be verified."); var package = LoadPackage() ?? throw new InvalidDataException("The bundled OBS integration could not be verified.");
var manifest = package.Manifest; var manifest = package.Manifest;
var bin = Path.GetDirectoryName(InstalledDll)!; var bin = Path.GetDirectoryName(InstalledDll)!;
var locale = Path.Combine(_installRoot, "data", "locale"); var locale = Path.Combine(_installRoot, "data", "locale");
@ -77,7 +74,7 @@ public sealed class ObsBridgeManager
else if (Directory.Exists(_installRoot)) Directory.Delete(_installRoot, true); else if (Directory.Exists(_installRoot)) Directory.Delete(_installRoot, true);
} }
public static bool IsMaintenanceRequest(string[] args) => args.Length == 2 && args[0].Equals("--manage-obs-bridge", StringComparison.OrdinalIgnoreCase); public static bool IsMaintenanceRequest(string[] args) => args.Length is 2 or 3 && args[0].Equals("--manage-obs-bridge", StringComparison.OrdinalIgnoreCase);
public static int RunMaintenance(string[] args) public static int RunMaintenance(string[] args)
{ {
@ -91,7 +88,11 @@ public sealed class ObsBridgeManager
else return 7; else return 7;
return 0; return 0;
} }
catch { return 8; } catch (Exception error)
{
if (args.Length == 3) try { File.WriteAllText(args[2], error.Message); } catch { }
return 8;
}
} }
private static bool IsElevated() private static bool IsElevated()
@ -104,19 +105,27 @@ public sealed class ObsBridgeManager
private static async Task RunElevatedAsync(string action, CancellationToken cancellationToken) 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."); 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 }; var start = new ProcessStartInfo(Environment.ProcessPath) { UseShellExecute = true, Verb = "runas", WindowStyle = ProcessWindowStyle.Hidden };
start.ArgumentList.Add("--manage-obs-bridge"); start.ArgumentList.Add("--manage-obs-bridge");
start.ArgumentList.Add(action); start.ArgumentList.Add(action);
start.ArgumentList.Add(resultPath);
try try
{ {
using var process = Process.Start(start) ?? throw new InvalidOperationException("The OBS integration maintenance process could not start."); using var process = Process.Start(start) ?? throw new InvalidOperationException("The OBS integration maintenance process could not start.");
await process.WaitForExitAsync(cancellationToken); await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode != 0) throw new InvalidOperationException("OBS integration maintenance did not complete."); 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) 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."); 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() private static void EnsureObsClosed()

View File

@ -28,11 +28,11 @@ The repository contains a pinned native whisper.cpp rolling-window worker and a
1. Serve Lumi through HTTPS and enable `lumi_transcription` under Admin > Plugins. Local development may use HTTP only from the exact `localhost` or loopback URL used to download Companion. 1. Serve Lumi through HTTPS and enable `lumi_transcription` under Admin > Plugins. Local development may use HTTP only from the exact `localhost` or loopback URL used to download Companion.
2. Use **Download Companion** in Admin or Plugins > Transcription. Lumi downloads and checksum-verifies the pinned Windows artifact, adds a short-lived single-use pairing file, and returns a private ZIP. 2. Use **Download Companion** in Admin or Plugins > Transcription. Lumi downloads and checksum-verifies the pinned Windows artifact, adds a short-lived single-use pairing file, and returns a private ZIP.
3. On the Windows streaming computer, extract the complete ZIP and start `Lumi.Companion.App.exe` within 15 minutes. Companion discovers the adjacent pairing file, exchanges it once, stores the credential with current-user DPAPI, and removes the pairing file after success. 3. On the Windows streaming computer, extract the complete ZIP and start `Lumi.Companion-Setup.exe` within 15 minutes. Setup installs the app under the current user's Local AppData, imports the adjacent pairing file, and launches Companion. Companion exchanges the pairing token once, stores the credential with current-user DPAPI, and removes the imported pairing file after success.
4. Install a pinned runtime/model only after explicit confirmation. `small.en` is recommended; `small.en-q5_1` and `base.en` are fallbacks. Every artifact is checksum-verified before install. 4. Install a pinned runtime/model only after explicit confirmation. `small.en` is recommended; `small.en-q5_1` and `base.en` are fallbacks. Every artifact is checksum-verified before install.
5. Build the supervised worker with `plugins/lumi_transcription/scripts/build-worker.ps1`. Lumi discovers the installed CUDA worker first and the CPU worker second. `LUMI_TRANSCRIPTION_WORKER` remains an explicit override for nonstandard installations. 5. Build the supervised worker with `plugins/lumi_transcription/scripts/build-worker.ps1`. Lumi discovers the installed CUDA worker first and the CPU worker second. `LUMI_TRANSCRIPTION_WORKER` remains an explicit override for nonstandard installations.
The Avalonia tray UI, self-contained paired ZIP, shared Lumi settings shell, Admin summary, and host-side model download/load controls now exist. The ZIP is not code-signed, so Windows may warn. The normal signed installer, managed bridge install/repair, live OBS source enumeration, and measured model benchmark wizard are not complete yet. The Avalonia tray UI, per-user Windows installer, shared Lumi settings shell, Admin summary, host-side model controls, managed OBS bridge install/repair, live source enumeration, and measured benchmark UI now exist. The installer is not code-signed yet, so Windows may warn.
## Operation and recovery ## Operation and recovery
@ -50,7 +50,7 @@ Use the compact Lumi Companion section on Admin for connection, device, inferenc
- No real whisper.cpp streaming worker has been integrated or benchmarked. - No real whisper.cpp streaming worker has been integrated or benchmarked.
- The bridge skeleton does not yet run its named-pipe worker or selected-source audio callback. - The bridge skeleton does not yet run its named-pipe worker or selected-source audio callback.
- Twitch toggleable caption behavior has not been tested; native API presence is not acceptance evidence. - Twitch toggleable caption behavior has not been tested; native API presence is not acceptance evidence.
- The downloadable bundle is an unsigned experimental self-contained ZIP, not the final signed installer. - The downloadable bundle contains an unsigned experimental per-user installer; production code signing is still required.
- Bridge repair, source discovery/nested Program-scene evaluation, benchmark UX, and conflict-resolution UI remain pending. - Bridge repair, source discovery/nested Program-scene evaluation, benchmark UX, and conflict-resolution UI remain pending.
See `docs/adr/0001-companion-transcription-boundaries.md`, `protocol/companion-protocol-v1.md`, and `companion/docs/obs-native-caption-compatibility-spike.md`. See `docs/adr/0001-companion-transcription-boundaries.md`, `protocol/companion-protocol-v1.md`, and `companion/docs/obs-native-caption-compatibility-spike.md`.

View File

@ -15,6 +15,7 @@ class CompanionPackageService {
currentManifest() { return this.manifestPath ? JSON.parse(fs.readFileSync(this.manifestPath, "utf8")) : this.manifest; } currentManifest() { return this.manifestPath ? JSON.parse(fs.readFileSync(this.manifestPath, "utf8")) : this.manifest; }
entry(manifest = this.currentManifest()) { return manifest?.artifacts?.find((entry) => entry.platform === "win32" && entry.architecture === "x64") || null; } entry(manifest = this.currentManifest()) { return manifest?.artifacts?.find((entry) => entry.platform === "win32" && entry.architecture === "x64") || null; }
installerEntry(manifest = this.currentManifest()) { return manifest?.installer || null; }
status() { status() {
const manifest = this.currentManifest(); const manifest = this.currentManifest();
@ -28,10 +29,18 @@ class CompanionPackageService {
const manifest = this.currentManifest(); const manifest = this.currentManifest();
const entry = this.entry(manifest); const entry = this.entry(manifest);
if (!entry) throw new Error("No Windows Companion artifact is configured."); if (!entry) throw new Error("No Windows Companion artifact is configured.");
const output = new AdmZip();
const installer = this.installerEntry(manifest);
if (installer) {
let installerStatus = this.artifacts.status(installer);
if (!installerStatus.valid) installerStatus = await this.artifacts.download(installer, { confirmed: true });
const installerName = safeArchivePath(installer.filename || "Lumi.Companion-Setup.exe");
if (!installerName.toLowerCase().endsWith(".exe")) throw new Error("The Companion installer entrypoint is invalid.");
output.addFile(installerName, fs.readFileSync(installerStatus.path));
} else {
let status = this.artifacts.status(entry); let status = this.artifacts.status(entry);
if (!status.valid) status = await this.artifacts.download(entry, { confirmed: true }); if (!status.valid) status = await this.artifacts.download(entry, { confirmed: true });
const source = new AdmZip(status.path); const source = new AdmZip(status.path);
const output = new AdmZip();
let expandedBytes = 0; let expandedBytes = 0;
for (const item of source.getEntries()) { for (const item of source.getEntries()) {
const name = safeArchivePath(item.entryName); const name = safeArchivePath(item.entryName);
@ -42,13 +51,15 @@ class CompanionPackageService {
output.addFile(name, body); output.addFile(name, body);
} }
if (!output.getEntry(entry.entrypoint)) throw new Error("The Companion artifact is missing its expected application entrypoint."); if (!output.getEntry(entry.entrypoint)) throw new Error("The Companion artifact is missing its expected application entrypoint.");
}
const pairingName = `lumi-companion-${pairing.pairing_id}.lumi-pairing.json`; const pairingName = `lumi-companion-${pairing.pairing_id}.lumi-pairing.json`;
output.addFile(pairingName, Buffer.from(`${JSON.stringify(pairing.bootstrap, null, 2)}\n`, "utf8")); output.addFile(pairingName, Buffer.from(`${JSON.stringify(pairing.bootstrap, null, 2)}\n`, "utf8"));
output.addFile("START-HERE.txt", Buffer.from([ output.addFile("START-HERE.txt", Buffer.from([
"Lumi Companion — experimental transcription MVP", "", "Lumi Companion — experimental transcription MVP", "",
"1. Extract every file in this ZIP to a folder on the Windows streaming computer.", "1. Extract both files in this ZIP to a temporary folder on the Windows streaming computer.",
`2. Start ${entry.entrypoint} within 15 minutes.`, `2. Start ${installer?.filename || entry.entrypoint} within 15 minutes.`,
"3. Companion finds the adjacent one-time pairing package automatically and removes it after successful pairing.", "", "3. Setup installs Companion in your Windows account, imports the adjacent one-time pairing package, and launches the durable installed copy.",
"4. After setup completes, this extracted folder can be deleted. Use the Start menu to open Lumi Companion.", "",
"Do not share this ZIP. Its pairing package works once and expires after 15 minutes.", "Do not share this ZIP. Its pairing package works once and expires after 15 minutes.",
"Windows may warn because this experimental build is not code-signed yet.", "" "Windows may warn because this experimental build is not code-signed yet.", ""
].join("\r\n"), "utf8")); ].join("\r\n"), "utf8"));

View File

@ -1,8 +1,18 @@
{ {
"schema_version": 1, "schema_version": 1,
"version": "0.1.0-experimental.6", "version": "0.1.0-experimental.7",
"signed": false, "signed": false,
"release_notes": "Makes the full-path check voice-free and reusable, adds a live dBFS meter, fixes repeat benchmark sessions, reports accurate setup state to Lumi, and embeds the verified OBS repair payload.", "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.",
"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": "df64ef4071edac02a5bce14ab067c68590fcc981922c498cb65bc18749cdd437",
"bytes": 32034028
},
"artifacts": [ "artifacts": [
{ {
"id": "windows-x64-self-contained", "id": "windows-x64-self-contained",
@ -10,9 +20,9 @@
"architecture": "x64", "architecture": "x64",
"label": "Windows x64 self-contained", "label": "Windows x64 self-contained",
"filename": "Lumi.Companion-win-x64.zip", "filename": "Lumi.Companion-win-x64.zip",
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.6/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": "bd487528208347ee469ee58eb1a01bab9ed0c6a4597c93241886fcfbc80c5815", "sha256": "f5cbbe7fed80a216b3b8a2832000228c71fb117e757d2b3011eeba66c451403e",
"bytes": 41756978, "bytes": 41757364,
"entrypoint": "Lumi.Companion.App.exe" "entrypoint": "Lumi.Companion.App.exe"
} }
] ]

View File

@ -138,7 +138,10 @@ module.exports = {
catch (error) { res.status(error.code === "PAIRING_ALREADY_USED" ? 409 : 400).json({ ok: false, code: error.code, error: error.message }); } catch (error) { res.status(error.code === "PAIRING_ALREADY_USED" ? 409 : 400).json({ ok: false, code: error.code, error: error.message }); }
}); });
router.get("/api/devices", requireAdmin, (req, res) => res.json({ devices: devices.list({ status: req.query.status }) })); router.get("/api/devices", requireAdmin, (req, res) => res.json({ devices: devices.list({ status: req.query.status }) }));
router.post("/api/devices/:id/revoke", requireAdmin, (req, res) => res.json({ ok: devices.revoke(req.params.id) })); router.post("/api/devices/:id/revoke", requireAdmin, (req, res) => {
if (!devices.revoke(req.params.id)) return res.status(404).json({ ok: false, error: "Active device was not found." });
res.json({ ok: true });
});
router.post("/api/devices/:id/capabilities", requireAdmin, (req, res) => { router.post("/api/devices/:id/capabilities", requireAdmin, (req, res) => {
const capabilities = devices.setCapabilities(req.params.id, req.body.capabilities); const capabilities = devices.setCapabilities(req.params.id, req.body.capabilities);
if (!capabilities) return res.status(404).json({ ok: false, error: "Device was not found or is revoked." }); if (!capabilities) return res.status(404).json({ ok: false, error: "Device was not found or is revoked." });

View File

@ -1,7 +1,7 @@
{ {
"id": "lumi_transcription", "id": "lumi_transcription",
"name": "Lumi Transcription", "name": "Lumi Transcription",
"version": "0.1.0-experimental.6", "version": "0.1.0-experimental.7",
"description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.", "description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.",
"main": "index.js", "main": "index.js",
"channel": "experimental", "channel": "experimental",

View File

@ -1,2 +1,3 @@
.transcription-overview > .section-header{align-items:flex-start}.transcription-overview .page-header{margin:0}.transcription-overview .page-header h1{font-size:clamp(2rem,5vw,3.7rem)}.setup-path ol{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));list-style:none;padding:0;margin:0 0 var(--lumi-space-5);counter-reset:steps;gap:var(--lumi-space-3)}.setup-path li{counter-increment:steps;padding:var(--lumi-space-4);border-radius:var(--lumi-radius-md);background:var(--lumi-surface-subtle);min-width:0}.setup-path li::before{content:counter(steps);display:grid;place-items:center;width:1.8rem;height:1.8rem;margin-bottom:var(--lumi-space-3);border:1px solid var(--lumi-border);border-radius:50%;font-weight:700}.setup-path li.is-current::before{background:var(--lumi-primary);border-color:var(--lumi-primary);color:var(--lumi-button-text)}.setup-path li.is-complete::before{content:"✓";background:var(--lumi-success-bg);border-color:var(--lumi-success);color:var(--lumi-success)}.setup-path li span{display:block;margin-top:var(--lumi-space-2);color:var(--lumi-text-muted);font-size:.9rem}.transcription-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--lumi-space-5)}.transcription-metrics{grid-template-columns:repeat(2,minmax(0,1fr));margin-bottom:var(--lumi-space-4)}.transcription-device-list{list-style:none;padding:0;margin:0;display:grid;gap:var(--lumi-space-2)}.transcription-device-list li,.model-row article{display:flex;align-items:center;justify-content:space-between;gap:var(--lumi-space-4);padding:var(--lumi-space-3) 0;border-bottom:1px solid var(--lumi-border)}.transcription-device-list li:last-child,.model-row article:last-child{border-bottom:0}.transcription-device-list .hint{display:block;margin-top:var(--lumi-space-1)}.model-row h3,.model-row p{margin:0}.model-actions{display:flex;align-items:center;justify-content:flex-end;gap:var(--lumi-space-2);flex-wrap:wrap}.empty-state{padding:var(--lumi-space-5);border-radius:var(--lumi-radius-md);background:var(--lumi-surface-subtle)}.empty-state p{margin-bottom:0;color:var(--lumi-text-muted)}@media(max-width:860px){.setup-path ol,.transcription-grid{grid-template-columns:1fr}.transcription-overview > .section-header{align-items:stretch}.transcription-metrics{grid-template-columns:1fr}}@media(max-width:560px){.model-row article,.transcription-device-list li{align-items:flex-start;flex-direction:column}.model-actions{justify-content:flex-start}} .transcription-overview > .section-header{align-items:flex-start}.transcription-overview .page-header{margin:0}.transcription-overview .page-header h1{font-size:clamp(2rem,5vw,3.7rem)}.setup-path ol{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));list-style:none;padding:0;margin:0 0 var(--lumi-space-5);counter-reset:steps;gap:var(--lumi-space-3)}.setup-path li{counter-increment:steps;padding:var(--lumi-space-4);border-radius:var(--lumi-radius-md);background:var(--lumi-surface-subtle);min-width:0}.setup-path li::before{content:counter(steps);display:grid;place-items:center;width:1.8rem;height:1.8rem;margin-bottom:var(--lumi-space-3);border:1px solid var(--lumi-border);border-radius:50%;font-weight:700}.setup-path li.is-current::before{background:var(--lumi-primary);border-color:var(--lumi-primary);color:var(--lumi-button-text)}.setup-path li.is-complete::before{content:"✓";background:var(--lumi-success-bg);border-color:var(--lumi-success);color:var(--lumi-success)}.setup-path li span{display:block;margin-top:var(--lumi-space-2);color:var(--lumi-text-muted);font-size:.9rem}.transcription-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--lumi-space-5)}.transcription-metrics{grid-template-columns:repeat(2,minmax(0,1fr));margin-bottom:var(--lumi-space-4)}.transcription-device-list{list-style:none;padding:0;margin:0;display:grid;gap:var(--lumi-space-2)}.transcription-device-list li,.model-row article{display:flex;align-items:center;justify-content:space-between;gap:var(--lumi-space-4);padding:var(--lumi-space-3) 0;border-bottom:1px solid var(--lumi-border)}.transcription-device-list li:last-child,.model-row article:last-child{border-bottom:0}.transcription-device-list .hint{display:block;margin-top:var(--lumi-space-1)}.model-row h3,.model-row p{margin:0}.model-actions{display:flex;align-items:center;justify-content:flex-end;gap:var(--lumi-space-2);flex-wrap:wrap}.empty-state{padding:var(--lumi-space-5);border-radius:var(--lumi-radius-md);background:var(--lumi-surface-subtle)}.empty-state p{margin-bottom:0;color:var(--lumi-text-muted)}@media(max-width:860px){.setup-path ol,.transcription-grid{grid-template-columns:1fr}.transcription-overview > .section-header{align-items:stretch}.transcription-metrics{grid-template-columns:1fr}}@media(max-width:560px){.model-row article,.transcription-device-list li{align-items:flex-start;flex-direction:column}.model-actions{justify-content:flex-start}}
.benchmark-history{margin-top:var(--lumi-space-5)}.benchmark-test{border:1px solid var(--lumi-border);border-radius:var(--lumi-radius-md);margin-top:var(--lumi-space-3);overflow:visible;background:var(--lumi-surface)}.benchmark-test>summary{display:flex;align-items:center;justify-content:space-between;gap:var(--lumi-space-4);padding:var(--lumi-space-4);cursor:pointer;list-style:none}.benchmark-test>summary::-webkit-details-marker{display:none}.benchmark-test>summary span:first-child{display:grid;gap:var(--lumi-space-1)}.benchmark-test>summary small{color:var(--lumi-text-muted);font-weight:400}.benchmark-body{display:grid;gap:var(--lumi-space-4);padding:0 var(--lumi-space-4) var(--lumi-space-4);border-top:1px solid var(--lumi-border)}.analysis-mode{display:flex;gap:var(--lumi-space-2);padding-top:var(--lumi-space-4);flex-wrap:wrap}.device-view-toggle{padding:0 0 var(--lumi-space-3)}.benchmark-legends{display:grid;gap:var(--lumi-space-2)}.benchmark-legend{display:flex;align-items:center;gap:var(--lumi-space-2);flex-wrap:wrap;font-size:.75rem}.benchmark-legend strong{min-width:5rem}.benchmark-legend span{padding:.25rem .5rem;border-radius:999px;color:#182026}.legend-fast{background:#6f9f82}.legend-good{background:#6f91a3}.legend-trouble{background:#c0a65b}.legend-borderline{background:#c18152}.legend-critical{background:#b5686b}.benchmark-transcript{line-height:2.2;padding:var(--lumi-space-4);border-radius:var(--lumi-radius-md);background:var(--lumi-surface-subtle)}.benchmark-word{position:relative;display:inline-block;margin:.12rem .14rem;padding:.08rem .3rem;border-radius:.35rem;color:#172126;outline-offset:2px;transition:background .16s ease}.benchmark-word:hover::after,.benchmark-word:focus-visible::after{content:attr(data-tooltip);position:absolute;z-index:20;left:50%;bottom:calc(100% + .45rem);transform:translateX(-50%);width:max-content;max-width:min(22rem,80vw);padding:.45rem .6rem;border-radius:.45rem;background:#182026;color:#fff;font-size:.75rem;line-height:1.35;white-space:normal;box-shadow:0 6px 24px rgba(0,0,0,.2);pointer-events:none}.benchmark-stat-columns{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--lumi-space-4)}.benchmark-stat-columns h3{margin:0 0 var(--lumi-space-3)}.benchmark-stat-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--lumi-space-2)}.benchmark-stat-grid>div{padding:var(--lumi-space-3);border-radius:var(--lumi-radius-sm);background:var(--lumi-surface-subtle)}.benchmark-stat-grid span,.benchmark-stat-grid strong{display:block}.benchmark-stat-grid span{color:var(--lumi-text-muted);font-size:.78rem}.benchmark-stat-grid strong{margin-top:.2rem}@media(max-width:760px){.benchmark-stat-columns{grid-template-columns:1fr}.benchmark-test>summary{align-items:flex-start;flex-direction:column}}@media(prefers-reduced-motion:reduce){.benchmark-word{transition:none}} .benchmark-history{margin-top:var(--lumi-space-5)}.benchmark-test{border:1px solid var(--lumi-border);border-radius:var(--lumi-radius-md);margin-top:var(--lumi-space-3);overflow:visible;background:var(--lumi-surface)}.benchmark-test>summary{display:flex;align-items:center;justify-content:space-between;gap:var(--lumi-space-4);padding:var(--lumi-space-4);cursor:pointer;list-style:none}.benchmark-test>summary::-webkit-details-marker{display:none}.benchmark-test>summary span:first-child{display:grid;gap:var(--lumi-space-1)}.benchmark-test>summary small{color:var(--lumi-text-muted);font-weight:400}.benchmark-body{display:grid;gap:var(--lumi-space-4);padding:0 var(--lumi-space-4) var(--lumi-space-4);border-top:1px solid var(--lumi-border)}.analysis-mode{display:flex;gap:var(--lumi-space-2);padding-top:var(--lumi-space-4);flex-wrap:wrap}.device-view-toggle{padding:0 0 var(--lumi-space-3)}.benchmark-legends{display:grid;gap:var(--lumi-space-2)}.benchmark-legend{display:flex;align-items:center;gap:var(--lumi-space-2);flex-wrap:wrap;font-size:.75rem}.benchmark-legend strong{min-width:5rem}.benchmark-legend span{padding:.25rem .5rem;border-radius:999px;color:#182026}.legend-fast{background:#6f9f82}.legend-good{background:#6f91a3}.legend-trouble{background:#c0a65b}.legend-borderline{background:#c18152}.legend-critical{background:#b5686b}.benchmark-transcript{line-height:2.2;padding:var(--lumi-space-4);border-radius:var(--lumi-radius-md);background:var(--lumi-surface-subtle)}.benchmark-word{position:relative;display:inline-block;margin:.12rem .14rem;padding:.08rem .3rem;border-radius:.35rem;color:#172126;outline-offset:2px;transition:background .16s ease}.benchmark-word:hover::after,.benchmark-word:focus-visible::after{content:attr(data-tooltip);position:absolute;z-index:20;left:50%;bottom:calc(100% + .45rem);transform:translateX(-50%);width:max-content;max-width:min(22rem,80vw);padding:.45rem .6rem;border-radius:.45rem;background:#182026;color:#fff;font-size:.75rem;line-height:1.35;white-space:normal;box-shadow:0 6px 24px rgba(0,0,0,.2);pointer-events:none}.benchmark-stat-columns{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--lumi-space-4)}.benchmark-stat-columns h3{margin:0 0 var(--lumi-space-3)}.benchmark-stat-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--lumi-space-2)}.benchmark-stat-grid>div{padding:var(--lumi-space-3);border-radius:var(--lumi-radius-sm);background:var(--lumi-surface-subtle)}.benchmark-stat-grid span,.benchmark-stat-grid strong{display:block}.benchmark-stat-grid span{color:var(--lumi-text-muted);font-size:.78rem}.benchmark-stat-grid strong{margin-top:.2rem}@media(max-width:760px){.benchmark-stat-columns{grid-template-columns:1fr}.benchmark-test>summary{align-items:flex-start;flex-direction:column}}@media(prefers-reduced-motion:reduce){.benchmark-word{transition:none}}
.device-actions{display:flex;align-items:center;justify-content:flex-end;gap:var(--lumi-space-2);flex-wrap:wrap}@media(max-width:560px){.device-actions{justify-content:flex-start}}

View File

@ -55,11 +55,41 @@
const link = document.createElement("a"); const link = document.createElement("a");
link.href = URL.createObjectURL(blob); link.download = filename; link.click(); link.href = URL.createObjectURL(blob); link.download = filename; link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 1000); setTimeout(() => URL.revokeObjectURL(link.href), 1000);
status.textContent = "Companion package created. Extract and start it within 15 minutes; the included pairing key works once."; status.textContent = "Companion package created. Extract it and run Lumi Companion Setup within 15 minutes; the included pairing key works once.";
} catch (error) { status.textContent = error.message; } } catch (error) { status.textContent = error.message; }
finally { button.disabled = false; } finally { button.disabled = false; }
}); });
root.addEventListener("click", async (event) => {
const revoke = event.target.closest("[data-revoke-device]");
if (!revoke) return;
if (!window.LumiConfirm?.destructiveFetch) {
status.textContent = "Timed confirmation is unavailable. Reload Lumi and try again.";
return;
}
const action = `/plugins/lumi_transcription/api/devices/${encodeURIComponent(revoke.dataset.revokeDevice)}/revoke`;
revoke.disabled = true;
try {
const response = await window.LumiConfirm.destructiveFetch(action, {
method: "POST",
headers: { Accept: "application/json" }
}, {
title: "Revoke Companion access",
text: `Permanently revoke ${revoke.dataset.deviceName || "this device"}? Its credential will stop working immediately and pairing it again will require a new download.`,
label: "Revoke access"
});
if (!response) return;
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || "Device access could not be revoked.");
status.textContent = "Companion access revoked. The device must be paired again before it can reconnect.";
setTimeout(() => window.location.reload(), 500);
} catch (error) {
status.textContent = error.message;
} finally {
revoke.disabled = false;
}
});
root.addEventListener("click", async (event) => { root.addEventListener("click", async (event) => {
const download = event.target.closest("[data-download-model]"); const download = event.target.closest("[data-download-model]");
const load = event.target.closest("[data-load-model]"); const load = event.target.closest("[data-load-model]");

View File

@ -38,6 +38,7 @@ async function run() {
verifyQueues(); verifyQueues();
verifyStabilization(); verifyStabilization();
verifyBenchmarkRetention(); verifyBenchmarkRetention();
verifyAdminDeviceRevocationUx();
await verifyBenchmarkStartRollback(); await verifyBenchmarkStartRollback();
await verifySessionLifecycle(); await verifySessionLifecycle();
await verifyProviderFailureFeedback(); await verifyProviderFailureFeedback();
@ -51,6 +52,16 @@ async function run() {
} finally { fs.rmSync(temp, { recursive: true, force: true }); } } finally { fs.rmSync(temp, { recursive: true, force: true }); }
} }
function verifyAdminDeviceRevocationUx() {
const view = fs.readFileSync(path.join(__dirname, "../views/settings.ejs"), "utf8");
const client = fs.readFileSync(path.join(__dirname, "../public/transcription.js"), "utf8");
const routes = fs.readFileSync(path.join(__dirname, "../index.js"), "utf8");
assert.match(view, /data-revoke-device/);
assert.match(client, /LumiConfirm\?\.destructiveFetch/);
assert.match(client, /Permanently revoke/);
assert.match(routes, /\/api\/devices\/:id\/revoke/);
}
function verifyProtocol() { function verifyProtocol() {
const sessionId = crypto.randomUUID(); const sessionId = crypto.randomUUID();
const sourceUuid = crypto.randomUUID(); const sourceUuid = crypto.randomUUID();
@ -106,6 +117,7 @@ function verifyLocalhostTransportPolicy() {
} }
function verifyCompanionVersionOrdering() { function verifyCompanionVersionOrdering() {
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.6", "0.1.0-experimental.5"), 1);
assert.equal(plugin.compareVersions("0.1.0-experimental.5", "0.1.0-experimental.4"), 1); assert.equal(plugin.compareVersions("0.1.0-experimental.5", "0.1.0-experimental.4"), 1);
assert.equal(plugin.compareVersions("0.1.0-experimental.4", "0.1.0-experimental.3"), 1); assert.equal(plugin.compareVersions("0.1.0-experimental.4", "0.1.0-experimental.3"), 1);
@ -423,6 +435,18 @@ async function verifyCompanionPackage(temp) {
fs.writeFileSync(manifestPath, JSON.stringify({ ...manifest, version: "refreshed" })); fs.writeFileSync(manifestPath, JSON.stringify({ ...manifest, version: "refreshed" }));
assert.equal(liveManifestService.status().version, "refreshed"); assert.equal(liveManifestService.status().version, "refreshed");
assert.throws(() => safeArchivePath("../escape.exe"), /unsafe path/i); assert.throws(() => safeArchivePath("../escape.exe"), /unsafe path/i);
const installerPath = path.join(root, "Lumi.Companion-Setup.exe");
fs.writeFileSync(installerPath, "installer");
const installerManifest = { ...manifest, installer: {
id: "windows-x64-installer", filename: "Lumi.Companion-Setup.exe", url: "https://example.invalid/setup.exe",
sha256: sha256File(installerPath), bytes: fs.statSync(installerPath).size
} };
const installerBundle = await new CompanionPackageService(root, installerManifest).build({ pairing_id: "installed", bootstrap: { token: "installer-pairing" } });
const installedOutput = new (require("adm-zip"))(installerBundle.buffer);
assert.equal(installedOutput.readAsText("Lumi.Companion-Setup.exe"), "installer");
assert.equal(installedOutput.getEntry("Lumi.Companion.App.exe"), null);
assert.match(installedOutput.readAsText("START-HERE.txt"), /Start menu/i);
} }
async function verifyPluginIsolation() { async function verifyPluginIsolation() {

View File

@ -59,7 +59,7 @@
<% if (devices.length) { %> <% if (devices.length) { %>
<ul class="transcription-device-list"> <ul class="transcription-device-list">
<% devices.forEach((device) => { %> <% devices.forEach((device) => { %>
<li><div><strong><%= device.name %></strong><span class="hint">Last connected <%= new Date(device.last_connected_at).toLocaleString() %><% if (device.metadata?.companion_version) { %> · Companion <%= displayCompanionVersion(device.metadata.companion_version) %><% } %></span></div><span class="badge success">Allowed</span></li> <li data-device-row="<%= device.id %>"><div><strong><%= device.name %></strong><span class="hint">Last connected <%= new Date(device.last_connected_at).toLocaleString() %><% if (device.metadata?.companion_version) { %> · Companion <%= displayCompanionVersion(device.metadata.companion_version) %><% } %></span></div><div class="device-actions"><span class="badge success">Allowed</span><button class="button danger" type="button" data-revoke-device="<%= device.id %>" data-device-name="<%= device.name %>">Revoke access</button></div></li>
<% }) %> <% }) %>
</ul> </ul>
<% } %> <% } %>