diff --git a/TODO.md b/TODO.md index 088abce..d53658a 100644 --- a/TODO.md +++ b/TODO.md @@ -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 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: - 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. -- Complete the signed companion installer and DPAPI migration UX; add a rollback - policy after the experimental updater has target-machine acceptance evidence. +- Sign the companion installer and add a rollback policy after the experimental + updater has target-machine acceptance evidence. - Complete target-machine bridge acceptance and source rename/missing recovery. - Run the OBS 31+/Twitch compatibility spike and prove toggleable closed captions; tune replacement/display duration from player behavior without open-caption fallback. diff --git a/companion/README.md b/companion/README.md index ebbb40c..d0a5e22 100644 --- a/companion/README.md +++ b/companion/README.md @@ -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. -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. @@ -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 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. diff --git a/companion/installer/Lumi.Companion.iss b/companion/installer/Lumi.Companion.iss new file mode 100644 index 0000000..fb4483d --- /dev/null +++ b/companion/installer/Lumi.Companion.iss @@ -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; diff --git a/companion/scripts/publish-companion.ps1 b/companion/scripts/publish-companion.ps1 index c54a3fb..0d35436 100644 --- a/companion/scripts/publish-companion.ps1 +++ b/companion/scripts/publish-companion.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "0.1.0-experimental.6", + [string]$Version = "0.1.0-experimental.7", [string]$BridgeVersion = "0.1.0-experimental.5" ) @@ -10,6 +10,7 @@ $outputRoot = Join-Path $repoRoot "companion\installer\output" $publishRoot = Join-Path $outputRoot "publish" $stageRoot = Join-Path $outputRoot "package" $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 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 Remove-Item $archive -Force -ErrorAction SilentlyContinue 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 $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 "Artifact: $archive" Write-Host "Bytes: $($file.Length)" Write-Host "SHA256: $sha" +Write-Host "Installer: $installer" +Write-Host "Installer bytes: $($setupFile.Length)" +Write-Host "Installer SHA256: $setupSha" diff --git a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj index cdd3f13..79b4f37 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.6 + 0.1.0-experimental.7 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 307f8ca..83c5517 100644 --- a/companion/src/Lumi.Companion.App/MainWindow.axaml.cs +++ b/companion/src/Lumi.Companion.App/MainWindow.axaml.cs @@ -191,7 +191,9 @@ public partial class MainWindow : Window UpdateStatusText.Text = state.UpdateDetail; BridgeStatusText.Text = state.ObsBridgeDetail; 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; if (!state.Paired) diff --git a/companion/src/Lumi.Companion.App/ObsBridgeManager.cs b/companion/src/Lumi.Companion.App/ObsBridgeManager.cs index 9c9193f..65b5240 100644 --- a/companion/src/Lumi.Companion.App/ObsBridgeManager.cs +++ b/companion/src/Lumi.Companion.App/ObsBridgeManager.cs @@ -11,16 +11,13 @@ namespace Lumi.Companion.App; public sealed class ObsBridgeManager { - private readonly Lazy _package; 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 InstalledManifest => Path.Combine(_installRoot, "lumi-managed.json"); - public ObsBridgeManager() => _package = new Lazy(LoadPackage); - public ObsBridgeStatus Inspect() { - var package = _package.Value; + var package = LoadPackage(); var installed = ReadManifest(InstalledManifest); var packageAvailable = package is not null; var fileInstalled = File.Exists(InstalledDll); @@ -48,7 +45,7 @@ public sealed class ObsBridgeManager private async Task 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 bin = Path.GetDirectoryName(InstalledDll)!; var locale = Path.Combine(_installRoot, "data", "locale"); @@ -77,7 +74,7 @@ public sealed class ObsBridgeManager 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) { @@ -91,7 +88,11 @@ public sealed class ObsBridgeManager else return 7; 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() @@ -104,19 +105,27 @@ public sealed class ObsBridgeManager private static async Task RunElevatedAsync(string action, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(Environment.ProcessPath)) throw new InvalidOperationException("The packaged Companion application is required to manage OBS integration."); + var resultPath = Path.Combine(Path.GetTempPath(), $"lumi-obs-maintenance-{Guid.NewGuid():N}.txt"); var start = new ProcessStartInfo(Environment.ProcessPath) { UseShellExecute = true, Verb = "runas", WindowStyle = ProcessWindowStyle.Hidden }; start.ArgumentList.Add("--manage-obs-bridge"); start.ArgumentList.Add(action); + start.ArgumentList.Add(resultPath); try { using var process = Process.Start(start) ?? throw new InvalidOperationException("The OBS integration maintenance process could not start."); await process.WaitForExitAsync(cancellationToken); - if (process.ExitCode != 0) 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) { 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() diff --git a/docs/lumi-companion-transcription.md b/docs/lumi-companion-transcription.md index 74f95ff..85b7357 100644 --- a/docs/lumi-companion-transcription.md +++ b/docs/lumi-companion-transcription.md @@ -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. 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. 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 @@ -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. - 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. -- 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. See `docs/adr/0001-companion-transcription-boundaries.md`, `protocol/companion-protocol-v1.md`, and `companion/docs/obs-native-caption-compatibility-spike.md`. diff --git a/plugins/lumi_transcription/backend/companion/package_service.js b/plugins/lumi_transcription/backend/companion/package_service.js index a6aa9ef..95e9d4f 100644 --- a/plugins/lumi_transcription/backend/companion/package_service.js +++ b/plugins/lumi_transcription/backend/companion/package_service.js @@ -15,6 +15,7 @@ class CompanionPackageService { 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; } + installerEntry(manifest = this.currentManifest()) { return manifest?.installer || null; } status() { const manifest = this.currentManifest(); @@ -28,27 +29,37 @@ class CompanionPackageService { const manifest = this.currentManifest(); const entry = this.entry(manifest); if (!entry) throw new Error("No Windows Companion artifact is configured."); - let status = this.artifacts.status(entry); - if (!status.valid) status = await this.artifacts.download(entry, { confirmed: true }); - const source = new AdmZip(status.path); const output = new AdmZip(); - let expandedBytes = 0; - for (const item of source.getEntries()) { - const name = safeArchivePath(item.entryName); - if (!name || item.isDirectory) continue; - const body = item.getData(); - expandedBytes += body.length; - if (expandedBytes > 300 * 1024 * 1024) throw new Error("The Companion artifact exceeds its expanded size limit."); - output.addFile(name, body); + 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); + if (!status.valid) status = await this.artifacts.download(entry, { confirmed: true }); + const source = new AdmZip(status.path); + let expandedBytes = 0; + for (const item of source.getEntries()) { + const name = safeArchivePath(item.entryName); + if (!name || item.isDirectory) continue; + const body = item.getData(); + expandedBytes += body.length; + if (expandedBytes > 300 * 1024 * 1024) throw new Error("The Companion artifact exceeds its expanded size limit."); + 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`; output.addFile(pairingName, Buffer.from(`${JSON.stringify(pairing.bootstrap, null, 2)}\n`, "utf8")); output.addFile("START-HERE.txt", Buffer.from([ "Lumi Companion — experimental transcription MVP", "", - "1. Extract every file in this ZIP to a folder on the Windows streaming computer.", - `2. Start ${entry.entrypoint} within 15 minutes.`, - "3. Companion finds the adjacent one-time pairing package automatically and removes it after successful pairing.", "", + "1. Extract both files in this ZIP to a temporary folder on the Windows streaming computer.", + `2. Start ${installer?.filename || entry.entrypoint} within 15 minutes.`, + "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.", "Windows may warn because this experimental build is not code-signed yet.", "" ].join("\r\n"), "utf8")); diff --git a/plugins/lumi_transcription/companion_manifest.json b/plugins/lumi_transcription/companion_manifest.json index 143d1d7..0570c9c 100644 --- a/plugins/lumi_transcription/companion_manifest.json +++ b/plugins/lumi_transcription/companion_manifest.json @@ -1,8 +1,18 @@ { "schema_version": 1, - "version": "0.1.0-experimental.6", + "version": "0.1.0-experimental.7", "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": [ { "id": "windows-x64-self-contained", @@ -10,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.6/Lumi.Companion-win-x64.zip", - "sha256": "bd487528208347ee469ee58eb1a01bab9ed0c6a4597c93241886fcfbc80c5815", - "bytes": 41756978, + "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.7/Lumi.Companion-win-x64.zip", + "sha256": "f5cbbe7fed80a216b3b8a2832000228c71fb117e757d2b3011eeba66c451403e", + "bytes": 41757364, "entrypoint": "Lumi.Companion.App.exe" } ] diff --git a/plugins/lumi_transcription/index.js b/plugins/lumi_transcription/index.js index 001fda8..a41a9d3 100644 --- a/plugins/lumi_transcription/index.js +++ b/plugins/lumi_transcription/index.js @@ -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 }); } }); 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) => { 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." }); diff --git a/plugins/lumi_transcription/plugin.json b/plugins/lumi_transcription/plugin.json index 4f1eae4..1637185 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.6", + "version": "0.1.0-experimental.7", "description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.", "main": "index.js", "channel": "experimental", diff --git a/plugins/lumi_transcription/public/transcription.css b/plugins/lumi_transcription/public/transcription.css index 79b0aa9..e1dd7b1 100644 --- a/plugins/lumi_transcription/public/transcription.css +++ b/plugins/lumi_transcription/public/transcription.css @@ -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}} .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}} diff --git a/plugins/lumi_transcription/public/transcription.js b/plugins/lumi_transcription/public/transcription.js index b3e0352..886a514 100644 --- a/plugins/lumi_transcription/public/transcription.js +++ b/plugins/lumi_transcription/public/transcription.js @@ -55,11 +55,41 @@ const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = filename; link.click(); 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; } 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) => { const download = event.target.closest("[data-download-model]"); const load = event.target.closest("[data-load-model]"); diff --git a/plugins/lumi_transcription/tests/verify.js b/plugins/lumi_transcription/tests/verify.js index 3a82cd8..b72f604 100644 --- a/plugins/lumi_transcription/tests/verify.js +++ b/plugins/lumi_transcription/tests/verify.js @@ -38,6 +38,7 @@ async function run() { verifyQueues(); verifyStabilization(); verifyBenchmarkRetention(); + verifyAdminDeviceRevocationUx(); await verifyBenchmarkStartRollback(); await verifySessionLifecycle(); await verifyProviderFailureFeedback(); @@ -51,6 +52,16 @@ async function run() { } 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() { const sessionId = crypto.randomUUID(); const sourceUuid = crypto.randomUUID(); @@ -106,6 +117,7 @@ function verifyLocalhostTransportPolicy() { } 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.5", "0.1.0-experimental.4"), 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" })); assert.equal(liveManifestService.status().version, "refreshed"); 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() { diff --git a/plugins/lumi_transcription/views/settings.ejs b/plugins/lumi_transcription/views/settings.ejs index 94d4daf..466fecb 100644 --- a/plugins/lumi_transcription/views/settings.ejs +++ b/plugins/lumi_transcription/views/settings.ejs @@ -59,7 +59,7 @@ <% if (devices.length) { %> <% devices.forEach((device) => { %> - <%= device.name %>Last connected <%= new Date(device.last_connected_at).toLocaleString() %><% if (device.metadata?.companion_version) { %> · Companion <%= displayCompanionVersion(device.metadata.companion_version) %><% } %>Allowed + <%= device.name %>Last connected <%= new Date(device.last_connected_at).toLocaleString() %><% if (device.metadata?.companion_version) { %> · Companion <%= displayCompanionVersion(device.metadata.companion_version) %><% } %>AllowedRevoke access <% }) %> <% } %>