Integrate Companion into Lumi admin
This commit is contained in:
parent
2f31390602
commit
27555e258e
9
TODO.md
9
TODO.md
@ -6,14 +6,19 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K
|
|||||||
|
|
||||||
Foundation implemented locally: independent `lumi_transcription` plugin; generic
|
Foundation implemented locally: independent `lumi_transcription` plugin; generic
|
||||||
core WebSocket-upgrade capability; single-use pairing and revocable devices;
|
core WebSocket-upgrade capability; single-use pairing and revocable devices;
|
||||||
TLS-only device transport; bounded PCM/session queues; revision-safe settings;
|
HTTPS/WSS device transport with exact localhost-origin HTTP allowed only for
|
||||||
|
locally generated pairing packages; bounded PCM/session queues; revision-safe settings;
|
||||||
provider/delivery interfaces; worker supervision; caption stabilization; pinned
|
provider/delivery interfaces; worker supervision; caption stabilization; pinned
|
||||||
whisper.cpp/model manifests; short-lived JSONL diagnostics; protocol schemas;
|
whisper.cpp/model manifests; short-lived JSONL diagnostics; protocol schemas;
|
||||||
and .NET/native companion boundaries with focused verification. The companion now
|
and .NET/native companion boundaries with focused verification. The companion now
|
||||||
has a Lumi-styled Avalonia 12 single-instance tray shell, guided real-boundary
|
has a Lumi-styled Avalonia 12 single-instance tray shell, guided real-boundary
|
||||||
test states, DPAPI pairing, local preferences/autostart, and bounded diagnostics.
|
test states, DPAPI pairing, local preferences/autostart, and bounded diagnostics.
|
||||||
A pinned native whisper.cpp worker builds on the CPU verification target and the
|
A pinned native whisper.cpp worker builds on the CPU verification target and the
|
||||||
server offers consent-gated model download/load controls.
|
server offers consent-gated model download/load controls. The Transcription
|
||||||
|
settings now render inside the shared Lumi shell, and Admin shows a compact
|
||||||
|
Companion health/download section beneath its shortcut cards. A checksum-pinned
|
||||||
|
self-contained Windows bundle includes a one-time pairing file and pairs after
|
||||||
|
extraction without a separate import workflow.
|
||||||
|
|
||||||
Release-blocking work remains:
|
Release-blocking work remains:
|
||||||
|
|
||||||
|
|||||||
@ -4,9 +4,9 @@ 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. It exchanges the embedded token once and stores the returned device credential with Windows DPAPI. 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 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 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. 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.
|
||||||
|
|
||||||
Build all managed projects from the repository root:
|
Build all managed projects from the repository root:
|
||||||
|
|
||||||
@ -15,3 +15,5 @@ dotnet build companion/Lumi.Companion.sln -p:EnableWindowsTargeting=true
|
|||||||
```
|
```
|
||||||
|
|
||||||
The signed installer, managed OBS bridge installation/repair service, and a target-machine OBS/Twitch acceptance run are still required. Until those pass, the UI deliberately stops its test at the unavailable real boundary.
|
The signed installer, managed OBS bridge installation/repair service, and a target-machine OBS/Twitch acceptance run are still required. Until those pass, the UI deliberately stops its test at the unavailable real boundary.
|
||||||
|
|
||||||
|
The Admin **Download Companion** action currently distributes a checksum-pinned, self-contained Windows x64 ZIP. It is intentionally marked experimental and is not code-signed yet.
|
||||||
|
|||||||
@ -67,6 +67,13 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
if (credential is null)
|
if (credential is null)
|
||||||
{
|
{
|
||||||
|
var bundledPairing = FindBundledPairingPackage();
|
||||||
|
if (bundledPairing is not null)
|
||||||
|
{
|
||||||
|
await PairAsync(bundledPairing);
|
||||||
|
try { File.Delete(bundledPairing); } catch { }
|
||||||
|
return;
|
||||||
|
}
|
||||||
SetState(State with { ObsBridgeInstalled = DetectBridgeInstallation(), Detail = "Download a pairing package from Lumi, then open it here." });
|
SetState(State with { ObsBridgeInstalled = DetectBridgeInstallation(), Detail = "Download a pairing package from Lumi, then open it here." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -354,6 +361,15 @@ public sealed class CompanionRuntime : IAsyncDisposable
|
|||||||
return File.Exists(obsData);
|
return File.Exists(obsData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? FindBundledPairingPackage()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Directory.EnumerateFiles(AppContext.BaseDirectory, "*.lumi-pairing.json", SearchOption.TopDirectoryOnly).Take(2).SingleOrDefault();
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
private void ApplyAutoStart(bool enabled)
|
private void ApplyAutoStart(bool enabled)
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows()) return;
|
if (!OperatingSystem.IsWindows()) return;
|
||||||
|
|||||||
@ -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.1</Version>
|
<Version>0.1.0-experimental.2</Version>
|
||||||
<AssemblyVersion>0.1.0.0</AssemblyVersion>
|
<AssemblyVersion>0.1.0.0</AssemblyVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@ -22,6 +22,8 @@ public sealed class CompanionSocket : IAsyncDisposable
|
|||||||
_socket = new ClientWebSocket();
|
_socket = new ClientWebSocket();
|
||||||
_socket.Options.SetRequestHeader("Authorization", $"LumiDevice {credential.DeviceId}.{credential.DeviceSecret}");
|
_socket.Options.SetRequestHeader("Authorization", $"LumiDevice {credential.DeviceId}.{credential.DeviceSecret}");
|
||||||
var host = new Uri(credential.Host);
|
var host = new Uri(credential.Host);
|
||||||
|
if (host.Scheme != "https" && !(host.Scheme == "http" && host.IsLoopback))
|
||||||
|
throw new InvalidDataException("Companion transport requires HTTPS unless this credential was issued for localhost.");
|
||||||
var uri = new UriBuilder(host) { Scheme = host.Scheme == "https" ? "wss" : "ws", Path = "/plugins/lumi_transcription/live" }.Uri;
|
var uri = new UriBuilder(host) { Scheme = host.Scheme == "https" ? "wss" : "ws", Path = "/plugins/lumi_transcription/live" }.Uri;
|
||||||
await _socket.ConnectAsync(uri, cancellationToken);
|
await _socket.ConnectAsync(uri, cancellationToken);
|
||||||
var hello = ProtocolV1.EncodeEnvelope("hello", new { companion_version = companionVersion, plugin_version = pluginVersion, obs_version = obsVersion, capabilities = credential.Capabilities, audio = new { codec = "pcm_s16le", sample_rate = 16000, channels = 1, bits = 16 } });
|
var hello = ProtocolV1.EncodeEnvelope("hello", new { companion_version = companionVersion, plugin_version = pluginVersion, obs_version = obsVersion, capabilities = credential.Capabilities, audio = new { codec = "pcm_s16le", sample_rate = 16000, channels = 1, bits = 16 } });
|
||||||
|
|||||||
@ -15,9 +15,11 @@ public sealed class PairingClient(HttpClient http)
|
|||||||
if (DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() >= bootstrap.ExpiresAt)
|
if (DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() >= bootstrap.ExpiresAt)
|
||||||
throw new InvalidDataException("Pairing package has expired. Download a new package from Lumi.");
|
throw new InvalidDataException("Pairing package has expired. Download a new package from Lumi.");
|
||||||
var exchangeUri = new Uri(bootstrap.ExchangeUrl);
|
var exchangeUri = new Uri(bootstrap.ExchangeUrl);
|
||||||
var insecureDev = Environment.GetEnvironmentVariable("LUMI_COMPANION_DEV_ALLOW_INSECURE") == "1" && exchangeUri.IsLoopback;
|
var hostUri = new Uri(bootstrap.Host);
|
||||||
if (!exchangeUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) && !insecureDev)
|
var secure = exchangeUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) && hostUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase);
|
||||||
throw new InvalidDataException("Pairing requires an HTTPS Lumi host.");
|
var localHttp = exchangeUri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) && hostUri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) && exchangeUri.IsLoopback && hostUri.IsLoopback;
|
||||||
|
if ((!secure && !localHttp) || exchangeUri.GetLeftPart(UriPartial.Authority) != hostUri.GetLeftPart(UriPartial.Authority))
|
||||||
|
throw new InvalidDataException("Pairing requires HTTPS unless the package explicitly uses one matching localhost origin.");
|
||||||
using var response = await http.PostAsJsonAsync(bootstrap.ExchangeUrl, new { token = bootstrap.Token, device }, ProtocolV1.JsonOptions, cancellationToken);
|
using var response = await http.PostAsJsonAsync(bootstrap.ExchangeUrl, new { token = bootstrap.Token, device }, ProtocolV1.JsonOptions, cancellationToken);
|
||||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"Pairing failed: {body}");
|
if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"Pairing failed: {body}");
|
||||||
|
|||||||
@ -19,20 +19,20 @@ The repository contains a pinned native whisper.cpp rolling-window worker and a
|
|||||||
|
|
||||||
- Pairing packages contain a cryptographically random credential that expires after 15 minutes and can be activated once. Lumi stores only its SHA-256 digest.
|
- Pairing packages contain a cryptographically random credential that expires after 15 minutes and can be activated once. Lumi stores only its SHA-256 digest.
|
||||||
- Activation returns a revocable device secret once. The Windows companion stores it with current-user DPAPI.
|
- Activation returns a revocable device secret once. The Windows companion stores it with current-user DPAPI.
|
||||||
- Device HTTP and WebSocket credentials require HTTPS/WSS. Insecure transport is available only from localhost when `LUMI_COMPANION_DEV_ALLOW_INSECURE=1` is explicitly set.
|
- Device HTTP and WebSocket credentials require HTTPS/WSS. HTTP/WS is accepted only when an administrator downloads the package through an explicit loopback URL and Companion returns to that exact loopback origin, including its port. There is no environment-variable bypass.
|
||||||
- The OBS bridge never receives the Lumi credential and never connects to Lumi directly.
|
- The OBS bridge never receives the Lumi credential and never connects to Lumi directly.
|
||||||
- Audio frames are capped at 200 ms and recovery buffers at five seconds. Old or excess frames are dropped; capture paths never block for inference.
|
- Audio frames are capped at 200 ms and recovery buffers at five seconds. Old or excess frames are dropped; capture paths never block for inference.
|
||||||
- Raw audio is never logged or written to disk. JSON Lines diagnostics default to seven days and 256 MiB. Caption text can be disabled in diagnostics.
|
- Raw audio is never logged or written to disk. JSON Lines diagnostics default to seven days and 256 MiB. Caption text can be disabled in diagnostics.
|
||||||
|
|
||||||
## Pairing and installation milestone
|
## Pairing and installation milestone
|
||||||
|
|
||||||
1. Serve Lumi through HTTPS and enable `lumi_transcription` under Admin > Plugins.
|
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. Open Plugins > Transcription and create a pairing package. It is a bootstrap package for the generic companion, not a credential that should be shared or committed.
|
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, build `companion/Lumi.Companion.sln`, start `Lumi.Companion.App`, and choose the package from the Overview or Connection page.
|
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.
|
||||||
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. Configure `LUMI_TRANSCRIPTION_WORKER` with the supervised streaming-worker executable once that worker is built for the target host.
|
5. Configure `LUMI_TRANSCRIPTION_WORKER` with the supervised streaming-worker executable once that worker is built for the target host.
|
||||||
|
|
||||||
The Avalonia tray UI and host-side model download/load controls now exist. 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, 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.
|
||||||
|
|
||||||
## Operation and recovery
|
## Operation and recovery
|
||||||
|
|
||||||
@ -42,15 +42,15 @@ Device and capability revocation take effect on the next authenticated request/c
|
|||||||
|
|
||||||
## Diagnostics
|
## Diagnostics
|
||||||
|
|
||||||
Use the Transcription admin page for provider, model, device, and log health. Recovery errors distinguish missing setup, unavailable inference, source inactivity, protocol incompatibility, and revoked access. The target-machine test must additionally record latency, resource, OBS missed-frame, audio-dropout, queue-drop, and network metrics using the acceptance template in `companion/docs/performance-acceptance-template.md`.
|
Use the compact Lumi Companion section on Admin for connection, device, inference, session, and package health. Open the shared-shell Transcription settings page for provider, model, device, and log details. Recovery errors distinguish missing setup, unavailable inference, source inactivity, protocol incompatibility, and revoked access. The target-machine test must additionally record latency, resource, OBS missed-frame, audio-dropout, queue-drop, and network metrics using the acceptance template in `companion/docs/performance-acceptance-template.md`.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
- The .NET SDK and OBS SDK are not available in the development environment used for this milestone, so those projects have not been compiled here.
|
- The managed .NET solution and self-contained Windows x64 application have been compiled in the development environment; the OBS SDK integration still requires its target toolchain and acceptance host.
|
||||||
- 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 companion shell is a protocol/bootstrap executable, not yet the Avalonia tray application.
|
- The downloadable bundle is an unsigned experimental self-contained ZIP, not the final signed installer.
|
||||||
- Installer signing, bridge repair, auto-start, 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`.
|
||||||
|
|||||||
@ -14,13 +14,14 @@ editable: false
|
|||||||
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
|
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
|
||||||
## Metadata
|
## Metadata
|
||||||
Plugin ID: lumi_transcription
|
Plugin ID: lumi_transcription
|
||||||
Version: 0.1.0-experimental.1
|
Version: 0.1.0-experimental.2
|
||||||
Default state: enabled
|
Default state: enabled
|
||||||
## Web Routes
|
## Web Routes
|
||||||
- /plugins/lumi_transcription
|
- /plugins/lumi_transcription
|
||||||
- GET /plugins/lumi_transcription
|
- GET /plugins/lumi_transcription
|
||||||
- GET /plugins/lumi_transcription/api/status
|
- GET /plugins/lumi_transcription/api/status
|
||||||
- POST /plugins/lumi_transcription/api/pairing-package
|
- POST /plugins/lumi_transcription/api/pairing-package
|
||||||
|
- POST /plugins/lumi_transcription/api/companion/download
|
||||||
- POST /plugins/lumi_transcription/api/pair
|
- POST /plugins/lumi_transcription/api/pair
|
||||||
- GET /plugins/lumi_transcription/api/devices
|
- GET /plugins/lumi_transcription/api/devices
|
||||||
- POST /plugins/lumi_transcription/api/devices/:id/revoke
|
- POST /plugins/lumi_transcription/api/devices/:id/revoke
|
||||||
@ -45,7 +46,7 @@ Default state: enabled
|
|||||||
|
|
||||||
- Purpose: Renders or serves the lumi_transcription plugin page.
|
- Purpose: Renders or serves the lumi_transcription plugin page.
|
||||||
- Inputs: No request parameters detected by static analysis.
|
- Inputs: No request parameters detected by static analysis.
|
||||||
- Response format: HTML page rendered from an EJS view
|
- Response format: plain or HTML response
|
||||||
- Access: admin access expected
|
- Access: admin access expected
|
||||||
- Side effects: Usually read-only.
|
- Side effects: Usually read-only.
|
||||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
|
||||||
@ -68,6 +69,15 @@ Default state: enabled
|
|||||||
- Side effects: Action route; side effects were not detected statically.
|
- Side effects: Action route; side effects were not detected statically.
|
||||||
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. API consumers should expect JSON unless the response format says otherwise.
|
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. API consumers should expect JSON unless the response format says otherwise.
|
||||||
|
|
||||||
|
### POST /plugins/lumi_transcription/api/companion/download
|
||||||
|
|
||||||
|
- Purpose: Processes the lumi_transcription plugin action for api companion download.
|
||||||
|
- Inputs: No request parameters detected by static analysis.
|
||||||
|
- Response format: plain or HTML response
|
||||||
|
- Access: admin access expected; logged-in session required or used
|
||||||
|
- Side effects: Action route; side effects were not detected statically.
|
||||||
|
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. API consumers should expect JSON unless the response format says otherwise.
|
||||||
|
|
||||||
### POST /plugins/lumi_transcription/api/pair
|
### POST /plugins/lumi_transcription/api/pair
|
||||||
|
|
||||||
- Purpose: Processes the lumi_transcription plugin action for api pair.
|
- Purpose: Processes the lumi_transcription plugin action for api pair.
|
||||||
|
|||||||
@ -7,7 +7,6 @@ class DeviceStore {
|
|||||||
this.db = db;
|
this.db = db;
|
||||||
this.now = options.now || Date.now;
|
this.now = options.now || Date.now;
|
||||||
this.randomBytes = options.randomBytes || crypto.randomBytes;
|
this.randomBytes = options.randomBytes || crypto.randomBytes;
|
||||||
this.allowInsecure = options.allowInsecure === true;
|
|
||||||
this.migrate();
|
this.migrate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,10 +19,12 @@ class DeviceStore {
|
|||||||
CREATE TABLE IF NOT EXISTS transcription_devices (
|
CREATE TABLE IF NOT EXISTS transcription_devices (
|
||||||
id TEXT PRIMARY KEY, install_id TEXT, name TEXT NOT NULL, lumi_user_id TEXT NOT NULL,
|
id TEXT PRIMARY KEY, install_id TEXT, name TEXT NOT NULL, lumi_user_id TEXT NOT NULL,
|
||||||
credential_hash TEXT NOT NULL, capabilities_json TEXT NOT NULL, metadata_json TEXT NOT NULL,
|
credential_hash TEXT NOT NULL, capabilities_json TEXT NOT NULL, metadata_json TEXT NOT NULL,
|
||||||
first_connected_at INTEGER NOT NULL, last_connected_at INTEGER NOT NULL, revoked_at INTEGER
|
first_connected_at INTEGER NOT NULL, last_connected_at INTEGER NOT NULL, revoked_at INTEGER,
|
||||||
|
pairing_host TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS transcription_devices_user_idx ON transcription_devices(lumi_user_id);
|
CREATE INDEX IF NOT EXISTS transcription_devices_user_idx ON transcription_devices(lumi_user_id);
|
||||||
`);
|
`);
|
||||||
|
ensureColumn(this.db, "transcription_devices", "pairing_host", "TEXT");
|
||||||
}
|
}
|
||||||
|
|
||||||
issuePairing({ userId, host, ttlMs = 15 * 60 * 1000 }) {
|
issuePairing({ userId, host, ttlMs = 15 * 60 * 1000 }) {
|
||||||
@ -32,8 +33,8 @@ class DeviceStore {
|
|||||||
const token = tokenValue(this.randomBytes(32));
|
const token = tokenValue(this.randomBytes(32));
|
||||||
const now = this.now();
|
const now = this.now();
|
||||||
this.db.prepare("INSERT INTO transcription_pairing_tokens (id, token_hash, lumi_user_id, host, expires_at, activated_at, created_at) VALUES (?, ?, ?, ?, ?, NULL, ?)")
|
this.db.prepare("INSERT INTO transcription_pairing_tokens (id, token_hash, lumi_user_id, host, expires_at, activated_at, created_at) VALUES (?, ?, ?, ?, ?, NULL, ?)")
|
||||||
.run(id, digest(token), String(userId), normalizeHost(host, this.allowInsecure), now + ttlMs, now);
|
.run(id, digest(token), String(userId), normalizeHost(host), now + ttlMs, now);
|
||||||
return { pairing_id: id, token, host: normalizeHost(host, this.allowInsecure), expires_at: now + ttlMs, protocol_version: 1 };
|
return { pairing_id: id, token, host: normalizeHost(host), expires_at: now + ttlMs, protocol_version: 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
exchange({ token, device = {} }) {
|
exchange({ token, device = {} }) {
|
||||||
@ -51,8 +52,8 @@ class DeviceStore {
|
|||||||
const transaction = this.db.transaction(() => {
|
const transaction = this.db.transaction(() => {
|
||||||
const consumed = this.db.prepare("UPDATE transcription_pairing_tokens SET activated_at = ? WHERE id = ? AND activated_at IS NULL").run(now, row.id);
|
const consumed = this.db.prepare("UPDATE transcription_pairing_tokens SET activated_at = ? WHERE id = ? AND activated_at IS NULL").run(now, row.id);
|
||||||
if (consumed.changes !== 1) { const error = new Error("This pairing package was already activated. Download a new companion package."); error.code = "PAIRING_ALREADY_USED"; throw error; }
|
if (consumed.changes !== 1) { const error = new Error("This pairing package was already activated. Download a new companion package."); error.code = "PAIRING_ALREADY_USED"; throw error; }
|
||||||
this.db.prepare("INSERT INTO transcription_devices (id, install_id, name, lumi_user_id, credential_hash, capabilities_json, metadata_json, first_connected_at, last_connected_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)")
|
this.db.prepare("INSERT INTO transcription_devices (id, install_id, name, lumi_user_id, credential_hash, capabilities_json, metadata_json, first_connected_at, last_connected_at, revoked_at, pairing_host) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)")
|
||||||
.run(deviceId, clean(device.install_id, 128) || null, clean(device.name, 160) || "Lumi Companion", row.lumi_user_id, digest(secret), JSON.stringify(capabilities), JSON.stringify(safeMetadata(device)), now, now);
|
.run(deviceId, clean(device.install_id, 128) || null, clean(device.name, 160) || "Lumi Companion", row.lumi_user_id, digest(secret), JSON.stringify(capabilities), JSON.stringify(safeMetadata(device)), now, now, row.host);
|
||||||
});
|
});
|
||||||
transaction();
|
transaction();
|
||||||
return { device_id: deviceId, device_secret: secret, host: row.host, capabilities, protocol_version: 1 };
|
return { device_id: deviceId, device_secret: secret, host: row.host, capabilities, protocol_version: 1 };
|
||||||
@ -76,15 +77,23 @@ class DeviceStore {
|
|||||||
const changed = this.db.prepare("UPDATE transcription_devices SET capabilities_json = ? WHERE id = ? AND revoked_at IS NULL").run(JSON.stringify(allowed), deviceId).changes;
|
const changed = this.db.prepare("UPDATE transcription_devices SET capabilities_json = ? WHERE id = ? AND revoked_at IS NULL").run(JSON.stringify(allowed), deviceId).changes;
|
||||||
return changed ? allowed : null;
|
return changed ? allowed : null;
|
||||||
}
|
}
|
||||||
|
pairingAllowsHttp(token, requestOrigin) {
|
||||||
|
const row = this.db.prepare("SELECT host, activated_at, expires_at FROM transcription_pairing_tokens WHERE token_hash = ?").get(digest(token));
|
||||||
|
return Boolean(row && !row.activated_at && row.expires_at >= this.now() && sameLoopbackOrigin(row.host, requestOrigin));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function digest(value) { return crypto.createHash("sha256").update(String(value || ""), "utf8").digest("hex"); }
|
function digest(value) { return crypto.createHash("sha256").update(String(value || ""), "utf8").digest("hex"); }
|
||||||
function safeEqual(left, right) { const a = Buffer.from(String(left)); const b = Buffer.from(String(right)); return a.length === b.length && crypto.timingSafeEqual(a, b); }
|
function safeEqual(left, right) { const a = Buffer.from(String(left)); const b = Buffer.from(String(right)); return a.length === b.length && crypto.timingSafeEqual(a, b); }
|
||||||
function tokenValue(buffer) { return Buffer.from(buffer).toString("base64url"); }
|
function tokenValue(buffer) { return Buffer.from(buffer).toString("base64url"); }
|
||||||
function normalizeHost(value, allowInsecure = false) { const url = new URL(String(value)); if (url.protocol !== "https:" && !(allowInsecure && url.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(url.hostname))) throw new Error("Lumi Companion requires HTTPS. Insecure HTTP is allowed only for explicit localhost development."); return url.origin; }
|
function normalizeHost(value) { const url = new URL(String(value)); if (url.protocol !== "https:" && !isLoopbackHttpOrigin(url)) throw new Error("Lumi Companion requires HTTPS. HTTP is allowed only when the pairing URL explicitly uses localhost or a loopback address."); return url.origin; }
|
||||||
|
function isLoopbackHttpOrigin(value) { try { const url = value instanceof URL ? value : new URL(String(value)); return url.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname); } catch { return false; } }
|
||||||
|
function sameLoopbackOrigin(left, right) { try { const a = new URL(String(left)); const b = new URL(String(right)); return isLoopbackHttpOrigin(a) && isLoopbackHttpOrigin(b) && a.origin === b.origin; } catch { return false; } }
|
||||||
|
function insecureDeviceAllowed(device, requestOrigin, remoteAddress) { return ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(String(remoteAddress || "")) && sameLoopbackOrigin(device?.pairing_host, requestOrigin); }
|
||||||
|
function ensureColumn(db, table, column, type) { if (!db.prepare(`PRAGMA table_info(${table})`).all().some((entry) => entry.name === column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`); }
|
||||||
function clean(value, max) { return String(value || "").trim().slice(0, max); }
|
function clean(value, max) { return String(value || "").trim().slice(0, max); }
|
||||||
function parseArray(value) { try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : []; } catch { return []; } }
|
function parseArray(value) { try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : []; } catch { return []; } }
|
||||||
function safeMetadata(device) { return { companion_version: clean(device.companion_version, 32), obs_version: clean(device.obs_version, 32), os: clean(device.os, 120), architecture: clean(device.architecture, 32), hardware: clean(device.hardware, 500) }; }
|
function safeMetadata(device) { return { companion_version: clean(device.companion_version, 32), obs_version: clean(device.obs_version, 32), os: clean(device.os, 120), architecture: clean(device.architecture, 32), hardware: clean(device.hardware, 500) }; }
|
||||||
function serialize(row, capabilities) { return { id: row.id, install_id: row.install_id, name: row.name, lumi_user_id: row.lumi_user_id, capabilities, metadata: JSON.parse(row.metadata_json || "{}"), first_connected_at: row.first_connected_at, last_connected_at: row.last_connected_at, revoked_at: row.revoked_at }; }
|
function serialize(row, capabilities) { return { id: row.id, install_id: row.install_id, name: row.name, lumi_user_id: row.lumi_user_id, capabilities, metadata: JSON.parse(row.metadata_json || "{}"), first_connected_at: row.first_connected_at, last_connected_at: row.last_connected_at, revoked_at: row.revoked_at, pairing_host: row.pairing_host || null }; }
|
||||||
|
|
||||||
module.exports = { DeviceStore, DEFAULT_CAPABILITIES, digest };
|
module.exports = { DeviceStore, DEFAULT_CAPABILITIES, digest, normalizeHost, isLoopbackHttpOrigin, sameLoopbackOrigin, insecureDeviceAllowed };
|
||||||
|
|||||||
@ -1,22 +1,23 @@
|
|||||||
const { WebSocketServer, WebSocket } = require("ws");
|
const { WebSocketServer, WebSocket } = require("ws");
|
||||||
const { MAX_JSON_BYTES, MAX_AUDIO_PAYLOAD_BYTES, AUDIO_HEADER_BYTES, parseEnvelope, validateHello, parseAudioFrame, envelope } = require("./protocol");
|
const { MAX_JSON_BYTES, MAX_AUDIO_PAYLOAD_BYTES, AUDIO_HEADER_BYTES, parseEnvelope, validateHello, parseAudioFrame, envelope } = require("./protocol");
|
||||||
|
const { insecureDeviceAllowed } = require("./device_store");
|
||||||
|
|
||||||
class CompanionGateway {
|
class CompanionGateway {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
this.devices = options.devices;
|
this.devices = options.devices;
|
||||||
this.sessions = options.sessions;
|
this.sessions = options.sessions;
|
||||||
this.log = options.log || { append() {} };
|
this.log = options.log || { append() {} };
|
||||||
this.allowInsecure = options.allowInsecure === true;
|
|
||||||
this.wss = new WebSocketServer({ noServer: true, maxPayload: Math.max(MAX_JSON_BYTES, AUDIO_HEADER_BYTES + MAX_AUDIO_PAYLOAD_BYTES), perMessageDeflate: false, clientTracking: true });
|
this.wss = new WebSocketServer({ noServer: true, maxPayload: Math.max(MAX_JSON_BYTES, AUDIO_HEADER_BYTES + MAX_AUDIO_PAYLOAD_BYTES), perMessageDeflate: false, clientTracking: true });
|
||||||
this.wss.on("connection", (socket, request, device) => this.connection(socket, request, device));
|
this.wss.on("connection", (socket, request, device) => this.connection(socket, request, device));
|
||||||
}
|
}
|
||||||
upgrade(request, socket, head) {
|
upgrade(request, socket, head) {
|
||||||
const forwardedProto = String(request.headers["x-forwarded-proto"] || "").split(",")[0].trim();
|
const forwardedProto = String(request.headers["x-forwarded-proto"] || "").split(",")[0].trim();
|
||||||
const secure = Boolean(request.socket.encrypted) || forwardedProto === "https";
|
const proxyIsLocal = ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(request.socket.remoteAddress);
|
||||||
const local = ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(request.socket.remoteAddress);
|
const secure = Boolean(request.socket.encrypted) || (proxyIsLocal && forwardedProto === "https");
|
||||||
if (!secure && !(this.allowInsecure && local)) return reject(socket, 426, "tls_required");
|
|
||||||
const auth = this.devices.authenticate(request.headers.authorization, "transcription.capture.v1");
|
const auth = this.devices.authenticate(request.headers.authorization, "transcription.capture.v1");
|
||||||
if (!auth.allowed) return reject(socket, auth.reason === "capability_revoked" ? 403 : 401, auth.reason);
|
if (!auth.allowed) return reject(socket, auth.reason === "capability_revoked" ? 403 : 401, auth.reason);
|
||||||
|
const requestOrigin = `http://${request.headers.host || "invalid"}`;
|
||||||
|
if (!secure && !insecureDeviceAllowed(auth.device, requestOrigin, request.socket.remoteAddress)) return reject(socket, 426, "tls_required");
|
||||||
const origin = request.headers.origin;
|
const origin = request.headers.origin;
|
||||||
if (origin && !sameHostOrigin(origin, request.headers.host)) return reject(socket, 403, "origin_rejected");
|
if (origin && !sameHostOrigin(origin, request.headers.host)) return reject(socket, 403, "origin_rejected");
|
||||||
this.wss.handleUpgrade(request, socket, head, (ws) => this.wss.emit("connection", ws, request, auth.device));
|
this.wss.handleUpgrade(request, socket, head, (ws) => this.wss.emit("connection", ws, request, auth.device));
|
||||||
|
|||||||
@ -0,0 +1,61 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const AdmZip = require("adm-zip");
|
||||||
|
const { ArtifactManager } = require("../models/artifact_manager");
|
||||||
|
|
||||||
|
class CompanionPackageService {
|
||||||
|
constructor(root, manifest, options = {}) {
|
||||||
|
this.root = root;
|
||||||
|
this.manifest = manifest;
|
||||||
|
this.artifacts = new ArtifactManager(root, { fetch: options.fetch });
|
||||||
|
fs.mkdirSync(root, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
entry() { return this.manifest?.artifacts?.find((entry) => entry.platform === "win32" && entry.architecture === "x64") || null; }
|
||||||
|
|
||||||
|
status() {
|
||||||
|
const entry = this.entry();
|
||||||
|
if (!entry) return { available: false, installed: false, valid: false, reason: "No Windows Companion artifact is configured." };
|
||||||
|
const artifact = this.artifacts.status(entry);
|
||||||
|
return { available: true, version: this.manifest.version, artifact: entry.id, ...artifact };
|
||||||
|
}
|
||||||
|
|
||||||
|
async build(pairing) {
|
||||||
|
const entry = this.entry();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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.", "",
|
||||||
|
"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"));
|
||||||
|
return { buffer: output.toBuffer(), filename: `Lumi-Companion-${this.manifest.version}-paired.zip`, pairingName };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeArchivePath(value) {
|
||||||
|
const portable = String(value || "").replace(/\\/g, "/");
|
||||||
|
const normalized = path.posix.normalize(portable).replace(/^\/+/, "");
|
||||||
|
if (!normalized || normalized === ".." || normalized.startsWith("../") || /^[A-Za-z]:/.test(normalized)) throw new Error("The Companion artifact contains an unsafe path.");
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { CompanionPackageService, safeArchivePath };
|
||||||
@ -111,6 +111,16 @@ class SessionCoordinator {
|
|||||||
const session = this.require(sessionId);
|
const session = this.require(sessionId);
|
||||||
return { session_id: session.id, state: session.state, mode: session.mode, obs: { ...session.obs }, grace_until: session.graceUntil || null, tracks: Array.from(session.tracks.values()).map(serializeTrack) };
|
return { session_id: session.id, state: session.state, mode: session.mode, obs: { ...session.obs }, grace_until: session.graceUntil || null, tracks: Array.from(session.tracks.values()).map(serializeTrack) };
|
||||||
}
|
}
|
||||||
|
summary() {
|
||||||
|
const sessions = Array.from(this.sessions.values());
|
||||||
|
return {
|
||||||
|
total: sessions.length,
|
||||||
|
connected: sessions.filter((session) => session.connected).length,
|
||||||
|
running: sessions.filter((session) => session.state === "running").length,
|
||||||
|
test_running: sessions.filter((session) => session.state === "running" && session.mode === "test").length,
|
||||||
|
live_running: sessions.filter((session) => session.state === "running" && session.mode === "live").length
|
||||||
|
};
|
||||||
|
}
|
||||||
async close() { for (const session of Array.from(this.sessions.values())) await this.stop(session.id, "plugin_shutdown"); this.sessions.clear(); }
|
async close() { for (const session of Array.from(this.sessions.values())) await this.stop(session.id, "plugin_shutdown"); this.sessions.clear(); }
|
||||||
require(id) { const session = this.sessions.get(id); if (!session) throw new Error("Session was not found."); return session; }
|
require(id) { const session = this.sessions.get(id); if (!session) throw new Error("Session was not found."); return session; }
|
||||||
beginGrace(session) {
|
beginGrace(session) {
|
||||||
|
|||||||
17
plugins/lumi_transcription/companion_manifest.json
Normal file
17
plugins/lumi_transcription/companion_manifest.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"version": "0.1.0-experimental.2",
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"id": "windows-x64-self-contained",
|
||||||
|
"platform": "win32",
|
||||||
|
"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.2/Lumi.Companion-win-x64.zip",
|
||||||
|
"sha256": "2884522cb368fa990ff6475c99324a41a9a7ce4871029078808f2f9a2902c6e5",
|
||||||
|
"bytes": 41568887,
|
||||||
|
"entrypoint": "Lumi.Companion.App.exe"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -1,8 +1,11 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
|
const ejs = require("ejs");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { DeviceStore } = require("./backend/companion/device_store");
|
const { DeviceStore } = require("./backend/companion/device_store");
|
||||||
|
const { insecureDeviceAllowed } = require("./backend/companion/device_store");
|
||||||
const { CompanionGateway } = require("./backend/companion/gateway");
|
const { CompanionGateway } = require("./backend/companion/gateway");
|
||||||
|
const { CompanionPackageService } = require("./backend/companion/package_service");
|
||||||
const { RevisionStore } = require("./backend/config/revision_store");
|
const { RevisionStore } = require("./backend/config/revision_store");
|
||||||
const { CompanionCaptionDeliveryAdapter } = require("./backend/delivery/caption_delivery");
|
const { CompanionCaptionDeliveryAdapter } = require("./backend/delivery/caption_delivery");
|
||||||
const { JsonlDiagnosticLog } = require("./backend/logs/jsonl_log");
|
const { JsonlDiagnosticLog } = require("./backend/logs/jsonl_log");
|
||||||
@ -12,6 +15,7 @@ const { WhisperWorkerSupervisor, WhisperCppServerProvider } = require("./backend
|
|||||||
const { ensureDataDirs, dataPath } = require("./backend/paths");
|
const { ensureDataDirs, dataPath } = require("./backend/paths");
|
||||||
const modelManifest = require("./models_manifest.json");
|
const modelManifest = require("./models_manifest.json");
|
||||||
const runtimeManifest = require("./runtime_manifest.json");
|
const runtimeManifest = require("./runtime_manifest.json");
|
||||||
|
const companionManifest = require("./companion_manifest.json");
|
||||||
const manifest = require("./plugin.json");
|
const manifest = require("./plugin.json");
|
||||||
|
|
||||||
const PLUGIN_ID = "lumi_transcription";
|
const PLUGIN_ID = "lumi_transcription";
|
||||||
@ -20,8 +24,7 @@ module.exports = {
|
|||||||
id: PLUGIN_ID,
|
id: PLUGIN_ID,
|
||||||
init({ web, db, logger }) {
|
init({ web, db, logger }) {
|
||||||
ensureDataDirs();
|
ensureDataDirs();
|
||||||
const allowInsecure = process.env.LUMI_COMPANION_DEV_ALLOW_INSECURE === "1";
|
const devices = new DeviceStore(db);
|
||||||
const devices = new DeviceStore(db, { allowInsecure });
|
|
||||||
const revisions = new RevisionStore(db);
|
const revisions = new RevisionStore(db);
|
||||||
const diagnosticLog = new JsonlDiagnosticLog(dataPath("logs"));
|
const diagnosticLog = new JsonlDiagnosticLog(dataPath("logs"));
|
||||||
diagnosticLog.cleanup();
|
diagnosticLog.cleanup();
|
||||||
@ -39,11 +42,12 @@ module.exports = {
|
|||||||
deliveryFactory: (send) => new CompanionCaptionDeliveryAdapter(send),
|
deliveryFactory: (send) => new CompanionCaptionDeliveryAdapter(send),
|
||||||
log: diagnosticLog
|
log: diagnosticLog
|
||||||
});
|
});
|
||||||
const gateway = new CompanionGateway({ devices, sessions, log: diagnosticLog, allowInsecure });
|
const gateway = new CompanionGateway({ devices, sessions, log: diagnosticLog });
|
||||||
const unregisterUpgrade = web.addUpgradeHandler("/plugins/lumi_transcription/live", (request, socket, head) => gateway.upgrade(request, socket, head));
|
const unregisterUpgrade = web.addUpgradeHandler("/plugins/lumi_transcription/live", (request, socket, head) => gateway.upgrade(request, socket, head));
|
||||||
const models = new ArtifactManager(dataPath("models"));
|
const models = new ArtifactManager(dataPath("models"));
|
||||||
const runtimeArchives = new ArtifactManager(dataPath("tmp"));
|
const runtimeArchives = new ArtifactManager(dataPath("tmp"));
|
||||||
const runtimes = new ArtifactManager(dataPath("runtime"));
|
const runtimes = new ArtifactManager(dataPath("runtime"));
|
||||||
|
const companionPackages = new CompanionPackageService(dataPath("companion"), companionManifest);
|
||||||
const selectedModelId = revisions.list().selected_model_id?.value;
|
const selectedModelId = revisions.list().selected_model_id?.value;
|
||||||
const selectedModel = modelManifest.models.find((entry) => entry.id === selectedModelId);
|
const selectedModel = modelManifest.models.find((entry) => entry.id === selectedModelId);
|
||||||
if (supervisor.executable && selectedModel) {
|
if (supervisor.executable && selectedModel) {
|
||||||
@ -55,8 +59,13 @@ module.exports = {
|
|||||||
const router = web.createRouter();
|
const router = web.createRouter();
|
||||||
router.use("/assets", express.static(path.join(__dirname, "public")));
|
router.use("/assets", express.static(path.join(__dirname, "public")));
|
||||||
router.get("/", requireAdmin, async (_req, res) => {
|
router.get("/", requireAdmin, async (_req, res) => {
|
||||||
res.render(path.join(__dirname, "views", "settings.ejs"), {
|
const locals = {
|
||||||
|
...res.locals,
|
||||||
title: "Lumi Transcription",
|
title: "Lumi Transcription",
|
||||||
|
pageWidth: "wide",
|
||||||
|
pageId: "lumi-transcription",
|
||||||
|
extraStyles: [`/plugins/${PLUGIN_ID}/assets/transcription.css?v=${manifest.version}`],
|
||||||
|
extraScripts: [`/plugins/${PLUGIN_ID}/assets/transcription.js?v=${manifest.version}`],
|
||||||
pluginVersion: manifest.version,
|
pluginVersion: manifest.version,
|
||||||
providerHealth: await provider.health(),
|
providerHealth: await provider.health(),
|
||||||
devices: devices.list(),
|
devices: devices.list(),
|
||||||
@ -64,7 +73,8 @@ module.exports = {
|
|||||||
models: modelManifest.models.map((entry) => ({ ...entry, status: models.status(entry) })),
|
models: modelManifest.models.map((entry) => ({ ...entry, status: models.status(entry) })),
|
||||||
runtimeManifest,
|
runtimeManifest,
|
||||||
logs: diagnosticLog.files()
|
logs: diagnosticLog.files()
|
||||||
});
|
};
|
||||||
|
res.send(await renderLumiPage(locals));
|
||||||
});
|
});
|
||||||
router.get("/api/status", requireAdmin, async (_req, res) => res.json({
|
router.get("/api/status", requireAdmin, async (_req, res) => res.json({
|
||||||
ok: true, plugin: { id: PLUGIN_ID, version: manifest.version }, protocol_version: 1,
|
ok: true, plugin: { id: PLUGIN_ID, version: manifest.version }, protocol_version: 1,
|
||||||
@ -82,7 +92,18 @@ module.exports = {
|
|||||||
res.send(`${JSON.stringify(bootstrap, null, 2)}\n`);
|
res.send(`${JSON.stringify(bootstrap, null, 2)}\n`);
|
||||||
} catch (error) { res.status(400).json({ ok: false, error: error.message }); }
|
} catch (error) { res.status(400).json({ ok: false, error: error.message }); }
|
||||||
});
|
});
|
||||||
router.post("/api/pair", requireSecureRequest(allowInsecure), (req, res) => {
|
router.post("/api/companion/download", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const host = requestHost(req);
|
||||||
|
const pairing = devices.issuePairing({ userId: req.session.user.id, host });
|
||||||
|
const bootstrap = { format: "lumi-companion-bootstrap-v1", ...pairing, exchange_url: `${host}/plugins/${PLUGIN_ID}/api/pair` };
|
||||||
|
const bundle = await companionPackages.build({ ...pairing, bootstrap });
|
||||||
|
res.set("Cache-Control", "no-store");
|
||||||
|
res.attachment(bundle.filename);
|
||||||
|
res.send(bundle.buffer);
|
||||||
|
} catch (error) { res.status(503).json({ ok: false, error: error.message }); }
|
||||||
|
});
|
||||||
|
router.post("/api/pair", requirePairingTransport(devices), (req, res) => {
|
||||||
try { res.set("Cache-Control", "no-store"); res.status(201).json({ ok: true, ...devices.exchange(req.body || {}) }); }
|
try { res.set("Cache-Control", "no-store"); res.status(201).json({ ok: true, ...devices.exchange(req.body || {}) }); }
|
||||||
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 }); }
|
||||||
});
|
});
|
||||||
@ -93,8 +114,8 @@ module.exports = {
|
|||||||
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." });
|
||||||
res.json({ ok: true, capabilities });
|
res.json({ ok: true, capabilities });
|
||||||
});
|
});
|
||||||
router.get("/api/settings", requireSettingsAccess(devices, allowInsecure), (_req, res) => res.json({ fields: revisions.list() }));
|
router.get("/api/settings", requireSettingsAccess(devices), (_req, res) => res.json({ fields: revisions.list() }));
|
||||||
router.patch("/api/settings", requireSettingsAccess(devices, allowInsecure), (req, res) => {
|
router.patch("/api/settings", requireSettingsAccess(devices), (req, res) => {
|
||||||
try {
|
try {
|
||||||
const actor = req.session?.user?.id || req.lumiDevice?.id;
|
const actor = req.session?.user?.id || req.lumiDevice?.id;
|
||||||
const result = revisions.apply(req.body.changes, actor);
|
const result = revisions.apply(req.body.changes, actor);
|
||||||
@ -134,7 +155,12 @@ module.exports = {
|
|||||||
|
|
||||||
web.mount(`/plugins/${PLUGIN_ID}`, router, { label: "Transcription", role: "admin", section: "plugins" });
|
web.mount(`/plugins/${PLUGIN_ID}`, router, { label: "Transcription", role: "admin", section: "plugins" });
|
||||||
global.lumiFrameworks = global.lumiFrameworks || {};
|
global.lumiFrameworks = global.lumiFrameworks || {};
|
||||||
global.lumiFrameworks.transcription = { version: manifest.version, protocol_version: 1, health: () => provider.health() };
|
global.lumiFrameworks.transcription = {
|
||||||
|
version: manifest.version,
|
||||||
|
protocol_version: 1,
|
||||||
|
health: () => provider.health(),
|
||||||
|
dashboardSummary: () => buildDashboardSummary({ provider, devices, sessions, companionPackages })
|
||||||
|
};
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
clearInterval(cleanupTimer);
|
clearInterval(cleanupTimer);
|
||||||
@ -149,6 +175,45 @@ module.exports = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function requireAdmin(req, res, next) { if (req.session?.user?.isAdmin) return next(); return res.status(403).json({ error: "Administrator access is required." }); }
|
function requireAdmin(req, res, next) { if (req.session?.user?.isAdmin) return next(); return res.status(403).json({ error: "Administrator access is required." }); }
|
||||||
function requireSettingsAccess(devices, allowInsecure) { return (req, res, next) => { if (req.session?.user?.isAdmin) return next(); const forwarded = String(req.headers["x-forwarded-proto"] || "").split(",")[0].trim(); const local = ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress); if (!req.secure && forwarded !== "https" && !(allowInsecure && local)) return res.status(426).json({ error: "Device settings synchronization requires HTTPS." }); const auth = devices.authenticate(req.headers.authorization, "transcription.settings.v1"); if (!auth.allowed) return res.status(401).json({ error: "A paired device credential is required." }); req.lumiDevice = auth.device; next(); }; }
|
async function buildDashboardSummary({ provider, devices, sessions, companionPackages }) {
|
||||||
function requestHost(req) { const forwarded = String(req.headers["x-forwarded-proto"] || "").split(",")[0].trim(); const protocol = forwarded === "https" ? "https" : req.protocol; return `${protocol}://${req.get("host")}`; }
|
const inference = await provider.health();
|
||||||
function requireSecureRequest(allowInsecure) { return (req, res, next) => { const forwarded = String(req.headers["x-forwarded-proto"] || "").split(",")[0].trim(); const local = ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress); if (req.secure || forwarded === "https" || (allowInsecure && local)) return next(); return res.status(426).json({ error: "Lumi Companion pairing requires HTTPS." }); }; }
|
const activeDevices = devices.list().filter((device) => !device.revoked_at);
|
||||||
|
const connections = sessions.summary();
|
||||||
|
const packageStatus = companionPackages.status();
|
||||||
|
const issues = [];
|
||||||
|
if (!inference.healthy) issues.push("Server-hosted speech recognition is not ready.");
|
||||||
|
if (!inference.model) issues.push("No speech model is loaded.");
|
||||||
|
if (!activeDevices.length) issues.push("No Companion device is paired.");
|
||||||
|
else if (!connections.connected) issues.push("No paired Companion is currently connected.");
|
||||||
|
if (!packageStatus.available) issues.push("The Windows Companion download is not configured.");
|
||||||
|
const tone = inference.state === "failed" ? "danger" : issues.length ? "warning" : "success";
|
||||||
|
return {
|
||||||
|
id: "lumi-companion", eyebrow: "Streaming computer", title: "Lumi Companion",
|
||||||
|
description: "Download the paired Windows Companion and review the live transcription path at a glance.",
|
||||||
|
status: { tone, label: issues.length ? `${issues.length} issue${issues.length === 1 ? "" : "s"} discovered` : "Everything healthy" },
|
||||||
|
metrics: [
|
||||||
|
{ label: "Connection", value: connections.connected ? "Active" : "Offline" },
|
||||||
|
{ label: "Paired devices", value: activeDevices.length },
|
||||||
|
{ label: "Inference", value: inference.healthy ? "Healthy" : "Needs setup" },
|
||||||
|
{ label: "Active sessions", value: connections.running }
|
||||||
|
],
|
||||||
|
issues,
|
||||||
|
actions: [
|
||||||
|
{ label: "Download Companion", href: `/plugins/${PLUGIN_ID}/api/companion/download`, method: "post", primary: true, disabled: !packageStatus.available, disabledReason: packageStatus.reason },
|
||||||
|
{ label: "Open transcription settings", href: `/plugins/${PLUGIN_ID}`, method: "get" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function renderLumiPage(locals) {
|
||||||
|
const coreViews = path.join(__dirname, "..", "..", "src", "web", "views");
|
||||||
|
const page = path.join(__dirname, "views", "settings.ejs");
|
||||||
|
const [top, body, bottom] = await Promise.all([
|
||||||
|
ejs.renderFile(path.join(coreViews, "partials", "layout-top.ejs"), locals),
|
||||||
|
ejs.renderFile(page, locals),
|
||||||
|
ejs.renderFile(path.join(coreViews, "partials", "layout-bottom.ejs"), locals)
|
||||||
|
]);
|
||||||
|
return `${top}${body}${bottom}`;
|
||||||
|
}
|
||||||
|
function requireSettingsAccess(devices) { return (req, res, next) => { if (req.session?.user?.isAdmin) return next(); const auth = devices.authenticate(req.headers.authorization, "transcription.settings.v1"); if (!auth.allowed) return res.status(401).json({ error: "A paired device credential is required." }); if (!req.secure && !insecureDeviceAllowed(auth.device, requestHost(req), req.socket.remoteAddress)) return res.status(426).json({ error: "Device settings synchronization requires HTTPS unless this device was paired from the same localhost origin." }); req.lumiDevice = auth.device; next(); }; }
|
||||||
|
function requestHost(req) { return `${req.protocol === "https" ? "https" : "http"}://${req.get("host")}`; }
|
||||||
|
function requirePairingTransport(devices) { return (req, res, next) => { if (req.secure) return next(); const local = ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress); if (local && devices.pairingAllowsHttp(req.body?.token, requestHost(req))) return next(); return res.status(426).json({ error: "Lumi Companion pairing requires HTTPS unless the package was generated from this exact localhost URL." }); }; }
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "lumi_transcription",
|
"id": "lumi_transcription",
|
||||||
"name": "Lumi Transcription",
|
"name": "Lumi Transcription",
|
||||||
"version": "0.1.0-experimental.1",
|
"version": "0.1.0-experimental.2",
|
||||||
"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",
|
||||||
|
|||||||
@ -1,2 +1 @@
|
|||||||
.transcription-shell{--accent:#85e3c4;--warn:#f5c56b;max-width:1120px;margin:0 auto;padding:clamp(1rem,3vw,2.5rem);display:grid;gap:1.35rem}.transcription-shell section{padding:1.35rem 0;border-top:1px solid color-mix(in srgb,currentColor 16%,transparent)}.transcription-hero{display:flex;justify-content:space-between;align-items:flex-start;gap:2rem;padding:1rem 0 2rem}.transcription-hero h1{font-size:clamp(2rem,5vw,4.25rem);line-height:.95;margin:.25rem 0 1rem}.transcription-hero p:not(.eyebrow){max-width:67ch;opacity:.78}.eyebrow{text-transform:uppercase;letter-spacing:.13em;font-size:.72rem;font-weight:750;opacity:.65;margin:0}.health-pill{display:inline-flex;align-items:center;gap:.55rem;padding:.55rem .8rem;border-radius:999px;white-space:nowrap;background:color-mix(in srgb,var(--warn) 16%,transparent)}.health-pill span{width:.65rem;height:.65rem;border-radius:50%;background:var(--warn)}.health-pill.is-ready{background:color-mix(in srgb,var(--accent) 16%,transparent)}.health-pill.is-ready span{background:var(--accent)}.section-heading{display:flex;justify-content:space-between;align-items:end;gap:1rem;margin-bottom:1rem}.section-heading h2{margin:.2rem 0 0;font-size:1.35rem}.setup-path ol{display:grid;grid-template-columns:repeat(4,1fr);list-style:none;padding:0;margin:0 0 1.25rem;counter-reset:steps}.setup-path li{counter-increment:steps;padding:0 1rem 1rem 2.25rem;position:relative;opacity:.62}.setup-path li:before{content:counter(steps);position:absolute;left:0;top:-.2rem;width:1.6rem;height:1.6rem;border:1px solid currentColor;border-radius:50%;display:grid;place-items:center;font-size:.75rem}.setup-path li.is-current{opacity:1}.setup-path li.is-current:before{background:var(--accent);color:#10251f;border-color:var(--accent)}.setup-path li span,.plain-list span{display:block;font-size:.88rem;opacity:.7;margin-top:.25rem}.primary-action{border:0;border-radius:.6rem;background:var(--accent);color:#10251f;font-weight:750;padding:.7rem 1rem;cursor:pointer}.primary-action:disabled{opacity:.55;cursor:wait}.inline-status{display:inline;margin-left:.8rem}.transcription-grid{display:grid;grid-template-columns:1fr 1fr;gap:2.5rem}.health-list{margin:0}.health-list div{display:flex;justify-content:space-between;gap:1rem;padding:.55rem 0}.health-list dt{opacity:.65}.notice,.privacy-note,.empty-state{padding:1rem;border-radius:.65rem;background:color-mix(in srgb,currentColor 6%,transparent);font-size:.9rem}.plain-list{list-style:none;padding:0;margin:0}.plain-list li{display:flex;justify-content:space-between;gap:1rem;padding:.7rem 0}.plain-list code{font-size:.72rem;opacity:.6}.model-row{display:grid;gap:.5rem}.model-row article{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.9rem 0}.model-row h3,.model-row p{margin:0}.model-row p{font-size:.88rem;opacity:.7;margin-top:.2rem}.state-label{font-size:.78rem;font-weight:700;white-space:nowrap}@media(max-width:760px){.transcription-hero,.transcription-grid{display:grid;grid-template-columns:1fr}.setup-path ol{grid-template-columns:1fr}.health-pill{justify-self:start}.plain-list li{display:block}.plain-list code{display:block;margin-top:.5rem;overflow-wrap:anywhere}}
|
.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}}
|
||||||
.model-actions{display:flex;align-items:center;justify-content:flex-end;gap:.65rem}.model-actions button{border:1px solid color-mix(in srgb,currentColor 24%,transparent);border-radius:.55rem;background:transparent;color:inherit;padding:.5rem .7rem;cursor:pointer}.model-actions button:disabled{opacity:.55;cursor:wait}@media(max-width:760px){.model-row article{align-items:flex-start}.model-actions{align-items:flex-end;flex-direction:column}}
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const root = document.querySelector("[data-transcription-admin]");
|
const root = document.querySelector('[data-lumi-page="lumi-transcription"]');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const button = root.querySelector("[data-create-pairing]");
|
const button = root.querySelector("[data-create-pairing]");
|
||||||
const status = root.querySelector("[data-status]");
|
const status = root.querySelector("[data-status]");
|
||||||
@ -7,15 +7,15 @@
|
|||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
status.textContent = "Creating a one-time pairing package…";
|
status.textContent = "Creating a one-time pairing package…";
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/plugins/lumi_transcription/api/pairing-package", { method: "POST", headers: { Accept: "application/json" } });
|
const response = await fetch("/plugins/lumi_transcription/api/companion/download", { method: "POST", headers: { Accept: "application/json" } });
|
||||||
if (!response.ok) throw new Error((await response.json()).error || "Pairing package could not be created.");
|
if (!response.ok) throw new Error((await response.json()).error || "Pairing package could not be created.");
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const disposition = response.headers.get("content-disposition") || "";
|
const disposition = response.headers.get("content-disposition") || "";
|
||||||
const filename = /filename="?([^";]+)"?/i.exec(disposition)?.[1] || "lumi-companion.lumi-pairing.json";
|
const filename = /filename="?([^";]+)"?/i.exec(disposition)?.[1] || "Lumi-Companion-paired.zip";
|
||||||
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 = "Pairing package created. It expires in 15 minutes and works once.";
|
status.textContent = "Companion package created. Extract and start it 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; }
|
||||||
});
|
});
|
||||||
|
|||||||
7
plugins/lumi_transcription/stats.js
Normal file
7
plugins/lumi_transcription/stats.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
async function getAdminDashboardStats() {
|
||||||
|
const api = global.lumiFrameworks?.transcription;
|
||||||
|
if (!api?.dashboardSummary) throw new Error("The transcription plugin is not ready.");
|
||||||
|
return api.dashboardSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getAdminDashboardStats };
|
||||||
9
plugins/lumi_transcription/stats.json
Normal file
9
plugins/lumi_transcription/stats.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"pluginId": "lumi_transcription",
|
||||||
|
"pluginName": "Lumi Companion",
|
||||||
|
"provider": "stats.js",
|
||||||
|
"adminDashboard": {
|
||||||
|
"title": "Lumi Companion",
|
||||||
|
"description": "Streaming-computer connection and server-hosted transcription health."
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,8 @@ const Database = require("better-sqlite3");
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const { WebSocket } = require("ws");
|
const { WebSocket } = require("ws");
|
||||||
const { DeviceStore } = require("../backend/companion/device_store");
|
const { DeviceStore } = require("../backend/companion/device_store");
|
||||||
|
const { normalizeHost } = require("../backend/companion/device_store");
|
||||||
|
const { CompanionPackageService, safeArchivePath } = require("../backend/companion/package_service");
|
||||||
const { CompanionGateway } = require("../backend/companion/gateway");
|
const { CompanionGateway } = require("../backend/companion/gateway");
|
||||||
const protocol = require("../backend/companion/protocol");
|
const protocol = require("../backend/companion/protocol");
|
||||||
const { RevisionStore } = require("../backend/config/revision_store");
|
const { RevisionStore } = require("../backend/config/revision_store");
|
||||||
@ -27,6 +29,7 @@ async function run() {
|
|||||||
try {
|
try {
|
||||||
verifyProtocol();
|
verifyProtocol();
|
||||||
verifyPairingAndRevocation();
|
verifyPairingAndRevocation();
|
||||||
|
verifyLocalhostTransportPolicy();
|
||||||
verifyRevisions();
|
verifyRevisions();
|
||||||
verifyQueues();
|
verifyQueues();
|
||||||
verifyStabilization();
|
verifyStabilization();
|
||||||
@ -35,8 +38,9 @@ async function run() {
|
|||||||
await verifyNativeWorkerBoundary();
|
await verifyNativeWorkerBoundary();
|
||||||
await verifyAuthenticatedGateway();
|
await verifyAuthenticatedGateway();
|
||||||
verifyArtifactsAndLogs(temp);
|
verifyArtifactsAndLogs(temp);
|
||||||
|
await verifyCompanionPackage(temp);
|
||||||
await verifyPluginIsolation();
|
await verifyPluginIsolation();
|
||||||
console.log("Lumi transcription verification passed: protocol, pairing, revocation, revisions, queues, stabilization, lifecycle, native worker boundary, worker recovery, artifacts, logs, and plugin isolation.");
|
console.log("Lumi transcription verification passed: protocol, pairing, localhost-only HTTP, revocation, revisions, queues, stabilization, lifecycle, native worker boundary, paired Companion package, worker recovery, artifacts, logs, dashboard summary, and plugin isolation.");
|
||||||
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
|
} finally { fs.rmSync(temp, { recursive: true, force: true }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,6 +79,21 @@ function verifyPairingAndRevocation() {
|
|||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function verifyLocalhostTransportPolicy() {
|
||||||
|
assert.equal(normalizeHost("http://localhost:3000/path"), "http://localhost:3000");
|
||||||
|
assert.equal(normalizeHost("http://127.0.0.1:3000"), "http://127.0.0.1:3000");
|
||||||
|
assert.throws(() => normalizeHost("http://lumi.example"), /localhost|HTTPS/i);
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
const store = new DeviceStore(db);
|
||||||
|
const local = store.issuePairing({ userId: "admin", host: "http://localhost:3000" });
|
||||||
|
assert.equal(store.pairingAllowsHttp(local.token, "http://localhost:3000"), true);
|
||||||
|
assert.equal(store.pairingAllowsHttp(local.token, "http://127.0.0.1:3000"), false);
|
||||||
|
assert.equal(store.pairingAllowsHttp(local.token, "http://localhost:3001"), false);
|
||||||
|
store.exchange({ token: local.token, device: {} });
|
||||||
|
assert.equal(store.pairingAllowsHttp(local.token, "http://localhost:3000"), false);
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
function verifyRevisions() {
|
function verifyRevisions() {
|
||||||
const db = new Database(":memory:");
|
const db = new Database(":memory:");
|
||||||
const store = new RevisionStore(db, { now: () => 1234 });
|
const store = new RevisionStore(db, { now: () => 1234 });
|
||||||
@ -193,8 +212,6 @@ async function verifyNativeWorkerBoundary() {
|
|||||||
async function verifyAuthenticatedGateway() {
|
async function verifyAuthenticatedGateway() {
|
||||||
const db = new Database(":memory:");
|
const db = new Database(":memory:");
|
||||||
const devices = new DeviceStore(db);
|
const devices = new DeviceStore(db);
|
||||||
const pairing = devices.issuePairing({ userId: "admin", host: "https://lumi.example" });
|
|
||||||
const credential = devices.exchange({ token: pairing.token, device: { name: "Stream PC" } });
|
|
||||||
const sessionId = crypto.randomUUID();
|
const sessionId = crypto.randomUUID();
|
||||||
let disconnected = false;
|
let disconnected = false;
|
||||||
const sessions = {
|
const sessions = {
|
||||||
@ -202,12 +219,14 @@ async function verifyAuthenticatedGateway() {
|
|||||||
disconnect: () => { disconnected = true; },
|
disconnect: () => { disconnected = true; },
|
||||||
audio: async () => ({ accepted: true }), updateSource: () => ({}), updateObsState: async () => ({}), start: async () => ({}), stop: async () => ({})
|
audio: async () => ({ accepted: true }), updateSource: () => ({}), updateObsState: async () => ({}), start: async () => ({}), stop: async () => ({})
|
||||||
};
|
};
|
||||||
const gateway = new CompanionGateway({ devices, sessions, allowInsecure: true });
|
const gateway = new CompanionGateway({ devices, sessions });
|
||||||
const registry = createWebUpgradeRegistry();
|
const registry = createWebUpgradeRegistry();
|
||||||
registry.add("/plugins/lumi_transcription/live", (request, socket, head) => gateway.upgrade(request, socket, head));
|
registry.add("/plugins/lumi_transcription/live", (request, socket, head) => gateway.upgrade(request, socket, head));
|
||||||
const server = http.createServer((_req, res) => res.end("ok"));
|
const server = http.createServer((_req, res) => res.end("ok"));
|
||||||
registry.attach(server);
|
registry.attach(server);
|
||||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const pairing = devices.issuePairing({ userId: "admin", host: `http://127.0.0.1:${server.address().port}` });
|
||||||
|
const credential = devices.exchange({ token: pairing.token, device: { name: "Stream PC" } });
|
||||||
const client = new WebSocket(`ws://127.0.0.1:${server.address().port}/plugins/lumi_transcription/live`, {
|
const client = new WebSocket(`ws://127.0.0.1:${server.address().port}/plugins/lumi_transcription/live`, {
|
||||||
headers: { Authorization: `LumiDevice ${credential.device_id}.${credential.device_secret}` }
|
headers: { Authorization: `LumiDevice ${credential.device_id}.${credential.device_secret}` }
|
||||||
});
|
});
|
||||||
@ -219,6 +238,17 @@ async function verifyAuthenticatedGateway() {
|
|||||||
await new Promise((resolve) => { client.once("close", resolve); client.close(); });
|
await new Promise((resolve) => { client.once("close", resolve); client.close(); });
|
||||||
for (let attempt = 0; attempt < 20 && !disconnected; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 5));
|
for (let attempt = 0; attempt < 20 && !disconnected; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
assert.equal(disconnected, true);
|
assert.equal(disconnected, true);
|
||||||
|
const productionPairing = devices.issuePairing({ userId: "admin", host: "https://lumi.example" });
|
||||||
|
const productionCredential = devices.exchange({ token: productionPairing.token, device: { name: "Production Stream PC" } });
|
||||||
|
const insecureProductionClient = new WebSocket(`ws://127.0.0.1:${server.address().port}/plugins/lumi_transcription/live`, {
|
||||||
|
headers: { Authorization: `LumiDevice ${productionCredential.device_id}.${productionCredential.device_secret}` }
|
||||||
|
});
|
||||||
|
const rejectionStatus = await new Promise((resolve, reject) => {
|
||||||
|
insecureProductionClient.once("unexpected-response", (_request, response) => { response.resume(); resolve(response.statusCode); });
|
||||||
|
insecureProductionClient.once("open", () => reject(new Error("A production credential connected over insecure WebSocket.")));
|
||||||
|
insecureProductionClient.once("error", (error) => { if (!String(error.message).includes("Unexpected server response")) reject(error); });
|
||||||
|
});
|
||||||
|
assert.equal(rejectionStatus, 426);
|
||||||
await gateway.close(); registry.close();
|
await gateway.close(); registry.close();
|
||||||
await new Promise((resolve) => server.close(resolve));
|
await new Promise((resolve) => server.close(resolve));
|
||||||
db.close();
|
db.close();
|
||||||
@ -238,6 +268,26 @@ function verifyArtifactsAndLogs(temp) {
|
|||||||
assert.equal(fs.readdirSync(logsRoot).some((name) => name.endsWith(".wav") || name.endsWith(".pcm")), false);
|
assert.equal(fs.readdirSync(logsRoot).some((name) => name.endsWith(".wav") || name.endsWith(".pcm")), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function verifyCompanionPackage(temp) {
|
||||||
|
const root = path.join(temp, "companion-package");
|
||||||
|
fs.mkdirSync(root, { recursive: true });
|
||||||
|
const sourcePath = path.join(root, "companion.zip");
|
||||||
|
const zip = new (require("adm-zip"))();
|
||||||
|
zip.addFile("Lumi.Companion.App.exe", Buffer.from("portable-app"));
|
||||||
|
zip.writeZip(sourcePath);
|
||||||
|
const manifest = { version: "test", artifacts: [{
|
||||||
|
id: "windows-x64", platform: "win32", architecture: "x64", filename: "companion.zip",
|
||||||
|
entrypoint: "Lumi.Companion.App.exe", url: "https://example.invalid/companion.zip", sha256: sha256File(sourcePath)
|
||||||
|
}] };
|
||||||
|
const service = new CompanionPackageService(root, manifest);
|
||||||
|
const bundle = await service.build({ pairing_id: "pairing", bootstrap: { format: "lumi-companion-bootstrap-v1", token: "single-use" } });
|
||||||
|
const output = new (require("adm-zip"))(bundle.buffer);
|
||||||
|
assert.equal(output.readAsText("Lumi.Companion.App.exe"), "portable-app");
|
||||||
|
assert.match(output.readAsText("lumi-companion-pairing.lumi-pairing.json"), /single-use/);
|
||||||
|
assert.match(output.readAsText("START-HERE.txt"), /works once/i);
|
||||||
|
assert.throws(() => safeArchivePath("../escape.exe"), /unsafe path/i);
|
||||||
|
}
|
||||||
|
|
||||||
async function verifyPluginIsolation() {
|
async function verifyPluginIsolation() {
|
||||||
const db = new Database(":memory:");
|
const db = new Database(":memory:");
|
||||||
const mounts = [];
|
const mounts = [];
|
||||||
@ -253,6 +303,9 @@ async function verifyPluginIsolation() {
|
|||||||
});
|
});
|
||||||
assert.deepEqual(mounts, ["/plugins/lumi_transcription"]);
|
assert.deepEqual(mounts, ["/plugins/lumi_transcription"]);
|
||||||
assert.equal(typeof global.lumiFrameworks.transcription.health, "function");
|
assert.equal(typeof global.lumiFrameworks.transcription.health, "function");
|
||||||
|
const dashboard = await global.lumiFrameworks.transcription.dashboardSummary();
|
||||||
|
assert.equal(dashboard.title, "Lumi Companion");
|
||||||
|
assert.equal(dashboard.metrics.find((entry) => entry.label === "Connection").value, "Offline");
|
||||||
await cleanup();
|
await cleanup();
|
||||||
assert.equal(upgradeRemoved, true);
|
assert.equal(upgradeRemoved, true);
|
||||||
assert.equal(global.lumiFrameworks.transcription, undefined);
|
assert.equal(global.lumiFrameworks.transcription, undefined);
|
||||||
|
|||||||
@ -1,66 +1,75 @@
|
|||||||
<link rel="stylesheet" href="/plugins/lumi_transcription/assets/transcription.css">
|
<section class="card transcription-overview" data-transcription-admin>
|
||||||
<main class="transcription-shell" data-transcription-admin>
|
<div class="section-header">
|
||||||
<header class="transcription-hero">
|
<%- include("../../../src/web/views/partials/page-header", {
|
||||||
<div>
|
eyebrow: "Experimental companion",
|
||||||
<p class="eyebrow">Experimental companion</p>
|
pageTitle: "Lumi Transcription",
|
||||||
<h1>Lumi Transcription</h1>
|
description: "Server-hosted speech recognition for selected OBS sources, managed through one Lumi Companion application."
|
||||||
<p>Server-hosted speech recognition for selected OBS sources. Live Twitch delivery remains blocked until the native OBS caption compatibility test passes on the target setup.</p>
|
}) %>
|
||||||
</div>
|
<span class="status-indicator <%= providerHealth.healthy ? 'status-success' : 'status-warning' %>">
|
||||||
<span class="health-pill <%= providerHealth.healthy ? 'is-ready' : 'is-blocked' %>">
|
<%= providerHealth.healthy ? 'Inference ready' : 'Setup required' %>
|
||||||
<span aria-hidden="true"></span><%= providerHealth.healthy ? 'Inference ready' : 'Inference setup required' %>
|
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</div>
|
||||||
|
<div class="callout warning">
|
||||||
|
<strong>Live Twitch delivery is not accepted yet</strong>
|
||||||
|
<p>The native OBS 31+ caption path still needs target-machine validation. Lumi will not present a local simulated caption as live Twitch success.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="setup-path" aria-labelledby="setup-title">
|
<section class="card setup-path" aria-labelledby="setup-title" data-transcription-admin>
|
||||||
<div class="section-heading"><div><p class="eyebrow">First usable path</p><h2 id="setup-title">Setup progress</h2></div></div>
|
<div class="section-header">
|
||||||
|
<div><span class="eyebrow">First usable path</span><h2 id="setup-title">Setup progress</h2><p class="hint">Complete one clear step at a time. Existing server administration remains in Lumi; streaming-computer controls remain in Companion.</p></div>
|
||||||
|
</div>
|
||||||
<ol>
|
<ol>
|
||||||
<li class="is-current"><strong>Pair a companion</strong><span>Create a single-use package for the streaming computer.</span></li>
|
<li class="<%= devices.some((device) => !device.revoked_at) ? 'is-complete' : 'is-current' %>"><strong>Download and pair Companion</strong><span>Create a short-lived, single-use package for the streaming computer.</span></li>
|
||||||
<li><strong>Install the OBS bridge</strong><span>Managed by Lumi Companion; no separate bridge settings.</span></li>
|
<li><strong>Install the OBS integration</strong><span>Companion owns installation and repair; the bridge has no separate settings.</span></li>
|
||||||
<li><strong>Install and benchmark a model</strong><span>Small English is recommended. Nothing downloads without confirmation.</span></li>
|
<li class="<%= providerHealth.model ? 'is-complete' : '' %>"><strong>Load and benchmark a model</strong><span>Small English is the recommended starting point.</span></li>
|
||||||
<li><strong>Select and test a microphone</strong><span>Test mode must pass before live delivery is enabled.</span></li>
|
<li><strong>Select and test a microphone</strong><span>The real safe test must pass before live delivery is enabled.</span></li>
|
||||||
</ol>
|
</ol>
|
||||||
<button class="primary-action" type="button" data-create-pairing>Create pairing package</button>
|
<div class="inline-actions">
|
||||||
<p class="inline-status" role="status" data-status></p>
|
<button class="button" type="button" data-create-pairing>Download Companion</button>
|
||||||
|
<a class="button subtle" href="#speech-models">Review speech models</a>
|
||||||
|
</div>
|
||||||
|
<p class="hint" role="status" aria-live="polite" data-status></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="transcription-grid">
|
<div class="transcription-grid" data-transcription-admin>
|
||||||
<section aria-labelledby="runtime-title">
|
<section class="card" aria-labelledby="runtime-title">
|
||||||
<div class="section-heading"><div><p class="eyebrow">Lumi host</p><h2 id="runtime-title">Inference</h2></div></div>
|
<div class="section-header"><div><span class="eyebrow">Lumi host</span><h2 id="runtime-title">Inference health</h2><p class="hint">Speech recognition runs on the Lumi server, never on the streaming computer.</p></div></div>
|
||||||
<dl class="health-list">
|
<div class="dashboard-metric-grid transcription-metrics">
|
||||||
<div><dt>Provider</dt><dd>whisper.cpp <%= runtimeManifest.tested_version %></dd></div>
|
<div><span>Provider</span><strong>whisper.cpp <%= runtimeManifest.tested_version %></strong></div>
|
||||||
<div><dt>Worker</dt><dd><%= providerHealth.state %></dd></div>
|
<div><span>Worker</span><strong><%= providerHealth.state %></strong></div>
|
||||||
<div><dt>Selected model</dt><dd><%= providerHealth.model || 'Not loaded' %></dd></div>
|
<div><span>Selected model</span><strong><%= providerHealth.model || 'Not loaded' %></strong></div>
|
||||||
<div><dt>Active sessions</dt><dd><%= providerHealth.sessions || 0 %></dd></div>
|
<div><span>Active sessions</span><strong><%= providerHealth.sessions || 0 %></strong></div>
|
||||||
</dl>
|
</div>
|
||||||
<p class="notice">Lumi AI may already occupy most GPU memory. The MVP warns and benchmarks; it does not unload Lumi AI models automatically.</p>
|
<div class="callout info"><strong>Shared GPU awareness</strong><p>Lumi warns and benchmarks when Lumi AI already occupies GPU memory. It never unloads AI models automatically.</p></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-labelledby="devices-title">
|
<section class="card" aria-labelledby="devices-title">
|
||||||
<div class="section-heading"><div><p class="eyebrow">Access</p><h2 id="devices-title">Paired devices</h2></div><span><%= devices.length %></span></div>
|
<div class="section-header"><div><span class="eyebrow">Access</span><h2 id="devices-title">Paired devices</h2><p class="hint">Credentials are revocable and scoped to Companion capabilities.</p></div><span class="badge"><%= devices.filter((device) => !device.revoked_at).length %> active</span></div>
|
||||||
<% if (!devices.length) { %><p class="empty-state">No companion is paired yet.</p><% } %>
|
<% if (!devices.length) { %><div class="empty-state"><strong>No Companion is paired</strong><p>Use Download Companion to create a package that expires after 15 minutes and works once.</p></div><% } %>
|
||||||
<ul class="plain-list">
|
<% if (devices.length) { %>
|
||||||
|
<ul class="transcription-device-list">
|
||||||
<% devices.forEach((device) => { %>
|
<% devices.forEach((device) => { %>
|
||||||
<li><div><strong><%= device.name %></strong><span><%= device.revoked_at ? 'Revoked' : 'Last connected ' + new Date(device.last_connected_at).toLocaleString() %></span></div><code><%= device.id %></code></li>
|
<li><div><strong><%= device.name %></strong><span class="hint"><%= device.revoked_at ? 'Revoked' : 'Last connected ' + new Date(device.last_connected_at).toLocaleString() %></span></div><span class="badge <%= device.revoked_at ? 'danger' : 'success' %>"><%= device.revoked_at ? 'Revoked' : 'Allowed' %></span></li>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</ul>
|
</ul>
|
||||||
|
<% } %>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section aria-labelledby="models-title">
|
<section class="card" id="speech-models" aria-labelledby="models-title" data-transcription-admin>
|
||||||
<div class="section-heading"><div><p class="eyebrow">Curated choices</p><h2 id="models-title">Speech models</h2></div></div>
|
<div class="section-header"><div><span class="eyebrow">Curated choices</span><h2 id="models-title">Speech models</h2><p class="hint">Downloads require confirmation and checksum verification before a model can load.</p></div></div>
|
||||||
<div class="model-row">
|
<div class="model-row">
|
||||||
<% models.forEach((model) => { %>
|
<% models.forEach((model) => { %>
|
||||||
<article>
|
<article>
|
||||||
<div><h3><%= model.label %></h3><p><%= model.recommended ? 'Recommended starting point' : 'Fallback option' %> · <%= Math.round(model.bytes / 1048576) %> MiB</p></div>
|
<div><h3><%= model.label %></h3><p class="hint"><%= model.recommended ? 'Recommended starting point' : 'Fallback option' %> · <%= Math.round(model.bytes / 1048576) %> MiB</p></div>
|
||||||
<div class="model-actions">
|
<div class="model-actions">
|
||||||
<span class="state-label"><%= providerHealth.model === model.id ? 'Loaded' : model.status.valid ? 'Verified' : model.status.installed ? 'Checksum failed' : 'Not installed' %></span>
|
<span class="badge <%= providerHealth.model === model.id ? 'success' : model.status.valid ? 'info' : model.status.installed ? 'danger' : 'muted' %>"><%= providerHealth.model === model.id ? 'Loaded' : model.status.valid ? 'Verified' : model.status.installed ? 'Checksum failed' : 'Not installed' %></span>
|
||||||
<% if (!model.status.valid) { %><button type="button" data-download-model="<%= model.id %>" data-model-label="<%= model.label %>" data-model-size="<%= Math.round(model.bytes / 1048576) %>">Download</button><% } %>
|
<% if (!model.status.valid) { %><button class="button subtle" type="button" data-download-model="<%= model.id %>" data-model-label="<%= model.label %>" data-model-size="<%= Math.round(model.bytes / 1048576) %>">Download</button><% } %>
|
||||||
<% if (model.status.valid && providerHealth.model !== model.id) { %><button type="button" data-load-model="<%= model.id %>">Load model</button><% } %>
|
<% if (model.status.valid && providerHealth.model !== model.id) { %><button class="button subtle" type="button" data-load-model="<%= model.id %>">Load model</button><% } %>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</div>
|
</div>
|
||||||
<p class="privacy-note"><strong>Privacy:</strong> raw audio is held only in bounded memory and is never written to disk by default. Diagnostic caption text is retained for seven days unless disabled.</p>
|
<div class="callout info"><strong>Audio privacy</strong><p>Raw audio is held only in bounded memory and is never written to disk by default. Diagnostic caption text is retained for seven days unless disabled.</p></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
<script src="/plugins/lumi_transcription/assets/transcription.js" defer></script>
|
|
||||||
|
|||||||
@ -95,6 +95,57 @@ function getPluginProfileStats(userId) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getAdminDashboardSections() {
|
||||||
|
const sections = await Promise.all(loadStatProviders().map(async ({ plugin, manifest, provider }) => {
|
||||||
|
if (!manifest.adminDashboard || typeof provider.getAdminDashboardStats !== "function") return null;
|
||||||
|
try {
|
||||||
|
const result = await provider.getAdminDashboardStats({ db, plugin, manifest });
|
||||||
|
if (!result || typeof result !== "object") return null;
|
||||||
|
return {
|
||||||
|
id: String(result.id || manifest.pluginId || plugin.id).slice(0, 120),
|
||||||
|
eyebrow: String(result.eyebrow || "Companion service").slice(0, 80),
|
||||||
|
title: String(result.title || manifest.adminDashboard.title || plugin.name).slice(0, 160),
|
||||||
|
description: String(result.description || manifest.adminDashboard.description || "").slice(0, 500),
|
||||||
|
status: normalizeDashboardStatus(result.status),
|
||||||
|
metrics: normalizeDashboardMetrics(result.metrics),
|
||||||
|
issues: Array.isArray(result.issues) ? result.issues.map((issue) => String(issue).slice(0, 240)).slice(0, 6) : [],
|
||||||
|
actions: normalizeDashboardActions(result.actions)
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load ${plugin.id} admin dashboard stats`, error);
|
||||||
|
return {
|
||||||
|
id: plugin.id,
|
||||||
|
eyebrow: "Companion service",
|
||||||
|
title: manifest.adminDashboard.title || plugin.name,
|
||||||
|
description: "This plugin's dashboard summary could not be loaded. Its normal settings remain available.",
|
||||||
|
status: { label: "Summary unavailable", tone: "danger" },
|
||||||
|
metrics: [], issues: ["Open the plugin page or diagnostics for details."],
|
||||||
|
actions: normalizeDashboardActions([{ label: "Open settings", href: `/plugins/${encodeURIComponent(plugin.id)}`, method: "get" }])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return sections.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDashboardStatus(value) {
|
||||||
|
const tone = ["success", "warning", "danger", "info", "muted"].includes(value?.tone) ? value.tone : "muted";
|
||||||
|
return { label: String(value?.label || "Status unavailable").slice(0, 120), tone };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDashboardMetrics(values) {
|
||||||
|
if (!Array.isArray(values)) return [];
|
||||||
|
return values.map((entry) => ({ label: String(entry?.label || "Status").slice(0, 80), value: String(entry?.value ?? "—").slice(0, 120) })).slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDashboardActions(values) {
|
||||||
|
if (!Array.isArray(values)) return [];
|
||||||
|
return values.filter((entry) => /^\/(?!\/)/.test(String(entry?.href || ""))).map((entry) => ({
|
||||||
|
label: String(entry.label || "Open").slice(0, 80), href: String(entry.href).slice(0, 300),
|
||||||
|
method: entry.method === "post" ? "post" : "get", primary: entry.primary === true,
|
||||||
|
disabled: entry.disabled === true, disabledReason: String(entry.disabledReason || "").slice(0, 200)
|
||||||
|
})).slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
function getPluginLeaderboards(limit = 10) {
|
function getPluginLeaderboards(limit = 10) {
|
||||||
return loadStatProviders()
|
return loadStatProviders()
|
||||||
.map((entry) => buildLeaderboardSection({ ...entry, limit }))
|
.map((entry) => buildLeaderboardSection({ ...entry, limit }))
|
||||||
@ -103,5 +154,6 @@ function getPluginLeaderboards(limit = 10) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getPluginProfileStats,
|
getPluginProfileStats,
|
||||||
getPluginLeaderboards
|
getPluginLeaderboards,
|
||||||
|
getAdminDashboardSections
|
||||||
};
|
};
|
||||||
|
|||||||
@ -48,7 +48,7 @@ const {
|
|||||||
exchangeYouTubeCode,
|
exchangeYouTubeCode,
|
||||||
fetchYouTubeChannel
|
fetchYouTubeChannel
|
||||||
} = require("../services/auth");
|
} = require("../services/auth");
|
||||||
const { getPluginProfileStats } = require("../services/plugin-stats");
|
const { getPluginProfileStats, getAdminDashboardSections } = require("../services/plugin-stats");
|
||||||
const {
|
const {
|
||||||
getLeaderboardSections,
|
getLeaderboardSections,
|
||||||
getTopCommandOptions
|
getTopCommandOptions
|
||||||
@ -5372,9 +5372,10 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/admin", requireRole("admin"), (req, res) => {
|
app.get("/admin", requireRole("admin"), async (req, res) => {
|
||||||
res.render("admin-dashboard", {
|
res.render("admin-dashboard", {
|
||||||
title: "Admin dashboard"
|
title: "Admin dashboard",
|
||||||
|
dashboardSections: await getAdminDashboardSections()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -48,6 +48,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<% (typeof dashboardSections !== "undefined" && Array.isArray(dashboardSections) ? dashboardSections : []).forEach((section) => { %>
|
||||||
|
<section class="card plugin-dashboard-section" data-dashboard-section="<%= section.id %>">
|
||||||
|
<div class="section-header">
|
||||||
|
<div><span class="eyebrow"><%= section.eyebrow %></span><h2><%= section.title %></h2><p class="hint"><%= section.description %></p></div>
|
||||||
|
<span class="status-indicator status-<%= section.status.tone %>"><%= section.status.label %></span>
|
||||||
|
</div>
|
||||||
|
<% if (section.metrics.length) { %>
|
||||||
|
<div class="dashboard-metric-grid">
|
||||||
|
<% section.metrics.forEach((metric) => { %><div><span><%= metric.label %></span><strong><%= metric.value %></strong></div><% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% if (section.issues.length) { %>
|
||||||
|
<div class="callout <%= section.status.tone === 'danger' ? 'danger' : 'warning' %>"><strong><%= section.issues.length %> issue<%= section.issues.length === 1 ? '' : 's' %> discovered</strong><ul><% section.issues.forEach((issue) => { %><li><%= issue %></li><% }) %></ul></div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="callout success"><strong>Everything healthy</strong><p>No Companion issues are currently reported.</p></div>
|
||||||
|
<% } %>
|
||||||
|
<div class="inline-actions">
|
||||||
|
<% section.actions.forEach((action) => { %>
|
||||||
|
<% if (action.method === "post") { %><form method="post" action="<%= action.href %>"><button class="button <%= action.primary ? '' : 'subtle' %>" type="submit" <%= action.disabled ? 'disabled' : '' %> title="<%= action.disabledReason %>"><%= action.label %></button></form>
|
||||||
|
<% } else { %><a class="button <%= action.primary ? '' : 'subtle' %> <%= action.disabled ? 'disabled' : '' %>" href="<%= action.disabled ? '#' : action.href %>" <%= action.disabled ? 'aria-disabled="true"' : '' %> title="<%= action.disabledReason %>"><%= action.label %></a><% } %>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% }) %>
|
||||||
<section class="card admin-metrics" data-dashboard-metrics>
|
<section class="card admin-metrics" data-dashboard-metrics>
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -166,5 +166,8 @@
|
|||||||
<script src="/lumi-state-button.js?v=<%= assetVersion %>"></script>
|
<script src="/lumi-state-button.js?v=<%= assetVersion %>"></script>
|
||||||
<script src="/lumi-interactions.js?v=<%= assetVersion %>"></script>
|
<script src="/lumi-interactions.js?v=<%= assetVersion %>"></script>
|
||||||
<script src="/app.js?v=<%= assetVersion %>"></script>
|
<script src="/app.js?v=<%= assetVersion %>"></script>
|
||||||
|
<% (typeof extraScripts !== "undefined" && Array.isArray(extraScripts) ? extraScripts : []).forEach((script) => { %>
|
||||||
|
<script src="<%= script %>" defer></script>
|
||||||
|
<% }) %>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -9,6 +9,9 @@
|
|||||||
<link rel="stylesheet" href="/lumi-layout.css?v=<%= assetVersion %>" />
|
<link rel="stylesheet" href="/lumi-layout.css?v=<%= assetVersion %>" />
|
||||||
<link rel="stylesheet" href="/lumi-components.css?v=<%= assetVersion %>" />
|
<link rel="stylesheet" href="/lumi-components.css?v=<%= assetVersion %>" />
|
||||||
<link rel="stylesheet" href="/navigation-builder.css?v=<%= assetVersion %>" />
|
<link rel="stylesheet" href="/navigation-builder.css?v=<%= assetVersion %>" />
|
||||||
|
<% (typeof extraStyles !== "undefined" && Array.isArray(extraStyles) ? extraStyles : []).forEach((stylesheet) => { %>
|
||||||
|
<link rel="stylesheet" href="<%= stylesheet %>" />
|
||||||
|
<% }) %>
|
||||||
<%- include("theme-vars", { theme }) %>
|
<%- include("theme-vars", { theme }) %>
|
||||||
</head>
|
</head>
|
||||||
<body data-theme-id="<%= theme ? theme.id : '' %>" data-authenticated="<%= user ? 'true' : 'false' %>">
|
<body data-theme-id="<%= theme ? theme.id : '' %>" data-authenticated="<%= user ? 'true' : 'false' %>">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user