Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f05251155 | ||
|
|
0fb0089e1c | ||
|
|
5c85ba400d | ||
|
|
655547938a | ||
|
|
bc7bff0eb9 | ||
|
|
480a34b0af | ||
|
|
dc87b7e8e2 | ||
|
|
2d8dc86b20 | ||
|
|
2368c01eb0 | ||
|
|
eb3d0baf43 | ||
|
|
ba049d682b | ||
|
|
c321d2b1ff | ||
|
|
ea212c31b8 | ||
|
|
dceebce43a | ||
|
|
5ec18ac0c4 | ||
|
|
9bfca09be5 | ||
|
|
19f599b2b5 | ||
|
|
7760ed0a3f | ||
|
|
63e52e3f6a | ||
|
|
fc5a9029ea | ||
|
|
27ae8dcb81 | ||
|
|
d87fe397ce | ||
|
|
2e09754b9e | ||
|
|
b2c9d4b8ac | ||
|
|
eb127eadeb | ||
|
|
739e96eee6 | ||
|
|
88d9718b8d | ||
|
|
7c571364c4 | ||
|
|
2e6de64e61 | ||
|
|
16f5f1fbb6 | ||
|
|
df23b57078 | ||
|
|
cc8d455b4e | ||
|
|
02bdec7584 | ||
|
|
37dda538b3 | ||
|
|
27555e258e | ||
|
|
2f31390602 | ||
|
|
29cd4a0365 | ||
|
|
134d3efa74 | ||
|
|
162c3b1df6 | ||
|
|
42a5e42d08 | ||
|
|
0b3c8adbc2 | ||
|
|
a95e0789cc | ||
|
|
7ec23e5709 | ||
|
|
155f080fc5 | ||
|
|
a5bd15cf3e | ||
|
|
66d0b63077 | ||
|
|
4853092956 | ||
|
|
7b7e49507c | ||
|
|
4bd337d5e3 | ||
|
|
f335bbe24a | ||
|
|
97e6615183 | ||
|
|
ac6f364327 | ||
|
|
a90f7071d9 | ||
|
|
1cd2a62302 | ||
|
|
6bf84122b0 |
18
.env.example
18
.env.example
@ -37,3 +37,21 @@ AUTO_UPDATE_ENABLED=false
|
||||
AUTO_UPDATE_INTERVAL_MINUTES=60
|
||||
GIT_REMOTE=origin
|
||||
GIT_BRANCH=main
|
||||
|
||||
# Lumi host operator identity shown in generated Companion pairing packages.
|
||||
# Set these when distributing Companion from a self-hosted Lumi installation.
|
||||
LUMI_OPERATOR_NAME=
|
||||
LUMI_OPERATOR_CONTACT=
|
||||
LUMI_OPERATOR_PRIVACY_URL=
|
||||
|
||||
# Runtime mode and bind host
|
||||
# Lumi auto-detects development mode from NODE_ENV, a loopback bind host, or a
|
||||
# source checkout. Explicit overrides always win.
|
||||
# LUMI_DEV_MODE=true
|
||||
# LUMI_HOST=127.0.0.1
|
||||
|
||||
# Private OBS stream testing (optional; see docs/stream-testing.md).
|
||||
# Public and local ingest ports are stored in Lumi settings and configured at
|
||||
# Admin > Stream testing. Nginx Proxy Manager owns production TLS termination.
|
||||
# Advanced/manual runtime override only; normal installations do not need this.
|
||||
# LUMI_MEDIAMTX_PATH=/absolute/path/to/mediamtx
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@ -1,4 +1,6 @@
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
/data/
|
||||
/updates/
|
||||
plugins/*/data/**
|
||||
@ -14,6 +16,12 @@ plugins/*/data/**
|
||||
*.sqlite-*
|
||||
npm-debug.log
|
||||
/dist/
|
||||
companion/**/bin/
|
||||
companion/**/obj/
|
||||
companion/installer/output/
|
||||
companion/src/Lumi.Companion.App/components/obs-bridge/
|
||||
companion/**/logs/
|
||||
*.lumi-pairing.json
|
||||
security-audit-*.json
|
||||
security-audit-*.md
|
||||
taskfile.txt
|
||||
@ -22,3 +30,9 @@ codex-guidelines
|
||||
Twitch.png
|
||||
twitch-credentials-lumi.png
|
||||
.secrets
|
||||
.secrets-*
|
||||
DEVNOTES.md
|
||||
.QWEN.md
|
||||
_*_changes.md
|
||||
Lumi-current.zip
|
||||
agent-task*.md
|
||||
197
CHANGELOG.md
197
CHANGELOG.md
@ -1,5 +1,202 @@
|
||||
# Lumi changelog
|
||||
|
||||
## 0.3.11
|
||||
|
||||
- Released Companion 0.2.8 with click-to-capture global hotkeys, independent Windows Media Player, YouTube, Spotify, VLC, Apple Music, SoundCloud, TIDAL, Bandcamp, and Qobuz discovery, and an interactive edit mode built on the actual capture-excluded native overlay renderer.
|
||||
- Refined Stats and Leaderboards to hide raw channel/server IDs, omit empty tenure boards, emphasize current tenure separately from recorded totals, and distinguish both values in comparisons.
|
||||
- Added per-user favorite command tracking and favorite Expression Interaction summaries through an additive usage table; favorite command history begins with this release.
|
||||
- Preserved existing Companion pairing, overlay and media settings, platform-tenure history, plugin data, OBS configuration, databases, uploads, models, and secrets through the normal updater.
|
||||
|
||||
## 0.3.10
|
||||
|
||||
- Added an extensible UTC interval-history engine for Twitch follows, subscriptions, moderators, editors, and VIPs; YouTube channel memberships and moderators; and Discord membership and server boosts.
|
||||
- Added idempotent live-event handling, complete-snapshot reconciliation that never treats API failures as empty state, authoritative timestamp backfill for open intervals, and preserved history across restarts and account changes.
|
||||
- Added contextual `current` and `total` age placeholders with deterministic years/months, selected-unit remainder flow, singular/plural output, and the shared embedded `{format.YY-MM-DD-HH-mm-ss}` grammar.
|
||||
- Added Platform tenure summaries to Stats and scope-separated Platform tenure boards to Leaderboards, plus admin interval diagnostics with source, precision, and authoritative/first-observed provenance.
|
||||
- Preserved all existing settings, identities, statistics, interval history, plugins, pairing records, databases, uploads, models, and secrets through additive migrations and the normal updater.
|
||||
|
||||
## 0.3.9
|
||||
|
||||
- Restored animated Twitch/BTTV and Discord emotes plus Discord GIF media across the existing normalized OBS and native Companion overlay paths, and rendered real Twitch badge artwork with safe fallbacks.
|
||||
- Released Companion 0.2.7 with bounded multi-frame GIF/WebP decoding for the capture-excluded native overlay while retaining the existing OBS browser renderer and media workflow.
|
||||
- Added configurable Auto VC room messages with the room-owner placeholder, persistent lock/unlock controls, timed ownership claiming, and short-lived action reports.
|
||||
- Preserved all existing settings, lobbies, active rooms, ownership, permissions, pairing identities, Companion settings, overlays, databases, uploads, models, and secrets through additive migrations and the normal updater.
|
||||
|
||||
## 0.3.8
|
||||
|
||||
- Added the native Windows Lumi Overlay using the existing paired-device authentication, normalized provider streams, live-state tracking, and Companion plugin transport without exposing provider credentials.
|
||||
- Added monitor-aware native layouts, visibility policies, capture-exclusion reporting, bounded animations, Twitch/BTTV emotes, local atomic settings, global hotkeys, preview controls, and direct tray integration.
|
||||
- Preserved unsaved overlay configuration through live feed/status refreshes, corrected divided-layout percentage presentation, clarified current visibility versus button actions, improved labels and conditional controls, and kept unavailable monitor/source/event selections visible.
|
||||
- Made single-page Song Overlay and Lumi Overlay plugins direct navigation entries while retaining collapsed grouping for genuinely multi-page plugins.
|
||||
- Released Companion 0.2.6 through the existing verified installer/updater path while preserving pairing identity, Companion settings, server/plugin data, OBS configuration, and revoked-device state.
|
||||
|
||||
## 0.3.7
|
||||
|
||||
- Moved production Stream Testing TLS termination to Nginx Proxy Manager: Companion still receives RTMPS on the paired Lumi hostname while MediaMTX accepts only the decrypted local RTMP stream.
|
||||
- Added DB-backed public and local ingest ports plus a full TLS and RTMP external-route check, while preserving direct loopback RTMP development and existing session authorization.
|
||||
- Removed Lumi-owned ACME, DNS-provider, certificate-path, and challenge-route code without deleting any existing operator data.
|
||||
|
||||
## 0.3.6
|
||||
|
||||
- Added encrypted Domeneshop DNS-01 automation for production RTMPS certificates when OpenResty, Nginx Proxy Manager, or another HTTPS proxy owns the reserved HTTP challenge path.
|
||||
- Added a Lumi-styled Stream Testing setup surface that verifies DNS credentials once, creates and removes only short-lived challenge records, and supports timed destructive credential removal.
|
||||
- Kept HTTP-01 for installations that forward it correctly, improved proxy-specific failure guidance, and bounded authoritative DNS propagation within Companion's startup window.
|
||||
- Released Companion 0.2.5 as the matching Transcription compatibility release while retaining OBS Bridge 0.2.5.
|
||||
- Preserved all server, plugin, pairing, OBS, media, and existing certificate data; no external ACME package or certificate-path environment variable is required.
|
||||
|
||||
## 0.3.5
|
||||
|
||||
- Released Companion 0.2.4 with silent Lumi reconnect, a direct transcription on/off control, consistent collapsed plugin navigation, and a one-click route from an active Stream Test to its Lumi viewer.
|
||||
- Made private-test captions progressively reveal speech instead of waiting for a full utterance, retained readable multi-line captions, and kept stale words moving out naturally.
|
||||
- Added Lumi-managed RTMPS certificates for public production ingest while retaining HTTP and RTMP for explicit localhost/private development use.
|
||||
- Refreshed Song Overlay capture and delivery, shared Companion authentication across installed plugins, and completed durable redacted logging across core and bundled plugins.
|
||||
- Released OBS Bridge 0.2.5 and synchronized the updated Transcription and Song Overlay plugins without replacing preserved server, plugin, pairing, or OBS data.
|
||||
|
||||
## 0.3.4
|
||||
|
||||
- Fixed production Companion pairing and download generation behind Lumi's private HTTPS reverse proxy, while continuing to reject forged forwarding headers from public clients.
|
||||
- Reused the same guarded request-origin policy for Companion HTTP, pairing, settings, and WebSocket traffic, with explicit regression coverage for private proxy HTTPS, public spoofing, and malformed hosts.
|
||||
- Standardized operational failures on Lumi's durable redacting logger, removed a placeholder metrics API that silently discarded events, and documented the actual logging and feature-owned metrics boundaries.
|
||||
- Included the existing Lumi favicon and Qwen workspace housekeeping from the branch without changing preserved server or plugin data.
|
||||
|
||||
## 0.3.3
|
||||
|
||||
- Isolated private tests in a dedicated libobs output, bypassed account OAuth stream-key mutation, retained crash-safe exact service restoration, and corrected OBS encoder ownership so failed outputs cannot corrupt a later retry.
|
||||
- Replaced the Stream Testing FFmpeg listener and rendition transcoder with a checksum-pinned, Lumi-managed MediaMTX runtime that receives OBS RTMP/RTMPS and remuxes the genuine source representation to low-latency HLS without recording.
|
||||
- Added explicit MediaMTX install, repair, and receiver-health actions; loopback-only API, metrics, and HLS; exact per-session hashed publisher authorization; bounded supervision and diagnostics; and authenticated same-origin streaming playback.
|
||||
- Kept OBS on the existing paired Lumi hostname, made unencrypted RTMP private-network-only by default, removed the extra FFmpeg/package requirement, and retained crash-safe exact OBS destination restoration and private caption delivery.
|
||||
- Updated Stream Testing to show only real source quality, factual OBS/MediaMTX/player diagnostics, and receiver warnings while preserving overlay, update, Companion plugin, transcription, and Song Overlay behavior.
|
||||
- Released Companion 0.2.3 with OBS Bridge 0.2.4 so the new private-output and recovery behavior reaches installed streaming computers through the normal updater.
|
||||
|
||||
## 0.3.2
|
||||
|
||||
- Fixed an OBS crash at private Stream Test startup by respecting the OBS Frontend API's borrowed streaming-service reference instead of releasing OBS-owned memory.
|
||||
- Rebuilt the managed OBS Bridge with a new version so Companion clearly requires and installs the corrected integration before another Stream Test can start.
|
||||
|
||||
## 0.3.1
|
||||
|
||||
- Fixed apparently inert Stream Testing starts by surfacing the server rejection immediately in the Stream Testing panel and local diagnostic log.
|
||||
- Added bounded Windows FFmpeg discovery for verified WinGet and conventional installations, while preserving explicit `LUMI_FFMPEG_PATH` configuration and Linux PATH behavior.
|
||||
- Added explicit loaded/installed/bundled OBS Bridge version comparison, update-required notices and actions on Overview and Connection & device, and blocked Stream Testing until an outdated bridge is updated and loaded.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
- Added admin-only private stream testing for the real OBS output with expiring authenticated sessions, supervised FFmpeg ingest, source/720p/480p no-upscale HLS, automatic/manual quality, audio, fullscreen, reused captions, real OBS/receiver diagnostics, bounded cleanup, and a deterministic test pattern.
|
||||
- Added a crash-safe two-phase OBS destination handoff: Companion protects the complete prior service with current-user DPAPI before redirecting, then restores it on stop, expiry, receiver failure, WebSocket loss, Companion exit, OBS restart, or startup recovery.
|
||||
- Made public video overlays pixel-only and playback-lifecycle aware, and converted audio sources from visual canvas objects into invisible managed outputs with inspector playback controls and ignored legacy layout data.
|
||||
- Made Companion update checks safely repeatable after success, no-update, failure, cancellation, or rapid repeated clicks.
|
||||
|
||||
## 0.2.27
|
||||
|
||||
- Completed bundled-plugin synchronization automatically on the first startup after a legacy core-only update, allowing production hosts on 0.2.25 to receive the Companion transcription and Song Overlay plugins in the same update flow.
|
||||
- Added a second snapshot-backed plugin synchronization boundary with automatic restoration, exact-release verification, preserved plugin data, and an idempotent completion marker.
|
||||
|
||||
## 0.2.26
|
||||
|
||||
- Added stable Lumi Companion distribution with a durable per-user installer, shared paired-device authentication, managed OBS integration, preserved identity, user-approved updates, complete legal notices, and matching application/tray branding.
|
||||
- Added server-hosted transcription with voice-free readiness checks, dedicated confidence and latency benchmarks, resilient worker recovery, detailed WebUI analysis, and no speech inference on the streaming computer.
|
||||
- Added Spotify-backed Song Overlay capture, transparent and live-updating OBS rendering, configurable announcements, and a shared-template `!music` command.
|
||||
- Made stable core updates snapshot, synchronize, verify, and restore bundled plugins as one transaction while preserving plugin data and local-only plugins, so hosts on `0.2.25` receive the full release in one update.
|
||||
- Stopped notification-area flicker by caching health icons, coalescing queued runtime state, and avoiding unchanged native icon/menu assignments.
|
||||
|
||||
## 0.2.25
|
||||
|
||||
- Added verified, snapshot-backed production deployment switching between `main` and exact `experimental-*` branches, including bundled plugin code, preserved plugin data, automatic failed-apply restoration, and graceful wrapper restarts.
|
||||
- Added a guarded compatibility bootstrap that completes bundled-plugin synchronization if stable `0.2.24` installs an experimental branch directly; metadata checks alone cannot trigger deployment.
|
||||
- Completed the DesignMotionHQ UX foundation and representative settings, navigation, theme, command, updates, resources, and OBS overlay workflows with a five-viewport browser/accessibility matrix.
|
||||
- Fixed overlay editor scrolling and viewport containment, interactive pop-out preview editing, and persistent resource storage-limit saves with immediate page reconciliation.
|
||||
|
||||
## 0.2.24
|
||||
|
||||
- Fixed resource locking and deletion by sending the shared timed-confirmation token directly with AJAX actions, and made deletion reliable on Windows and network-backed storage.
|
||||
- Added dedicated attachment downloads instead of relying on browser-player behavior.
|
||||
- Reworked resource cards with contained filenames and actions, compact accurate empty/filter states, inline image previews, and fading play/stop timelines over audio and video previews.
|
||||
- Moved rename and delete actions into a predictable split layout and only reveals the save-name button when the name has actually changed.
|
||||
- Made saved storage and upload limits immediately visible and enforced in the current page without requiring a refresh or restart.
|
||||
|
||||
## 0.2.23
|
||||
|
||||
- Added an admin-only content library for verified image, audio, video, caption, document, data, and font uploads with previews, searching, storage quotas, disk reserves, and upload progress.
|
||||
- Added locked resources for internal/plugin use and revocable exposed resources with encrypted high-entropy tokens, read-only URLs, CORS, ETags, and HTTP byte-range delivery suitable for OBS and streaming media.
|
||||
- Added a shared `global.lumiFrameworks.resources` API, centralized additive storage migration, focused verification, and operator documentation.
|
||||
- Fixed clean OBS Browser Source CSP rules so configured remote and `blob:` audio/video media can load.
|
||||
- Connected resource deletion and URL revocation to Lumi's existing server-validated timed confirmation flow.
|
||||
|
||||
## 0.2.22
|
||||
|
||||
- Added reusable per-overlay event alerts that can queue one-shot Audio/Video playback or show Text/Image sources for a chosen duration without refreshing OBS.
|
||||
- Added Twitch EventSub follow, incoming raid, subscription, and gifted-subscription inputs with server-side OAuth, token refresh, reconnect recovery, and subscription-tier filters.
|
||||
- Added Discord member-join alerts through the existing bot connection, live test-delivery feedback, safe hook duplication/cleanup, and plugin event registration/emission extension points.
|
||||
- Preserved all existing overlays, sources, tokens, settings, databases, plugin data, models, uploads, feedback, and secrets; the new event-hook table and Twitch event settings are additive.
|
||||
|
||||
## 0.2.21
|
||||
|
||||
- Added cached BetterTTV global, channel, and shared emote rendering to Twitch messages in Lumi chat overlays and standalone OBS chat docks, with safe plain-text fallback during provider failures.
|
||||
|
||||
## 0.2.20
|
||||
|
||||
- Added comma-separated aliases to conditional command reply rules, allowing several keywords to return one shared response while retaining exact and fuzzy matching.
|
||||
|
||||
## 0.2.19
|
||||
|
||||
- Added a protected, implicitly inherited default command group that can be edited but not deleted.
|
||||
- Replaced inherited policy inputs with accurate read-only effective summaries and made group changes recalculate those summaries live.
|
||||
- Added clearer policy-section hierarchy, friendly command-source badges, fuzzy group/command suggestions, and positive-only setting/source filters.
|
||||
- Replaced per-command submission with one atomic page-wide save so edits to multiple collapsed or filtered commands are retained together.
|
||||
|
||||
## 0.2.18
|
||||
|
||||
- Added centralized command groups and per-command overrides for time-window limits, crash-tolerant per-stream limits, platform roles/subscriber tiers, and Economy Framework costs with logged refunds.
|
||||
- Added conditional keyword custom replies with optional conservative typo matching and shared placeholder handling.
|
||||
- Added core `!clip [label]` orchestration for platform clips plus OBS replay saves through local WebSocket or the Browser Bridge, and Twitch `!raid <channel>` / `!raid cancel`.
|
||||
- Added an admin command-policy page, command-list access summaries, focused verification, and operator documentation.
|
||||
|
||||
## 0.2.17
|
||||
|
||||
- Added a signed standalone OBS Custom Browser Dock page for every native Lumi chat source, with admin-only copy/open controls.
|
||||
- Reused each chat source's saved filters and appearance while deliberately ignoring overlay coordinates, source dimensions, and entry/exit animations.
|
||||
- Made dock chat responsive and normally scrollable at any dock size, with bottom-follow behavior that pauses when an operator scrolls up.
|
||||
- Bound dock access to existing revocable overlay and scene credentials so regenerating private links or deleting the source invalidates its dock URL.
|
||||
- Added shared live-event credential revalidation so already-open overlay and dock streams close after their bearer URL is revoked.
|
||||
|
||||
## 0.2.16
|
||||
|
||||
- Kept keyboard focus in source fields while server-normalized live previews rerender, preventing interrupted typing and other continuous input.
|
||||
- Added cached Twitch profile-image lookup and Discord custom/animated emoji, GIF, GIFV, attachment, and sticker rendering for native chat overlays.
|
||||
- Made chat alignment apply to complete message groups, made message backgrounds fit their content, and added directional entry/exit animations.
|
||||
- Expanded the editor simulation to cycle realistic short, wrapped, avatar, badge, platform, arrival, and departure examples continuously.
|
||||
|
||||
## 0.2.15
|
||||
|
||||
- Fixed Lumi's own Twitch, YouTube, and Discord messages being omitted from native chat overlays and added platform-specific plus linked-Lumi-account user blocking.
|
||||
- Replaced text platform/badge labels with platform artwork, official Twitch badge images where available, and image fallbacks for other badge types.
|
||||
- Added server-normalized unsaved source previews, temporary website-CSS previews, visible draft/revert controls, contrast backgrounds, an alignment grid, and reachable sticky source actions.
|
||||
- Hardened alert-site transparency with an optional page/root guard that remains effective when apps such as alert providers modify their document after loading.
|
||||
|
||||
## 0.2.14
|
||||
|
||||
- Added a native Lumi chat overlay source that combines live Twitch, YouTube, and Discord messages with optional per-channel filters.
|
||||
- Added visual controls for typography, colors and transparency, placement, message limits, timeout removal, message details, and entry/exit animations, plus isolated custom CSS.
|
||||
- Added live avatars, badges, Twitch emotes, editor preview messages, and bounded replay after temporary Browser Source connection loss.
|
||||
|
||||
## 0.2.13
|
||||
|
||||
- Changed external overlay Website CSS from frame-only styling to document-level injection, matching OBS Browser Source Custom CSS behavior.
|
||||
- Applied OBS's transparent body stylesheet inside alert websites by default while allowing administrators to add selectors for provider-specific elements.
|
||||
- Added scoped revocable website-render tickets, opaque sandboxing, no-referrer handling, bounded public document retrieval, private-network blocking, and a direct compatibility fallback.
|
||||
|
||||
## 0.2.12
|
||||
|
||||
- Fixed OBS Browser Source transparency by applying OBS's standard transparent-page CSS to clean overlay pages and embedded frame surfaces.
|
||||
- Made the management preview and public OBS output share one fixed full-resolution canvas, preventing responsive alert pages from changing text size or position in the smaller editor preview.
|
||||
- Added clear, copyable OBS Browser Source width, height, and default Custom CSS guidance to each overlay.
|
||||
|
||||
## 0.2.11
|
||||
|
||||
- Added structured component, activity, event, and request metadata across Core, plugins, platforms, commands, webhooks, diagnostics, and WebUI administrator actions.
|
||||
- Added recursive credential redaction, bounded log payloads, automatic age/count retention, and a scoped logger supplied to plugins.
|
||||
- Upgraded Admin Logs with full server-side search and filters, filtered totals, live entries, clearer metadata, configurable retention, and text or JSON Lines downloads.
|
||||
|
||||
## 0.2.10
|
||||
|
||||
- Fixed local Windows startup under Node.js 24 by invoking npm through its JavaScript CLI instead of directly spawning `npm.cmd`.
|
||||
|
||||
24
README.md
24
README.md
@ -25,6 +25,14 @@ Before starting Lumi, you can check the runtime and native dependency with
|
||||
`npm run verify:preflight`. Run the complete syntax and focused test suite with
|
||||
`npm run verify:all`; it stops at the first failure and prints the failing check.
|
||||
|
||||
## Logs
|
||||
|
||||
Administrators can review structured Core, plugin, platform, command, WebUI,
|
||||
security, and integration activity under **Admin → Logs**. Logs support live
|
||||
updates, component/activity filters, server-side search, redacted details,
|
||||
request IDs, retention limits, and text or JSON Lines downloads. Plugin and
|
||||
core logging conventions are documented in [`docs/logging.md`](docs/logging.md).
|
||||
|
||||
You can also seed local configuration with a `.env` file. Use `.env.example`
|
||||
as the template; `.env` is ignored by git.
|
||||
|
||||
@ -47,6 +55,13 @@ You can set these in `.env` or change role IDs in **Admin → Settings**.
|
||||
Use **Admin → Plugins** to install, enable, update, or uninstall plugins.
|
||||
You can also create a local plugin from the WebUI.
|
||||
|
||||
Lumi Companion provides the streaming-computer boundary for transcription,
|
||||
media capture, and private stream testing. Its trust boundaries and setup are documented in
|
||||
[`docs/lumi-companion-transcription.md`](docs/lumi-companion-transcription.md).
|
||||
Private OBS output testing, Lumi-managed MediaMTX setup, recovery behavior, and
|
||||
network safety are documented in
|
||||
[`docs/stream-testing.md`](docs/stream-testing.md).
|
||||
|
||||
## Updates and recovery
|
||||
|
||||
Use **Admin → Updates** for version-aware core and plugin updates. Lumi reads
|
||||
@ -69,7 +84,11 @@ so dependency repair does not rely on spawning a command script directly.
|
||||
Recovery mode can be started with `LUMI_SAFE_MODE=1 npm run run`,
|
||||
`node run.js --safe-mode`, or `data/recovery/safe-mode.flag`. See
|
||||
[`docs/updates.md`](docs/updates.md) and
|
||||
[`docs/recovery-mode.md`](docs/recovery-mode.md).
|
||||
[`docs/recovery-mode.md`](docs/recovery-mode.md). Production can also be moved
|
||||
between `main` and an exact `experimental-*` branch from **Admin > Updates**
|
||||
with a verified pre-deployment snapshot, graceful restart, one-click return to
|
||||
`main`, and emergency restore. See
|
||||
[`docs/branch-deployments.md`](docs/branch-deployments.md).
|
||||
|
||||
## Twitch bot
|
||||
|
||||
@ -111,7 +130,8 @@ Developer and modding conventions are documented in
|
||||
|
||||
Admins can create live, token-protected OBS Browser Sources under **Admin → OBS
|
||||
overlays**, with server-controlled or fixed scenes and optional local OBS
|
||||
WebSocket or zero-credential OBS Browser Bridge synchronization. See
|
||||
WebSocket or zero-credential OBS Browser Bridge synchronization. Native sources
|
||||
include a configurable combined Twitch, YouTube, and Discord chat overlay. See
|
||||
[`docs/obs-overlays.md`](docs/obs-overlays.md).
|
||||
|
||||
## Notes
|
||||
|
||||
215
TODO.md
215
TODO.md
@ -2,6 +2,190 @@
|
||||
|
||||
This file tracks larger Lumi work that cannot safely be completed in one pass. Keep pending work under the relevant category and move completed items to the Done section with a short note.
|
||||
|
||||
## Lumi Companion transcription — experimental-companion (2026-07-22)
|
||||
|
||||
Foundation implemented locally: independent `lumi_transcription` plugin; generic
|
||||
core WebSocket-upgrade capability; single-use pairing and revocable devices;
|
||||
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
|
||||
whisper.cpp/model manifests; short-lived JSONL diagnostics; protocol schemas;
|
||||
and .NET/native companion boundaries with focused verification. The companion now
|
||||
has a Lumi-styled Avalonia 12 single-instance tray shell, guided real-boundary
|
||||
test states, DPAPI pairing, local preferences/autostart, and bounded diagnostics.
|
||||
A pinned native whisper.cpp worker builds on the CPU verification target and the
|
||||
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. The pinned CPU whisper worker now
|
||||
has a reproducible Windows build/install script and is discovered automatically;
|
||||
the production CUDA build and target benchmark remain pending below. Companion
|
||||
experimental.3 adds user-approved, checksum-verified, stream-safe in-place updates;
|
||||
a reproducible native OBS 31+ bridge build; one-click UAC install/repair/removal;
|
||||
selected-source PCM capture; nested Program-scene evaluation; bounded same-user
|
||||
IPC; bridge health; and native caption submission. Experimental.2 must be replaced
|
||||
manually once to bootstrap the updater.
|
||||
|
||||
Experimental.4 contains Windows speech-path reliability fixes: native PCM stdin
|
||||
is explicitly binary-safe, broken worker pipes no longer terminate Lumi, the
|
||||
worker restarts and reloads the selected model automatically, and the Companion
|
||||
test reports the server failure at Speech recognition instead of hanging.
|
||||
|
||||
Experimental.5 retries and acknowledges the saved OBS source attachment after
|
||||
reconnects, waits for finalized path-test captions, and adds a separate
|
||||
user-controlled accuracy/latency test with a ten-second silence cutoff. The host
|
||||
retains per-word latency/confidence inspection data for one hour, while paired
|
||||
device management now defaults to active credentials and purges revoked history
|
||||
after 30 days.
|
||||
|
||||
Experimental.6 makes the full-path readiness check voice-free, preserves a valid
|
||||
pass across restarts until a relevant source/model/bridge configuration changes,
|
||||
adds a live dBFS meter to the dedicated speech test, and fixes reusable benchmark
|
||||
sessions so failed starts cannot leave worker activity behind. Companion now also
|
||||
reports verified OBS integration and path readiness to the WebUI setup progress.
|
||||
|
||||
Experimental.7 replaces the portable paired app download with a durable per-user
|
||||
Windows installer while preserving the verified in-place updater and DPAPI data.
|
||||
It also makes OBS bridge repair recoverable with fresh validation and actionable
|
||||
elevated-process errors, and adds timed permanent device revocation in the WebUI.
|
||||
|
||||
Experimental.8 makes the installed single-file build discover and verify its OBS
|
||||
payload deterministically, adds an executable-level package diagnostic, and shows
|
||||
OBS maintenance failures directly instead of leaving them only in diagnostics.
|
||||
|
||||
Experimental.9 coalesces OBS callbacks into fixed 20 ms network frames and gives
|
||||
audio and initial OBS source inventory their own soft gateway budgets, so normal
|
||||
or pathological capture cadence cannot rate-limit and disconnect the Companion
|
||||
control connection.
|
||||
|
||||
Experimental.10 deduplicates OBS source inventories and unchanged source-state
|
||||
notifications at both ends, removing the high-volume source-update feedback seen
|
||||
with large scene collections while retaining the non-disconnecting safety budget.
|
||||
|
||||
Experimental.11 removes server-side inference backlog, filters whisper.cpp
|
||||
control tokens, preserves first-visible word latency while retaining finalized
|
||||
confidence, and makes manual test completion wait for the final worker result.
|
||||
The portable CUDA 12.4 worker targets RTX 3060 and newer NVIDIA GPUs, activates
|
||||
from the Lumi WebUI without a host SDK dependency, and retains a CPU
|
||||
compatibility package. On the RTX 3080 Ti acceptance phrase, `small.en` returned
|
||||
the exact 19-word transcript at 300 ms average, 106 ms median, and 1,007 ms maximum
|
||||
first-visible latency.
|
||||
|
||||
Release-blocking work remains:
|
||||
|
||||
- Repeat the `small.en` latency gate and benchmark quantized `small.en` and
|
||||
`base.en` on the production RTX 3060.
|
||||
- Sign the companion installer and add a rollback policy after the experimental
|
||||
updater has target-machine acceptance evidence.
|
||||
- Complete target-machine bridge acceptance and source rename/missing recovery.
|
||||
- Run the OBS 31+/Twitch compatibility spike and prove toggleable closed captions;
|
||||
tune replacement/display duration from player behavior without open-caption fallback.
|
||||
- Complete the remaining live source/setup steps, conflict-resolution UI, and
|
||||
live settings synchronization, then run the target-topology failure and
|
||||
performance matrix.
|
||||
|
||||
## Remaining DesignMotionHQ UX work — experimental-ux checkpoint (2026-07-22)
|
||||
|
||||
The shared UX foundation and representative settings, navigation, theme, command,
|
||||
updates, resources, and overlay workflows are implemented. Continue applying the
|
||||
same hierarchy, progressive disclosure, motion, state feedback, accessibility,
|
||||
and context-preservation principles globally in this order:
|
||||
|
||||
- Migrate and browser-accept the remaining core route groups: Feedback;
|
||||
Logs/Diagnostics; Users/Privileges/Pages/Plugins; and their empty, loading,
|
||||
partial, error, offline, permission-limited, disabled, and read-only states.
|
||||
- Migrate remaining plugin-owned routes to the shared request, lifecycle, dialog,
|
||||
field-state, responsive-list, and no-forced-reload contracts.
|
||||
- Add a plugin-enabled authenticated browser fixture so Lumi AI polling,
|
||||
Improvement Center live updates, and plugin settings are exercised in the
|
||||
Playwright viewport matrix rather than only source/focused verification.
|
||||
- Browser-test the complete built-in/custom-theme contrast matrix, including
|
||||
default badges, disabled controls, preview surfaces, alerts, and plugin pages.
|
||||
- Complete manual keyboard and screen-reader acceptance for every destructive
|
||||
and entity-edit dialog, including focus restoration and timed confirmation.
|
||||
- Continue replacing broad legacy selectors and route-specific controls with
|
||||
explicit shared components; finish wording, density, helper-text, and advanced
|
||||
disclosure cleanup on lower-priority plugin and diagnostics surfaces.
|
||||
- Finish homepage hero/embed usability: non-technical configuration copy,
|
||||
unavailable/fallback explanations, Twitch parent-domain guidance, YouTube live
|
||||
failure states, and external iframe refusal handling.
|
||||
- Re-run the checkpoint review after each route group and remove completed items
|
||||
from `docs/lumi-ux-checkpoint-review.md` and the route manifest P1 list.
|
||||
|
||||
## Command policy and stream controls
|
||||
|
||||
Implemented on 2026-07-19: centralized command groups and per-command overrides
|
||||
for every routed custom/core/plugin command, with global/per-user time windows,
|
||||
persistent crash-tolerant per-stream limits, platform role requirements,
|
||||
subscriber tiers, and Economy Framework costs/refunds in normal banking history.
|
||||
Added conditional keyword custom replies with conservative fuzzy matching, plus
|
||||
core `!clip` platform/OBS replay orchestration and Twitch `!raid`/`!raid cancel`.
|
||||
OBS replay saving works through local WebSocket and the credential-free Browser
|
||||
Bridge BASIC permission. Admin UI and focused verification cover the shared
|
||||
storage and enforcement path.
|
||||
|
||||
Follow-up completed on 2026-07-19: added a protected, implicitly inherited
|
||||
default group; accurate read-only inherited-value summaries; conditional child
|
||||
fields; clearer nested visual hierarchy; friendly command-source labels; fuzzy
|
||||
search suggestions with positive-only live setting/source filters; and atomic
|
||||
page-wide saving for every command so collapsed or filtered drafts survive the
|
||||
save.
|
||||
|
||||
Patch completed on 2026-07-19: conditional reply rules now accept
|
||||
comma-separated keyword aliases that share one response.
|
||||
|
||||
Overlay chat follow-up completed on 2026-07-19: Twitch messages resolve cached
|
||||
global, channel, and shared BetterTTV emotes without affecting chat delivery
|
||||
when BetterTTV is unavailable.
|
||||
|
||||
Remaining work:
|
||||
|
||||
- Add platform action providers when YouTube or future chat integrations expose
|
||||
supported clip/raid APIs.
|
||||
- Replace heuristic Discord subscriber/VIP role-name matching with explicit
|
||||
per-server role mapping if communities need stricter Discord entitlements.
|
||||
|
||||
## Event automation and audience tracking
|
||||
|
||||
Overlay event-alert baseline completed on 2026-07-19: a shared, extensible event
|
||||
registry now drives saved per-overlay actions through the existing scoped live
|
||||
event stream. Admins can play queued audio/video once or show text/images for a
|
||||
chosen duration, test alerts against connected Browser Sources, and receive
|
||||
Twitch follows, incoming raids, subscriptions, gifted subscriptions, and Discord
|
||||
member joins. Twitch uses a revocable server-side EventSub authorization with
|
||||
automatic reconnect/token refresh; plugins can register and emit additional
|
||||
event types through the shared event API.
|
||||
|
||||
Remaining work:
|
||||
|
||||
- Add centralized named counters with atomic modification actions, persistent or
|
||||
per-stream reset modes, admin controls, and placeholders such as
|
||||
`{{counter.twitch.followers}}`.
|
||||
- Add watchtime, followage, subscription age, and equivalent provider-backed
|
||||
audience tracking, then expose suitable totals and durations in leaderboards.
|
||||
- Add more trigger adapters as integrations expose reliable events, including
|
||||
chat-presence joins where the platform can distinguish a real join from an
|
||||
ordinary message or reconnect.
|
||||
|
||||
## Content delivery library
|
||||
|
||||
Implemented on 2026-07-20: administrators can upload and manage verified
|
||||
streaming resources through a shared core library with previews, filters,
|
||||
storage quotas, physical free-space reserves, configurable upload limits, and
|
||||
progress feedback. Locked resources remain available only to admins and trusted
|
||||
Lumi code; exposed resources receive revocable encrypted bearer URLs. Delivery
|
||||
supports safe MIME handling, CORS, ETags, and byte ranges for OBS and browser
|
||||
media seeking. Plugins use the shared `global.lumiFrameworks.resources` API.
|
||||
OKF's generated core reference now discovers shared route modules so the AI can
|
||||
also find and explain the Resources and overlay endpoints.
|
||||
|
||||
Follow-up completed on 2026-07-20: repaired shared timed confirmation for lock
|
||||
and delete actions, added reliable attachment downloads and network-drive-safe
|
||||
deletion, redesigned cards for responsive filenames/actions and embedded media
|
||||
controls, corrected empty/filter messages, and made storage/upload limit changes
|
||||
take effect and update the open page immediately.
|
||||
|
||||
## Current Local State / Source of Truth
|
||||
|
||||
### P0 OBS Overlay System
|
||||
@ -19,7 +203,28 @@ website-overlay health checks plus manual and automatic refresh. Video and audio
|
||||
sources now include playback controls and OBS-routable sound; web sources include
|
||||
isolated custom frame CSS, cropping, and zoom. An optional OBS Browser Bridge now
|
||||
syncs supported OBS scene, transition, canvas, output, permission, and source
|
||||
state without WebSocket credentials, with a one-action setup importer.
|
||||
state without WebSocket credentials, with a one-action setup importer. The clean
|
||||
render page and editor now share a fixed full-resolution canvas, transparent OBS
|
||||
defaults, and explicit Browser Source dimensions so responsive third-party alerts
|
||||
render consistently in the preview and OBS. Website CSS is injected into the
|
||||
third-party document itself through scoped revocable render tickets, matching OBS
|
||||
Custom CSS behavior while keeping the page isolated from Lumi sessions and local
|
||||
network targets. A native multi-platform Lumi chat source now combines connected
|
||||
Twitch, YouTube, and Discord chats with channel filters, full visual controls,
|
||||
animations, timeout removal, short reconnect replay, Twitch emotes, and isolated
|
||||
custom CSS. The editor now previews server-normalized unsaved changes (including
|
||||
temporary website CSS), marks/reverts drafts, provides contrast and grid views,
|
||||
and keeps save actions reachable. Alert documents also retain transparent page
|
||||
and app-root backgrounds after late DOM/style changes. Chat includes Lumi's own
|
||||
platform messages, image-based platform/badge marks, and server-enforced platform
|
||||
or linked-Lumi-account user blocking. Live draft rerenders preserve form focus;
|
||||
chat messages now use content-sized cards and whole-message alignment, cached
|
||||
Twitch avatars, Discord emoji/GIF/sticker media, four-direction entry/exit
|
||||
animations, and a continuously animated multi-message editor simulation.
|
||||
Each saved native chat source also has a signed standalone OBS Custom Browser
|
||||
Dock URL. The dock reuses its filters and visual settings without overlay canvas
|
||||
coordinates, fixed source dimensions, or message animations, and its private URL
|
||||
is revoked through the existing overlay/scene token lifecycle.
|
||||
|
||||
Remaining work:
|
||||
|
||||
@ -677,6 +882,14 @@ This section is for Lumi chat answer feedback and AI Improvement Center work, no
|
||||
|
||||
## Done
|
||||
|
||||
- 2026-07-19: Fixed native chat overlays to include Lumi's own platform messages; added `platform:username|id` and linked `lumi:username|id` blocking before scoped delivery; replaced text platform/badge labels with images and official Twitch badge catalog artwork; added normalized unsaved source previews, secured website-CSS drafts, visible dirty/revert state, sticky save controls, contrast backgrounds, and a placement grid; and hardened transparency against late-loading alert-app DOM/style changes.
|
||||
|
||||
- 2026-07-19: Added the native Lumi chat overlay source with combined Twitch, YouTube, and Discord messages; optional platform/channel filters; avatars, badges, Twitch emotes, timestamps, typography, transparent colors, alignment and spacing; entry/exit animations; bounded timeout/capacity removal; isolated CSS; representative editor previews; and short-lived reconnect replay through the existing scoped overlay event stream.
|
||||
|
||||
- 2026-07-18: Replaced frame-only website styling with OBS-style document CSS injection. External alert pages now receive OBS's transparent body CSS by default plus administrator selectors inside the website document; scoped revocable source tickets, opaque sandboxing, no-referrer handling, bounded public fetching, and private-network blocking protect Lumi and its overlay secrets.
|
||||
- 2026-07-18: Fixed OBS overlay transparency and preview fidelity by applying OBS's exact default Browser Source CSS, rendering the editor and clean output from the same full-resolution canvas coordinate system, keeping iframe surfaces transparent, and showing the required OBS width/height plus copyable CSS in setup.
|
||||
|
||||
- 2026-07-18: Reworked shared logging repo-wide with structured component/category/event/request metadata, recursive secret redaction, bounded details, automatic retention, scoped plugin loggers, platform/command/webhook/lifecycle/admin HTTP auditing, live `/admin/logs` updates, complete server-side filters and summaries, and text/JSONL exports.
|
||||
- 2026-07-18: Fixed core 0.2.10 local Windows startup under Node.js 24 by resolving npm's JavaScript CLI through the active Node installation, retaining a command-shell fallback, and verifying automatic dependency repair through the real launcher on port 3000.
|
||||
- 2026-07-18: Fixed core 0.2.9 and Lumi AI 0.8.5 forms whose `action` or `method` fields could shadow the form endpoint, added a shared clobber-safe resolver, and migrated the affected feedback/settings requests to it.
|
||||
- 2026-07-18: Fixed Lumi AI 0.8.4 Improvement Center state changes for no-change, dismiss, edit, and restore actions with an explicit authenticated JSON contract, actionable errors, safe diagnostics, and focused regression coverage.
|
||||
|
||||
163
companion/Lumi.Companion.sln
Normal file
163
companion/Lumi.Companion.sln
Normal file
@ -0,0 +1,163 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.Abstractions", "src\Lumi.Companion.Abstractions\Lumi.Companion.Abstractions.csproj", "{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.Protocol", "src\Lumi.Companion.Protocol\Lumi.Companion.Protocol.csproj", "{3703AF57-828D-4E2F-BFBA-A95833555A5A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.Core", "src\Lumi.Companion.Core\Lumi.Companion.Core.csproj", "{0BDAE25E-C487-456D-A465-41A0CA48AF30}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.PluginHost", "src\Lumi.Companion.PluginHost\Lumi.Companion.PluginHost.csproj", "{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.App", "src\Lumi.Companion.App\Lumi.Companion.App.csproj", "{2F0041FD-FD06-4312-BF1E-180F0201EB97}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "plugins", "plugins", "{07D57EEB-2F50-60C4-C011-FE4FA775C9A8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.Transcription", "plugins\Lumi.Companion.Transcription\Lumi.Companion.Transcription.csproj", "{49C19CA8-4669-4C33-B3A8-9CB934CD1171}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.SongOverlay", "plugins\Lumi.Companion.SongOverlay\Lumi.Companion.SongOverlay.csproj", "{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.Core.Tests", "tests\Lumi.Companion.Core.Tests\Lumi.Companion.Core.Tests.csproj", "{AAFDF982-BE30-405F-A39B-E82F1B11D46A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lumi.Companion.Overlay", "plugins\Lumi.Companion.Overlay\Lumi.Companion.Overlay.csproj", "{E6AC503B-9723-441C-8882-D0EC4A845F9D}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285}.Release|x86.Build.0 = Release|Any CPU
|
||||
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{3703AF57-828D-4E2F-BFBA-A95833555A5A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0BDAE25E-C487-456D-A465-41A0CA48AF30}.Release|x86.Build.0 = Release|Any CPU
|
||||
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Release|x64.Build.0 = Release|Any CPU
|
||||
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2F0041FD-FD06-4312-BF1E-180F0201EB97}.Release|x86.Build.0 = Release|Any CPU
|
||||
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Release|x64.Build.0 = Release|Any CPU
|
||||
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{49C19CA8-4669-4C33-B3A8-9CB934CD1171}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2}.Release|x86.Build.0 = Release|Any CPU
|
||||
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{AAFDF982-BE30-405F-A39B-E82F1B11D46A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E6AC503B-9723-441C-8882-D0EC4A845F9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E6AC503B-9723-441C-8882-D0EC4A845F9D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E6AC503B-9723-441C-8882-D0EC4A845F9D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E6AC503B-9723-441C-8882-D0EC4A845F9D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E6AC503B-9723-441C-8882-D0EC4A845F9D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E6AC503B-9723-441C-8882-D0EC4A845F9D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E6AC503B-9723-441C-8882-D0EC4A845F9D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E6AC503B-9723-441C-8882-D0EC4A845F9D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E6AC503B-9723-441C-8882-D0EC4A845F9D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E6AC503B-9723-441C-8882-D0EC4A845F9D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E6AC503B-9723-441C-8882-D0EC4A845F9D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E6AC503B-9723-441C-8882-D0EC4A845F9D}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{0B3C2C62-2CCB-43C9-9AF9-769AC7F22285} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{3703AF57-828D-4E2F-BFBA-A95833555A5A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{0BDAE25E-C487-456D-A465-41A0CA48AF30} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{64C2B7E5-9356-457A-BAF8-F18F67DAE4CF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{2F0041FD-FD06-4312-BF1E-180F0201EB97} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{49C19CA8-4669-4C33-B3A8-9CB934CD1171} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
|
||||
{F72BAFAD-17D3-4EF2-8690-B3B5646556C2} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
|
||||
{AAFDF982-BE30-405F-A39B-E82F1B11D46A} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{E6AC503B-9723-441C-8882-D0EC4A845F9D} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
164
companion/README.md
Normal file
164
companion/README.md
Normal file
@ -0,0 +1,164 @@
|
||||
# Lumi Companion
|
||||
|
||||
Lumi Companion is the Windows streaming-computer client for Lumi. It provides a
|
||||
single paired-device shell for Companion plugins, including server-hosted
|
||||
transcription, Song Overlay capture, and the native Lumi monitor overlay. The app
|
||||
is single-instance, installs per Windows user, closes to the notification area,
|
||||
and preserves pairing and settings across updates.
|
||||
|
||||
## Install and pair
|
||||
|
||||
From Lumi, open **Admin > Companion** and choose **Download Companion**. The
|
||||
private ZIP contains:
|
||||
|
||||
- the checksum-pinned per-user Windows installer;
|
||||
- a one-time `.lumi-pairing.json` package;
|
||||
- a Host Operator notice and start instructions.
|
||||
|
||||
Extract the ZIP and run `Lumi.Companion-Setup.exe`. Setup installs to
|
||||
`%LocalAppData%\Programs\Lumi Companion`, displays the licence and privacy
|
||||
notices, identifies the paired Lumi host, imports the adjacent pairing package,
|
||||
and opens the installed copy. The extracted download can then be deleted.
|
||||
|
||||
Existing portable or installed builds retain the current Windows user's
|
||||
DPAPI-protected device identity, so reinstalling does not create a duplicate
|
||||
paired device. Pairing packages expire after 15 minutes, work once, and must not
|
||||
be shared or committed.
|
||||
|
||||
## Companion features
|
||||
|
||||
Global hotkey fields are buttons: select one, press the desired modifier/key
|
||||
combination, then release it to save the draft. Capture cancels after seven
|
||||
seconds without a complete shortcut.
|
||||
|
||||
Song Overlay discovers independently enabled Windows media sessions. Windows
|
||||
Media Player, YouTube/YouTube Music, and Spotify are enabled by default; VLC,
|
||||
iTunes/Apple Music, SoundCloud, TIDAL, Bandcamp, and Qobuz can be enabled
|
||||
individually.
|
||||
|
||||
Lumi Overlay Edit Mode opens the real monitor renderer with sample content and
|
||||
interactive container outlines. Drag a container to move it or its lower-right
|
||||
corner to resize it; the app retains these as an unsaved draft until Save.
|
||||
|
||||
## Security boundaries
|
||||
|
||||
- Companion plugins inherit the shell's paired-device authentication. Plugins do
|
||||
not store or request separate Lumi credentials.
|
||||
- Production Companion traffic requires HTTPS. Plain HTTP/WebSocket traffic is
|
||||
accepted only when the device was paired from the exact matching loopback Lumi
|
||||
origin and the request remains on loopback.
|
||||
- The OBS integration communicates with Companion through a same-user named pipe.
|
||||
- Transcription audio is normalized to 16 kHz mono signed 16-bit PCM, kept in
|
||||
bounded memory, and sent to the paired Lumi host. Companion does not perform
|
||||
local speech recognition.
|
||||
- Installing or repairing the OBS integration requests elevation only while OBS
|
||||
is closed and only to copy the verified managed component into OBS's shared
|
||||
ProgramData plugin directory.
|
||||
|
||||
## Updates
|
||||
|
||||
Companion checks after connecting and every six hours. Updates are
|
||||
user-approved, size- and checksum-verified, and refused while OBS is streaming or
|
||||
recording. Applying an update replaces the installed executable, bundled
|
||||
components, and legal bundle, then restarts Companion. Pairing credentials,
|
||||
settings, and plugin state remain in the per-user data directory.
|
||||
|
||||
If the paired Lumi host is temporarily unavailable, Companion remains paired and
|
||||
reconnects quietly in the background with bounded backoff. The Overview and
|
||||
Connection pages show the offline state without repeated dialogs; **Retry now**
|
||||
remains available for an immediate manual attempt.
|
||||
|
||||
Localhost development builds can also receive checksum-addressed same-version
|
||||
updates without publishing a release. See
|
||||
[Localhost development updates](../docs/local-development-updates.md).
|
||||
|
||||
## Transcription control
|
||||
|
||||
The Transcription page has a persistent **Generate and include captions** switch,
|
||||
also available from the tray menu. Turning it off stops the active transcription
|
||||
session, excludes captions from live and private-test output, and stops sending
|
||||
microphone audio to Lumi for speech recognition. Private Stream Testing remains
|
||||
available as a video-only test. Turning the switch back on restores automatic
|
||||
caption startup with OBS and can start captions while a private test is already
|
||||
running.
|
||||
|
||||
## Private Stream Testing
|
||||
|
||||
The core **Stream Testing** page asks the paired Lumi host for an expiring
|
||||
destination, then uses the managed OBS Bridge to snapshot the complete current
|
||||
OBS streaming service before installing that destination. OBS sends its ordinary
|
||||
encoded video and audio to the existing paired Lumi hostname on the configured
|
||||
RTMP/RTMPS port; Companion does not need a second login, destination editor, or
|
||||
local media runtime.
|
||||
|
||||
While a private test is active, Companion exposes **Open stream viewer**, which
|
||||
opens the paired Lumi host directly at **Admin > Stream testing**.
|
||||
|
||||
Lumi owns and verifies the MediaMTX receiver. MediaMTX remuxes the source stream
|
||||
for same-origin Admin playback without transcoding or recording it. In v0.3.3,
|
||||
`Source` is therefore the normal and only quality choice unless a future
|
||||
provider supplies genuine additional representations.
|
||||
|
||||
Stopping the test, losing the Lumi connection, receiver failure, Companion
|
||||
exit, expiry, or an OBS restart all enter the same recovery path. Companion
|
||||
keeps the DPAPI-protected recovery record until the OBS Bridge confirms that the
|
||||
exact saved service type and settings were restored and saved. If automatic
|
||||
restoration cannot finish while OBS is unavailable, the recovery remains
|
||||
visible and can be retried after OBS and the bridge reconnect.
|
||||
|
||||
## Build and verify
|
||||
|
||||
Requirements:
|
||||
|
||||
- Windows x64;
|
||||
- a current .NET SDK with the .NET 8 targeting pack;
|
||||
- CMake and Visual Studio C++ tools for the native OBS bridge;
|
||||
- Inno Setup 6 for the installer.
|
||||
|
||||
Build the managed solution:
|
||||
|
||||
```powershell
|
||||
dotnet build companion/Lumi.Companion.sln -c Release -p:EnableWindowsTargeting=true
|
||||
```
|
||||
|
||||
Build the pinned OBS bridge, self-contained archive, legal bundle, and installer:
|
||||
|
||||
```powershell
|
||||
companion/scripts/publish-companion.ps1
|
||||
```
|
||||
|
||||
The publish script generates the shared application/tray icon, verifies the OBS
|
||||
payload inside the published executable, collects NuGet notices, and packages the
|
||||
corresponding source for the GPL-licensed OBS bridge.
|
||||
|
||||
Run Song Overlay's focused verification with:
|
||||
|
||||
```powershell
|
||||
companion/scripts/verify-song-overlay.ps1
|
||||
```
|
||||
|
||||
Run the native Lumi Overlay feed, layout/lifecycle, settings, and full managed
|
||||
build verification with:
|
||||
|
||||
```powershell
|
||||
companion/scripts/verify-lumi-overlay.ps1
|
||||
```
|
||||
|
||||
Native overlay behavior and capture-exclusion limitations are documented in
|
||||
[`docs/companion-native-overlay.md`](../docs/companion-native-overlay.md).
|
||||
|
||||
Generated installers, pairing packages, credentials, build output, and logs must
|
||||
not be committed.
|
||||
|
||||
## Distribution and legal notices
|
||||
|
||||
The release is currently unsigned, so Windows may show an unknown-publisher
|
||||
warning. The installer and installed `legal` directory include the Companion
|
||||
licence, default privacy notice, third-party notices, exact licence texts, and
|
||||
the corresponding OBS bridge source bundle.
|
||||
|
||||
Self-hosters should set `LUMI_OPERATOR_NAME`, `LUMI_OPERATOR_CONTACT`, and
|
||||
`LUMI_OPERATOR_PRIVACY_URL`. Those values identify the Host Operator responsible
|
||||
for its hosted service and server-side processing; OokamiKunTV is the software
|
||||
developer and is not automatically the operator of an independently hosted Lumi
|
||||
installation.
|
||||
14
companion/docs/obs-native-caption-compatibility-spike.md
Normal file
14
companion/docs/obs-native-caption-compatibility-spike.md
Normal file
@ -0,0 +1,14 @@
|
||||
# OBS 31+ native caption compatibility spike
|
||||
|
||||
Implementation source inspection confirms OBS 31 exposes `obs_output_output_caption_text2` and `obs_frontend_get_streaming_output`. The bridge calls the caption API only from its IPC worker against the active streaming output; it never calls it from an audio/render callback.
|
||||
|
||||
This is not Twitch acceptance evidence. The following target-topology test remains mandatory before live delivery can be labelled ready:
|
||||
|
||||
1. Build and install the signed bridge through the companion on Windows 11 with OBS 31+.
|
||||
2. Start a private Twitch stream using the production service/output configuration.
|
||||
3. Submit incrementally revised text with 1.5–3 second display durations.
|
||||
4. Verify the Twitch web player and at least one mobile client expose a user-toggleable CC control.
|
||||
5. Record whether OBS/Twitch queue, replace, or drop revisions and tune the adapter to prevent stale caption backlog.
|
||||
6. Capture OBS CPU, render/encoder missed frames, audio dropouts, bridge queue drops, and network throughput before/after.
|
||||
|
||||
Until this passes, the companion must allow the exact simulated delivery stream in Test mode but refuse to describe Twitch closed captions as verified. It must never substitute a baked-in open-caption overlay.
|
||||
23
companion/docs/performance-acceptance-template.md
Normal file
23
companion/docs/performance-acceptance-template.md
Normal file
@ -0,0 +1,23 @@
|
||||
# Transcription target-topology acceptance report
|
||||
|
||||
Status: not run. Do not replace blank measurements with estimates.
|
||||
|
||||
Topology: Windows 11 streaming PC / OBS version ___ / Windows Server 2022 Lumi host / RTX 3060 driver ___ / wired LAN / Twitch test URL or evidence reference ___.
|
||||
|
||||
| Measurement | Before | Live transcription | Result |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Companion CPU / working set | ___ | ___ | ___ |
|
||||
| OBS CPU | ___ | ___ | ___ |
|
||||
| OBS render missed frames | ___ | ___ | ___ |
|
||||
| OBS encoder missed frames | ___ | ___ | ___ |
|
||||
| Audio dropouts | ___ | ___ | ___ |
|
||||
| Bridge queue drops | ___ | ___ | ___ |
|
||||
| Network throughput | ___ | ___ | ___ |
|
||||
| Whisper CPU / GPU / VRAM / RAM | n/a | ___ | ___ |
|
||||
| Model load time | n/a | ___ | ___ |
|
||||
| Decode latency / real-time factor | n/a | ___ | ___ |
|
||||
| First stable caption p50 / p95 | n/a | ___ | ___ |
|
||||
| End-to-end caption p50 / p95 | n/a | ___ | ___ |
|
||||
| Maximum queue depth / backlog growth | n/a | ___ | ___ |
|
||||
|
||||
Confirm: test-mode real path ___; Twitch player toggleable CC ___; stable sustained speech ___; no stale dump after network flap ___; 30-second stream grace/resume ___; raw audio absent from disk ___; fallback activation evidence ___.
|
||||
38
companion/docs/song-overlay-companion.md
Normal file
38
companion/docs/song-overlay-companion.md
Normal file
@ -0,0 +1,38 @@
|
||||
# Song Overlay Companion integration
|
||||
|
||||
This integration adds provider-neutral Windows media-session discovery to Lumi Companion. It can identify Spotify, Windows Media Player, YouTube and YouTube Music in Chrome or Edge, VLC, iTunes and Apple Music, SoundCloud, TIDAL, Bandcamp, and Qobuz. Each source is independently switchable; Windows Media Player, YouTube, and Spotify are enabled by default.
|
||||
|
||||
## Navigation contract
|
||||
|
||||
Every Companion plugin implements `ICompanionPluginContribution` and receives one nested root in both navigation surfaces:
|
||||
|
||||
- Tray menu: core actions, `Plugins`, one submenu per plugin, then health/update/log/settings controls and Quit.
|
||||
- App sidebar: Overview, `PLUGINS`, one expandable root per plugin, then Connection, Logs, and Settings at the bottom.
|
||||
|
||||
Plugin actions are caught at the shell boundary so a routine plugin exception does not terminate Companion. The Song Overlay runtime also catches provider callbacks, transport failures, heartbeats, and initialization failures and reports an actionable plugin status.
|
||||
|
||||
## Song Overlay flow
|
||||
|
||||
1. Windows Global System Media Transport Controls exposes playback from enabled desktop and browser sources.
|
||||
2. The provider identifies the source where Windows exposes enough app or metadata context, then raises media, playback, timeline, and session-availability events.
|
||||
3. The runtime sends only meaningful deltas to Lumi: track change, inferred next/previous, play, resume, pause, stop, seek, metadata enrichment, or a sparse recovery heartbeat.
|
||||
4. Playback progress is projected from the last event. Continuous progress messages are not sent.
|
||||
5. Cover art is resized and sent only with track metadata.
|
||||
6. The existing Lumi `plugins/now_playing` endpoint updates the Song Overlay source and optional chat announcement.
|
||||
|
||||
## Build and verification
|
||||
|
||||
From the repository root on Windows:
|
||||
|
||||
```powershell
|
||||
dotnet build companion/Lumi.Companion.sln -c Release -p:EnableWindowsTargeting=true
|
||||
node plugins/now_playing/tests/verify.js
|
||||
```
|
||||
|
||||
Or run:
|
||||
|
||||
```powershell
|
||||
companion/scripts/verify-song-overlay.ps1
|
||||
```
|
||||
|
||||
The optional Spotify Web API connection is used only for metadata enrichment such as exact links and release year. Core playback detection works through Windows without Spotify authorization.
|
||||
241
companion/installer/Lumi.Companion.iss
Normal file
241
companion/installer/Lumi.Companion.iss
Normal file
@ -0,0 +1,241 @@
|
||||
#ifndef AppVersion
|
||||
#define AppVersion "0.2.8"
|
||||
#endif
|
||||
#ifndef SourceRoot
|
||||
#error SourceRoot must point at the self-contained Companion publish directory.
|
||||
#endif
|
||||
#ifndef OutputRoot
|
||||
#error OutputRoot must point at the installer output directory.
|
||||
#endif
|
||||
|
||||
[Setup]
|
||||
AppId={{EDEB45E4-41CB-4D7A-A479-4073A1A08B93}
|
||||
AppName=Lumi Companion
|
||||
AppVersion={#AppVersion}
|
||||
AppVerName=Lumi Companion {#AppVersion}
|
||||
AppPublisher=OokamiKunTV
|
||||
AppCopyright=Copyright (c) 2026 OokamiKunTV
|
||||
DefaultDirName={localappdata}\Programs\Lumi Companion
|
||||
DefaultGroupName=Lumi Companion
|
||||
DisableProgramGroupPage=yes
|
||||
PrivilegesRequired=lowest
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
OutputDir={#OutputRoot}
|
||||
OutputBaseFilename=Lumi.Companion-Setup
|
||||
Compression=lzma2/ultra64
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
SetupLogging=yes
|
||||
CloseApplications=yes
|
||||
CloseApplicationsFilter=Lumi.Companion.App.exe
|
||||
RestartApplications=no
|
||||
UninstallDisplayName=Lumi Companion
|
||||
UninstallDisplayIcon={app}\Lumi.Companion.App.exe
|
||||
VersionInfoCompany=OokamiKunTV
|
||||
VersionInfoCopyright=Copyright (c) 2026 OokamiKunTV
|
||||
VersionInfoDescription=Lumi Companion installer
|
||||
VersionInfoProductName=Lumi Companion
|
||||
VersionInfoProductTextVersion={#AppVersion}
|
||||
LicenseFile={#SourceRoot}\legal\LUMI-COMPANION-LICENCE.txt
|
||||
InfoBeforeFile={#SourceRoot}\legal\PRIVACY-NOTICE.txt
|
||||
InfoAfterFile={#SourceRoot}\legal\THIRD-PARTY-NOTICES.txt
|
||||
|
||||
[Files]
|
||||
Source: "{#SourceRoot}\Lumi.Companion.App.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#SourceRoot}\components\*"; DestDir: "{app}\components"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
Source: "{#SourceRoot}\legal\*"; DestDir: "{app}\legal"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\Lumi Companion"; Filename: "{app}\Lumi.Companion.App.exe"
|
||||
Name: "{autoprograms}\Lumi Companion - Legal and Privacy"; Filename: "{app}\legal\LEGAL-README.txt"
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\Lumi.Companion.App.exe"; Description: "Open Lumi Companion"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
[Code]
|
||||
var
|
||||
PairingPackagePath: String;
|
||||
PairingHost: String;
|
||||
PairingOperatorName: String;
|
||||
PairingOperatorContact: String;
|
||||
PairingPrivacyUrl: String;
|
||||
HostAcknowledgementPage: TInputOptionWizardPage;
|
||||
|
||||
function LocatePairingPackage: String;
|
||||
var
|
||||
FindRec: TFindRec;
|
||||
begin
|
||||
Result := '';
|
||||
if FindFirst(ExpandConstant('{src}\*.lumi-pairing.json'), FindRec) then
|
||||
begin
|
||||
try
|
||||
Result := AddBackslash(ExpandConstant('{src}')) + FindRec.Name;
|
||||
finally
|
||||
FindClose(FindRec);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
function JsonStringValue(const Json: String; const Key: String): String;
|
||||
var
|
||||
Marker: String;
|
||||
Tail: String;
|
||||
MarkerPos: Integer;
|
||||
ColonPos: Integer;
|
||||
EndQuotePos: Integer;
|
||||
begin
|
||||
Result := '';
|
||||
Marker := '"' + Key + '"';
|
||||
MarkerPos := Pos(Marker, Json);
|
||||
if MarkerPos = 0 then
|
||||
Exit;
|
||||
|
||||
Tail := Copy(Json, MarkerPos + Length(Marker), Length(Json));
|
||||
ColonPos := Pos(':', Tail);
|
||||
if ColonPos = 0 then
|
||||
Exit;
|
||||
|
||||
Tail := Trim(Copy(Tail, ColonPos + 1, Length(Tail)));
|
||||
if (Length(Tail) < 2) or (Tail[1] <> '"') then
|
||||
Exit;
|
||||
|
||||
Tail := Copy(Tail, 2, Length(Tail));
|
||||
EndQuotePos := Pos('"', Tail);
|
||||
if EndQuotePos = 0 then
|
||||
Exit;
|
||||
|
||||
Result := Copy(Tail, 1, EndQuotePos - 1);
|
||||
end;
|
||||
|
||||
function LoadPairingJson(const PackagePath: String; var Json: String): Boolean;
|
||||
var
|
||||
Lines: TArrayOfString;
|
||||
Index: Integer;
|
||||
begin
|
||||
Result := False;
|
||||
Json := '';
|
||||
if (PackagePath = '') or not LoadStringsFromFile(PackagePath, Lines) then
|
||||
Exit;
|
||||
|
||||
for Index := 0 to GetArrayLength(Lines) - 1 do
|
||||
begin
|
||||
if Index > 0 then
|
||||
Json := Json + #10;
|
||||
Json := Json + Lines[Index];
|
||||
end;
|
||||
Result := True;
|
||||
end;
|
||||
|
||||
function ReadPairingHost(const Json: String): String;
|
||||
var
|
||||
Candidate: String;
|
||||
LowerCandidate: String;
|
||||
begin
|
||||
Result := '';
|
||||
Candidate := JsonStringValue(Json, 'host');
|
||||
LowerCandidate := Lowercase(Candidate);
|
||||
if ((Pos('https://', LowerCandidate) = 1) or (Pos('http://', LowerCandidate) = 1)) and
|
||||
(Length(Candidate) <= 500) then
|
||||
Result := Candidate;
|
||||
end;
|
||||
|
||||
procedure InitializeWizard;
|
||||
var
|
||||
HostDisplay: String;
|
||||
Explanation: String;
|
||||
OperatorDetails: String;
|
||||
Json: String;
|
||||
begin
|
||||
PairingPackagePath := LocatePairingPackage;
|
||||
if LoadPairingJson(PairingPackagePath, Json) then
|
||||
begin
|
||||
PairingHost := ReadPairingHost(Json);
|
||||
PairingOperatorName := Copy(JsonStringValue(Json, 'operator_name'), 1, 200);
|
||||
PairingOperatorContact := Copy(JsonStringValue(Json, 'operator_contact'), 1, 300);
|
||||
PairingPrivacyUrl := Copy(JsonStringValue(Json, 'privacy_url'), 1, 500);
|
||||
end;
|
||||
|
||||
if PairingHost <> '' then
|
||||
HostDisplay := PairingHost
|
||||
else
|
||||
HostDisplay := 'No paired Lumi host was identified beside this installer.';
|
||||
|
||||
OperatorDetails := '';
|
||||
if PairingOperatorName <> '' then
|
||||
OperatorDetails := OperatorDetails + #13#10 + 'Operator name: ' + PairingOperatorName;
|
||||
if PairingOperatorContact <> '' then
|
||||
OperatorDetails := OperatorDetails + #13#10 + 'Operator contact: ' + PairingOperatorContact;
|
||||
if PairingPrivacyUrl <> '' then
|
||||
OperatorDetails := OperatorDetails + #13#10 + 'Privacy information: ' + PairingPrivacyUrl;
|
||||
|
||||
Explanation :=
|
||||
'This copy of Lumi Companion is intended to connect to:' + #13#10#13#10 +
|
||||
HostDisplay + OperatorDetails + #13#10#13#10 +
|
||||
'The person or organisation controlling that Lumi installation is the Host Operator. ' +
|
||||
'The Host Operator is responsible for the Hosted Service it controls, including server configuration, ' +
|
||||
'access, server-side data processing, retention, integrations, and required user notices. ' +
|
||||
'OokamiKunTV is the software developer and is not automatically the operator of an independently hosted Lumi installation. ' +
|
||||
'Responsibility always follows the actual facts and applicable law.';
|
||||
|
||||
HostAcknowledgementPage := CreateInputOptionPage(
|
||||
wpInfoBefore,
|
||||
'Lumi host and data responsibility',
|
||||
'Review the host that will receive Companion data.',
|
||||
Explanation,
|
||||
False,
|
||||
False);
|
||||
HostAcknowledgementPage.Add(
|
||||
'I understand which Lumi host this Companion will use and that the Host Operator controls its server-side service and processing.');
|
||||
end;
|
||||
|
||||
function NextButtonClick(CurPageID: Integer): Boolean;
|
||||
begin
|
||||
Result := True;
|
||||
{ A silent deployment is an administrator/operator action and has no wizard
|
||||
UI in which an interactive acknowledgement can be collected. }
|
||||
if WizardSilent then
|
||||
Exit;
|
||||
|
||||
if (HostAcknowledgementPage <> nil) and (CurPageID = HostAcknowledgementPage.ID) and
|
||||
not HostAcknowledgementPage.Values[0] then
|
||||
begin
|
||||
MsgBox(
|
||||
'Confirm that you understand the host and operator responsibility before continuing.',
|
||||
mbInformation,
|
||||
MB_OK);
|
||||
Result := False;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure ImportPairingPackage;
|
||||
var
|
||||
SourcePath: String;
|
||||
TargetPath: String;
|
||||
begin
|
||||
{ Portable and installed builds share the same DPAPI credential directory. Do
|
||||
not create another authorized device merely because an existing user moves
|
||||
the executable into its durable installed location. }
|
||||
if FileExists(ExpandConstant('{localappdata}\Lumi\Companion\device.credential')) then
|
||||
Exit;
|
||||
|
||||
SourcePath := PairingPackagePath;
|
||||
if SourcePath = '' then
|
||||
SourcePath := LocatePairingPackage;
|
||||
if SourcePath = '' then
|
||||
Exit;
|
||||
|
||||
TargetPath := AddBackslash(ExpandConstant('{app}')) + ExtractFileName(SourcePath);
|
||||
if not CopyFile(SourcePath, TargetPath, False) then
|
||||
MsgBox(
|
||||
'Lumi Companion was installed, but its one-time pairing package could not be imported. ' +
|
||||
'Open Companion and choose the pairing file manually.',
|
||||
mbError,
|
||||
MB_OK);
|
||||
end;
|
||||
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
begin
|
||||
if CurStep = ssPostInstall then
|
||||
ImportPairingPackage;
|
||||
end;
|
||||
10
companion/legal/HOST-OPERATOR-NOTICE.template.txt
Normal file
10
companion/legal/HOST-OPERATOR-NOTICE.template.txt
Normal file
@ -0,0 +1,10 @@
|
||||
LUMI COMPANION — HOST OPERATOR NOTICE
|
||||
|
||||
Paired Lumi host: {{HOST_URL}}
|
||||
Operator name: {{OPERATOR_NAME}}
|
||||
Operator contact: {{OPERATOR_CONTACT}}
|
||||
Privacy information: {{PRIVACY_URL}}
|
||||
|
||||
This package connects Companion to the Lumi installation at the host shown above. The person or organisation controlling that installation is responsible for its Hosted Service, including server configuration, user access, server-side processing, retention, integrations, notices, and compliance obligations.
|
||||
|
||||
OokamiKunTV is the developer of Lumi Companion and is not automatically the operator of an independently hosted Lumi installation. Responsibility follows the actual facts and applicable law. The host URL is a technical identifier and may not by itself identify the operator’s legal name.
|
||||
12
companion/legal/LEGAL-MAINTAINER-NOTES.md
Normal file
12
companion/legal/LEGAL-MAINTAINER-NOTES.md
Normal file
@ -0,0 +1,12 @@
|
||||
# Legal maintainer notes
|
||||
|
||||
This bundle is a practical baseline, not a substitute for review by a qualified lawyer familiar with the actual distribution, business model, and jurisdictions.
|
||||
|
||||
Before a public or paid release:
|
||||
|
||||
- publish a stable developer contact channel and, if applicable, the legal person/business name and address;
|
||||
- let self-hosters configure an operator name, contact address, and privacy URL in generated pairing packages;
|
||||
- confirm the project-wide licence strategy, especially the GPL-2.0-or-later OBS bridge boundary;
|
||||
- retain the generated `third-party/nuget/NuGet-LICENCE-INVENTORY.txt` and copied package notices from the exact resolved .NET/NuGet dependency set for each release;
|
||||
- preserve the corresponding-source archive for every distributed OBS bridge binary for as long as the relevant GPL obligations require; and
|
||||
- review consumer terms, data-processing roles, and any paid-service terms before charging users.
|
||||
10
companion/legal/LEGAL-README.txt
Normal file
10
companion/legal/LEGAL-README.txt
Normal file
@ -0,0 +1,10 @@
|
||||
LUMI COMPANION — LEGAL AND PRIVACY DOCUMENTS
|
||||
|
||||
- LUMI-COMPANION-LICENCE.txt: licence terms for developer-authored proprietary portions.
|
||||
- PRIVACY-NOTICE.txt: default client-side data behaviour and Host Operator responsibility.
|
||||
- THIRD-PARTY-NOTICES.txt: third-party components and licence locations.
|
||||
- third-party directory: full third-party licence and notice files supplied with this build.
|
||||
- third-party\nuget\NuGet-LICENCE-INVENTORY.txt: generated inventory and package-supplied notices for the exact restored NuGet graph.
|
||||
- Lumi-OBS-Bridge-Source.zip: corresponding source and build materials for the GPL-licensed OBS bridge.
|
||||
|
||||
The installer also displays the paired Lumi host URL when a pairing package is present. The person or organisation controlling that host is responsible for its own Hosted Service and server-side processing, subject to the actual facts and applicable law.
|
||||
102
companion/legal/LUMI-COMPANION-LICENCE.txt
Normal file
102
companion/legal/LUMI-COMPANION-LICENCE.txt
Normal file
@ -0,0 +1,102 @@
|
||||
LUMI COMPANION SOFTWARE LICENCE AND TERMS OF USE
|
||||
Effective date: 23 July 2026
|
||||
|
||||
PLEASE READ THESE TERMS BEFORE INSTALLING OR USING LUMI COMPANION.
|
||||
|
||||
These terms govern the Lumi Companion desktop application and installer, except for components that are supplied under separate open-source or third-party licences. By installing, copying, pairing, updating, or using Lumi Companion, you accept these terms. If you do not accept them, do not install or use the software.
|
||||
|
||||
1. PARTIES AND DEFINITIONS
|
||||
|
||||
“Developer” means OokamiKunTV, the developer and copyright holder of Lumi Companion and the Lumi project components authored by OokamiKunTV.
|
||||
|
||||
“Companion” means the Lumi Companion application, installer, updates, documentation, and developer-authored components supplied with it.
|
||||
|
||||
“Host Operator” means the person or organisation that controls the Lumi installation to which Companion is paired. The paired host URL shown by the installer identifies the technical endpoint, but may not by itself identify the Host Operator’s legal name.
|
||||
|
||||
“Hosted Service” means the Lumi installation, its configuration, accounts, models, storage, integrations, moderation, transcription, and other server-side functions operated by the Host Operator.
|
||||
|
||||
2. SEPARATE RESPONSIBILITIES
|
||||
|
||||
Companion is client software. It connects to a separately operated Lumi host selected through a pairing package or by the user.
|
||||
|
||||
The Host Operator is responsible for the Hosted Service it controls, including its configuration, availability, security, access management, user notices, legal basis for processing, retention, integrations, and compliance with laws applicable to that operation. The Developer is not automatically the Host Operator merely because the Host Operator uses Lumi software.
|
||||
|
||||
Where the Developer also operates the paired Lumi host, the Developer may additionally have responsibilities in that separate role. Legal responsibility always follows the actual facts and applicable law; these terms do not reassign a statutory responsibility that cannot lawfully be reassigned.
|
||||
|
||||
3. LICENCE GRANT
|
||||
|
||||
Subject to these terms, the Developer grants you a limited, non-exclusive, non-transferable, revocable licence to install and use Companion on devices you own or control for personal or internal organisational use with a Lumi installation you are authorised to access.
|
||||
|
||||
A Host Operator may redistribute an unmodified official Companion installer together with a pairing package to users authorised to access that Host Operator’s Lumi installation. No right is granted to sell, rebrand, misrepresent, or distribute modified proprietary Companion binaries without the Developer’s permission.
|
||||
|
||||
You may make reasonable backup copies. You may not remove proprietary notices, bypass access controls, use Companion to gain unauthorised access, or reverse engineer proprietary portions except to the extent that applicable law expressly permits this despite this restriction.
|
||||
|
||||
Open-source components are governed only by their respective licences. Nothing in these terms limits rights granted under those licences.
|
||||
|
||||
4. EXPERIMENTAL SOFTWARE AND USER RESPONSIBILITY
|
||||
|
||||
Companion may be identified as experimental, preview, beta, or pre-release software. It may contain defects, change without notice, fail to interoperate with OBS, Windows, hardware, networks, third-party services, or a particular Lumi host, and may be discontinued.
|
||||
|
||||
You are responsible for deciding whether Companion is suitable for your use, maintaining backups, testing changes before relying on them, protecting your accounts and devices, reviewing the Host Operator’s policies, and obtaining any permissions required before capturing or transmitting audio, captions, account information, or other data.
|
||||
|
||||
Companion is not designed for emergency, medical, safety-critical, legal-compliance, life-support, or other use where failure could foreseeably cause death, personal injury, or substantial physical damage.
|
||||
|
||||
5. DATA AND NETWORK FUNCTIONS
|
||||
|
||||
Companion stores local settings, an installation identifier, diagnostic logs, and a Windows user-protected device credential. When paired, it communicates with the paired Lumi host and may transmit selected OBS audio, device and component status, configuration information, test data, captions, and diagnostics needed for enabled functions.
|
||||
|
||||
The Privacy Notice supplied with Companion explains the default client-side behaviour. The Host Operator must provide information about server-side processing for the Hosted Service. You must not assume that the Developer receives or controls data sent to an independently operated Lumi host.
|
||||
|
||||
6. UPDATES, CHANGES, AND THIRD-PARTY SERVICES
|
||||
|
||||
Companion may check the paired Lumi host for updates. Installation of an update remains subject to the behaviour presented by the application. Updates may add, remove, or change features and may be required for compatibility or security.
|
||||
|
||||
Companion may interoperate with third-party software and services, including OBS Studio and Windows. Those products are controlled by their respective providers and may be subject to separate terms. The Developer does not control and is not responsible for their continued availability, behaviour, policies, or changes.
|
||||
|
||||
7. SUPPORT
|
||||
|
||||
Unless the Developer or Host Operator separately agrees in writing, no support, maintenance, service level, response time, continued hosting, compatibility period, or update schedule is promised.
|
||||
|
||||
8. NO WARRANTY
|
||||
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, COMPANION IS PROVIDED “AS IS” AND “AS AVAILABLE”, WITH ALL FAULTS AND WITHOUT WARRANTIES, REPRESENTATIONS, CONDITIONS, OR GUARANTEES OF ANY KIND, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE.
|
||||
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE DEVELOPER DISCLAIMS IMPLIED WARRANTIES OR CONDITIONS OF MERCHANTABILITY, SATISFACTORY QUALITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, ACCURACY, SECURITY, AVAILABILITY, COMPATIBILITY, AND ERROR-FREE OR UNINTERRUPTED OPERATION.
|
||||
|
||||
No statement, documentation, test result, roadmap, or assistance creates a warranty unless the Developer expressly agrees to it in a signed written agreement.
|
||||
|
||||
9. LIMITATION OF LIABILITY
|
||||
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, THE DEVELOPER WILL NOT BE LIABLE FOR INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, PUNITIVE, OR CONSEQUENTIAL LOSS; LOSS OF DATA, CONTENT, PRIVACY, REVENUE, PROFITS, GOODWILL, BUSINESS, OPPORTUNITY, OR USE; SERVICE INTERRUPTION; DEVICE OR SOFTWARE DAMAGE; THIRD-PARTY CLAIMS; OR THE CONDUCT, SECURITY, AVAILABILITY, OR DATA PROCESSING OF AN INDEPENDENT HOST OPERATOR OR THIRD-PARTY SERVICE.
|
||||
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE DEVELOPER’S TOTAL AGGREGATE LIABILITY ARISING FROM OR RELATING TO COMPANION WILL NOT EXCEED THE AMOUNT YOU PAID DIRECTLY TO THE DEVELOPER FOR COMPANION DURING THE TWELVE MONTHS BEFORE THE EVENT GIVING RISE TO THE CLAIM.
|
||||
|
||||
These exclusions and limits do not apply where liability cannot lawfully be excluded or limited, including mandatory consumer rights and, where applicable, liability for fraud, intentional misconduct, gross negligence, or death or personal injury caused by negligence.
|
||||
|
||||
10. BUSINESS-USER INDEMNITY
|
||||
|
||||
If you use Companion on behalf of a business or organisation, that business or organisation will, to the extent permitted by law, defend and indemnify the Developer against third-party claims, losses, and reasonable costs arising from its unlawful use of Companion, infringement of third-party rights, failure to obtain required permissions, or operation of a Lumi host in breach of law. This section does not apply to consumers acting wholly outside a trade or profession.
|
||||
|
||||
11. TERMINATION
|
||||
|
||||
This licence ends automatically if you materially breach these terms. On termination, you must stop using proprietary portions of Companion and remove copies under your control, except where retention is required by law or permitted by an applicable open-source licence. Sections intended by their nature to survive termination remain effective.
|
||||
|
||||
12. GOVERNING LAW AND MANDATORY RIGHTS
|
||||
|
||||
These terms are governed by the laws of Norway, excluding conflict-of-law rules, unless mandatory law requires otherwise. Norwegian courts have non-exclusive jurisdiction where legally permitted.
|
||||
|
||||
If you are a consumer, you retain all mandatory protections and rights available under the law of your country of residence. Nothing in these terms waives a right or remedy that cannot legally be waived, restricts your right to bring proceedings in a court available under mandatory law, or requires you to accept a lower level of protection than mandatory consumer law provides.
|
||||
|
||||
13. GENERAL
|
||||
|
||||
If any provision is invalid or unenforceable, it will be limited or removed only to the minimum extent necessary, and the remaining provisions will continue in effect. Failure to enforce a provision is not a waiver. These terms, the Privacy Notice, and applicable third-party licences form the agreement for Companion unless a separate written agreement expressly replaces them.
|
||||
|
||||
The English version controls to the extent permitted by law. A translation may be provided for convenience.
|
||||
|
||||
14. CONTACT AND OPERATOR INFORMATION
|
||||
|
||||
Developer: OokamiKunTV
|
||||
Project: Lumi / Lumi Companion
|
||||
Developer contact: use the official Lumi project support or contact channel from which the software was obtained.
|
||||
|
||||
For Hosted Service, account, server-side data, moderation, retention, or access questions, contact the Host Operator identified through the paired Lumi host. The installer displays the host endpoint when a pairing package is present.
|
||||
82
companion/legal/PRIVACY-NOTICE.txt
Normal file
82
companion/legal/PRIVACY-NOTICE.txt
Normal file
@ -0,0 +1,82 @@
|
||||
LUMI COMPANION PRIVACY NOTICE
|
||||
Effective date: 25 July 2026
|
||||
|
||||
This notice describes the default data behaviour of the Lumi Companion Windows application. It does not replace the privacy notice of the person or organisation operating the Lumi host to which Companion is paired.
|
||||
|
||||
1. WHO IS RESPONSIBLE
|
||||
|
||||
Companion is client software developed by OokamiKunTV. It can connect to Lumi installations operated by different people or organisations.
|
||||
|
||||
The person or organisation operating the paired Lumi installation is normally responsible for deciding why and how server-side personal data is processed through that installation. That operator is referred to here as the “Host Operator”. The installer shows the paired host URL when it can read one from the adjacent pairing package.
|
||||
|
||||
The host URL identifies a technical endpoint, not necessarily the Host Operator’s full legal identity. Obtain the operator’s identity, contact details, purposes, legal bases, recipients, retention periods, and rights information from the operator or the paired Lumi WebUI.
|
||||
|
||||
OokamiKunTV is not automatically responsible for processing performed by an independently operated Lumi host. If OokamiKunTV also operates the paired host or receives information for support, security, or another stated purpose, responsibility for that processing follows the actual arrangement and applicable law. This notice does not override statutory controller or processor roles.
|
||||
|
||||
2. DATA STORED ON THE WINDOWS COMPUTER
|
||||
|
||||
Companion may store the following under the current Windows user profile:
|
||||
|
||||
- settings, selected source identifiers, and feature preferences;
|
||||
- a random installation identifier;
|
||||
- a device credential containing the paired host address, device identifier, secret, capabilities, and protocol version;
|
||||
- short-lived diagnostic log files containing timestamps, connection and component events, error messages, host addresses, device names, and potentially caption or test text;
|
||||
- staged update files while an update is being prepared; and
|
||||
- a copied one-time pairing package until it is successfully used, found redundant, manually removed, or expires.
|
||||
|
||||
The device credential is protected with Windows Data Protection API for the current Windows user. This reduces casual disclosure but does not protect against every compromise of the Windows account or device.
|
||||
|
||||
Diagnostic logs are pruned after seven days and capped at a combined 256 MiB by default. Uninstalling Companion may leave the per-user settings, credential, installation identifier, logs, and update directory in %LocalAppData%\Lumi\Companion so that reinstalling does not silently create a new authorised device. Use “Forget this device” before uninstalling when available, revoke the device from the Lumi host, and delete that folder manually if you want the remaining local data removed.
|
||||
|
||||
3. DATA SENT TO THE PAIRED LUMI HOST
|
||||
|
||||
Depending on the features you enable, Companion may send:
|
||||
|
||||
- the computer or device name, installation identifier, Companion version, protocol version, capabilities, and component status;
|
||||
- OBS state and selected-source information;
|
||||
- audio from the OBS source you select, converted to 16 kHz mono signed 16-bit PCM and transmitted in bounded frames;
|
||||
- when you explicitly start a private Stream Test, the real OBS audio/video output, temporary Stream Test destination facts, and output diagnostics required to preview and troubleshoot that output;
|
||||
- readiness, test, benchmark, connection, and diagnostic information; and
|
||||
- settings or control messages required for enabled Companion features.
|
||||
|
||||
Raw audio is held in bounded memory and is not written to disk by Companion by default. Audio is transmitted to the paired Lumi host for server-side processing. The Host Operator controls the server-side speech model, storage, logs, caption delivery, integrations, and retention. Review the Host Operator’s notice before enabling capture.
|
||||
|
||||
Private Stream Test media is sent only after you start a test. The default Lumi host repackages it into a short in-memory live window for authenticated administrators and does not retain a recording. A modified or independently operated host may behave differently; review the Host Operator’s notice and configuration.
|
||||
|
||||
You are responsible for having authority to capture and transmit audio or other information, including informing participants and obtaining consent where required.
|
||||
|
||||
4. NETWORK CONNECTIONS
|
||||
|
||||
Companion connects to:
|
||||
|
||||
- the host and exchange URL contained in the pairing package;
|
||||
- the paired Lumi host over secure HTTP and WebSocket connections, except for an exact matching loopback host where local unencrypted transport is permitted; and
|
||||
- an update artifact URL supplied by the paired Lumi host when you approve or initiate an update.
|
||||
|
||||
Companion does not include advertising or general-purpose analytics telemetry in the inspected release. It does not send data directly to OokamiKunTV merely because OokamiKunTV wrote the software. An independently operated host may add integrations or server-side logging outside Companion’s control.
|
||||
|
||||
5. PURPOSES AND LEGAL BASES
|
||||
|
||||
Local processing is performed to pair the device, remember settings, operate enabled features, diagnose failures, verify components, and apply approved updates.
|
||||
|
||||
The Host Operator determines the purposes and legal bases for server-side processing. Depending on the context, those bases may include performance of a contract, legitimate interests, consent, legal obligations, or another basis available under applicable law. Contact the Host Operator for the basis that applies to your use.
|
||||
|
||||
6. RETENTION AND DELETION
|
||||
|
||||
Local diagnostic logs follow the default limits described above. Credentials and settings remain until removed by the user, the application, or the operating system. Pairing tokens are designed to be single-use and expire after the period stated in the pairing package.
|
||||
|
||||
Server-side retention is controlled by the Host Operator and may differ from local retention. Deleting local data does not automatically delete data already sent to the host. Revoking a device on the host does not necessarily delete prior server-side logs or content.
|
||||
|
||||
7. SECURITY
|
||||
|
||||
Companion validates the pairing origin, normally requires HTTPS, protects the device credential with Windows user-scoped encryption, verifies update checksums, and keeps raw audio in bounded memory by default. No security measure is perfect. Keep Windows, OBS, Companion, and the Lumi host updated; protect the Windows account; and revoke devices that are lost, shared, or no longer trusted.
|
||||
|
||||
8. YOUR RIGHTS
|
||||
|
||||
Depending on applicable law, you may have rights to information, access, correction, deletion, restriction, objection, portability, withdrawal of consent, and complaint to a supervisory authority.
|
||||
|
||||
For data held by the paired Lumi host, direct requests to the Host Operator. For local data, use Companion controls where available, revoke the device from the host, and remove the local Companion data directory. For information actually received and controlled by OokamiKunTV, use the official Lumi project contact channel through which that processing occurred.
|
||||
|
||||
9. CHANGES
|
||||
|
||||
This notice may be updated when Companion’s data behaviour changes. The effective date identifies the version of the notice supplied with the installer. Material changes should be presented with a new release or through the relevant Host Operator.
|
||||
42
companion/legal/THIRD-PARTY-NOTICES.txt
Normal file
42
companion/legal/THIRD-PARTY-NOTICES.txt
Normal file
@ -0,0 +1,42 @@
|
||||
LUMI COMPANION THIRD-PARTY AND OPEN-SOURCE NOTICES
|
||||
Effective date: 23 July 2026
|
||||
|
||||
Lumi Companion contains or interoperates with software supplied under separate licences. Those licences govern the relevant components and take priority over the Lumi Companion Software Licence for those components.
|
||||
|
||||
1. Avalonia UI
|
||||
Copyright (c) AvaloniaUI OÜ. Licensed under the MIT License.
|
||||
Licence file: third-party\Avalonia-MIT.txt
|
||||
Project: https://github.com/AvaloniaUI/Avalonia
|
||||
|
||||
2. Microsoft .NET Runtime and libraries
|
||||
Copyright (c) .NET Foundation and Contributors. Licensed primarily under the MIT License and incorporating additional third-party material.
|
||||
Licence files: third-party\dotnet-MIT.txt and third-party\dotnet-THIRD-PARTY-NOTICES.txt
|
||||
Project: https://github.com/dotnet/runtime
|
||||
|
||||
The release build also generates third-party\nuget\NuGet-LICENCE-INVENTORY.txt from the exact restored package graph and copies package-supplied licence, notice, copying, and copyright files into adjacent package directories. This covers transitive packages and native assets, including Avalonia rendering dependencies, that are present in the actual build.
|
||||
|
||||
3. Inter font family
|
||||
Copyright (c) The Inter Project Authors. Licensed under the SIL Open Font License, Version 1.1.
|
||||
Licence file: third-party\Inter-OFL-1.1.txt
|
||||
Project: https://github.com/rsms/inter
|
||||
|
||||
4. nlohmann/json 3.12.0
|
||||
Copyright (c) 2013-2025 Niels Lohmann. Licensed primarily under the MIT License. The upstream project also identifies incorporated material under compatible licences, including Apache-2.0 and public-domain/CC0 material.
|
||||
Licence files: third-party\nlohmann-json-MIT.txt and third-party\Apache-2.0.txt
|
||||
Project: https://github.com/nlohmann/json
|
||||
|
||||
5. OBS Studio / libobs and Lumi OBS Bridge
|
||||
OBS Studio is distributed under the GNU General Public License, version 2 or any later version. Lumi Companion does not distribute OBS Studio itself, but the bundled Lumi OBS Bridge links with OBS/libobs and is distributed under GPL-2.0-or-later.
|
||||
Licence file: third-party\GPL-2.0-or-later.txt
|
||||
Corresponding source: Lumi-OBS-Bridge-Source.zip installed in the legal directory.
|
||||
OBS project: https://github.com/obsproject/obs-studio
|
||||
|
||||
6. Inno Setup
|
||||
The Windows installer was created with Inno Setup by Jordan Russell and Martijn Laan. Inno Setup is supplied under its own licence.
|
||||
Licence file: third-party\Inno-Setup-License.txt
|
||||
Project: https://jrsoftware.org/isinfo.php
|
||||
|
||||
7. Windows and third-party products
|
||||
Microsoft Windows, Windows Data Protection API, OBS Studio, and other named products are trademarks or products of their respective owners. Their inclusion or mention does not imply sponsorship or endorsement.
|
||||
|
||||
The source archive and licence files are provided to preserve the rights granted by the applicable open-source licences. If a required notice is believed to be missing, report it through the official Lumi project contact channel so it can be corrected.
|
||||
73
companion/legal/third-party/Apache-2.0.txt
vendored
Normal file
73
companion/legal/third-party/Apache-2.0.txt
vendored
Normal file
@ -0,0 +1,73 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
22
companion/legal/third-party/Avalonia-MIT.txt
vendored
Normal file
22
companion/legal/third-party/Avalonia-MIT.txt
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) AvaloniaUI OÜ
|
||||
All Rights Reserved
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
339
companion/legal/third-party/GPL-2.0-or-later.txt
vendored
Normal file
339
companion/legal/third-party/GPL-2.0-or-later.txt
vendored
Normal file
@ -0,0 +1,339 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
32
companion/legal/third-party/Inno-Setup-License.txt
vendored
Normal file
32
companion/legal/third-party/Inno-Setup-License.txt
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
Inno Setup License
|
||||
==================
|
||||
|
||||
Except where otherwise noted, all of the documentation and software included in the Inno
|
||||
Setup package is copyrighted by Jordan Russell.
|
||||
|
||||
Copyright (C) 1997-2026 Jordan Russell. All rights reserved.
|
||||
Portions Copyright (C) 2000-2026 Martijn Laan. All rights reserved.
|
||||
|
||||
This software is provided "as-is," without any express or implied warranty. In no event shall
|
||||
the author be held liable for any damages arising from the use of this software.
|
||||
|
||||
Permission is granted to anyone to use this software for any purpose, including commercial
|
||||
applications, and to alter and redistribute it, provided that the following conditions are met:
|
||||
|
||||
1. All redistributions of source code files must retain all copyright notices that are currently
|
||||
in place, and this list of conditions without modification.
|
||||
|
||||
2. All redistributions in binary form must retain all occurrences of the above copyright notice
|
||||
and web site addresses that are currently in place (for example, in the About boxes).
|
||||
|
||||
3. The origin of this software must not be misrepresented; you must not claim that you wrote
|
||||
the original software. If you use this software to distribute a product, an acknowledgment
|
||||
in the product documentation would be appreciated but is not required.
|
||||
|
||||
4. Modified versions in source or binary form must be plainly marked as such, and must not
|
||||
be misrepresented as being the original software.
|
||||
|
||||
|
||||
Jordan Russell
|
||||
jr-2020 AT jrsoftware.org
|
||||
https://jrsoftware.org/
|
||||
92
companion/legal/third-party/Inter-OFL-1.1.txt
vendored
Normal file
92
companion/legal/third-party/Inter-OFL-1.1.txt
vendored
Normal file
@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
23
companion/legal/third-party/dotnet-MIT.txt
vendored
Normal file
23
companion/legal/third-party/dotnet-MIT.txt
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) .NET Foundation and Contributors
|
||||
|
||||
All rights reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
1272
companion/legal/third-party/dotnet-THIRD-PARTY-NOTICES.txt
vendored
Normal file
1272
companion/legal/third-party/dotnet-THIRD-PARTY-NOTICES.txt
vendored
Normal file
File diff suppressed because it is too large
Load Diff
21
companion/legal/third-party/nlohmann-json-MIT.txt
vendored
Normal file
21
companion/legal/third-party/nlohmann-json-MIT.txt
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2013-2025 Niels Lohmann
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
39
companion/native/obs-bridge/CMakeLists.txt
Normal file
39
companion/native/obs-bridge/CMakeLists.txt
Normal file
@ -0,0 +1,39 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
# Copyright (c) 2026 OokamiKunTV
|
||||
|
||||
cmake_minimum_required(VERSION 3.28)
|
||||
project(lumi-obs-bridge VERSION 0.2.5 LANGUAGES CXX)
|
||||
set(LUMI_BRIDGE_VERSION "0.2.5-development" CACHE STRING "Lumi Companion bridge release version")
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.12.0/json.tar.xz
|
||||
URL_HASH SHA256=42f6e95cad6ec532fd372391373363b62a14af6d771056dbfc86160e6dfff7aa)
|
||||
FetchContent_MakeAvailable(json)
|
||||
|
||||
if(OBS_SOURCE_DIR AND OBS_IMPORT_DIR)
|
||||
set(OBS_DATA_PATH "data")
|
||||
set(OBS_PLUGIN_PATH "obs-plugins")
|
||||
set(OBS_PLUGIN_DESTINATION "obs-plugins/64bit")
|
||||
set(OBS_RELEASE_CANDIDATE 0)
|
||||
set(OBS_BETA 0)
|
||||
configure_file("${OBS_SOURCE_DIR}/libobs/obsconfig.h.in" "${CMAKE_CURRENT_BINARY_DIR}/config/obsconfig.h" @ONLY)
|
||||
add_library(obs-lib SHARED IMPORTED)
|
||||
set_target_properties(obs-lib PROPERTIES IMPORTED_IMPLIB "${OBS_IMPORT_DIR}/obs.lib")
|
||||
target_include_directories(obs-lib INTERFACE "${OBS_SOURCE_DIR}/libobs" "${CMAKE_CURRENT_BINARY_DIR}/config")
|
||||
add_library(obs-frontend SHARED IMPORTED)
|
||||
set_target_properties(obs-frontend PROPERTIES IMPORTED_IMPLIB "${OBS_IMPORT_DIR}/obs-frontend-api.lib")
|
||||
target_include_directories(obs-frontend INTERFACE "${OBS_SOURCE_DIR}/frontend/api")
|
||||
else()
|
||||
find_package(libobs REQUIRED)
|
||||
find_package(obs-frontend-api REQUIRED)
|
||||
add_library(obs-lib ALIAS OBS::libobs)
|
||||
add_library(obs-frontend ALIAS OBS::obs-frontend-api)
|
||||
endif()
|
||||
|
||||
add_library(lumi-obs-bridge MODULE src/plugin.cpp)
|
||||
target_include_directories(lumi-obs-bridge PRIVATE include)
|
||||
target_compile_definitions(lumi-obs-bridge PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN LUMI_BRIDGE_VERSION="${LUMI_BRIDGE_VERSION}")
|
||||
target_link_libraries(lumi-obs-bridge PRIVATE obs-lib obs-frontend nlohmann_json::nlohmann_json bcrypt)
|
||||
set_target_properties(lumi-obs-bridge PROPERTIES PREFIX "" OUTPUT_NAME "lumi-obs-bridge")
|
||||
339
companion/native/obs-bridge/COPYING
Normal file
339
companion/native/obs-bridge/COPYING
Normal file
@ -0,0 +1,339 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
9
companion/native/obs-bridge/README.md
Normal file
9
companion/native/obs-bridge/README.md
Normal file
@ -0,0 +1,9 @@
|
||||
# Lumi OBS Bridge
|
||||
|
||||
The Lumi OBS Bridge is the native OBS module bundled with Lumi Companion.
|
||||
|
||||
Copyright (c) 2026 OokamiKunTV.
|
||||
|
||||
This component is free software licensed under the GNU General Public License, version 2 or (at your option) any later version (`GPL-2.0-or-later`). See `COPYING` for the complete licence.
|
||||
|
||||
The release build includes a corresponding-source archive containing this directory and the build script used to produce the distributed binary. OBS Studio and nlohmann/json remain available from their upstream projects under their respective licences.
|
||||
2
companion/native/obs-bridge/data/locale/en-US.ini
Normal file
2
companion/native/obs-bridge/data/locale/en-US.ini
Normal file
@ -0,0 +1,2 @@
|
||||
Module.Name="Lumi Companion OBS Integration"
|
||||
Module.Description="Connects selected OBS audio sources and native captions to Lumi Companion."
|
||||
35
companion/native/obs-bridge/include/bounded_spsc_queue.hpp
Normal file
35
companion/native/obs-bridge/include/bounded_spsc_queue.hpp
Normal file
@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (c) 2026 OokamiKunTV
|
||||
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
|
||||
namespace lumi {
|
||||
template <typename T, std::size_t Capacity> class bounded_spsc_queue {
|
||||
static_assert(Capacity > 1);
|
||||
std::array<T, Capacity> values_{};
|
||||
alignas(64) std::atomic<std::size_t> head_{0};
|
||||
alignas(64) std::atomic<std::size_t> tail_{0};
|
||||
std::atomic<std::uint64_t> dropped_{0};
|
||||
public:
|
||||
bool try_push(T value) noexcept {
|
||||
const auto head = head_.load(std::memory_order_relaxed);
|
||||
const auto next = (head + 1) % Capacity;
|
||||
if (next == tail_.load(std::memory_order_acquire)) { dropped_.fetch_add(1, std::memory_order_relaxed); return false; }
|
||||
values_[head] = std::move(value);
|
||||
head_.store(next, std::memory_order_release);
|
||||
return true;
|
||||
}
|
||||
std::optional<T> try_pop() noexcept {
|
||||
const auto tail = tail_.load(std::memory_order_relaxed);
|
||||
if (tail == head_.load(std::memory_order_acquire)) return std::nullopt;
|
||||
T value = std::move(values_[tail]);
|
||||
tail_.store((tail + 1) % Capacity, std::memory_order_release);
|
||||
return value;
|
||||
}
|
||||
std::uint64_t dropped() const noexcept { return dropped_.load(std::memory_order_relaxed); }
|
||||
};
|
||||
}
|
||||
802
companion/native/obs-bridge/src/plugin.cpp
Normal file
802
companion/native/obs-bridge/src/plugin.cpp
Normal file
@ -0,0 +1,802 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (c) 2026 OokamiKunTV
|
||||
|
||||
#include <obs-module.h>
|
||||
#include <obs-frontend-api.h>
|
||||
#include <util/config-file.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <windows.h>
|
||||
#include <bcrypt.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "bounded_spsc_queue.hpp"
|
||||
|
||||
#pragma comment(lib, "bcrypt.lib")
|
||||
|
||||
#ifndef LUMI_BRIDGE_VERSION
|
||||
#define LUMI_BRIDGE_VERSION "0.1.0-development"
|
||||
#endif
|
||||
|
||||
OBS_DECLARE_MODULE()
|
||||
OBS_MODULE_USE_DEFAULT_LOCALE("lumi-obs-bridge", "en-US")
|
||||
|
||||
namespace {
|
||||
using json = nlohmann::json;
|
||||
constexpr uint8_t protocol_version = 1;
|
||||
constexpr size_t max_json_bytes = 64 * 1024;
|
||||
constexpr size_t max_input_frames = 4096;
|
||||
constexpr uint32_t output_sample_rate = 16000;
|
||||
constexpr char bridge_version[] = LUMI_BRIDGE_VERSION;
|
||||
|
||||
struct raw_audio_packet {
|
||||
std::array<float, max_input_frames> mono{};
|
||||
std::array<uint8_t, 16> source_uuid{};
|
||||
uint64_t timestamp_ns = 0;
|
||||
uint32_t frames = 0;
|
||||
uint32_t sample_rate = 48000;
|
||||
bool active = false;
|
||||
bool muted = false;
|
||||
};
|
||||
|
||||
struct source_description {
|
||||
std::string uuid;
|
||||
std::string name;
|
||||
bool program_active = false;
|
||||
};
|
||||
|
||||
static lumi::bounded_spsc_queue<raw_audio_packet, 32> audio_queue;
|
||||
static std::condition_variable worker_signal;
|
||||
static std::mutex worker_signal_mutex;
|
||||
static std::thread worker_thread;
|
||||
static std::atomic_bool stopping{false};
|
||||
static std::atomic_bool source_list_dirty{true};
|
||||
static std::atomic_bool obs_state_dirty{true};
|
||||
static std::atomic_uint32_t audio_sequence{0};
|
||||
static obs_source_t *captured_source = nullptr;
|
||||
static std::mutex captured_source_mutex;
|
||||
static std::mutex stream_test_mutex;
|
||||
static std::string stream_test_session;
|
||||
static obs_output_t *stream_test_output = nullptr;
|
||||
|
||||
static std::string wide_to_utf8(const std::wstring &value)
|
||||
{
|
||||
if (value.empty()) return {};
|
||||
const int bytes = WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0, nullptr, nullptr);
|
||||
std::string output(static_cast<size_t>(bytes), '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), output.data(), bytes, nullptr, nullptr);
|
||||
return output;
|
||||
}
|
||||
|
||||
static std::wstring environment_value(const wchar_t *name)
|
||||
{
|
||||
const DWORD needed = GetEnvironmentVariableW(name, nullptr, 0);
|
||||
if (!needed) return {};
|
||||
std::wstring value(needed - 1, L'\0');
|
||||
GetEnvironmentVariableW(name, value.data(), needed);
|
||||
return value;
|
||||
}
|
||||
|
||||
static std::string current_user_hash()
|
||||
{
|
||||
const auto identity = wide_to_utf8(environment_value(L"USERDOMAIN") + L"\\" + environment_value(L"USERNAME"));
|
||||
BCRYPT_ALG_HANDLE algorithm = nullptr;
|
||||
BCRYPT_HASH_HANDLE hash = nullptr;
|
||||
std::array<uint8_t, 32> digest{};
|
||||
if (BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA256_ALGORITHM, nullptr, 0) < 0) return {};
|
||||
DWORD object_size = 0, result_size = 0;
|
||||
BCryptGetProperty(algorithm, BCRYPT_OBJECT_LENGTH, reinterpret_cast<PUCHAR>(&object_size), sizeof(object_size), &result_size, 0);
|
||||
std::vector<uint8_t> object(object_size);
|
||||
const bool ok = BCryptCreateHash(algorithm, &hash, object.data(), object_size, nullptr, 0, 0) >= 0 &&
|
||||
BCryptHashData(hash, reinterpret_cast<PUCHAR>(const_cast<char *>(identity.data())), static_cast<ULONG>(identity.size()), 0) >= 0 &&
|
||||
BCryptFinishHash(hash, digest.data(), static_cast<ULONG>(digest.size()), 0) >= 0;
|
||||
if (hash) BCryptDestroyHash(hash);
|
||||
BCryptCloseAlgorithmProvider(algorithm, 0);
|
||||
if (!ok) return {};
|
||||
constexpr char hex[] = "0123456789ABCDEF";
|
||||
std::string output;
|
||||
output.reserve(16);
|
||||
for (size_t index = 0; index < 8; ++index) {
|
||||
output.push_back(hex[digest[index] >> 4]);
|
||||
output.push_back(hex[digest[index] & 0x0f]);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static bool parse_uuid(const std::string &value, std::array<uint8_t, 16> &output)
|
||||
{
|
||||
std::string hex;
|
||||
for (const auto character : value) if (character != '-') hex.push_back(character);
|
||||
if (hex.size() != 32) return false;
|
||||
const auto nibble = [](char value) -> int {
|
||||
if (value >= '0' && value <= '9') return value - '0';
|
||||
if (value >= 'a' && value <= 'f') return value - 'a' + 10;
|
||||
if (value >= 'A' && value <= 'F') return value - 'A' + 10;
|
||||
return -1;
|
||||
};
|
||||
for (size_t index = 0; index < output.size(); ++index) {
|
||||
const auto high = nibble(hex[index * 2]);
|
||||
const auto low = nibble(hex[index * 2 + 1]);
|
||||
if (high < 0 || low < 0) return false;
|
||||
output[index] = static_cast<uint8_t>((high << 4) | low);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static void write_u16(std::vector<uint8_t> &value, size_t offset, uint16_t input)
|
||||
{
|
||||
value[offset] = static_cast<uint8_t>(input);
|
||||
value[offset + 1] = static_cast<uint8_t>(input >> 8);
|
||||
}
|
||||
|
||||
static void write_u32(std::vector<uint8_t> &value, size_t offset, uint32_t input)
|
||||
{
|
||||
for (size_t index = 0; index < 4; ++index) value[offset + index] = static_cast<uint8_t>(input >> (index * 8));
|
||||
}
|
||||
|
||||
static void write_u64(std::vector<uint8_t> &value, size_t offset, uint64_t input)
|
||||
{
|
||||
for (size_t index = 0; index < 8; ++index) value[offset + index] = static_cast<uint8_t>(input >> (index * 8));
|
||||
}
|
||||
|
||||
static bool write_exact(HANDLE pipe, const uint8_t *data, size_t size)
|
||||
{
|
||||
while (size > 0) {
|
||||
DWORD written = 0;
|
||||
const auto chunk = static_cast<DWORD>(std::min<size_t>(size, 64 * 1024));
|
||||
if (!WriteFile(pipe, data, chunk, &written, nullptr) || written == 0) return false;
|
||||
data += written;
|
||||
size -= written;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool read_exact(HANDLE pipe, uint8_t *data, size_t size)
|
||||
{
|
||||
while (size > 0) {
|
||||
DWORD read = 0;
|
||||
if (!ReadFile(pipe, data, static_cast<DWORD>(size), &read, nullptr) || read == 0) return false;
|
||||
data += read;
|
||||
size -= read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool write_frame(HANDLE pipe, const uint8_t *data, size_t size)
|
||||
{
|
||||
std::array<uint8_t, 4> prefix{
|
||||
static_cast<uint8_t>(size), static_cast<uint8_t>(size >> 8),
|
||||
static_cast<uint8_t>(size >> 16), static_cast<uint8_t>(size >> 24)};
|
||||
return write_exact(pipe, prefix.data(), prefix.size()) && write_exact(pipe, data, size);
|
||||
}
|
||||
|
||||
static bool write_json(HANDLE pipe, const json &message)
|
||||
{
|
||||
const auto body = message.dump();
|
||||
return body.size() <= max_json_bytes && write_frame(pipe, reinterpret_cast<const uint8_t *>(body.data()), body.size());
|
||||
}
|
||||
|
||||
static bool scene_item_active(obs_scene_t *, obs_sceneitem_t *item, void *data);
|
||||
|
||||
static void collect_scene_sources(obs_source_t *scene_source, std::set<std::string> &active, std::set<std::string> &visited)
|
||||
{
|
||||
if (!scene_source) return;
|
||||
const char *uuid = obs_source_get_uuid(scene_source);
|
||||
if (uuid && !visited.insert(uuid).second) return;
|
||||
obs_scene_t *scene = obs_scene_from_source(scene_source);
|
||||
if (!scene) scene = obs_group_from_source(scene_source);
|
||||
if (scene) {
|
||||
std::pair values{&active, &visited};
|
||||
obs_scene_enum_items(scene, scene_item_active, &values);
|
||||
}
|
||||
}
|
||||
|
||||
static bool scene_item_active(obs_scene_t *, obs_sceneitem_t *item, void *data)
|
||||
{
|
||||
if (!obs_sceneitem_visible(item)) return true;
|
||||
auto &sets = *static_cast<std::pair<std::set<std::string> *, std::set<std::string> *> *>(data);
|
||||
obs_source_t *source = obs_sceneitem_get_source(item);
|
||||
if (!source) return true;
|
||||
if (const char *uuid = obs_source_get_uuid(source)) sets.first->insert(uuid);
|
||||
collect_scene_sources(source, *sets.first, *sets.second);
|
||||
return true;
|
||||
}
|
||||
|
||||
static std::set<std::string> program_sources()
|
||||
{
|
||||
std::set<std::string> active, visited;
|
||||
obs_source_t *scene = obs_frontend_get_current_scene();
|
||||
collect_scene_sources(scene, active, visited);
|
||||
if (scene) obs_source_release(scene);
|
||||
return active;
|
||||
}
|
||||
|
||||
static bool enumerate_source(void *data, obs_source_t *source)
|
||||
{
|
||||
if (!source || !(obs_source_get_output_flags(source) & OBS_SOURCE_AUDIO)) return true;
|
||||
auto &values = *static_cast<std::pair<std::vector<source_description> *, const std::set<std::string> *> *>(data);
|
||||
const char *uuid = obs_source_get_uuid(source);
|
||||
if (!uuid || !*uuid) return true;
|
||||
values.first->push_back({uuid, obs_source_get_name(source) ? obs_source_get_name(source) : "OBS source", values.second->contains(uuid)});
|
||||
return true;
|
||||
}
|
||||
|
||||
static std::vector<source_description> enumerate_sources()
|
||||
{
|
||||
const auto active = program_sources();
|
||||
std::vector<source_description> sources;
|
||||
std::pair values{&sources, &active};
|
||||
obs_enum_sources(enumerate_source, &values);
|
||||
std::sort(sources.begin(), sources.end(), [](const auto &left, const auto &right) { return left.name < right.name; });
|
||||
return sources;
|
||||
}
|
||||
|
||||
static void on_audio(void *, obs_source_t *source, const audio_data *audio, bool muted)
|
||||
{
|
||||
if (!audio || !source || audio->frames == 0 || audio->frames > max_input_frames) return;
|
||||
obs_audio_info info{};
|
||||
if (!obs_get_audio_info(&info) || !info.samples_per_sec) return;
|
||||
const auto channels = std::clamp<uint32_t>(get_audio_channels(info.speakers), 1, MAX_AUDIO_CHANNELS);
|
||||
raw_audio_packet packet;
|
||||
packet.frames = audio->frames;
|
||||
packet.sample_rate = info.samples_per_sec;
|
||||
packet.timestamp_ns = audio->timestamp;
|
||||
packet.active = obs_source_active(source);
|
||||
packet.muted = muted || obs_source_muted(source);
|
||||
const char *uuid = obs_source_get_uuid(source);
|
||||
if (!uuid || !parse_uuid(uuid, packet.source_uuid)) return;
|
||||
for (uint32_t frame = 0; frame < audio->frames; ++frame) {
|
||||
float sample = 0.0f;
|
||||
for (uint32_t channel = 0; channel < channels; ++channel) {
|
||||
if (audio->data[channel]) sample += reinterpret_cast<const float *>(audio->data[channel])[frame];
|
||||
}
|
||||
packet.mono[frame] = sample / static_cast<float>(channels);
|
||||
}
|
||||
if (audio_queue.try_push(std::move(packet))) worker_signal.notify_one();
|
||||
}
|
||||
|
||||
static void detach_source()
|
||||
{
|
||||
std::scoped_lock lock(captured_source_mutex);
|
||||
if (!captured_source) return;
|
||||
obs_source_remove_audio_capture_callback(captured_source, on_audio, nullptr);
|
||||
obs_source_release(captured_source);
|
||||
captured_source = nullptr;
|
||||
}
|
||||
|
||||
static bool select_source(const std::string &uuid)
|
||||
{
|
||||
detach_source();
|
||||
if (uuid.empty()) return true;
|
||||
obs_source_t *source = obs_get_source_by_uuid(uuid.c_str());
|
||||
if (!source || !(obs_source_get_output_flags(source) & OBS_SOURCE_AUDIO)) {
|
||||
if (source) obs_source_release(source);
|
||||
return false;
|
||||
}
|
||||
{
|
||||
std::scoped_lock lock(captured_source_mutex);
|
||||
captured_source = source;
|
||||
obs_source_add_audio_capture_callback(captured_source, on_audio, nullptr);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> encode_audio(const raw_audio_packet &packet)
|
||||
{
|
||||
const auto output_frames = std::min<uint32_t>(3200, static_cast<uint32_t>((static_cast<uint64_t>(packet.frames) * output_sample_rate) / packet.sample_rate));
|
||||
std::vector<uint8_t> frame(64 + static_cast<size_t>(output_frames) * 2, 0);
|
||||
std::memcpy(frame.data(), "LACP", 4);
|
||||
frame[4] = protocol_version;
|
||||
frame[5] = static_cast<uint8_t>((packet.active ? 1 : 0) | (packet.muted ? 2 : 0));
|
||||
write_u16(frame, 6, 64);
|
||||
write_u32(frame, 8, audio_sequence.fetch_add(1, std::memory_order_relaxed));
|
||||
write_u64(frame, 12, packet.timestamp_ns / 1000);
|
||||
std::copy(packet.source_uuid.begin(), packet.source_uuid.end(), frame.begin() + 36);
|
||||
write_u32(frame, 52, output_sample_rate);
|
||||
write_u16(frame, 56, 1);
|
||||
write_u16(frame, 58, 16);
|
||||
write_u32(frame, 60, output_frames * 2);
|
||||
for (uint32_t index = 0; index < output_frames; ++index) {
|
||||
const auto source_position = std::min<uint32_t>(packet.frames - 1, static_cast<uint32_t>((static_cast<uint64_t>(index) * packet.sample_rate) / output_sample_rate));
|
||||
const auto sample = static_cast<int16_t>(std::lrint(std::clamp(packet.mono[source_position], -1.0f, 1.0f) * 32767.0f));
|
||||
write_u16(frame, 64 + static_cast<size_t>(index) * 2, static_cast<uint16_t>(sample));
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
static json source_list_message()
|
||||
{
|
||||
json sources = json::array();
|
||||
for (const auto &source : enumerate_sources()) {
|
||||
sources.push_back({{"source_uuid", source.uuid}, {"display_name", source.name}, {"program_active", source.program_active}, {"source_missing", false}});
|
||||
}
|
||||
return {{"type", "source_list"}, {"protocol_version", protocol_version}, {"sources", std::move(sources)}};
|
||||
}
|
||||
|
||||
static json obs_state_message()
|
||||
{
|
||||
obs_video_info video{};
|
||||
obs_get_video_info(&video);
|
||||
return {{"type", "obs_state"}, {"protocol_version", protocol_version}, {"version", obs_get_version_string()},
|
||||
{"streaming", obs_frontend_streaming_active()}, {"recording", obs_frontend_recording_active()},
|
||||
{"output_width", video.output_width}, {"output_height", video.output_height},
|
||||
{"fps_num", video.fps_num}, {"fps_den", video.fps_den}};
|
||||
}
|
||||
|
||||
struct service_command_context {
|
||||
bool begin = false;
|
||||
std::string server;
|
||||
std::string key;
|
||||
std::string session_id;
|
||||
std::string restore_type;
|
||||
std::string restore_settings;
|
||||
bool ok = false;
|
||||
std::string error;
|
||||
std::string snapshot_type;
|
||||
std::string snapshot_settings;
|
||||
};
|
||||
|
||||
static bool restore_frontend_service(const std::string &type, const std::string &settings_json, std::string &error)
|
||||
{
|
||||
obs_data_t *settings = obs_data_create_from_json(settings_json.c_str());
|
||||
if (!settings) {
|
||||
error = "The encrypted OBS recovery snapshot is invalid.";
|
||||
return false;
|
||||
}
|
||||
obs_service_t *restored = obs_service_create(type.c_str(), "Restored streaming service", settings, nullptr);
|
||||
obs_data_release(settings);
|
||||
if (!restored) {
|
||||
error = "OBS could not restore the saved streaming service.";
|
||||
return false;
|
||||
}
|
||||
obs_frontend_set_streaming_service(restored);
|
||||
obs_frontend_save_streaming_service();
|
||||
obs_service_release(restored);
|
||||
return true;
|
||||
}
|
||||
|
||||
static obs_output_t *create_stream_test_output(obs_service_t *service, std::string &error)
|
||||
{
|
||||
config_t *profile = obs_frontend_get_profile_config();
|
||||
const char *mode = profile ? config_get_string(profile, "Output", "Mode") : nullptr;
|
||||
const bool advanced = mode && std::strcmp(mode, "Advanced") == 0;
|
||||
const char *configured_video = profile
|
||||
? config_get_string(profile, advanced ? "AdvOut" : "SimpleOutput", advanced ? "Encoder" : "StreamEncoder")
|
||||
: nullptr;
|
||||
const char *configured_audio = profile && advanced
|
||||
? config_get_string(profile, "AdvOut", "AudioEncoder")
|
||||
: "ffmpeg_aac";
|
||||
const char *video_id = configured_video && *configured_video ? configured_video : "obs_x264";
|
||||
const char *audio_id = configured_audio && *configured_audio ? configured_audio : "ffmpeg_aac";
|
||||
|
||||
obs_data_t *video_settings = obs_encoder_defaults(video_id);
|
||||
if (!video_settings) video_settings = obs_data_create();
|
||||
char *profile_path = obs_frontend_get_current_profile_path();
|
||||
if (profile_path) {
|
||||
const std::string encoder_path = std::string(profile_path) + "/streamEncoder.json";
|
||||
obs_data_t *saved = obs_data_create_from_json_file(encoder_path.c_str());
|
||||
if (saved) {
|
||||
obs_data_apply(video_settings, saved);
|
||||
obs_data_release(saved);
|
||||
}
|
||||
bfree(profile_path);
|
||||
}
|
||||
if (!obs_data_has_user_value(video_settings, "bitrate"))
|
||||
obs_data_set_int(video_settings, "bitrate", profile ? config_get_uint(profile, "SimpleOutput", "VBitrate") : 6000);
|
||||
obs_data_set_int(video_settings, "keyint_sec", 2);
|
||||
obs_encoder_t *video = obs_video_encoder_create(video_id, "Lumi private stream video", video_settings, nullptr);
|
||||
obs_data_release(video_settings);
|
||||
if (!video && std::strcmp(video_id, "obs_x264") != 0) {
|
||||
obs_data_t *fallback = obs_encoder_defaults("obs_x264");
|
||||
video = obs_video_encoder_create("obs_x264", "Lumi private stream video", fallback, nullptr);
|
||||
if (fallback) obs_data_release(fallback);
|
||||
}
|
||||
if (!video) {
|
||||
error = "OBS could not create a video encoder for the private output.";
|
||||
return nullptr;
|
||||
}
|
||||
obs_encoder_set_video(video, obs_get_video());
|
||||
if (advanced && profile && config_get_bool(profile, "AdvOut", "Rescale")) {
|
||||
const char *resolution = config_get_string(profile, "AdvOut", "RescaleRes");
|
||||
unsigned int width = 0, height = 0;
|
||||
if (resolution && std::sscanf(resolution, "%ux%u", &width, &height) == 2 && width && height)
|
||||
obs_encoder_set_scaled_size(video, width, height);
|
||||
}
|
||||
|
||||
obs_data_t *audio_settings = obs_encoder_defaults(audio_id);
|
||||
if (!audio_settings) audio_settings = obs_data_create();
|
||||
const uint64_t audio_bitrate = profile
|
||||
? config_get_uint(profile, advanced ? "AdvOut" : "SimpleOutput", advanced ? "Track1Bitrate" : "ABitrate")
|
||||
: 160;
|
||||
obs_data_set_int(audio_settings, "bitrate", audio_bitrate ? static_cast<long long>(audio_bitrate) : 160);
|
||||
obs_encoder_t *audio = obs_audio_encoder_create(audio_id, "Lumi private stream audio", audio_settings, 0, nullptr);
|
||||
obs_data_release(audio_settings);
|
||||
if (!audio && std::strcmp(audio_id, "ffmpeg_aac") != 0) {
|
||||
obs_data_t *fallback = obs_encoder_defaults("ffmpeg_aac");
|
||||
audio = obs_audio_encoder_create("ffmpeg_aac", "Lumi private stream audio", fallback, 0, nullptr);
|
||||
if (fallback) obs_data_release(fallback);
|
||||
}
|
||||
if (!audio) {
|
||||
obs_encoder_release(video);
|
||||
error = "OBS could not create an audio encoder for the private output.";
|
||||
return nullptr;
|
||||
}
|
||||
obs_encoder_set_audio(audio, obs_get_audio());
|
||||
|
||||
obs_data_t *output_settings = obs_output_defaults("rtmp_output");
|
||||
if (!output_settings) output_settings = obs_data_create();
|
||||
obs_data_set_string(output_settings, "bind_ip", "default");
|
||||
obs_data_set_bool(output_settings, "dyn_bitrate", false);
|
||||
obs_output_t *output = obs_output_create("rtmp_output", "Lumi private stream output", output_settings, nullptr);
|
||||
obs_data_release(output_settings);
|
||||
if (!output) {
|
||||
obs_encoder_release(audio);
|
||||
obs_encoder_release(video);
|
||||
error = "OBS could not create the private RTMP output.";
|
||||
return nullptr;
|
||||
}
|
||||
obs_output_set_video_encoder(output, video);
|
||||
obs_output_set_audio_encoder(output, audio, 0);
|
||||
obs_encoder_release(audio);
|
||||
obs_encoder_release(video);
|
||||
obs_output_set_service(output, service);
|
||||
obs_output_set_reconnect_settings(output, 0, 1);
|
||||
return output;
|
||||
}
|
||||
|
||||
static void apply_stream_test_service(void *opaque)
|
||||
{
|
||||
auto &context = *static_cast<service_command_context *>(opaque);
|
||||
if (context.begin) {
|
||||
if (obs_frontend_streaming_active()) {
|
||||
context.error = "OBS is already streaming. Stop the current output before starting a private test.";
|
||||
return;
|
||||
}
|
||||
obs_service_t *existing = obs_frontend_get_streaming_service();
|
||||
if (!existing) {
|
||||
context.error = "OBS does not have a streaming service to restore after the test.";
|
||||
return;
|
||||
}
|
||||
const char *type = obs_service_get_id(existing);
|
||||
obs_data_t *settings = obs_service_get_settings(existing);
|
||||
context.snapshot_type = type ? type : "";
|
||||
context.snapshot_settings = settings ? obs_data_get_json(settings) : "";
|
||||
if (settings) obs_data_release(settings);
|
||||
// obs_frontend_get_streaming_service returns a borrowed reference.
|
||||
// Releasing it here destroys OBS's current service; SetService then
|
||||
// releases the same object again while replacing it.
|
||||
if (context.snapshot_type.empty() || context.snapshot_settings.empty()) {
|
||||
context.error = "The current OBS stream service could not be snapshotted safely.";
|
||||
return;
|
||||
}
|
||||
|
||||
obs_data_t *test_settings = obs_data_create();
|
||||
obs_data_set_string(test_settings, "server", context.server.c_str());
|
||||
obs_data_set_string(test_settings, "key", context.key.c_str());
|
||||
obs_data_set_bool(test_settings, "use_auth", false);
|
||||
obs_service_t *test = obs_service_create("rtmp_custom", "Lumi private stream test", test_settings, nullptr);
|
||||
obs_data_release(test_settings);
|
||||
if (!test) {
|
||||
context.error = "OBS could not create the private test service.";
|
||||
return;
|
||||
}
|
||||
// Use a dedicated libobs output so platform OAuth cannot replace the
|
||||
// expiring private stream key. Point the frontend at the same private
|
||||
// service only as a fail-safe: pressing OBS's normal Start button
|
||||
// cannot accidentally publish to the saved public destination.
|
||||
obs_frontend_set_streaming_service(test);
|
||||
obs_output_t *output = create_stream_test_output(test, context.error);
|
||||
if (!output) {
|
||||
restore_frontend_service(context.snapshot_type, context.snapshot_settings, context.error);
|
||||
obs_service_release(test);
|
||||
return;
|
||||
}
|
||||
if (!obs_output_start(output)) {
|
||||
const char *error = obs_output_get_last_error(output);
|
||||
const std::string start_error = error && *error ? error : "OBS could not start the private output.";
|
||||
obs_output_release(output);
|
||||
restore_frontend_service(context.snapshot_type, context.snapshot_settings, context.error);
|
||||
obs_service_release(test);
|
||||
context.error = start_error;
|
||||
return;
|
||||
}
|
||||
obs_service_release(test);
|
||||
{
|
||||
std::scoped_lock lock(stream_test_mutex);
|
||||
if (stream_test_output) obs_output_release(stream_test_output);
|
||||
stream_test_output = output;
|
||||
stream_test_session = context.session_id;
|
||||
}
|
||||
context.ok = true;
|
||||
return;
|
||||
}
|
||||
|
||||
obs_output_t *output = nullptr;
|
||||
{
|
||||
std::scoped_lock lock(stream_test_mutex);
|
||||
output = stream_test_output;
|
||||
stream_test_output = nullptr;
|
||||
stream_test_session.clear();
|
||||
}
|
||||
if (output) {
|
||||
if (obs_output_active(output)) obs_output_force_stop(output);
|
||||
if (obs_output_active(output)) {
|
||||
std::scoped_lock lock(stream_test_mutex);
|
||||
stream_test_output = output;
|
||||
context.error = "The private OBS output is still stopping. Try restoration again.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (output) obs_output_release(output);
|
||||
context.ok = restore_frontend_service(context.restore_type, context.restore_settings, context.error);
|
||||
}
|
||||
|
||||
static json stream_test_metrics_message()
|
||||
{
|
||||
std::string session_id;
|
||||
obs_output_t *output = nullptr;
|
||||
{
|
||||
std::scoped_lock lock(stream_test_mutex);
|
||||
session_id = stream_test_session;
|
||||
if (stream_test_output) output = obs_output_get_ref(stream_test_output);
|
||||
}
|
||||
if (session_id.empty()) return json();
|
||||
obs_video_info video{};
|
||||
obs_get_video_info(&video);
|
||||
json value{{"type", "stream_test_metrics"}, {"protocol_version", protocol_version}, {"session_id", session_id},
|
||||
{"active", output && obs_output_active(output)}, {"width", video.output_width}, {"height", video.output_height},
|
||||
{"fps", video.fps_den ? static_cast<double>(video.fps_num) / video.fps_den : 0.0}};
|
||||
if (output) {
|
||||
value["total_bytes"] = obs_output_get_total_bytes(output);
|
||||
value["dropped_frames"] = obs_output_get_frames_dropped(output);
|
||||
value["total_frames"] = obs_output_get_total_frames(output);
|
||||
value["congestion"] = obs_output_get_congestion(output);
|
||||
obs_encoder_t *encoder = obs_output_get_video_encoder(output);
|
||||
value["encoder"] = encoder && obs_encoder_get_id(encoder) ? obs_encoder_get_id(encoder) : "";
|
||||
// obs_output_get_video_encoder returns a borrowed reference.
|
||||
obs_output_release(output);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static bool output_caption(const std::string &text, double display_seconds)
|
||||
{
|
||||
if (text.empty()) return false;
|
||||
obs_output_t *output = nullptr;
|
||||
{
|
||||
std::scoped_lock lock(stream_test_mutex);
|
||||
if (stream_test_output) output = obs_output_get_ref(stream_test_output);
|
||||
}
|
||||
if (!output) output = obs_frontend_get_streaming_output();
|
||||
if (!output) return false;
|
||||
if (!obs_output_active(output)) {
|
||||
obs_output_release(output);
|
||||
return false;
|
||||
}
|
||||
obs_output_output_caption_text2(output, text.c_str(), std::clamp(display_seconds, 0.5, 10.0));
|
||||
obs_output_release(output);
|
||||
return true;
|
||||
}
|
||||
|
||||
static std::optional<json> handle_command(const json &message)
|
||||
{
|
||||
const auto type = message.value("type", "");
|
||||
if (type == "select_sources") {
|
||||
const auto uuid = message.value("primary_source_uuid", "");
|
||||
const auto installed = select_source(uuid);
|
||||
blog(installed ? LOG_INFO : LOG_WARNING, "[Lumi Companion] event=audio_source_selection_changed %s selected OBS audio source %s",
|
||||
installed ? "Attached" : "Could not attach", uuid.c_str());
|
||||
return json{{"type", "selection_state"}, {"protocol_version", protocol_version}, {"source_uuid", uuid}, {"attached", installed}};
|
||||
} else if (type == "caption" && message.contains("payload")) {
|
||||
const auto &payload = message["payload"];
|
||||
const auto text = payload.value("stable_text", "");
|
||||
const auto duration = payload.value("display_seconds", 2.0);
|
||||
output_caption(text, duration);
|
||||
} else if (type == "stream_test_snapshot") {
|
||||
service_command_context context;
|
||||
obs_queue_task(OBS_TASK_UI, [](void *opaque) {
|
||||
auto &value = *static_cast<service_command_context *>(opaque);
|
||||
if (obs_frontend_streaming_active()) {
|
||||
value.error = "OBS is already streaming. Stop the current output before starting a private test.";
|
||||
return;
|
||||
}
|
||||
obs_service_t *existing = obs_frontend_get_streaming_service();
|
||||
if (!existing) {
|
||||
value.error = "OBS does not have a streaming service to restore after the test.";
|
||||
return;
|
||||
}
|
||||
const char *type_id = obs_service_get_id(existing);
|
||||
obs_data_t *settings = obs_service_get_settings(existing);
|
||||
value.snapshot_type = type_id ? type_id : "";
|
||||
value.snapshot_settings = settings ? obs_data_get_json(settings) : "";
|
||||
if (settings) obs_data_release(settings);
|
||||
// The frontend owns this borrowed service reference.
|
||||
value.ok = !value.snapshot_type.empty() && !value.snapshot_settings.empty();
|
||||
if (!value.ok) value.error = "The current OBS stream service could not be snapshotted safely.";
|
||||
}, &context, true);
|
||||
return json{{"type", "stream_test_service_state"}, {"protocol_version", protocol_version},
|
||||
{"request_id", message.value("request_id", "")}, {"state", context.ok ? "snapshotted" : "failed"},
|
||||
{"error", context.error}, {"snapshot", {{"service_type", context.snapshot_type}, {"settings_json", context.snapshot_settings}}}};
|
||||
} else if (type == "stream_test_begin" && message.contains("payload")) {
|
||||
const auto &payload = message["payload"];
|
||||
service_command_context context;
|
||||
context.begin = true;
|
||||
context.server = payload.value("server", "");
|
||||
context.key = payload.value("key", "");
|
||||
context.session_id = payload.value("session_id", "");
|
||||
if ((context.server.rfind("rtmp://", 0) != 0 && context.server.rfind("rtmps://", 0) != 0) ||
|
||||
context.server.size() > 1000 || context.key.empty() || context.key.size() > 1000 || context.session_id.size() > 80) {
|
||||
context.error = "The private ingest destination is invalid.";
|
||||
} else {
|
||||
obs_queue_task(OBS_TASK_UI, apply_stream_test_service, &context, true);
|
||||
}
|
||||
return json{{"type", "stream_test_service_state"}, {"protocol_version", protocol_version},
|
||||
{"request_id", message.value("request_id", "")}, {"state", context.ok ? "started" : "failed"},
|
||||
{"session_id", context.session_id}, {"error", context.error}};
|
||||
} else if (type == "stream_test_restore" && message.contains("payload")) {
|
||||
const auto &payload = message["payload"];
|
||||
service_command_context context;
|
||||
context.restore_type = payload.value("service_type", "");
|
||||
context.restore_settings = payload.value("settings_json", "");
|
||||
context.session_id = payload.value("session_id", "");
|
||||
if (context.restore_type.empty() || context.restore_type.size() > 200 || context.restore_settings.empty() ||
|
||||
context.restore_settings.size() > max_json_bytes / 2) {
|
||||
context.error = "The OBS recovery snapshot is incomplete.";
|
||||
} else {
|
||||
obs_queue_task(OBS_TASK_UI, apply_stream_test_service, &context, true);
|
||||
}
|
||||
return json{{"type", "stream_test_service_state"}, {"protocol_version", protocol_version},
|
||||
{"request_id", message.value("request_id", "")}, {"state", context.ok ? "restored" : "failed"},
|
||||
{"session_id", context.session_id}, {"error", context.error}};
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
static bool read_available_command(HANDLE pipe)
|
||||
{
|
||||
DWORD available = 0;
|
||||
if (!PeekNamedPipe(pipe, nullptr, 0, nullptr, &available, nullptr)) return false;
|
||||
if (available < 4) return true;
|
||||
std::array<uint8_t, 4> prefix{};
|
||||
if (!read_exact(pipe, prefix.data(), prefix.size())) return false;
|
||||
const uint32_t size = static_cast<uint32_t>(prefix[0]) | (static_cast<uint32_t>(prefix[1]) << 8) |
|
||||
(static_cast<uint32_t>(prefix[2]) << 16) | (static_cast<uint32_t>(prefix[3]) << 24);
|
||||
if (size == 0 || size > max_json_bytes) return false;
|
||||
std::vector<uint8_t> body(size);
|
||||
if (!read_exact(pipe, body.data(), body.size())) return false;
|
||||
try {
|
||||
const auto response = handle_command(json::parse(body.begin(), body.end()));
|
||||
if (response && !write_json(pipe, *response)) return false;
|
||||
}
|
||||
catch (const std::exception &error) { blog(LOG_WARNING, "[Lumi Companion] event=ipc_command_rejected Ignored invalid IPC command: %s", error.what()); }
|
||||
return true;
|
||||
}
|
||||
|
||||
static HANDLE connect_pipe()
|
||||
{
|
||||
const auto hash = current_user_hash();
|
||||
if (hash.empty()) return INVALID_HANDLE_VALUE;
|
||||
const auto name = L"\\\\.\\pipe\\Lumi.Companion.ObsBridge.v1." + std::wstring(hash.begin(), hash.end());
|
||||
return CreateFileW(name.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
}
|
||||
|
||||
static void run_pipe_worker()
|
||||
{
|
||||
while (!stopping.load(std::memory_order_acquire)) {
|
||||
HANDLE pipe = connect_pipe();
|
||||
if (pipe == INVALID_HANDLE_VALUE) {
|
||||
std::unique_lock lock(worker_signal_mutex);
|
||||
worker_signal.wait_for(lock, std::chrono::seconds(2));
|
||||
continue;
|
||||
}
|
||||
blog(LOG_INFO, "[Lumi Companion] event=ipc_connected connected to the same-user Companion IPC endpoint");
|
||||
bool connected = write_json(pipe, {{"type", "hello"}, {"protocol_version", protocol_version},
|
||||
{"obs_version", obs_get_version_string()}, {"bridge_version", bridge_version},
|
||||
{"obs_process_id", static_cast<uint32_t>(GetCurrentProcessId())}});
|
||||
source_list_dirty.store(true, std::memory_order_release);
|
||||
obs_state_dirty.store(true, std::memory_order_release);
|
||||
auto health_sent = std::chrono::steady_clock::now();
|
||||
auto stream_metrics_sent = std::chrono::steady_clock::now();
|
||||
while (connected && !stopping.load(std::memory_order_acquire)) {
|
||||
connected = read_available_command(pipe);
|
||||
if (connected && source_list_dirty.exchange(false, std::memory_order_acq_rel)) connected = write_json(pipe, source_list_message());
|
||||
if (connected && obs_state_dirty.exchange(false, std::memory_order_acq_rel)) connected = write_json(pipe, obs_state_message());
|
||||
while (connected) {
|
||||
auto packet = audio_queue.try_pop();
|
||||
if (!packet) break;
|
||||
const auto encoded = encode_audio(*packet);
|
||||
connected = write_frame(pipe, encoded.data(), encoded.size());
|
||||
}
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (connected && now - health_sent >= std::chrono::seconds(10)) {
|
||||
connected = write_json(pipe, {{"type", "health"}, {"protocol_version", protocol_version},
|
||||
{"status", "healthy"}, {"audio_frames_dropped", audio_queue.dropped()}});
|
||||
health_sent = now;
|
||||
}
|
||||
if (connected && now - stream_metrics_sent >= std::chrono::seconds(1)) {
|
||||
const auto metrics = stream_test_metrics_message();
|
||||
if (!metrics.empty()) connected = write_json(pipe, metrics);
|
||||
stream_metrics_sent = now;
|
||||
}
|
||||
std::unique_lock lock(worker_signal_mutex);
|
||||
worker_signal.wait_for(lock, std::chrono::milliseconds(10));
|
||||
}
|
||||
CloseHandle(pipe);
|
||||
blog(LOG_INFO, "[Lumi Companion] event=ipc_disconnected disconnected from Companion IPC; retrying safely");
|
||||
}
|
||||
}
|
||||
|
||||
static void frontend_event(obs_frontend_event event, void *)
|
||||
{
|
||||
switch (event) {
|
||||
case OBS_FRONTEND_EVENT_STREAMING_STARTED:
|
||||
case OBS_FRONTEND_EVENT_STREAMING_STOPPED:
|
||||
case OBS_FRONTEND_EVENT_RECORDING_STARTED:
|
||||
case OBS_FRONTEND_EVENT_RECORDING_STOPPED:
|
||||
obs_state_dirty.store(true, std::memory_order_release);
|
||||
break;
|
||||
case OBS_FRONTEND_EVENT_SCENE_CHANGED:
|
||||
case OBS_FRONTEND_EVENT_SCENE_LIST_CHANGED:
|
||||
case OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGED:
|
||||
case OBS_FRONTEND_EVENT_FINISHED_LOADING:
|
||||
source_list_dirty.store(true, std::memory_order_release);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
worker_signal.notify_one();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
const char *obs_module_description(void)
|
||||
{
|
||||
return "Companion-managed Lumi audio and native-caption bridge";
|
||||
}
|
||||
|
||||
bool obs_module_load(void)
|
||||
{
|
||||
const char *version = obs_get_version_string();
|
||||
const int major = version ? std::atoi(version) : 0;
|
||||
if (major < 31) {
|
||||
blog(LOG_ERROR, "[Lumi Companion] event=obs_version_unsupported OBS %s is unsupported; version 31 or newer is required", version ? version : "unknown");
|
||||
return false;
|
||||
}
|
||||
stopping.store(false, std::memory_order_release);
|
||||
obs_frontend_add_event_callback(frontend_event, nullptr);
|
||||
worker_thread = std::thread(run_pipe_worker);
|
||||
blog(LOG_INFO, "[Lumi Companion] event=bridge_loaded bridge %s loaded", bridge_version);
|
||||
return true;
|
||||
}
|
||||
|
||||
void obs_module_unload(void)
|
||||
{
|
||||
obs_frontend_remove_event_callback(frontend_event, nullptr);
|
||||
detach_source();
|
||||
{
|
||||
std::scoped_lock lock(stream_test_mutex);
|
||||
if (stream_test_output) {
|
||||
if (obs_output_active(stream_test_output)) obs_output_force_stop(stream_test_output);
|
||||
obs_output_release(stream_test_output);
|
||||
stream_test_output = nullptr;
|
||||
}
|
||||
stream_test_session.clear();
|
||||
}
|
||||
stopping.store(true, std::memory_order_release);
|
||||
worker_signal.notify_all();
|
||||
if (worker_thread.joinable()) worker_thread.join();
|
||||
blog(LOG_INFO, "[Lumi Companion] event=bridge_unloaded bridge unloaded");
|
||||
}
|
||||
40
companion/plugins/Lumi.Companion.Overlay/AnchorLayout.cs
Normal file
40
companion/plugins/Lumi.Companion.Overlay/AnchorLayout.cs
Normal file
@ -0,0 +1,40 @@
|
||||
namespace Lumi.Companion.Overlay;
|
||||
|
||||
public readonly record struct PhysicalRect(int X, int Y, int Width, int Height);
|
||||
|
||||
public static class AnchorLayout
|
||||
{
|
||||
public static void ClampPadding(OverlayContainerSettings settings)
|
||||
{
|
||||
var top = settings.Anchor is OverlayAnchor.TopLeft or OverlayAnchor.Top or OverlayAnchor.TopRight or OverlayAnchor.Left or OverlayAnchor.Right;
|
||||
var bottom = settings.Anchor is OverlayAnchor.BottomLeft or OverlayAnchor.Bottom or OverlayAnchor.BottomRight or OverlayAnchor.Left or OverlayAnchor.Right;
|
||||
var left = settings.Anchor is OverlayAnchor.TopLeft or OverlayAnchor.Left or OverlayAnchor.BottomLeft or OverlayAnchor.Top or OverlayAnchor.Bottom;
|
||||
var right = settings.Anchor is OverlayAnchor.TopRight or OverlayAnchor.Right or OverlayAnchor.BottomRight or OverlayAnchor.Top or OverlayAnchor.Bottom;
|
||||
if (!top) settings.PaddingTop = 0;
|
||||
if (!right) settings.PaddingRight = 0;
|
||||
if (!bottom) settings.PaddingBottom = 0;
|
||||
if (!left) settings.PaddingLeft = 0;
|
||||
}
|
||||
|
||||
public static PhysicalRect Calculate(OverlayContainerSettings value, int monitorWidth, int monitorHeight)
|
||||
{
|
||||
ClampPadding(value);
|
||||
var availableWidth = Math.Max(1, monitorWidth - value.PaddingLeft - value.PaddingRight);
|
||||
var availableHeight = Math.Max(1, monitorHeight - value.PaddingTop - value.PaddingBottom);
|
||||
var width = Math.Clamp(value.Width, 1, availableWidth);
|
||||
var height = Math.Clamp(value.Height, 1, availableHeight);
|
||||
var x = value.Anchor switch
|
||||
{
|
||||
OverlayAnchor.TopRight or OverlayAnchor.Right or OverlayAnchor.BottomRight => monitorWidth - value.PaddingRight - width,
|
||||
OverlayAnchor.Top or OverlayAnchor.Bottom => value.PaddingLeft + (availableWidth - width) / 2,
|
||||
_ => value.PaddingLeft
|
||||
};
|
||||
var y = value.Anchor switch
|
||||
{
|
||||
OverlayAnchor.BottomLeft or OverlayAnchor.Bottom or OverlayAnchor.BottomRight => monitorHeight - value.PaddingBottom - height,
|
||||
OverlayAnchor.Left or OverlayAnchor.Right => value.PaddingTop + (availableHeight - height) / 2,
|
||||
_ => value.PaddingTop
|
||||
};
|
||||
return new(x, y, width, height);
|
||||
}
|
||||
}
|
||||
105
companion/plugins/Lumi.Companion.Overlay/GlobalHotkeyManager.cs
Normal file
105
companion/plugins/Lumi.Companion.Overlay/GlobalHotkeyManager.cs
Normal file
@ -0,0 +1,105 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Lumi.Companion.Overlay;
|
||||
|
||||
public sealed class GlobalHotkeyManager : IDisposable
|
||||
{
|
||||
private const uint WmHotkey = 0x0312;
|
||||
private const uint WmQuit = 0x0012;
|
||||
private readonly Dictionary<int, Action> _actions = [];
|
||||
private readonly ManualResetEventSlim _ready = new();
|
||||
private readonly Thread _thread;
|
||||
private IntPtr _window;
|
||||
private uint _threadId;
|
||||
private int _nextId = 0x4C00;
|
||||
|
||||
public GlobalHotkeyManager()
|
||||
{
|
||||
_thread = new Thread(MessageLoop) { IsBackground = true, Name = "Lumi Overlay hotkeys" };
|
||||
_thread.Start();
|
||||
_ready.Wait(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
|
||||
public bool Register(string chord, Action action)
|
||||
{
|
||||
if (!TryParse(chord, out var modifiers, out var key) || _window == IntPtr.Zero) return false;
|
||||
var id = Interlocked.Increment(ref _nextId);
|
||||
if (!RegisterHotKey(_window, id, modifiers | 0x4000, key)) return false;
|
||||
lock (_actions) _actions[id] = action;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_actions)
|
||||
{
|
||||
foreach (var id in _actions.Keys) UnregisterHotKey(_window, id);
|
||||
_actions.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void MessageLoop()
|
||||
{
|
||||
_threadId = GetCurrentThreadId();
|
||||
_window = CreateWindowEx(0, "STATIC", "LumiOverlayHotkeys", 0, 0, 0, 0, 0, new IntPtr(-3), IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
|
||||
_ready.Set();
|
||||
while (GetMessage(out var message, IntPtr.Zero, 0, 0) > 0)
|
||||
{
|
||||
if (message.message == WmHotkey)
|
||||
{
|
||||
Action? action;
|
||||
lock (_actions) _actions.TryGetValue(message.wParam.ToInt32(), out action);
|
||||
try { action?.Invoke(); } catch { }
|
||||
}
|
||||
TranslateMessage(ref message);
|
||||
DispatchMessage(ref message);
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryParse(string chord, out uint modifiers, out uint key)
|
||||
{
|
||||
modifiers = 0;
|
||||
key = 0;
|
||||
var parts = (chord ?? string.Empty).Split('+', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (part.Equals("Ctrl", StringComparison.OrdinalIgnoreCase)) modifiers |= 0x0002;
|
||||
else if (part.Equals("Alt", StringComparison.OrdinalIgnoreCase)) modifiers |= 0x0001;
|
||||
else if (part.Equals("Shift", StringComparison.OrdinalIgnoreCase)) modifiers |= 0x0004;
|
||||
else if (part.Equals("Win", StringComparison.OrdinalIgnoreCase)) modifiers |= 0x0008;
|
||||
else if (part.Length == 1 && char.IsAsciiLetterOrDigit(part[0])) key = char.ToUpperInvariant(part[0]);
|
||||
else if (part.StartsWith('F') && int.TryParse(part[1..], out var function) && function is >= 1 and <= 24) key = (uint)(0x70 + function - 1);
|
||||
else if (VirtualKeys.TryGetValue(part, out var virtualKey)) key = virtualKey;
|
||||
else return false;
|
||||
}
|
||||
return key != 0;
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, uint> VirtualKeys = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Backspace"] = 0x08, ["Tab"] = 0x09, ["Enter"] = 0x0D, ["Pause"] = 0x13,
|
||||
["CapsLock"] = 0x14, ["Escape"] = 0x1B, ["Space"] = 0x20, ["PageUp"] = 0x21,
|
||||
["PageDown"] = 0x22, ["End"] = 0x23, ["Home"] = 0x24, ["Left"] = 0x25,
|
||||
["Up"] = 0x26, ["Right"] = 0x27, ["Down"] = 0x28, ["Insert"] = 0x2D,
|
||||
["Delete"] = 0x2E, ["NumLock"] = 0x90, ["ScrollLock"] = 0x91
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Clear();
|
||||
if (_threadId != 0) PostThreadMessage(_threadId, WmQuit, IntPtr.Zero, IntPtr.Zero);
|
||||
_thread.Join(TimeSpan.FromSeconds(1));
|
||||
_ready.Dispose();
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct Msg { public IntPtr hwnd; public uint message; public IntPtr wParam; public IntPtr lParam; public uint time; public int x; public int y; }
|
||||
[DllImport("user32.dll", SetLastError = true)] private static extern bool RegisterHotKey(IntPtr hwnd, int id, uint modifiers, uint key);
|
||||
[DllImport("user32.dll", SetLastError = true)] private static extern bool UnregisterHotKey(IntPtr hwnd, int id);
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern IntPtr CreateWindowEx(uint exStyle, string className, string windowName, uint style, int x, int y, int width, int height, IntPtr parent, IntPtr menu, IntPtr instance, IntPtr parameter);
|
||||
[DllImport("user32.dll")] private static extern int GetMessage(out Msg message, IntPtr hwnd, uint min, uint max);
|
||||
[DllImport("user32.dll")] private static extern bool TranslateMessage(ref Msg message);
|
||||
[DllImport("user32.dll")] private static extern IntPtr DispatchMessage(ref Msg message);
|
||||
[DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId();
|
||||
[DllImport("user32.dll")] private static extern bool PostThreadMessage(uint threadId, uint message, IntPtr wParam, IntPtr lParam);
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>Lumi.Companion.Overlay</AssemblyName>
|
||||
<RootNamespace>Lumi.Companion.Overlay</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/Lumi.Companion.Abstractions/Lumi.Companion.Abstractions.csproj" />
|
||||
<PackageReference Include="Avalonia" Version="12.1.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
|
||||
<PackageReference Include="Microsoft.Win32.SystemEvents" Version="8.0.0" />
|
||||
<PackageReference Include="SkiaSharp" Version="3.119.4" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
292
companion/plugins/Lumi.Companion.Overlay/LumiOverlayRuntime.cs
Normal file
292
companion/plugins/Lumi.Companion.Overlay/LumiOverlayRuntime.cs
Normal file
@ -0,0 +1,292 @@
|
||||
using System.Text.Json;
|
||||
using Avalonia.Threading;
|
||||
using Lumi.Companion.Abstractions;
|
||||
|
||||
namespace Lumi.Companion.Overlay;
|
||||
|
||||
public sealed class LumiOverlayRuntime : ICompanionPluginContribution, IAsyncDisposable
|
||||
{
|
||||
private const string PluginId = "lumi_overlay";
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly OverlaySettingsStore _store;
|
||||
private readonly ICompanionPluginTransport _transport;
|
||||
private readonly NativeOverlayWindow _window;
|
||||
private readonly GlobalHotkeyManager _hotkeys;
|
||||
private readonly CancellationTokenSource _lifetime = new();
|
||||
private readonly List<OverlaySource> _catalog = [];
|
||||
private readonly List<OverlayEventType> _eventCatalog = [];
|
||||
private readonly List<OverlayFeedStatus> _feedStatuses = [];
|
||||
private ICompanionPluginLiveChannel? _channel;
|
||||
private Task? _feedTask;
|
||||
private bool? _visibilityOverride;
|
||||
private bool _preview;
|
||||
private bool _initialized;
|
||||
private string _hotkeyStatus = "Hotkeys are not registered yet.";
|
||||
|
||||
public LumiOverlayRuntime(string dataDirectory, ICompanionPluginTransport transport)
|
||||
{
|
||||
Directory.CreateDirectory(dataDirectory);
|
||||
_store = new OverlaySettingsStore(Path.Combine(dataDirectory, "settings.json"));
|
||||
_transport = transport;
|
||||
_window = new NativeOverlayWindow();
|
||||
_window.NativeStatusChanged += () => RaiseChanged();
|
||||
_window.EditSettingsChanged += () => RaiseChanged();
|
||||
_hotkeys = new GlobalHotkeyManager();
|
||||
Actions =
|
||||
[
|
||||
new("visibility", () => VisibilityPresentation.Action, _ => { ToggleVisibilityOverride(); return Task.CompletedTask; }, () => _initialized && VisibilityPresentation.CanToggle, 10),
|
||||
new("preview", () => _preview ? "End configuration preview" : "Start configuration preview", _ => { TogglePreview(); return Task.CompletedTask; }, () => _initialized, 20)
|
||||
];
|
||||
}
|
||||
|
||||
public CompanionPluginDescriptor Descriptor { get; } = new(PluginId, "Lumi Overlay", new Version(0, 1, 2),
|
||||
"Native, capture-excluded monitor overlay for Lumi chat and stream events.", 150);
|
||||
public IReadOnlyList<CompanionPluginPage> Pages { get; } = [new("LumiOverlay", "Overview & settings", 10)];
|
||||
public IReadOnlyList<CompanionPluginAction> Actions { get; }
|
||||
public CompanionPluginStatus Status { get; private set; } = new(CompanionPluginHealth.Ready, "Starting", "Loading local overlay settings.");
|
||||
public LumiOverlaySettings Settings => _store.Current;
|
||||
public IReadOnlyList<OverlaySource> Sources { get { lock (_catalog) return _catalog.ToList(); } }
|
||||
public IReadOnlyList<OverlayEventType> AvailableEventTypes { get { lock (_catalog) return _eventCatalog.ToList(); } }
|
||||
public IReadOnlyList<OverlayFeedStatus> FeedStatuses { get { lock (_catalog) return _feedStatuses.ToList(); } }
|
||||
public CaptureExclusionStatus CaptureExclusion => _window.CaptureExclusion;
|
||||
public string? MonitorWarning => _window.MonitorWarning;
|
||||
public IReadOnlyList<(string Id, string Label)> Monitors => _window.Monitors();
|
||||
public bool PreviewActive => _preview;
|
||||
public bool EditModeActive { get; private set; }
|
||||
public bool VisibilityOverride => _visibilityOverride.HasValue;
|
||||
public bool IsInitialized => _initialized;
|
||||
public string HotkeyStatus => _hotkeyStatus;
|
||||
public OverlayVisibilityPresentation VisibilityPresentation =>
|
||||
OverlayVisibilityPresenter.Describe(Settings, AutomaticVisibility(), _preview, _visibilityOverride);
|
||||
public event Action? Changed;
|
||||
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _store.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
_initialized = true;
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
_window.ApplySettings(Settings);
|
||||
ConfigureHotkeys();
|
||||
ApplyVisibility();
|
||||
});
|
||||
_feedTask = _transport.RunLiveJsonAsync("/plugins/lumi_overlay/live", RunConnectedAsync, OnLiveStatus, _lifetime.Token);
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
public Task SaveSettingsAsync(CancellationToken cancellationToken = default) =>
|
||||
SaveSettingsAsync(Settings, cancellationToken);
|
||||
|
||||
public async Task SaveSettingsAsync(LumiOverlaySettings settings, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _store.SaveAsync(settings, cancellationToken).ConfigureAwait(false);
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
_window.ApplySettings(Settings);
|
||||
ConfigureHotkeys();
|
||||
ApplyVisibility();
|
||||
});
|
||||
await SendSubscriptionAsync(cancellationToken).ConfigureAwait(false);
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
public void ToggleVisibilityOverride()
|
||||
{
|
||||
if (!VisibilityPresentation.CanToggle) return;
|
||||
_visibilityOverride = _visibilityOverride.HasValue ? null : !AutomaticVisibility();
|
||||
Dispatcher.UIThread.Post(ApplyVisibility);
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
public void TogglePreview()
|
||||
{
|
||||
_preview = !_preview;
|
||||
if (_preview)
|
||||
{
|
||||
_window.AddChat(new OverlayFeedMessage
|
||||
{
|
||||
Id = $"sample-chat-{Guid.NewGuid():N}", Platform = "twitch", Text = "This is a native Lumi Overlay preview ✨",
|
||||
Author = new() { Name = "Lumi viewer", Badges = [new() { Label = "SUB" }] }
|
||||
});
|
||||
_window.AddEvent(new($"sample-event-{Guid.NewGuid():N}", "twitch.follow", "twitch", "A new viewer followed the channel", default));
|
||||
}
|
||||
else _window.EndPreview();
|
||||
Dispatcher.UIThread.Post(ApplyVisibility);
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
public void BeginEdit(LumiOverlaySettings draft)
|
||||
{
|
||||
EditModeActive = true;
|
||||
_window.BeginEdit(draft);
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
public void EndEdit()
|
||||
{
|
||||
if (!EditModeActive) return;
|
||||
EditModeActive = false;
|
||||
_window.EndEdit();
|
||||
_window.ApplySettings(Settings);
|
||||
ApplyVisibility();
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
private async Task RunConnectedAsync(ICompanionPluginLiveChannel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
_channel = channel;
|
||||
await SendSubscriptionAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await foreach (var json in channel.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
var type = root.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null;
|
||||
if (type is "hello" or "catalog") UpdateCatalog(root);
|
||||
else if (type == "chat") HandleChat(root);
|
||||
else if (type == "event") HandleEvent(root);
|
||||
else if (type == "ping") await channel.SendJsonAsync(new { type = "pong" }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_channel, channel)) _channel = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateCatalog(JsonElement root)
|
||||
{
|
||||
var sources = new List<OverlaySource>();
|
||||
if (root.TryGetProperty("sources", out var sourceValues))
|
||||
foreach (var value in sourceValues.EnumerateArray())
|
||||
sources.Add(new(
|
||||
Read(value, "id"), Read(value, "platform"), Read(value, "label"),
|
||||
value.TryGetProperty("live", out var live) && live.ValueKind is JsonValueKind.True or JsonValueKind.False ? live.GetBoolean() : null,
|
||||
!value.TryGetProperty("available", out var available) || available.GetBoolean(), Read(value, "status")));
|
||||
var eventTypes = new List<OverlayEventType>();
|
||||
if (root.TryGetProperty("event_types", out var eventValues))
|
||||
foreach (var value in eventValues.EnumerateArray())
|
||||
eventTypes.Add(new(Read(value, "id"), Read(value, "label"), Read(value, "platform")));
|
||||
var statuses = new List<OverlayFeedStatus>();
|
||||
if (root.TryGetProperty("statuses", out var statusValues))
|
||||
foreach (var value in statusValues.EnumerateArray())
|
||||
statuses.Add(new(Read(value, "platform"), Read(value, "state"), Read(value, "detail")));
|
||||
lock (_catalog)
|
||||
{
|
||||
if (sources.Count > 0) { _catalog.Clear(); _catalog.AddRange(sources); }
|
||||
if (eventTypes.Count > 0) { _eventCatalog.Clear(); _eventCatalog.AddRange(eventTypes); }
|
||||
if (statuses.Count > 0) { _feedStatuses.Clear(); _feedStatuses.AddRange(statuses); }
|
||||
}
|
||||
Dispatcher.UIThread.Post(ApplyVisibility);
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
private void HandleChat(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("message", out var value)) return;
|
||||
var message = value.Deserialize<OverlayFeedMessage>(JsonOptions);
|
||||
if (message is null || string.IsNullOrWhiteSpace(message.Id)) return;
|
||||
Dispatcher.UIThread.Post(() => _window.AddChat(message));
|
||||
}
|
||||
|
||||
private void HandleEvent(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("event", out var value)) return;
|
||||
var id = Read(value, "id");
|
||||
var type = Read(value, "type");
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(type)) return;
|
||||
var platform = type.Split('.')[0];
|
||||
var payload = value.TryGetProperty("payload", out var payloadValue) ? payloadValue.Clone() : default;
|
||||
var summary = EventSummary(type, payload);
|
||||
Dispatcher.UIThread.Post(() => _window.AddEvent(new(id, type, platform, summary, payload)));
|
||||
}
|
||||
|
||||
private async Task SendSubscriptionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var channel = _channel;
|
||||
if (channel is null) return;
|
||||
await channel.SendJsonAsync(new
|
||||
{
|
||||
type = "subscribe",
|
||||
sources = Settings.SelectedSourceIds.ToArray(),
|
||||
events = Settings.EventTypes.ToArray()
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void ApplyVisibility()
|
||||
{
|
||||
_window.SetRequestedVisible(EditModeActive || OverlayVisibilityPolicy.Effective(Settings, Sources, _preview, _visibilityOverride));
|
||||
}
|
||||
|
||||
private bool AutomaticVisibility() => OverlayVisibilityPolicy.Automatic(Settings, Sources);
|
||||
|
||||
private void ConfigureHotkeys()
|
||||
{
|
||||
_hotkeys.Clear();
|
||||
var visibility = _hotkeys.Register(Settings.ToggleVisibilityHotkey, () => Dispatcher.UIThread.Post(ToggleVisibilityOverride));
|
||||
var preview = _hotkeys.Register(Settings.TogglePreviewHotkey, () => Dispatcher.UIThread.Post(TogglePreview));
|
||||
_hotkeyStatus = visibility && preview
|
||||
? "Both global hotkeys are active."
|
||||
: !visibility && !preview
|
||||
? "Neither hotkey could be registered. Check the format or choose shortcuts not reserved by another app."
|
||||
: visibility
|
||||
? "Visibility hotkey is active; the preview shortcut is invalid or reserved by another app."
|
||||
: "Preview hotkey is active; the visibility shortcut is invalid or reserved by another app.";
|
||||
}
|
||||
|
||||
private void OnLiveStatus(CompanionPluginLiveStatus value)
|
||||
{
|
||||
Status = value.State switch
|
||||
{
|
||||
CompanionPluginLiveState.Connected => new(CompanionPluginHealth.Healthy, "Feed connected", value.Detail),
|
||||
CompanionPluginLiveState.WaitingForPairing => new(CompanionPluginHealth.Warning, "Pairing required", value.Detail),
|
||||
CompanionPluginLiveState.Reconnecting => new(CompanionPluginHealth.Warning, "Reconnecting", value.Detail),
|
||||
CompanionPluginLiveState.Stopped when _lifetime.IsCancellationRequested => new(CompanionPluginHealth.Ready, "Stopped", value.Detail),
|
||||
_ => new(CompanionPluginHealth.Ready, "Connecting", value.Detail)
|
||||
};
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
private static string EventSummary(string type, JsonElement payload)
|
||||
{
|
||||
string Field(params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
if (payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty(name, out var value))
|
||||
return value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : value.ToString();
|
||||
return "";
|
||||
}
|
||||
var user = Field("user_name", "member_name", "author_name");
|
||||
return type switch
|
||||
{
|
||||
"twitch.follow" => $"{user.Or("A viewer")} followed",
|
||||
"twitch.raid" => $"{user.Or("A channel")} raided with {Field("viewers").Or("viewers")}",
|
||||
"twitch.subscribe" => $"{user.Or("A viewer")} subscribed",
|
||||
"twitch.subscription_gift" => $"{user.Or("A viewer")} gifted {Field("total").Or("subscriptions")}",
|
||||
"twitch.cheer" => $"{user.Or("A viewer")} cheered {Field("bits").Or("bits")}",
|
||||
"twitch.channel_points" => $"{user.Or("A viewer")} redeemed {Field("reward_title").Or("a reward")}",
|
||||
"discord.member_join" => $"{user.Or("A member")} joined Discord",
|
||||
_ => $"{type.Replace('.', ' ')} · {user}".TrimEnd(' ', '·')
|
||||
};
|
||||
}
|
||||
|
||||
private static string Read(JsonElement value, string property) =>
|
||||
value.TryGetProperty(property, out var found) && found.ValueKind == JsonValueKind.String ? found.GetString() ?? "" : "";
|
||||
|
||||
private void RaiseChanged() => Changed?.Invoke();
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_lifetime.Cancel();
|
||||
if (_feedTask is not null) try { await _feedTask.ConfigureAwait(false); } catch (OperationCanceledException) { }
|
||||
await Dispatcher.UIThread.InvokeAsync(async () => await _window.DisposeAsync());
|
||||
_hotkeys.Dispose();
|
||||
_lifetime.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class OverlayStringExtensions
|
||||
{
|
||||
public static string Or(this string value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value;
|
||||
}
|
||||
837
companion/plugins/Lumi.Companion.Overlay/NativeOverlayWindow.cs
Normal file
837
companion/plugins/Lumi.Companion.Overlay/NativeOverlayWindow.cs
Normal file
@ -0,0 +1,837 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using Microsoft.Win32;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Lumi.Companion.Overlay;
|
||||
|
||||
public enum CaptureExclusionState { Pending, Active, Unsupported, Failed }
|
||||
public sealed record CaptureExclusionStatus(CaptureExclusionState State, string Detail);
|
||||
|
||||
public sealed class NativeOverlayWindow : Window, IAsyncDisposable
|
||||
{
|
||||
private const int GwlExStyle = -20;
|
||||
private const long WsExTransparent = 0x20;
|
||||
private const long WsExToolWindow = 0x80;
|
||||
private const long WsExLayered = 0x80000;
|
||||
private const long WsExNoActivate = 0x08000000;
|
||||
private const uint SwpNoActivate = 0x0010;
|
||||
private const uint SwpFrameChanged = 0x0020;
|
||||
private const uint WdaExcludeFromCapture = 0x11;
|
||||
private static readonly IntPtr HwndTopmost = new(-1);
|
||||
|
||||
private readonly Grid _root = new();
|
||||
private readonly Canvas _surface = new();
|
||||
private readonly Grid _unifiedHolder = new();
|
||||
private readonly Grid _chatHolder = new();
|
||||
private readonly Grid _eventHolder = new();
|
||||
private readonly StackPanel _unifiedPanel = new() { Spacing = 8 };
|
||||
private readonly StackPanel _chatPanel = new() { Spacing = 8 };
|
||||
private readonly StackPanel _eventPanel = new() { Spacing = 8 };
|
||||
private readonly Border _unifiedEditor;
|
||||
private readonly Border _chatEditor;
|
||||
private readonly Border _eventEditor;
|
||||
private readonly Dictionary<string, CardVisual> _cards = [];
|
||||
private readonly DispatcherTimer _timer;
|
||||
private readonly HttpsImageCache _images = new();
|
||||
private LumiOverlaySettings _settings = new();
|
||||
private OverlayLifecycleQueue _chat;
|
||||
private OverlayLifecycleQueue _events;
|
||||
private bool _disposed;
|
||||
private bool _visibleRequested;
|
||||
private string? _temporaryMonitorWarning;
|
||||
private bool _editing;
|
||||
private Border? _dragEditor;
|
||||
private OverlayContainerSettings? _dragSettings;
|
||||
private Point _dragOrigin;
|
||||
private PhysicalRect _dragRect;
|
||||
private bool _resizing;
|
||||
|
||||
public NativeOverlayWindow()
|
||||
{
|
||||
WindowDecorations = Avalonia.Controls.WindowDecorations.None;
|
||||
TransparencyLevelHint = [WindowTransparencyLevel.Transparent];
|
||||
Background = Brushes.Transparent;
|
||||
Topmost = true;
|
||||
ShowInTaskbar = false;
|
||||
ShowActivated = false;
|
||||
CanResize = false;
|
||||
Focusable = false;
|
||||
Content = _root;
|
||||
_root.Children.Add(_surface);
|
||||
_unifiedHolder.Children.Add(_unifiedPanel);
|
||||
_chatHolder.Children.Add(_chatPanel);
|
||||
_eventHolder.Children.Add(_eventPanel);
|
||||
_surface.Children.Add(_unifiedHolder);
|
||||
_surface.Children.Add(_chatHolder);
|
||||
_surface.Children.Add(_eventHolder);
|
||||
_unifiedEditor = CreateEditor("Overlay container", "unified");
|
||||
_chatEditor = CreateEditor("Chat", "chat");
|
||||
_eventEditor = CreateEditor("Events", "events");
|
||||
_surface.Children.Add(_unifiedEditor);
|
||||
_surface.Children.Add(_chatEditor);
|
||||
_surface.Children.Add(_eventEditor);
|
||||
_chat = new(() => ContainerFor("chat"));
|
||||
_events = new(() => ContainerFor("event"));
|
||||
_chat.Changed += Render;
|
||||
_events.Changed += Render;
|
||||
Opened += (_, _) => { ApplyMonitorAndStyles(); Render(); };
|
||||
Screens.Changed += OnScreensChanged;
|
||||
SystemEvents.DisplaySettingsChanged += OnSystemDisplayChanged;
|
||||
SystemEvents.PowerModeChanged += OnPowerChanged;
|
||||
SystemEvents.SessionSwitch += OnSessionSwitch;
|
||||
_timer = new DispatcherTimer(TimeSpan.FromMilliseconds(33), DispatcherPriority.Render, (_, _) =>
|
||||
{
|
||||
_chat.Advance();
|
||||
_events.Advance();
|
||||
});
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
public CaptureExclusionStatus CaptureExclusion { get; private set; } = new(CaptureExclusionState.Pending, "Waiting for the native overlay window.");
|
||||
public string? MonitorWarning => _temporaryMonitorWarning;
|
||||
public event Action? NativeStatusChanged;
|
||||
public event Action? EditSettingsChanged;
|
||||
|
||||
public void ApplySettings(LumiOverlaySettings settings)
|
||||
{
|
||||
_settings = settings;
|
||||
_unifiedPanel.Children.Clear();
|
||||
_chatPanel.Children.Clear();
|
||||
_eventPanel.Children.Clear();
|
||||
_cards.Clear();
|
||||
ApplyMonitorAndStyles();
|
||||
Render();
|
||||
}
|
||||
|
||||
public void SetRequestedVisible(bool visible)
|
||||
{
|
||||
_visibleRequested = visible;
|
||||
if (visible)
|
||||
{
|
||||
if (!IsVisible) Show();
|
||||
ApplyMonitorAndStyles();
|
||||
}
|
||||
else if (IsVisible) Hide();
|
||||
}
|
||||
|
||||
public void BeginEdit(LumiOverlaySettings settings)
|
||||
{
|
||||
_editing = true;
|
||||
ApplySettings(settings);
|
||||
if (!_chat.Items.Any(item => item.Id.StartsWith("edit-sample-", StringComparison.Ordinal)))
|
||||
{
|
||||
AddChat(new OverlayFeedMessage
|
||||
{
|
||||
Id = $"edit-sample-chat-{Guid.NewGuid():N}",
|
||||
Platform = "twitch",
|
||||
Text = "Drag this container to move it · drag the lower-right corner to resize",
|
||||
Author = new() { Name = "Lumi preview", Badges = [new() { Label = "LIVE" }] }
|
||||
});
|
||||
AddEvent(new($"edit-sample-event-{Guid.NewGuid():N}", "twitch.follow", "twitch", "New follower preview", default));
|
||||
}
|
||||
SetRequestedVisible(true);
|
||||
ApplyMonitorAndStyles();
|
||||
Activate();
|
||||
}
|
||||
|
||||
public void EndEdit()
|
||||
{
|
||||
if (!_editing) return;
|
||||
_editing = false;
|
||||
Clear();
|
||||
ApplyMonitorAndStyles();
|
||||
Render();
|
||||
}
|
||||
|
||||
public void AddChat(OverlayFeedMessage message) => _chat.Add(message.Id, "chat", message);
|
||||
public void AddEvent(OverlayRenderedEvent value) => _events.Add(value.Id, "event", value);
|
||||
public void Clear() { _chat.Clear(); _events.Clear(); }
|
||||
public void EndPreview()
|
||||
{
|
||||
_chat.ExitWhere(item => item.Id.StartsWith("sample-", StringComparison.Ordinal));
|
||||
_events.ExitWhere(item => item.Id.StartsWith("sample-", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private OverlayContainerSettings ContainerFor(string kind) =>
|
||||
_settings.Layout == OverlayLayoutMode.Unified ? _settings.Unified : kind == "chat" ? _settings.Chat : _settings.Events;
|
||||
|
||||
private void Render()
|
||||
{
|
||||
if (!Dispatcher.UIThread.CheckAccess()) { Dispatcher.UIThread.Post(Render); return; }
|
||||
var screen = ResolveScreen();
|
||||
if (screen is null) return;
|
||||
var width = screen.Bounds.Width;
|
||||
var height = screen.Bounds.Height;
|
||||
var scaling = screen.Scaling;
|
||||
if (_settings.Layout == OverlayLayoutMode.Unified)
|
||||
{
|
||||
var combined = _chat.Items.Concat(_events.Items).OrderBy(item => item.QueuedAt).ToList();
|
||||
ConfigureContainer(_unifiedHolder, _unifiedPanel, combined, _settings.Unified,
|
||||
Logical(AnchorLayout.Calculate(_settings.Unified, width, height), scaling), true);
|
||||
ConfigureContainer(_chatHolder, _chatPanel, [], _settings.Chat, default, false);
|
||||
ConfigureContainer(_eventHolder, _eventPanel, [], _settings.Events, default, false);
|
||||
}
|
||||
else if (_settings.Layout is OverlayLayoutMode.DividedHorizontal or OverlayLayoutMode.DividedVertical)
|
||||
{
|
||||
var rect = AnchorLayout.Calculate(_settings.Unified, width, height);
|
||||
if (_settings.Layout == OverlayLayoutMode.DividedHorizontal)
|
||||
{
|
||||
var chatWidth = rect.Width * _settings.ChatPercentage / 100;
|
||||
ConfigureContainer(_chatHolder, _chatPanel, _chat.Items, _settings.Chat, Logical(new(rect.X, rect.Y, chatWidth, rect.Height), scaling), true);
|
||||
ConfigureContainer(_eventHolder, _eventPanel, _events.Items, _settings.Events, Logical(new(rect.X + chatWidth, rect.Y, rect.Width - chatWidth, rect.Height), scaling), true);
|
||||
}
|
||||
else
|
||||
{
|
||||
var chatHeight = rect.Height * _settings.ChatPercentage / 100;
|
||||
ConfigureContainer(_chatHolder, _chatPanel, _chat.Items, _settings.Chat, Logical(new(rect.X, rect.Y, rect.Width, chatHeight), scaling), true);
|
||||
ConfigureContainer(_eventHolder, _eventPanel, _events.Items, _settings.Events, Logical(new(rect.X, rect.Y + chatHeight, rect.Width, rect.Height - chatHeight), scaling), true);
|
||||
}
|
||||
ConfigureContainer(_unifiedHolder, _unifiedPanel, [], _settings.Unified, default, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
ConfigureContainer(_unifiedHolder, _unifiedPanel, [], _settings.Unified, default, false);
|
||||
ConfigureContainer(_chatHolder, _chatPanel, _chat.Items, _settings.Chat, Logical(AnchorLayout.Calculate(_settings.Chat, width, height), scaling), true);
|
||||
ConfigureContainer(_eventHolder, _eventPanel, _events.Items, _settings.Events, Logical(AnchorLayout.Calculate(_settings.Events, width, height), scaling), true);
|
||||
}
|
||||
var activeIds = _chat.Items.Concat(_events.Items).Select(item => item.Id).ToHashSet();
|
||||
foreach (var stale in _cards.Keys.Where(id => !activeIds.Contains(id)).ToList()) _cards.Remove(stale);
|
||||
RenderEditors(width, height, scaling);
|
||||
}
|
||||
|
||||
private Border CreateEditor(string label, string tag)
|
||||
{
|
||||
var editor = new Border
|
||||
{
|
||||
Tag = tag,
|
||||
BorderBrush = Brush.Parse("#28B7C8"),
|
||||
BorderThickness = new Thickness(3),
|
||||
Background = Brush.Parse("#082C3333"),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Child = new Border
|
||||
{
|
||||
Background = Brush.Parse("#D9121212"),
|
||||
CornerRadius = new CornerRadius(6),
|
||||
Padding = new Thickness(9, 5),
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top,
|
||||
Child = new TextBlock { Text = label, Foreground = Brushes.White, FontWeight = FontWeight.SemiBold, FontSize = 12 }
|
||||
},
|
||||
Cursor = new Cursor(StandardCursorType.SizeAll),
|
||||
IsVisible = false
|
||||
};
|
||||
editor.PointerPressed += OnEditorPointerPressed;
|
||||
editor.PointerMoved += OnEditorPointerMoved;
|
||||
editor.PointerReleased += OnEditorPointerReleased;
|
||||
return editor;
|
||||
}
|
||||
|
||||
private void RenderEditors(int width, int height, double scaling)
|
||||
{
|
||||
if (!_editing)
|
||||
{
|
||||
_unifiedEditor.IsVisible = _chatEditor.IsVisible = _eventEditor.IsVisible = false;
|
||||
return;
|
||||
}
|
||||
if (_settings.Layout is OverlayLayoutMode.Unified or OverlayLayoutMode.DividedHorizontal or OverlayLayoutMode.DividedVertical)
|
||||
{
|
||||
ConfigureEditor(_unifiedEditor, Logical(AnchorLayout.Calculate(_settings.Unified, width, height), scaling), true);
|
||||
ConfigureEditor(_chatEditor, default, false);
|
||||
ConfigureEditor(_eventEditor, default, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
ConfigureEditor(_unifiedEditor, default, false);
|
||||
ConfigureEditor(_chatEditor, Logical(AnchorLayout.Calculate(_settings.Chat, width, height), scaling), true);
|
||||
ConfigureEditor(_eventEditor, Logical(AnchorLayout.Calculate(_settings.Events, width, height), scaling), true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureEditor(Border editor, PhysicalRect rect, bool visible)
|
||||
{
|
||||
editor.IsVisible = visible;
|
||||
if (!visible) return;
|
||||
editor.Width = rect.Width;
|
||||
editor.Height = rect.Height;
|
||||
Canvas.SetLeft(editor, rect.X);
|
||||
Canvas.SetTop(editor, rect.Y);
|
||||
}
|
||||
|
||||
private void OnEditorPointerPressed(object? sender, PointerPressedEventArgs args)
|
||||
{
|
||||
if (!_editing || sender is not Border editor) return;
|
||||
var screen = ResolveScreen();
|
||||
if (screen is null) return;
|
||||
var tag = editor.Tag?.ToString();
|
||||
_dragSettings = tag == "chat" ? _settings.Chat : tag == "events" ? _settings.Events : _settings.Unified;
|
||||
_dragRect = AnchorLayout.Calculate(_dragSettings, screen.Bounds.Width, screen.Bounds.Height);
|
||||
_dragOrigin = args.GetPosition(_surface);
|
||||
var position = args.GetPosition(editor);
|
||||
_resizing = position.X >= editor.Bounds.Width - 24 && position.Y >= editor.Bounds.Height - 24;
|
||||
_dragEditor = editor;
|
||||
editor.Cursor = new Cursor(_resizing ? StandardCursorType.BottomRightCorner : StandardCursorType.SizeAll);
|
||||
args.Pointer.Capture(editor);
|
||||
args.Handled = true;
|
||||
}
|
||||
|
||||
private void OnEditorPointerMoved(object? sender, PointerEventArgs args)
|
||||
{
|
||||
if (_dragEditor is null || _dragSettings is null) return;
|
||||
var screen = ResolveScreen();
|
||||
if (screen is null) return;
|
||||
var current = args.GetPosition(_surface);
|
||||
var dx = (int)Math.Round((current.X - _dragOrigin.X) * screen.Scaling);
|
||||
var dy = (int)Math.Round((current.Y - _dragOrigin.Y) * screen.Scaling);
|
||||
if (_resizing)
|
||||
{
|
||||
_dragSettings.Width = Math.Clamp(_dragRect.Width + dx, 160, screen.Bounds.Width);
|
||||
_dragSettings.Height = Math.Clamp(_dragRect.Height + dy, 100, screen.Bounds.Height);
|
||||
}
|
||||
else
|
||||
{
|
||||
_dragSettings.Anchor = OverlayAnchor.TopLeft;
|
||||
_dragSettings.PaddingLeft = Math.Clamp(_dragRect.X + dx, 0, Math.Max(0, screen.Bounds.Width - _dragSettings.Width));
|
||||
_dragSettings.PaddingTop = Math.Clamp(_dragRect.Y + dy, 0, Math.Max(0, screen.Bounds.Height - _dragSettings.Height));
|
||||
_dragSettings.PaddingRight = 0;
|
||||
_dragSettings.PaddingBottom = 0;
|
||||
}
|
||||
Render();
|
||||
args.Handled = true;
|
||||
}
|
||||
|
||||
private void OnEditorPointerReleased(object? sender, PointerReleasedEventArgs args)
|
||||
{
|
||||
if (_dragEditor is null) return;
|
||||
args.Pointer.Capture(null);
|
||||
_dragEditor.Cursor = new Cursor(StandardCursorType.SizeAll);
|
||||
_dragEditor = null;
|
||||
_dragSettings = null;
|
||||
EditSettingsChanged?.Invoke();
|
||||
args.Handled = true;
|
||||
}
|
||||
|
||||
private void ConfigureContainer(Grid holder, StackPanel panel, IEnumerable<OverlayQueueItem> values,
|
||||
OverlayContainerSettings settings, PhysicalRect rect, bool visible)
|
||||
{
|
||||
holder.IsVisible = visible;
|
||||
if (!visible) { panel.Children.Clear(); return; }
|
||||
holder.Width = rect.Width;
|
||||
holder.Height = rect.Height;
|
||||
panel.Width = rect.Width;
|
||||
panel.MaxHeight = rect.Height;
|
||||
panel.HorizontalAlignment = settings.HorizontalAlignment switch
|
||||
{
|
||||
OverlayHorizontalAlignment.Left => Avalonia.Layout.HorizontalAlignment.Left,
|
||||
OverlayHorizontalAlignment.Middle => Avalonia.Layout.HorizontalAlignment.Center,
|
||||
_ => Avalonia.Layout.HorizontalAlignment.Right
|
||||
};
|
||||
panel.VerticalAlignment = settings.VerticalAlignment switch
|
||||
{
|
||||
OverlayVerticalAlignment.Top => Avalonia.Layout.VerticalAlignment.Top,
|
||||
OverlayVerticalAlignment.Middle => Avalonia.Layout.VerticalAlignment.Center,
|
||||
_ => Avalonia.Layout.VerticalAlignment.Bottom
|
||||
};
|
||||
Canvas.SetLeft(holder, rect.X);
|
||||
Canvas.SetTop(holder, rect.Y);
|
||||
var items = (settings.NewestDirection == OverlayNewestDirection.End ? values : values.Reverse()).ToList();
|
||||
var oldPositions = panel.Children.OfType<Border>().Where(card => card.Tag is CardVisual)
|
||||
.ToDictionary(card => ((CardVisual)card.Tag!).Id, card => rect.Y + card.Bounds.Y);
|
||||
foreach (var other in new[] { _unifiedPanel, _chatPanel, _eventPanel })
|
||||
if (!ReferenceEquals(other, panel))
|
||||
foreach (var card in other.Children.OfType<Border>().Where(card => card.Tag is CardVisual visual && items.Any(item => item.Id == visual.Id)).ToList())
|
||||
other.Children.Remove(card);
|
||||
panel.Children.Clear();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var visual = GetOrCreateCard(item, settings, out var created);
|
||||
visual.Card.MaxWidth = rect.Width;
|
||||
panel.Children.Add(visual.Card);
|
||||
if (!created) ApplyCardState(visual, item, settings);
|
||||
}
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
foreach (var visual in panel.Children.OfType<Border>().Select(card => card.Tag as CardVisual).Where(value => value is not null))
|
||||
{
|
||||
if (!oldPositions.TryGetValue(visual!.Id, out var old)) continue;
|
||||
var target = visual.Translate.Y;
|
||||
var transitions = visual.Translate.Transitions;
|
||||
visual.Translate.Transitions = null;
|
||||
visual.Translate.Y += old - (rect.Y + visual.Card.Bounds.Y);
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
visual.Translate.Transitions = transitions;
|
||||
visual.Translate.Y = target;
|
||||
}, DispatcherPriority.Render);
|
||||
}
|
||||
}, DispatcherPriority.Render);
|
||||
}
|
||||
|
||||
private CardVisual GetOrCreateCard(OverlayQueueItem item, OverlayContainerSettings settings, out bool created)
|
||||
{
|
||||
if (_cards.TryGetValue(item.Id, out var existing)) { created = false; return existing; }
|
||||
var content = item.Payload switch
|
||||
{
|
||||
OverlayFeedMessage message => CreateChatContent(message, settings),
|
||||
OverlayRenderedEvent value => CreateEventContent(value, settings),
|
||||
_ => new TextBlock { Text = item.Payload.ToString() ?? "" }
|
||||
};
|
||||
var translate = new TranslateTransform();
|
||||
var scale = new ScaleTransform();
|
||||
var transforms = new TransformGroup();
|
||||
transforms.Children.Add(scale);
|
||||
transforms.Children.Add(translate);
|
||||
var border = new Border
|
||||
{
|
||||
Background = OverlayColor.BrushFromRgba(settings.CardBackground),
|
||||
CornerRadius = new CornerRadius(9),
|
||||
Padding = new Thickness(12, 9),
|
||||
Child = content,
|
||||
HorizontalAlignment = settings.HorizontalAlignment switch
|
||||
{
|
||||
OverlayHorizontalAlignment.Left => Avalonia.Layout.HorizontalAlignment.Left,
|
||||
OverlayHorizontalAlignment.Middle => Avalonia.Layout.HorizontalAlignment.Center,
|
||||
_ => Avalonia.Layout.HorizontalAlignment.Right
|
||||
},
|
||||
RenderTransform = transforms,
|
||||
RenderTransformOrigin = RelativePoint.Center
|
||||
};
|
||||
var visual = new CardVisual(item.Id, border, translate, scale);
|
||||
border.Tag = visual;
|
||||
_cards[item.Id] = visual;
|
||||
created = true;
|
||||
SetInitialState(visual, settings.EntryAnimation, settings);
|
||||
Dispatcher.UIThread.Post(() => ApplyCardState(visual, item, settings), DispatcherPriority.Render);
|
||||
return visual;
|
||||
}
|
||||
|
||||
private static void SetInitialState(CardVisual visual, OverlayAnimation animation, OverlayContainerSettings settings)
|
||||
{
|
||||
visual.Card.Opacity = animation is OverlayAnimation.Fade or OverlayAnimation.FadeSlide or OverlayAnimation.ScaleFade ? 0 : 1;
|
||||
visual.Translate.X = animation is OverlayAnimation.Slide or OverlayAnimation.FadeSlide ? settings.SlideDistance : 0;
|
||||
visual.Scale.ScaleX = visual.Scale.ScaleY = animation == OverlayAnimation.ScaleFade ? settings.ScaleAmount : 1;
|
||||
}
|
||||
|
||||
private static void ApplyCardState(CardVisual visual, OverlayQueueItem item, OverlayContainerSettings settings)
|
||||
{
|
||||
var exiting = item.State == OverlayItemState.Exiting;
|
||||
var animation = exiting ? settings.ExitAnimation : settings.EntryAnimation;
|
||||
var duration = TimeSpan.FromMilliseconds(exiting ? settings.ExitDurationMs : settings.EntryDurationMs);
|
||||
var easing = EasingFor(settings.Easing);
|
||||
visual.Card.Transitions = [new DoubleTransition { Property = OpacityProperty, Duration = duration, Easing = easing }];
|
||||
visual.Translate.Transitions =
|
||||
[
|
||||
new DoubleTransition { Property = TranslateTransform.XProperty, Duration = duration, Easing = easing },
|
||||
new DoubleTransition { Property = TranslateTransform.YProperty, Duration = duration, Easing = easing }
|
||||
];
|
||||
visual.Scale.Transitions =
|
||||
[
|
||||
new DoubleTransition { Property = ScaleTransform.ScaleXProperty, Duration = duration, Easing = easing },
|
||||
new DoubleTransition { Property = ScaleTransform.ScaleYProperty, Duration = duration, Easing = easing }
|
||||
];
|
||||
visual.Card.Opacity = exiting && animation is OverlayAnimation.Fade or OverlayAnimation.FadeSlide or OverlayAnimation.ScaleFade ? 0 : 1;
|
||||
visual.Translate.X = exiting && animation is OverlayAnimation.Slide or OverlayAnimation.FadeSlide ? -settings.SlideDistance : 0;
|
||||
var scale = exiting && animation == OverlayAnimation.ScaleFade ? settings.ScaleAmount : 1;
|
||||
visual.Scale.ScaleX = visual.Scale.ScaleY = scale;
|
||||
}
|
||||
|
||||
private static Easing EasingFor(string value) => value switch
|
||||
{
|
||||
"linear" => new LinearEasing(),
|
||||
"cubic-in-out" => new CubicEaseInOut(),
|
||||
_ => new CubicEaseOut()
|
||||
};
|
||||
|
||||
private Control CreateChatContent(OverlayFeedMessage message, OverlayContainerSettings settings)
|
||||
{
|
||||
var grid = new Grid { ColumnDefinitions = ColumnDefinitions.Parse(settings.ShowAvatar && message.Author.Avatar is not null ? "40,*" : "*"), ColumnSpacing = 10 };
|
||||
var contentColumn = 0;
|
||||
if (settings.ShowAvatar && message.Author.Avatar is not null)
|
||||
{
|
||||
var avatar = new AnimatedOverlayImage { Width = 36, Height = 36, Stretch = Stretch.UniformToFill };
|
||||
_ = LoadImageAsync(avatar, message.Author.Avatar);
|
||||
grid.Children.Add(avatar);
|
||||
contentColumn = 1;
|
||||
}
|
||||
var stack = new StackPanel { Spacing = 3 };
|
||||
var header = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 7 };
|
||||
if (settings.ShowPlatform) header.Children.Add(Text(message.Platform.ToUpperInvariant(), settings, 11, FontWeight.Bold));
|
||||
foreach (var badge in message.Author.Badges.Take(6))
|
||||
{
|
||||
if (badge.Image is null)
|
||||
{
|
||||
header.Children.Add(Text(badge.Label, settings, 10, FontWeight.SemiBold));
|
||||
continue;
|
||||
}
|
||||
var token = new Grid();
|
||||
ToolTip.SetTip(token, badge.Label);
|
||||
var fallback = Text(badge.Label, settings, 10, FontWeight.SemiBold);
|
||||
var image = new AnimatedOverlayImage { Width = 18, Height = 18, Stretch = Stretch.Uniform, Opacity = 0 };
|
||||
token.Children.Add(fallback);
|
||||
token.Children.Add(image);
|
||||
header.Children.Add(token);
|
||||
_ = LoadImageAsync(image, badge.Image, fallback);
|
||||
}
|
||||
if (settings.ShowUsername) header.Children.Add(Text(message.Author.Name, settings, settings.FontSize * .72, FontWeight.Bold));
|
||||
if (header.Children.Count > 0) stack.Children.Add(header);
|
||||
stack.Children.Add(CreateMessageBody(message, settings));
|
||||
var media = CreateMessageMedia(message);
|
||||
if (media is not null) stack.Children.Add(media);
|
||||
Grid.SetColumn(stack, contentColumn);
|
||||
grid.Children.Add(stack);
|
||||
return grid;
|
||||
}
|
||||
|
||||
private Control CreateMessageBody(OverlayFeedMessage message, OverlayContainerSettings settings)
|
||||
{
|
||||
var panel = new WrapPanel { Orientation = Avalonia.Layout.Orientation.Horizontal };
|
||||
var cursor = 0;
|
||||
foreach (var emote in message.Emotes.OrderBy(value => value.Start))
|
||||
{
|
||||
if (emote.Start < cursor || emote.Start > message.Text.Length) continue;
|
||||
if (emote.Start > cursor) panel.Children.Add(Text(message.Text[cursor..emote.Start], settings, settings.FontSize));
|
||||
if (emote.Image is not null)
|
||||
{
|
||||
var token = new Grid { Margin = new Thickness(2, 0) };
|
||||
var fallback = Text(string.IsNullOrWhiteSpace(emote.Label) ? message.Text[emote.Start..Math.Min(message.Text.Length, emote.End + 1)] : emote.Label, settings, settings.FontSize);
|
||||
var image = new AnimatedOverlayImage { Width = settings.FontSize * 1.3, Height = settings.FontSize * 1.3, Opacity = 0 };
|
||||
token.Children.Add(fallback);
|
||||
token.Children.Add(image);
|
||||
panel.Children.Add(token);
|
||||
_ = LoadImageAsync(image, emote.Image, fallback);
|
||||
}
|
||||
else panel.Children.Add(Text(emote.Label, settings, settings.FontSize));
|
||||
cursor = Math.Min(message.Text.Length, emote.End + 1);
|
||||
}
|
||||
if (cursor < message.Text.Length) panel.Children.Add(Text(message.Text[cursor..], settings, settings.FontSize));
|
||||
if (panel.Children.Count == 0) panel.Children.Add(Text(message.Text, settings, settings.FontSize));
|
||||
return panel;
|
||||
}
|
||||
|
||||
private Control? CreateMessageMedia(OverlayFeedMessage message)
|
||||
{
|
||||
var panel = new WrapPanel
|
||||
{
|
||||
Orientation = Avalonia.Layout.Orientation.Horizontal,
|
||||
ItemWidth = 220,
|
||||
ItemHeight = 150
|
||||
};
|
||||
foreach (var media in message.Media.Take(4))
|
||||
{
|
||||
var source = media.Type == "video" ? media.Preview : media.Url;
|
||||
if (string.IsNullOrWhiteSpace(source)) continue;
|
||||
var image = new AnimatedOverlayImage
|
||||
{
|
||||
Width = 220,
|
||||
Height = 150,
|
||||
Stretch = Stretch.Uniform
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(media.Alt)) ToolTip.SetTip(image, media.Alt);
|
||||
panel.Children.Add(image);
|
||||
_ = LoadImageAsync(image, source);
|
||||
}
|
||||
return panel.Children.Count == 0 ? null : panel;
|
||||
}
|
||||
|
||||
private Control CreateEventContent(OverlayRenderedEvent value, OverlayContainerSettings settings)
|
||||
{
|
||||
var stack = new StackPanel { Spacing = 3 };
|
||||
if (settings.ShowPlatform) stack.Children.Add(Text(value.Platform.ToUpperInvariant(), settings, 11, FontWeight.Bold));
|
||||
stack.Children.Add(Text(value.Summary, settings, settings.FontSize, FontWeight.SemiBold));
|
||||
return stack;
|
||||
}
|
||||
|
||||
private static TextBlock Text(string value, OverlayContainerSettings settings, double size, FontWeight? weight = null)
|
||||
{
|
||||
var block = new TextBlock
|
||||
{
|
||||
Text = value,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Foreground = Brush.Parse(settings.TextColor),
|
||||
TextAlignment = settings.HorizontalAlignment switch
|
||||
{
|
||||
OverlayHorizontalAlignment.Left => TextAlignment.Left,
|
||||
OverlayHorizontalAlignment.Middle => TextAlignment.Center,
|
||||
_ => TextAlignment.Right
|
||||
},
|
||||
FontFamily = new FontFamily(settings.FontFamily),
|
||||
FontSize = size,
|
||||
FontWeight = weight ?? FontWeight.Normal
|
||||
};
|
||||
block.Effect = new DropShadowEffect
|
||||
{
|
||||
Color = OverlayColor.ColorFromRgba(settings.TextShadow),
|
||||
OffsetX = settings.ShadowX,
|
||||
OffsetY = settings.ShadowY,
|
||||
BlurRadius = settings.ShadowBlur
|
||||
};
|
||||
return block;
|
||||
}
|
||||
|
||||
private async Task LoadImageAsync(AnimatedOverlayImage target, string url, Control? fallback = null)
|
||||
{
|
||||
var asset = await _images.GetAsync(url).ConfigureAwait(false);
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (asset is not null)
|
||||
{
|
||||
target.SetAsset(asset);
|
||||
target.Opacity = 1;
|
||||
if (fallback is not null) fallback.IsVisible = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Screen? ResolveScreen()
|
||||
{
|
||||
var all = Screens.All;
|
||||
var primary = Screens.Primary ?? all.FirstOrDefault();
|
||||
var resolution = OverlayMonitorPolicy.Resolve(_settings.MonitorId, all.Select(ScreenId), primary is null ? null : ScreenId(primary));
|
||||
var selected = all.FirstOrDefault(screen => ScreenId(screen) == resolution.MonitorId);
|
||||
if (resolution.Warning != _temporaryMonitorWarning)
|
||||
{
|
||||
_temporaryMonitorWarning = resolution.Warning;
|
||||
NativeStatusChanged?.Invoke();
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
public IReadOnlyList<(string Id, string Label)> Monitors() => Screens.All.Select((screen, index) =>
|
||||
(ScreenId(screen), $"{(screen.IsPrimary ? "Primary · " : "")}{screen.DisplayName ?? $"Monitor {index + 1}"} · {screen.Bounds.Width}×{screen.Bounds.Height}")).ToList();
|
||||
|
||||
public static string ScreenId(Screen screen) => !string.IsNullOrWhiteSpace(screen.DisplayName)
|
||||
? $"display:{screen.DisplayName}"
|
||||
: $"bounds:{screen.Bounds.X},{screen.Bounds.Y},{screen.Bounds.Width},{screen.Bounds.Height}";
|
||||
|
||||
private static PhysicalRect Logical(PhysicalRect value, double scaling) => new(
|
||||
(int)Math.Round(value.X / scaling),
|
||||
(int)Math.Round(value.Y / scaling),
|
||||
Math.Max(1, (int)Math.Round(value.Width / scaling)),
|
||||
Math.Max(1, (int)Math.Round(value.Height / scaling)));
|
||||
|
||||
private void ApplyMonitorAndStyles()
|
||||
{
|
||||
if (!Dispatcher.UIThread.CheckAccess()) { Dispatcher.UIThread.Post(ApplyMonitorAndStyles); return; }
|
||||
var screen = ResolveScreen();
|
||||
if (screen is null) return;
|
||||
Position = screen.Bounds.Position;
|
||||
Width = screen.Bounds.Width / screen.Scaling;
|
||||
Height = screen.Bounds.Height / screen.Scaling;
|
||||
var hwnd = TryGetPlatformHandle()?.Handle ?? IntPtr.Zero;
|
||||
if (hwnd == IntPtr.Zero) return;
|
||||
var style = GetWindowLongPtr(hwnd, GwlExStyle).ToInt64() | WsExToolWindow | WsExLayered;
|
||||
if (_editing) style &= ~(WsExTransparent | WsExNoActivate);
|
||||
else style |= WsExTransparent | WsExNoActivate;
|
||||
SetWindowLongPtr(hwnd, GwlExStyle, new IntPtr(style));
|
||||
SetWindowPos(hwnd, HwndTopmost, screen.Bounds.X, screen.Bounds.Y, screen.Bounds.Width, screen.Bounds.Height, SwpNoActivate | SwpFrameChanged);
|
||||
if (SetWindowDisplayAffinity(hwnd, WdaExcludeFromCapture))
|
||||
CaptureExclusion = new(CaptureExclusionState.Active, "Windows capture exclusion is active (best effort; capture software may still include the overlay).");
|
||||
else
|
||||
{
|
||||
var error = Marshal.GetLastWin32Error();
|
||||
CaptureExclusion = error == 87
|
||||
? new(CaptureExclusionState.Unsupported, "This Windows/capture path does not support display-affinity exclusion.")
|
||||
: new(CaptureExclusionState.Failed, $"Windows could not apply capture exclusion (error {error}).");
|
||||
}
|
||||
NativeStatusChanged?.Invoke();
|
||||
if (!_visibleRequested && IsVisible) Hide();
|
||||
}
|
||||
|
||||
private void OnScreensChanged(object? sender, EventArgs args) => Dispatcher.UIThread.Post(ApplyMonitorAndStyles);
|
||||
private void OnSystemDisplayChanged(object? sender, EventArgs args) => Dispatcher.UIThread.Post(ApplyMonitorAndStyles);
|
||||
private void OnPowerChanged(object sender, PowerModeChangedEventArgs args) => Dispatcher.UIThread.Post(ApplyMonitorAndStyles);
|
||||
private void OnSessionSwitch(object sender, SessionSwitchEventArgs args) => Dispatcher.UIThread.Post(ApplyMonitorAndStyles);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return ValueTask.CompletedTask;
|
||||
_disposed = true;
|
||||
_timer.Stop();
|
||||
Screens.Changed -= OnScreensChanged;
|
||||
SystemEvents.DisplaySettingsChanged -= OnSystemDisplayChanged;
|
||||
SystemEvents.PowerModeChanged -= OnPowerChanged;
|
||||
SystemEvents.SessionSwitch -= OnSessionSwitch;
|
||||
_images.Dispose();
|
||||
Close();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)] private static extern IntPtr GetWindowLongPtr(IntPtr hwnd, int index);
|
||||
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)] private static extern IntPtr SetWindowLongPtr(IntPtr hwnd, int index, IntPtr value);
|
||||
[DllImport("user32.dll", SetLastError = true)] private static extern bool SetWindowPos(IntPtr hwnd, IntPtr after, int x, int y, int cx, int cy, uint flags);
|
||||
[DllImport("user32.dll", SetLastError = true)] private static extern bool SetWindowDisplayAffinity(IntPtr hwnd, uint affinity);
|
||||
|
||||
private sealed record CardVisual(string Id, Border Card, TranslateTransform Translate, ScaleTransform Scale);
|
||||
}
|
||||
|
||||
internal sealed class HttpsImageCache : IDisposable
|
||||
{
|
||||
private const int Limit = 128;
|
||||
private const int MaxEncodedBytes = 2 * 1024 * 1024;
|
||||
private const int MaxDimension = 384;
|
||||
private const int MaxFrames = 60;
|
||||
private const long MaxDecodedPixels = 12_000_000;
|
||||
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(5) };
|
||||
private readonly ConcurrentDictionary<string, Lazy<Task<OverlayImageAsset?>>> _entries = new();
|
||||
private readonly Queue<string> _order = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
public Task<OverlayImageAsset?> GetAsync(string value)
|
||||
{
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) return Task.FromResult<OverlayImageAsset?>(null);
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_entries.ContainsKey(uri.AbsoluteUri))
|
||||
{
|
||||
while (_entries.Count >= Limit && _order.TryDequeue(out var oldest))
|
||||
if (_entries.TryRemove(oldest, out var removed) && removed.IsValueCreated && removed.Value.IsCompletedSuccessfully) removed.Value.Result?.Dispose();
|
||||
_order.Enqueue(uri.AbsoluteUri);
|
||||
}
|
||||
}
|
||||
return _entries.GetOrAdd(uri.AbsoluteUri, key => new(() => DownloadAsync(key))).Value;
|
||||
}
|
||||
|
||||
private async Task<OverlayImageAsset?> DownloadAsync(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode || response.Content.Headers.ContentLength > MaxEncodedBytes) return null;
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
|
||||
if (bytes.Length > MaxEncodedBytes) return null;
|
||||
return Decode(bytes);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static OverlayImageAsset? Decode(byte[] bytes)
|
||||
{
|
||||
using var data = SKData.CreateCopy(bytes);
|
||||
using var codec = SKCodec.Create(data);
|
||||
if (codec is null || codec.Info.Width <= 0 || codec.Info.Height <= 0) return null;
|
||||
var scale = Math.Min(1d, MaxDimension / (double)Math.Max(codec.Info.Width, codec.Info.Height));
|
||||
var dimensions = codec.GetScaledDimensions((float)scale);
|
||||
var width = Math.Max(1, dimensions.Width);
|
||||
var height = Math.Max(1, dimensions.Height);
|
||||
var frameCount = Math.Clamp(codec.FrameCount, 1, MaxFrames);
|
||||
frameCount = (int)Math.Min(frameCount, Math.Max(1, MaxDecodedPixels / ((long)width * height)));
|
||||
var info = new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul);
|
||||
using var working = new SKBitmap(info);
|
||||
var frameInfo = codec.FrameInfo;
|
||||
var frames = new List<Bitmap>(frameCount);
|
||||
var delays = new List<TimeSpan>(frameCount);
|
||||
try
|
||||
{
|
||||
for (var index = 0; index < frameCount; index++)
|
||||
{
|
||||
var requiredFrame = index < frameInfo.Length ? frameInfo[index].RequiredFrame : -1;
|
||||
if (requiredFrame < 0) working.Erase(SKColors.Transparent);
|
||||
var result = codec.GetPixels(info, working.GetPixels(), working.RowBytes, new SKCodecOptions(index, requiredFrame));
|
||||
if (result is not (SKCodecResult.Success or SKCodecResult.IncompleteInput)) break;
|
||||
using var image = SKImage.FromBitmap(working);
|
||||
using var encoded = image.Encode(SKEncodedImageFormat.Png, 100);
|
||||
frames.Add(new Bitmap(new MemoryStream(encoded.ToArray())));
|
||||
var duration = index < frameInfo.Length ? frameInfo[index].Duration : 100;
|
||||
delays.Add(TimeSpan.FromMilliseconds(Math.Clamp(duration <= 0 ? 100 : duration, 20, 10_000)));
|
||||
}
|
||||
return frames.Count == 0 ? null : new OverlayImageAsset(frames, delays);
|
||||
}
|
||||
catch
|
||||
{
|
||||
foreach (var frame in frames) frame.Dispose();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_http.Dispose();
|
||||
foreach (var value in _entries.Values)
|
||||
if (value.IsValueCreated && value.Value.IsCompletedSuccessfully) value.Value.Result?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class OverlayImageAsset(IReadOnlyList<Bitmap> frames, IReadOnlyList<TimeSpan> delays) : IDisposable
|
||||
{
|
||||
public IReadOnlyList<Bitmap> Frames { get; } = frames;
|
||||
public IReadOnlyList<TimeSpan> Delays { get; } = delays;
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var frame in Frames) frame.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AnimatedOverlayImage : Image
|
||||
{
|
||||
private DispatcherTimer? _timer;
|
||||
private OverlayImageAsset? _asset;
|
||||
private int _frame;
|
||||
|
||||
public AnimatedOverlayImage()
|
||||
{
|
||||
DetachedFromVisualTree += (_, _) => Stop();
|
||||
AttachedToVisualTree += (_, _) => Start();
|
||||
}
|
||||
|
||||
public void SetAsset(OverlayImageAsset asset)
|
||||
{
|
||||
Stop();
|
||||
_asset = asset;
|
||||
_frame = 0;
|
||||
Source = asset.Frames[0];
|
||||
Start();
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_asset is null || _asset.Frames.Count < 2 || _timer is not null) return;
|
||||
_timer = new DispatcherTimer { Interval = DelayFor(0) };
|
||||
_timer.Tick += Advance;
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
private void Advance(object? sender, EventArgs args)
|
||||
{
|
||||
if (_asset is null || _asset.Frames.Count < 2) return;
|
||||
_frame = (_frame + 1) % _asset.Frames.Count;
|
||||
Source = _asset.Frames[_frame];
|
||||
if (_timer is not null) _timer.Interval = DelayFor(_frame);
|
||||
}
|
||||
|
||||
private TimeSpan DelayFor(int frame) =>
|
||||
_asset is not null && frame < _asset.Delays.Count ? _asset.Delays[frame] : TimeSpan.FromMilliseconds(100);
|
||||
|
||||
private void Stop()
|
||||
{
|
||||
if (_timer is null) return;
|
||||
_timer.Stop();
|
||||
_timer.Tick -= Advance;
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class OverlayColor
|
||||
{
|
||||
public static Color ColorFromRgba(string value)
|
||||
{
|
||||
var hex = value.TrimStart('#');
|
||||
if (hex.Length != 8) return Color.Parse(value);
|
||||
return Color.FromArgb(
|
||||
Convert.ToByte(hex[6..8], 16),
|
||||
Convert.ToByte(hex[0..2], 16),
|
||||
Convert.ToByte(hex[2..4], 16),
|
||||
Convert.ToByte(hex[4..6], 16));
|
||||
}
|
||||
|
||||
public static IBrush BrushFromRgba(string value) => new SolidColorBrush(ColorFromRgba(value));
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Lumi.Companion.Overlay;
|
||||
|
||||
public sealed class OverlayFeedAuthor
|
||||
{
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
[JsonPropertyName("username")] public string Username { get; set; } = "";
|
||||
[JsonPropertyName("avatar")] public string? Avatar { get; set; }
|
||||
[JsonPropertyName("badges")] public List<OverlayFeedBadge> Badges { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class OverlayFeedBadge
|
||||
{
|
||||
[JsonPropertyName("label")] public string Label { get; set; } = "";
|
||||
[JsonPropertyName("image")] public string? Image { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OverlayFeedEmote
|
||||
{
|
||||
[JsonPropertyName("start")] public int Start { get; set; }
|
||||
[JsonPropertyName("end")] public int End { get; set; }
|
||||
[JsonPropertyName("label")] public string Label { get; set; } = "";
|
||||
[JsonPropertyName("image")] public string? Image { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OverlayFeedMedia
|
||||
{
|
||||
[JsonPropertyName("url")] public string? Url { get; set; }
|
||||
[JsonPropertyName("type")] public string Type { get; set; } = "image";
|
||||
[JsonPropertyName("alt")] public string Alt { get; set; } = "";
|
||||
[JsonPropertyName("preview")] public string? Preview { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OverlayFeedMessage
|
||||
{
|
||||
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||
[JsonPropertyName("platform")] public string Platform { get; set; } = "";
|
||||
[JsonPropertyName("text")] public string Text { get; set; } = "";
|
||||
[JsonPropertyName("author")] public OverlayFeedAuthor Author { get; set; } = new();
|
||||
[JsonPropertyName("emotes")] public List<OverlayFeedEmote> Emotes { get; set; } = [];
|
||||
[JsonPropertyName("media")] public List<OverlayFeedMedia> Media { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed record OverlayRenderedEvent(string Id, string Type, string Platform, string Summary, JsonElement Payload);
|
||||
|
||||
public sealed record OverlayFeedStatus(string Platform, string State, string Detail);
|
||||
@ -0,0 +1,90 @@
|
||||
namespace Lumi.Companion.Overlay;
|
||||
|
||||
public enum OverlayItemState { Queued, Entering, Visible, Exiting, Removed }
|
||||
|
||||
public sealed class OverlayQueueItem(string id, string kind, object payload, DateTimeOffset queuedAt)
|
||||
{
|
||||
public string Id { get; } = id;
|
||||
public string Kind { get; } = kind;
|
||||
public object Payload { get; } = payload;
|
||||
public DateTimeOffset QueuedAt { get; } = queuedAt;
|
||||
public DateTimeOffset StateAt { get; internal set; } = queuedAt;
|
||||
public OverlayItemState State { get; internal set; } = OverlayItemState.Queued;
|
||||
}
|
||||
|
||||
public sealed class OverlayLifecycleQueue(Func<OverlayContainerSettings> settings, Func<DateTimeOffset>? now = null)
|
||||
{
|
||||
private readonly List<OverlayQueueItem> _items = [];
|
||||
private readonly Func<DateTimeOffset> _now = now ?? (() => DateTimeOffset.UtcNow);
|
||||
public IReadOnlyList<OverlayQueueItem> Items => _items;
|
||||
public event Action? Changed;
|
||||
|
||||
public bool Add(string id, string kind, object payload)
|
||||
{
|
||||
if (_items.Any(item => item.Id == id && item.State != OverlayItemState.Removed)) return false;
|
||||
var value = settings();
|
||||
if (_items.Count(item => item.State != OverlayItemState.Removed) >= Math.Max(value.QueueLimit + 1, value.QueueLimit * 2))
|
||||
return false;
|
||||
var active = _items.Where(item => item.State is not OverlayItemState.Exiting and not OverlayItemState.Removed).ToList();
|
||||
while (active.Count >= value.QueueLimit)
|
||||
{
|
||||
var oldest = active[0];
|
||||
oldest.State = OverlayItemState.Exiting;
|
||||
oldest.StateAt = _now();
|
||||
active.RemoveAt(0);
|
||||
}
|
||||
var created = new OverlayQueueItem(id, kind, payload, _now()) { State = OverlayItemState.Entering, StateAt = _now() };
|
||||
_items.Add(created);
|
||||
Changed?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Advance()
|
||||
{
|
||||
var current = _now();
|
||||
var value = settings();
|
||||
var changed = false;
|
||||
foreach (var item in _items)
|
||||
{
|
||||
var elapsed = current - item.StateAt;
|
||||
if (item.State == OverlayItemState.Entering && elapsed.TotalMilliseconds >= value.EntryDurationMs)
|
||||
{
|
||||
item.State = OverlayItemState.Visible; item.StateAt = current; changed = true;
|
||||
}
|
||||
else if (item.State == OverlayItemState.Visible && elapsed.TotalMilliseconds >= value.LifetimeMs)
|
||||
{
|
||||
item.State = OverlayItemState.Exiting; item.StateAt = current; changed = true;
|
||||
}
|
||||
else if (item.State == OverlayItemState.Exiting && elapsed.TotalMilliseconds >= value.ExitDurationMs)
|
||||
{
|
||||
item.State = OverlayItemState.Removed; changed = true;
|
||||
}
|
||||
}
|
||||
if (_items.RemoveAll(item => item.State == OverlayItemState.Removed) > 0) changed = true;
|
||||
if (changed) Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
var current = _now();
|
||||
foreach (var item in _items.Where(item => item.State is not OverlayItemState.Exiting and not OverlayItemState.Removed))
|
||||
{
|
||||
item.State = OverlayItemState.Exiting;
|
||||
item.StateAt = current;
|
||||
}
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void ExitWhere(Func<OverlayQueueItem, bool> predicate)
|
||||
{
|
||||
var current = _now();
|
||||
var changed = false;
|
||||
foreach (var item in _items.Where(item => item.State is not OverlayItemState.Exiting and not OverlayItemState.Removed && predicate(item)))
|
||||
{
|
||||
item.State = OverlayItemState.Exiting;
|
||||
item.StateAt = current;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
namespace Lumi.Companion.Overlay;
|
||||
|
||||
public static class OverlayVisibilityPolicy
|
||||
{
|
||||
public static bool Automatic(LumiOverlaySettings settings, IEnumerable<OverlaySource> sources)
|
||||
{
|
||||
if (!settings.Enabled || settings.Visibility == OverlayVisibilityMode.Off) return false;
|
||||
if (settings.Visibility == OverlayVisibilityMode.On) return true;
|
||||
var selected = settings.SelectedSourceIds.ToHashSet(StringComparer.Ordinal);
|
||||
return sources.Any(source => source.Live == true &&
|
||||
source.Platform is "twitch" or "youtube" &&
|
||||
selected.Contains(source.Id));
|
||||
}
|
||||
|
||||
public static bool Effective(LumiOverlaySettings settings, IEnumerable<OverlaySource> sources, bool preview, bool? visibilityOverride) =>
|
||||
preview || settings.Enabled && (visibilityOverride ?? Automatic(settings, sources));
|
||||
}
|
||||
|
||||
public sealed record OverlayVisibilityPresentation(bool Visible, string State, string Action, bool CanToggle);
|
||||
|
||||
public static class OverlayVisibilityPresenter
|
||||
{
|
||||
public static OverlayVisibilityPresentation Describe(
|
||||
LumiOverlaySettings settings,
|
||||
bool automaticVisible,
|
||||
bool preview,
|
||||
bool? visibilityOverride)
|
||||
{
|
||||
if (preview)
|
||||
return new(true, "Shown — sample preview is controlling visibility", "End preview to change visibility", false);
|
||||
if (!settings.Enabled)
|
||||
return new(false, "Hidden — Lumi Overlay is disabled", "Enable and save Lumi Overlay first", false);
|
||||
|
||||
var visible = visibilityOverride ?? automaticVisible;
|
||||
if (visibilityOverride.HasValue)
|
||||
return new(visible,
|
||||
visible ? "Shown — temporary override" : "Hidden — temporary override",
|
||||
"Restore automatic visibility",
|
||||
true);
|
||||
|
||||
var automaticDetail = settings.Visibility switch
|
||||
{
|
||||
OverlayVisibilityMode.Off => "visibility is set to Off",
|
||||
OverlayVisibilityMode.WhenLive when visible => "a selected channel is live",
|
||||
OverlayVisibilityMode.WhenLive => "waiting for a selected channel to go live",
|
||||
_ => "visibility is set to Always on"
|
||||
};
|
||||
return new(visible,
|
||||
$"{(visible ? "Shown" : "Hidden")} — automatic; {automaticDetail}",
|
||||
visible ? "Hide temporarily" : "Show temporarily",
|
||||
true);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MonitorResolution(string? MonitorId, bool UsedFallback, string? Warning);
|
||||
|
||||
public static class OverlayMonitorPolicy
|
||||
{
|
||||
public static MonitorResolution Resolve(string? savedMonitorId, IEnumerable<string> availableMonitorIds, string? primaryMonitorId)
|
||||
{
|
||||
var available = availableMonitorIds.Where(value => !string.IsNullOrWhiteSpace(value)).ToHashSet(StringComparer.Ordinal);
|
||||
if (savedMonitorId is not null && available.Contains(savedMonitorId)) return new(savedMonitorId, false, null);
|
||||
var fallback = primaryMonitorId is not null && available.Contains(primaryMonitorId) ? primaryMonitorId : available.FirstOrDefault();
|
||||
return new(fallback, savedMonitorId is not null,
|
||||
savedMonitorId is null ? null : "The selected monitor is unavailable. Lumi Overlay is temporarily using the primary monitor.");
|
||||
}
|
||||
}
|
||||
|
||||
public static class CompanionLiveReconnectPolicy
|
||||
{
|
||||
public static TimeSpan Delay(int attempt) => TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, Math.Min(Math.Max(1, attempt), 5))));
|
||||
}
|
||||
192
companion/plugins/Lumi.Companion.Overlay/OverlaySettings.cs
Normal file
192
companion/plugins/Lumi.Companion.Overlay/OverlaySettings.cs
Normal file
@ -0,0 +1,192 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Lumi.Companion.Overlay;
|
||||
|
||||
public enum OverlayVisibilityMode { Off, WhenLive, On }
|
||||
public enum OverlayLayoutMode { Unified, DividedHorizontal, DividedVertical, Separate }
|
||||
public enum OverlayAnchor { TopLeft, Top, TopRight, Left, Right, BottomLeft, Bottom, BottomRight }
|
||||
public enum OverlayHorizontalAlignment { Left, Middle, Right }
|
||||
public enum OverlayVerticalAlignment { Top, Middle, Bottom }
|
||||
public enum OverlayNewestDirection { Start, End }
|
||||
public enum OverlayAnimation { None, Fade, Slide, FadeSlide, ScaleFade }
|
||||
|
||||
public sealed class OverlaySourceSetting
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Platform { get; set; } = "";
|
||||
public string Label { get; set; } = "";
|
||||
public bool Selected { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OverlayContainerSettings
|
||||
{
|
||||
public OverlayAnchor Anchor { get; set; } = OverlayAnchor.BottomLeft;
|
||||
public int PaddingTop { get; set; } = 24;
|
||||
public int PaddingRight { get; set; } = 24;
|
||||
public int PaddingBottom { get; set; } = 24;
|
||||
public int PaddingLeft { get; set; } = 24;
|
||||
public int Width { get; set; } = 700;
|
||||
public int Height { get; set; } = 600;
|
||||
public OverlayHorizontalAlignment HorizontalAlignment { get; set; } = OverlayHorizontalAlignment.Left;
|
||||
public OverlayVerticalAlignment VerticalAlignment { get; set; } = OverlayVerticalAlignment.Bottom;
|
||||
public OverlayNewestDirection NewestDirection { get; set; } = OverlayNewestDirection.End;
|
||||
public string CardBackground { get; set; } = "#12121266";
|
||||
public string TextColor { get; set; } = "#bbbbbb";
|
||||
public string FontFamily { get; set; } = "Segoe UI";
|
||||
public double FontSize { get; set; } = 22;
|
||||
public string TextShadow { get; set; } = "#000000aa";
|
||||
public int ShadowX { get; set; } = 1;
|
||||
public int ShadowY { get; set; } = 2;
|
||||
public int ShadowBlur { get; set; } = 3;
|
||||
public bool ShowAvatar { get; set; } = true;
|
||||
public bool ShowUsername { get; set; } = true;
|
||||
public bool ShowPlatform { get; set; } = true;
|
||||
public OverlayAnimation EntryAnimation { get; set; } = OverlayAnimation.FadeSlide;
|
||||
public OverlayAnimation ExitAnimation { get; set; } = OverlayAnimation.FadeSlide;
|
||||
public int EntryDurationMs { get; set; } = 260;
|
||||
public int ExitDurationMs { get; set; } = 220;
|
||||
public string Easing { get; set; } = "cubic-out";
|
||||
public int SlideDistance { get; set; } = 24;
|
||||
public double ScaleAmount { get; set; } = 0.92;
|
||||
public int LifetimeMs { get; set; } = 12000;
|
||||
public int QueueLimit { get; set; } = 12;
|
||||
|
||||
public OverlayContainerSettings CreateCopy() => new()
|
||||
{
|
||||
Anchor = Anchor,
|
||||
PaddingTop = PaddingTop,
|
||||
PaddingRight = PaddingRight,
|
||||
PaddingBottom = PaddingBottom,
|
||||
PaddingLeft = PaddingLeft,
|
||||
Width = Width,
|
||||
Height = Height,
|
||||
HorizontalAlignment = HorizontalAlignment,
|
||||
VerticalAlignment = VerticalAlignment,
|
||||
NewestDirection = NewestDirection,
|
||||
CardBackground = CardBackground,
|
||||
TextColor = TextColor,
|
||||
FontFamily = FontFamily,
|
||||
FontSize = FontSize,
|
||||
TextShadow = TextShadow,
|
||||
ShadowX = ShadowX,
|
||||
ShadowY = ShadowY,
|
||||
ShadowBlur = ShadowBlur,
|
||||
ShowAvatar = ShowAvatar,
|
||||
ShowUsername = ShowUsername,
|
||||
ShowPlatform = ShowPlatform,
|
||||
EntryAnimation = EntryAnimation,
|
||||
ExitAnimation = ExitAnimation,
|
||||
EntryDurationMs = EntryDurationMs,
|
||||
ExitDurationMs = ExitDurationMs,
|
||||
Easing = Easing,
|
||||
SlideDistance = SlideDistance,
|
||||
ScaleAmount = ScaleAmount,
|
||||
LifetimeMs = LifetimeMs,
|
||||
QueueLimit = QueueLimit
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class LumiOverlaySettings
|
||||
{
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
public string ActiveProfileId { get; set; } = "default";
|
||||
public bool Enabled { get; set; } = true;
|
||||
public OverlayVisibilityMode Visibility { get; set; } = OverlayVisibilityMode.On;
|
||||
public string? MonitorId { get; set; }
|
||||
public List<OverlaySourceSetting> Sources { get; set; } = [];
|
||||
public HashSet<string> EventTypes { get; set; } = [];
|
||||
public OverlayLayoutMode Layout { get; set; } = OverlayLayoutMode.Unified;
|
||||
public int ChatPercentage { get; set; } = 70;
|
||||
public OverlayContainerSettings Unified { get; set; } = new();
|
||||
public OverlayContainerSettings Chat { get; set; } = new();
|
||||
public OverlayContainerSettings Events { get; set; } = new() { Anchor = OverlayAnchor.TopRight, Width = 520, Height = 420 };
|
||||
public string ToggleVisibilityHotkey { get; set; } = "Ctrl+Alt+L";
|
||||
public string TogglePreviewHotkey { get; set; } = "Ctrl+Alt+P";
|
||||
|
||||
[JsonIgnore]
|
||||
public IEnumerable<string> SelectedSourceIds => Sources.Where(source => source.Selected).Select(source => source.Id);
|
||||
|
||||
public LumiOverlaySettings CreateCopy() => new()
|
||||
{
|
||||
SchemaVersion = SchemaVersion,
|
||||
ActiveProfileId = ActiveProfileId,
|
||||
Enabled = Enabled,
|
||||
Visibility = Visibility,
|
||||
MonitorId = MonitorId,
|
||||
Sources = Sources.Select(source => new OverlaySourceSetting
|
||||
{
|
||||
Id = source.Id,
|
||||
Platform = source.Platform,
|
||||
Label = source.Label,
|
||||
Selected = source.Selected
|
||||
}).ToList(),
|
||||
EventTypes = EventTypes.ToHashSet(StringComparer.Ordinal),
|
||||
Layout = Layout,
|
||||
ChatPercentage = ChatPercentage,
|
||||
Unified = Unified.CreateCopy(),
|
||||
Chat = Chat.CreateCopy(),
|
||||
Events = Events.CreateCopy(),
|
||||
ToggleVisibilityHotkey = ToggleVisibilityHotkey,
|
||||
TogglePreviewHotkey = TogglePreviewHotkey
|
||||
};
|
||||
|
||||
public void Normalize()
|
||||
{
|
||||
SchemaVersion = 1;
|
||||
ActiveProfileId = Clean(ActiveProfileId, 80, "default");
|
||||
MonitorId = string.IsNullOrWhiteSpace(MonitorId) ? null : MonitorId.Trim()[..Math.Min(MonitorId.Trim().Length, 300)];
|
||||
Sources = Sources.Where(source => !string.IsNullOrWhiteSpace(source.Id))
|
||||
.GroupBy(source => source.Id, StringComparer.Ordinal).Select(group => group.First()).Take(250).ToList();
|
||||
foreach (var source in Sources)
|
||||
{
|
||||
source.Id = Clean(source.Id, 300, "");
|
||||
source.Platform = Clean(source.Platform, 24, "");
|
||||
source.Label = Clean(source.Label, 300, source.Id);
|
||||
}
|
||||
EventTypes = EventTypes.Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => Clean(value, 120, "")).Take(100).ToHashSet();
|
||||
ChatPercentage = Math.Clamp(ChatPercentage, 10, 90);
|
||||
NormalizeContainer(Unified);
|
||||
NormalizeContainer(Chat);
|
||||
NormalizeContainer(Events);
|
||||
ToggleVisibilityHotkey = Clean(ToggleVisibilityHotkey, 80, "Ctrl+Alt+L");
|
||||
TogglePreviewHotkey = Clean(TogglePreviewHotkey, 80, "Ctrl+Alt+P");
|
||||
}
|
||||
|
||||
private static void NormalizeContainer(OverlayContainerSettings value)
|
||||
{
|
||||
value.Width = Math.Clamp(value.Width, 160, 16000);
|
||||
value.Height = Math.Clamp(value.Height, 100, 16000);
|
||||
value.PaddingTop = Math.Clamp(value.PaddingTop, 0, 4000);
|
||||
value.PaddingRight = Math.Clamp(value.PaddingRight, 0, 4000);
|
||||
value.PaddingBottom = Math.Clamp(value.PaddingBottom, 0, 4000);
|
||||
value.PaddingLeft = Math.Clamp(value.PaddingLeft, 0, 4000);
|
||||
AnchorLayout.ClampPadding(value);
|
||||
value.CardBackground = ColorValue(value.CardBackground, true, "#12121266");
|
||||
value.TextColor = ColorValue(value.TextColor, false, "#bbbbbb");
|
||||
value.TextShadow = ColorValue(value.TextShadow, true, "#000000aa");
|
||||
value.FontFamily = Clean(value.FontFamily, 120, "Segoe UI");
|
||||
value.FontSize = Math.Clamp(value.FontSize, 8, 120);
|
||||
value.ShadowX = Math.Clamp(value.ShadowX, -40, 40);
|
||||
value.ShadowY = Math.Clamp(value.ShadowY, -40, 40);
|
||||
value.ShadowBlur = Math.Clamp(value.ShadowBlur, 0, 40);
|
||||
value.EntryDurationMs = Math.Clamp(value.EntryDurationMs, 0, 5000);
|
||||
value.ExitDurationMs = Math.Clamp(value.ExitDurationMs, 0, 5000);
|
||||
value.SlideDistance = Math.Clamp(value.SlideDistance, 0, 1000);
|
||||
value.ScaleAmount = Math.Clamp(value.ScaleAmount, 0.1, 2);
|
||||
value.LifetimeMs = Math.Clamp(value.LifetimeMs, 1000, 300000);
|
||||
value.QueueLimit = Math.Clamp(value.QueueLimit, 1, 100);
|
||||
value.Easing = Clean(value.Easing, 40, "cubic-out");
|
||||
}
|
||||
|
||||
private static string Clean(string? value, int max, string fallback)
|
||||
{
|
||||
var clean = (value ?? "").Trim();
|
||||
return clean.Length == 0 ? fallback : clean[..Math.Min(clean.Length, max)];
|
||||
}
|
||||
|
||||
private static string ColorValue(string? value, bool alpha, string fallback) =>
|
||||
System.Text.RegularExpressions.Regex.IsMatch(value ?? "", alpha ? "^#[0-9a-fA-F]{8}$" : "^#[0-9a-fA-F]{6}$") ? value! : fallback;
|
||||
}
|
||||
|
||||
public sealed record OverlaySource(string Id, string Platform, string Label, bool? Live, bool Available, string Status);
|
||||
public sealed record OverlayEventType(string Id, string Label, string Platform);
|
||||
@ -0,0 +1,40 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Lumi.Companion.Overlay;
|
||||
|
||||
public sealed class OverlaySettingsStore(string path)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||
public LumiOverlaySettings Current { get; private set; } = new();
|
||||
|
||||
public async Task LoadAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(path)) { Current.Normalize(); return; }
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
Current = await JsonSerializer.DeserializeAsync<LumiOverlaySettings>(stream, JsonOptions, cancellationToken).ConfigureAwait(false) ?? new();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
var backup = path + $".invalid-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}";
|
||||
File.Copy(path, backup, true);
|
||||
Current = new();
|
||||
}
|
||||
Current.Normalize();
|
||||
}
|
||||
|
||||
public async Task SaveAsync(LumiOverlaySettings value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
value.Normalize();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
var temporary = path + ".tmp";
|
||||
await using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, value, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
File.Move(temporary, path, true);
|
||||
Current = value;
|
||||
}
|
||||
}
|
||||
16
companion/plugins/Lumi.Companion.Overlay/plugin.json
Normal file
16
companion/plugins/Lumi.Companion.Overlay/plugin.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "lumi_overlay",
|
||||
"name": "Lumi Overlay",
|
||||
"version": "0.1.2",
|
||||
"provider_api": 1,
|
||||
"capabilities": [
|
||||
"network.lumi.overlay.read",
|
||||
"windows.monitor.overlay",
|
||||
"windows.capture-exclusion",
|
||||
"windows.global-hotkeys"
|
||||
],
|
||||
"navigation": {
|
||||
"root": "Lumi Overlay",
|
||||
"pages": ["LumiOverlay"]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseSystemDrawing>true</UseSystemDrawing>
|
||||
<AssemblyName>Lumi.Companion.SongOverlay</AssemblyName>
|
||||
<RootNamespace>Lumi.Companion.SongOverlay</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/Lumi.Companion.Abstractions/Lumi.Companion.Abstractions.csproj" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
53
companion/plugins/Lumi.Companion.SongOverlay/MediaModels.cs
Normal file
53
companion/plugins/Lumi.Companion.SongOverlay/MediaModels.cs
Normal file
@ -0,0 +1,53 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Lumi.Companion.SongOverlay;
|
||||
|
||||
internal sealed record MediaTrack(
|
||||
string Key,
|
||||
string Title,
|
||||
string Artist,
|
||||
string Album,
|
||||
string ReleaseYear,
|
||||
string Link,
|
||||
long DurationMilliseconds,
|
||||
CoverPayload? Cover);
|
||||
|
||||
internal sealed record MediaSnapshot(
|
||||
string Provider,
|
||||
string Status,
|
||||
long PositionMilliseconds,
|
||||
long DurationMilliseconds,
|
||||
double PlaybackRate,
|
||||
MediaTrack? Track,
|
||||
DateTimeOffset CapturedAt);
|
||||
|
||||
internal sealed record CoverPayload(
|
||||
[property: JsonPropertyName("mime")] string Mime,
|
||||
[property: JsonPropertyName("base64")] string Base64,
|
||||
[property: JsonPropertyName("primary")] string Primary,
|
||||
[property: JsonPropertyName("secondary")] string Secondary);
|
||||
|
||||
internal sealed record TrackPayload(
|
||||
[property: JsonPropertyName("key")] string Key,
|
||||
[property: JsonPropertyName("title")] string Title,
|
||||
[property: JsonPropertyName("artist")] string Artist,
|
||||
[property: JsonPropertyName("album")] string Album,
|
||||
[property: JsonPropertyName("release_year")] string ReleaseYear,
|
||||
[property: JsonPropertyName("link")] string Link,
|
||||
[property: JsonPropertyName("cover")] CoverPayload? Cover);
|
||||
|
||||
internal sealed record PlaybackPayload(
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("position_ms")] long PositionMilliseconds,
|
||||
[property: JsonPropertyName("duration_ms")] long DurationMilliseconds,
|
||||
[property: JsonPropertyName("rate")] double Rate);
|
||||
|
||||
internal sealed record NowPlayingEventPayload(
|
||||
[property: JsonPropertyName("protocol_version")] int ProtocolVersion,
|
||||
[property: JsonPropertyName("provider")] string Provider,
|
||||
[property: JsonPropertyName("session_id")] string SessionId,
|
||||
[property: JsonPropertyName("sequence")] long Sequence,
|
||||
[property: JsonPropertyName("event")] string Event,
|
||||
[property: JsonPropertyName("occurred_at")] long OccurredAt,
|
||||
[property: JsonPropertyName("playback")] PlaybackPayload Playback,
|
||||
[property: JsonPropertyName("track")] TrackPayload? Track);
|
||||
@ -0,0 +1,66 @@
|
||||
namespace Lumi.Companion.SongOverlay;
|
||||
|
||||
public sealed record MediaSourceDefinition(string Id, string Label, string Detail, bool EnabledByDefault);
|
||||
|
||||
public static class MediaSourceCatalog
|
||||
{
|
||||
public static IReadOnlyList<MediaSourceDefinition> All { get; } =
|
||||
[
|
||||
new("wmp", "Windows Media Player", "Windows Media Player and the modern Windows Media Player app", true),
|
||||
new("youtube", "YouTube / YouTube Music", "YouTube playback exposed by Chrome or Edge", true),
|
||||
new("spotify", "Spotify", "Spotify desktop playback", true),
|
||||
new("vlc", "VLC", "VLC media playback", false),
|
||||
new("apple_music", "iTunes / Apple Music", "iTunes and Apple Music for Windows", false),
|
||||
new("soundcloud", "SoundCloud", "SoundCloud playback exposed by a browser or app", false),
|
||||
new("tidal", "TIDAL", "TIDAL playback exposed by a browser or app", false),
|
||||
new("bandcamp", "Bandcamp", "Bandcamp playback exposed by a browser", false),
|
||||
new("qobuz", "Qobuz", "Qobuz playback exposed by a browser or app", false)
|
||||
];
|
||||
|
||||
public static HashSet<string> Defaults() =>
|
||||
All.Where(source => source.EnabledByDefault).Select(source => source.Id).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
public static string? Detect(string? sourceAppId, string? title, string? artist, string? album)
|
||||
{
|
||||
var source = (sourceAppId ?? "").ToLowerInvariant();
|
||||
var metadata = string.Join(" ", title, artist, album).ToLowerInvariant();
|
||||
if (source.Contains("spotify")) return "spotify";
|
||||
if (source.Contains("vlc")) return "vlc";
|
||||
if (source.Contains("itunes") || source.Contains("applemusic") || source.Contains("apple music")) return "apple_music";
|
||||
if (source.Contains("soundcloud")) return "soundcloud";
|
||||
if (source.Contains("tidal")) return "tidal";
|
||||
if (source.Contains("bandcamp")) return "bandcamp";
|
||||
if (source.Contains("qobuz")) return "qobuz";
|
||||
if (source.Contains("wmplayer") || source.Contains("zunemusic") || source.Contains("mediaplayer")) return "wmp";
|
||||
|
||||
var browser = source.Contains("chrome") || source.Contains("msedge") || source.Contains("firefox");
|
||||
if (!browser) return null;
|
||||
if (metadata.Contains("soundcloud")) return "soundcloud";
|
||||
if (metadata.Contains("tidal")) return "tidal";
|
||||
if (metadata.Contains("bandcamp")) return "bandcamp";
|
||||
if (metadata.Contains("qobuz")) return "qobuz";
|
||||
if (metadata.Contains("youtube") || metadata.Contains("yt music")) return "youtube";
|
||||
|
||||
// Browser media sessions do not consistently expose their origin. Treat
|
||||
// an otherwise unidentified browser session as YouTube, the enabled
|
||||
// browser default, while named services remain independently switchable.
|
||||
return "youtube";
|
||||
}
|
||||
|
||||
public static string? DetectEnabled(
|
||||
string? sourceAppId,
|
||||
string? title,
|
||||
string? artist,
|
||||
string? album,
|
||||
IReadOnlySet<string> enabled)
|
||||
{
|
||||
var detected = Detect(sourceAppId, title, artist, album);
|
||||
if (detected != "youtube" || enabled.Contains("youtube")) return detected;
|
||||
var source = (sourceAppId ?? "").ToLowerInvariant();
|
||||
if (!source.Contains("chrome") && !source.Contains("msedge") && !source.Contains("firefox")) return detected;
|
||||
return new[] { "soundcloud", "tidal", "bandcamp", "qobuz" }.FirstOrDefault(enabled.Contains);
|
||||
}
|
||||
|
||||
public static string Label(string? id) =>
|
||||
All.FirstOrDefault(source => source.Id == id)?.Label ?? "Windows media";
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
namespace Lumi.Companion.SongOverlay.Providers;
|
||||
|
||||
internal interface IMediaProvider : IAsyncDisposable
|
||||
{
|
||||
string Id { get; }
|
||||
string DisplayName { get; }
|
||||
event EventHandler<ProviderStateChangedEventArgs>? StateChanged;
|
||||
event EventHandler<string>? AvailabilityChanged;
|
||||
Task StartAsync(CancellationToken cancellationToken);
|
||||
Task StopAsync(CancellationToken cancellationToken);
|
||||
Task<MediaSnapshot?> GetSnapshotAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class ProviderStateChangedEventArgs : EventArgs
|
||||
{
|
||||
public ProviderStateChangedEventArgs(string reason, MediaSnapshot? snapshot)
|
||||
{
|
||||
Reason = reason;
|
||||
Snapshot = snapshot;
|
||||
}
|
||||
|
||||
public string Reason { get; }
|
||||
public MediaSnapshot? Snapshot { get; }
|
||||
}
|
||||
@ -0,0 +1,349 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Windows.Media.Control;
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
namespace Lumi.Companion.SongOverlay.Providers;
|
||||
|
||||
internal sealed class WindowsMediaProvider : IMediaProvider
|
||||
{
|
||||
private const int MaxCoverInputBytes = 3 * 1024 * 1024;
|
||||
private const int MaxCoverOutputBytes = 500 * 1024;
|
||||
private readonly Func<SongOverlaySettings> _settings;
|
||||
private readonly Func<MediaTrack, CancellationToken, Task<MediaTrack>> _enrich;
|
||||
private readonly Action<string, string, Exception?> _log;
|
||||
private GlobalSystemMediaTransportControlsSessionManager? _manager;
|
||||
private GlobalSystemMediaTransportControlsSession? _session;
|
||||
private MediaSnapshot? _last;
|
||||
private MediaTrack? _trackCache;
|
||||
private string _sourceId = "windows-media";
|
||||
private bool _started;
|
||||
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
||||
|
||||
public WindowsMediaProvider(Func<SongOverlaySettings> settings, Func<MediaTrack, CancellationToken, Task<MediaTrack>> enrich, Action<string, string, Exception?> log)
|
||||
{
|
||||
_settings = settings;
|
||||
_enrich = enrich;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public string Id => "windows-media";
|
||||
public string DisplayName => "Windows media discovery";
|
||||
public event EventHandler<ProviderStateChangedEventArgs>? StateChanged;
|
||||
public event EventHandler<string>? AvailabilityChanged;
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_started) return;
|
||||
_manager = await GlobalSystemMediaTransportControlsSessionManager.RequestAsync();
|
||||
_manager.CurrentSessionChanged += OnSessionCollectionChanged;
|
||||
_manager.SessionsChanged += OnSessionCollectionChanged;
|
||||
_started = true;
|
||||
await SelectSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_started) return;
|
||||
if (_manager is not null)
|
||||
{
|
||||
_manager.CurrentSessionChanged -= OnSessionCollectionChanged;
|
||||
_manager.SessionsChanged -= OnSessionCollectionChanged;
|
||||
}
|
||||
DetachSession();
|
||||
_manager = null;
|
||||
_started = false;
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<MediaSnapshot?> GetSnapshotAsync(CancellationToken cancellationToken) => RefreshAsync("snapshot", true, cancellationToken);
|
||||
|
||||
private async void OnSessionCollectionChanged(GlobalSystemMediaTransportControlsSessionManager sender, object args)
|
||||
{
|
||||
try { await SelectSessionAsync(CancellationToken.None).ConfigureAwait(false); }
|
||||
catch (Exception error) { _log("media_session_refresh_failed", "Could not refresh Windows media sessions", error); }
|
||||
}
|
||||
|
||||
private async Task SelectSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var selected = await SelectEnabledSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (ReferenceEquals(selected, _session))
|
||||
{
|
||||
if (_session is not null) await RefreshAndPublishAsync("media", true, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
DetachSession();
|
||||
_session = selected;
|
||||
if (_session is null)
|
||||
{
|
||||
AvailabilityChanged?.Invoke(this, "No enabled source is exposing a Windows media session.");
|
||||
if (_last is not null)
|
||||
{
|
||||
_last = null;
|
||||
StateChanged?.Invoke(this, new ProviderStateChangedEventArgs("stop", null));
|
||||
}
|
||||
return;
|
||||
}
|
||||
_session.MediaPropertiesChanged += OnMediaPropertiesChanged;
|
||||
_session.PlaybackInfoChanged += OnPlaybackInfoChanged;
|
||||
_session.TimelinePropertiesChanged += OnTimelinePropertiesChanged;
|
||||
AvailabilityChanged?.Invoke(this, $"{MediaSourceCatalog.Label(_sourceId)} media session connected.");
|
||||
await RefreshAndPublishAsync("snapshot", true, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void DetachSession()
|
||||
{
|
||||
if (_session is null) return;
|
||||
_session.MediaPropertiesChanged -= OnMediaPropertiesChanged;
|
||||
_session.PlaybackInfoChanged -= OnPlaybackInfoChanged;
|
||||
_session.TimelinePropertiesChanged -= OnTimelinePropertiesChanged;
|
||||
_session = null;
|
||||
_trackCache = null;
|
||||
}
|
||||
|
||||
private async void OnMediaPropertiesChanged(GlobalSystemMediaTransportControlsSession sender, MediaPropertiesChangedEventArgs args)
|
||||
{
|
||||
try { await SelectSessionAsync(CancellationToken.None).ConfigureAwait(false); }
|
||||
catch (Exception error) { _log("media_property_update_failed", "Windows media-property update failed", error); }
|
||||
}
|
||||
|
||||
private async void OnPlaybackInfoChanged(GlobalSystemMediaTransportControlsSession sender, PlaybackInfoChangedEventArgs args)
|
||||
{
|
||||
try { await RefreshAndPublishAsync("playback", false, CancellationToken.None).ConfigureAwait(false); }
|
||||
catch (Exception error) { _log("media_playback_update_failed", "Windows media playback update failed", error); }
|
||||
}
|
||||
|
||||
private async void OnTimelinePropertiesChanged(GlobalSystemMediaTransportControlsSession sender, TimelinePropertiesChangedEventArgs args)
|
||||
{
|
||||
try { await RefreshAndPublishAsync("timeline", false, CancellationToken.None).ConfigureAwait(false); }
|
||||
catch (Exception error) { _log("media_timeline_update_failed", "Windows media timeline update failed", error); }
|
||||
}
|
||||
|
||||
private async Task RefreshAndPublishAsync(string reason, bool enrichTrack, CancellationToken cancellationToken)
|
||||
{
|
||||
var snapshot = await RefreshAsync(reason, enrichTrack, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null) return;
|
||||
StateChanged?.Invoke(this, new ProviderStateChangedEventArgs(reason, snapshot));
|
||||
}
|
||||
|
||||
private async Task<MediaSnapshot?> RefreshAsync(string reason, bool enrichTrack, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_session is null) return null;
|
||||
await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var playback = _session.GetPlaybackInfo();
|
||||
var timeline = _session.GetTimelineProperties();
|
||||
var duration = Math.Max(0, (long)timeline.EndTime.TotalMilliseconds);
|
||||
var position = Math.Max(0, (long)timeline.Position.TotalMilliseconds);
|
||||
var status = playback.PlaybackStatus switch
|
||||
{
|
||||
GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing => "playing",
|
||||
GlobalSystemMediaTransportControlsSessionPlaybackStatus.Paused => "paused",
|
||||
GlobalSystemMediaTransportControlsSessionPlaybackStatus.Stopped => "stopped",
|
||||
GlobalSystemMediaTransportControlsSessionPlaybackStatus.Closed => "closed",
|
||||
_ => "unknown"
|
||||
};
|
||||
var capturedAt = DateTimeOffset.UtcNow;
|
||||
var rate = playback.PlaybackRate ?? 1d;
|
||||
if (rate <= 0) rate = 1d;
|
||||
if (status == "playing" && timeline.LastUpdatedTime != default)
|
||||
{
|
||||
var elapsed = Math.Max(0, (capturedAt - timeline.LastUpdatedTime).TotalMilliseconds);
|
||||
position += (long)(elapsed * rate);
|
||||
if (duration > 0) position = Math.Min(duration, position);
|
||||
}
|
||||
|
||||
var needsMediaRefresh = _trackCache is null || reason is "media" or "snapshot";
|
||||
if (needsMediaRefresh)
|
||||
{
|
||||
var media = await _session.TryGetMediaPropertiesAsync();
|
||||
var title = Clean(media.Title);
|
||||
var artist = Clean(media.Artist);
|
||||
var album = Clean(media.AlbumTitle);
|
||||
_sourceId = MediaSourceCatalog.DetectEnabled(
|
||||
_session.SourceAppUserModelId, title, artist, album, _settings().EnabledSources) ?? _sourceId;
|
||||
if (!string.IsNullOrWhiteSpace(title) || !string.IsNullOrWhiteSpace(artist))
|
||||
{
|
||||
var key = Fingerprint(title, artist, album, duration);
|
||||
var cover = _settings().SendCoverArt ? await ReadCoverAsync(media.Thumbnail, cancellationToken).ConfigureAwait(false) : null;
|
||||
var fallbackLink = _sourceId == "spotify" && _settings().UseSearchLinkFallback
|
||||
? "https://open.spotify.com/search/" + Uri.EscapeDataString(string.Join(" ", new[] { title, artist }.Where(value => value.Length > 0)))
|
||||
: "";
|
||||
var track = new MediaTrack(key, title, artist, album, "", fallbackLink, duration, cover);
|
||||
_trackCache = track;
|
||||
if (enrichTrack && _sourceId == "spotify") _ = EnrichAndPublishAsync(track);
|
||||
}
|
||||
else
|
||||
{
|
||||
_trackCache = null;
|
||||
}
|
||||
}
|
||||
else if (_trackCache is not null && _trackCache.DurationMilliseconds != duration)
|
||||
{
|
||||
_trackCache = _trackCache with { DurationMilliseconds = duration };
|
||||
}
|
||||
|
||||
var snapshot = new MediaSnapshot(_sourceId, status, position, duration, rate, _trackCache, capturedAt);
|
||||
_last = snapshot;
|
||||
return snapshot;
|
||||
}
|
||||
finally { _refreshLock.Release(); }
|
||||
}
|
||||
|
||||
|
||||
private async Task EnrichAndPublishAsync(MediaTrack track)
|
||||
{
|
||||
try
|
||||
{
|
||||
var enriched = await _enrich(track, CancellationToken.None).ConfigureAwait(false);
|
||||
if (enriched == track) return;
|
||||
await _refreshLock.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_trackCache?.Key != track.Key) return;
|
||||
_trackCache = enriched;
|
||||
}
|
||||
finally { _refreshLock.Release(); }
|
||||
await RefreshAndPublishAsync("metadata", false, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception error) { _log("spotify_metadata_update_failed", "Spotify metadata enrichment update failed", error); }
|
||||
}
|
||||
|
||||
private async Task<GlobalSystemMediaTransportControlsSession?> SelectEnabledSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_manager is null) return null;
|
||||
GlobalSystemMediaTransportControlsSession? fallback = null;
|
||||
string? fallbackSource = null;
|
||||
foreach (var session in _manager.GetSessions())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var media = await session.TryGetMediaPropertiesAsync();
|
||||
var sourceId = MediaSourceCatalog.DetectEnabled(
|
||||
session.SourceAppUserModelId, media.Title, media.Artist, media.AlbumTitle, _settings().EnabledSources);
|
||||
if (sourceId is null || !_settings().EnabledSources.Contains(sourceId)) continue;
|
||||
var playing = session.GetPlaybackInfo().PlaybackStatus ==
|
||||
GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing;
|
||||
if (playing)
|
||||
{
|
||||
_sourceId = sourceId;
|
||||
return session;
|
||||
}
|
||||
fallback ??= session;
|
||||
fallbackSource ??= sourceId;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
if (fallbackSource is not null) _sourceId = fallbackSource;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static async Task<CoverPayload?> ReadCoverAsync(IRandomAccessStreamReference? reference, CancellationToken cancellationToken)
|
||||
{
|
||||
if (reference is null) return null;
|
||||
try
|
||||
{
|
||||
using var stream = await reference.OpenReadAsync();
|
||||
var length = checked((uint)Math.Min(stream.Size, MaxCoverInputBytes));
|
||||
if (length == 0) return null;
|
||||
using var reader = new DataReader(stream.GetInputStreamAt(0));
|
||||
var loaded = await reader.LoadAsync(length);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (loaded == 0) return null;
|
||||
var bytes = new byte[checked((int)loaded)];
|
||||
reader.ReadBytes(bytes);
|
||||
var mime = string.IsNullOrWhiteSpace(stream.ContentType) ? "image/jpeg" : stream.ContentType;
|
||||
return CreateCoverPayload(bytes, mime);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
|
||||
internal static CoverPayload? CreateCoverPayload(byte[] bytes, string sourceMime)
|
||||
{
|
||||
if (bytes.Length == 0 || bytes.Length > MaxCoverInputBytes) return null;
|
||||
try
|
||||
{
|
||||
var encoded = ResizeJpeg(bytes, 512, 82L);
|
||||
if (encoded.Length > MaxCoverOutputBytes) encoded = ResizeJpeg(bytes, 320, 68L);
|
||||
if (encoded.Length == 0 || encoded.Length > MaxCoverOutputBytes) return null;
|
||||
var colors = ExtractColors(encoded);
|
||||
return new CoverPayload("image/jpeg", Convert.ToBase64String(encoded), colors.Primary, colors.Secondary);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static byte[] ResizeJpeg(byte[] bytes, int maximumDimension, long quality)
|
||||
{
|
||||
using var input = new MemoryStream(bytes);
|
||||
using var original = new Bitmap(input);
|
||||
var ratio = Math.Min(1d, Math.Min((double)maximumDimension / original.Width, (double)maximumDimension / original.Height));
|
||||
var width = Math.Max(1, (int)Math.Round(original.Width * ratio));
|
||||
var height = Math.Max(1, (int)Math.Round(original.Height * ratio));
|
||||
using var resized = new Bitmap(width, height, PixelFormat.Format24bppRgb);
|
||||
using (var graphics = Graphics.FromImage(resized))
|
||||
{
|
||||
graphics.Clear(Color.Black);
|
||||
graphics.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality;
|
||||
graphics.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
|
||||
graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality;
|
||||
graphics.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality;
|
||||
graphics.DrawImage(original, 0, 0, width, height);
|
||||
}
|
||||
using var output = new MemoryStream();
|
||||
var codec = ImageCodecInfo.GetImageEncoders().First(item => item.FormatID == ImageFormat.Jpeg.Guid);
|
||||
using var parameters = new EncoderParameters(1);
|
||||
parameters.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, quality);
|
||||
resized.Save(output, codec, parameters);
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
internal static (string Primary, string Secondary) ExtractColors(byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var memory = new MemoryStream(bytes);
|
||||
using var bitmap = new Bitmap(memory);
|
||||
var samples = new List<Color>();
|
||||
var stepX = Math.Max(1, bitmap.Width / 12);
|
||||
var stepY = Math.Max(1, bitmap.Height / 12);
|
||||
for (var y = stepY / 2; y < bitmap.Height; y += stepY)
|
||||
for (var x = stepX / 2; x < bitmap.Width; x += stepX)
|
||||
{
|
||||
var color = bitmap.GetPixel(x, y);
|
||||
if (color.A > 100) samples.Add(color);
|
||||
}
|
||||
if (samples.Count == 0) return ("#1f2937", "#111827");
|
||||
var primary = Average(samples.OrderByDescending(c => Saturation(c)).Take(Math.Max(1, samples.Count / 3)));
|
||||
var secondary = Average(samples.OrderBy(c => Brightness(c)).Take(Math.Max(1, samples.Count / 3)));
|
||||
return (Hex(primary), Hex(secondary));
|
||||
}
|
||||
catch { return ("#1f2937", "#111827"); }
|
||||
}
|
||||
|
||||
private static Color Average(IEnumerable<Color> colors)
|
||||
{
|
||||
var list = colors.ToList();
|
||||
return list.Count == 0 ? Color.FromArgb(31, 41, 55) : Color.FromArgb(
|
||||
(int)list.Average(c => c.R), (int)list.Average(c => c.G), (int)list.Average(c => c.B));
|
||||
}
|
||||
private static double Saturation(Color c) => c.GetSaturation();
|
||||
private static double Brightness(Color c) => c.GetBrightness();
|
||||
private static string Hex(Color c) => $"#{c.R:X2}{c.G:X2}{c.B:X2}";
|
||||
private static string Clean(string? value) => string.Join(" ", (value ?? "").Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).Trim();
|
||||
private static string Fingerprint(string title, string artist, string album, long duration)
|
||||
{
|
||||
var input = Encoding.UTF8.GetBytes($"{title}\u001f{artist}\u001f{album}\u001f{duration}");
|
||||
return Convert.ToHexString(SHA256.HashData(input)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
_refreshLock.Dispose();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,411 @@
|
||||
using Lumi.Companion.Abstractions;
|
||||
using Lumi.Companion.SongOverlay.Providers;
|
||||
using Lumi.Companion.SongOverlay.Spotify;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Lumi.Companion.SongOverlay;
|
||||
|
||||
public sealed class SongOverlayRuntime : ICompanionPluginContribution, IAsyncDisposable
|
||||
{
|
||||
private const string PluginId = "now_playing";
|
||||
private readonly SongOverlaySettingsStore _settingsStore;
|
||||
private readonly SongOverlaySecretProtector _secrets = new();
|
||||
private readonly SongOverlayTransport _transport;
|
||||
private readonly string _logDirectory;
|
||||
private IMediaProvider? _provider;
|
||||
private SpotifyWebApiEnricher? _spotify;
|
||||
private CancellationTokenSource? _lifetime;
|
||||
private PeriodicTimer? _heartbeat;
|
||||
private Task? _heartbeatTask;
|
||||
private readonly SemaphoreSlim _eventLock = new(1, 1);
|
||||
private MediaSnapshot? _lastObserved;
|
||||
private MediaSnapshot? _lastDelivered;
|
||||
private readonly List<string> _trackHistory = [];
|
||||
private int _historyIndex = -1;
|
||||
private long _sequence;
|
||||
private readonly string _sessionId = Guid.NewGuid().ToString("N");
|
||||
private bool _initialized;
|
||||
|
||||
public SongOverlayRuntime(
|
||||
string pluginDataDirectory,
|
||||
string logDirectory,
|
||||
ICompanionPluginTransport hostTransport)
|
||||
{
|
||||
Directory.CreateDirectory(pluginDataDirectory);
|
||||
_logDirectory = logDirectory;
|
||||
_settingsStore = new SongOverlaySettingsStore(Path.Combine(pluginDataDirectory, "settings.json"));
|
||||
_transport = new SongOverlayTransport(hostTransport);
|
||||
Actions =
|
||||
[
|
||||
new CompanionPluginAction("send", () => "Send current song now", SendSnapshotAsync, () => _initialized && Settings.Enabled, 10),
|
||||
new CompanionPluginAction("toggle", () => Settings.Enabled ? "Disable monitoring" : "Enable monitoring", ToggleEnabledAsync, () => _initialized, 20)
|
||||
];
|
||||
}
|
||||
|
||||
public CompanionPluginDescriptor Descriptor { get; } = new(
|
||||
PluginId,
|
||||
"Song Overlay",
|
||||
new Version(0, 1, 2),
|
||||
"Reads provider-neutral Windows media-session events and sends minimal playback changes to Lumi.",
|
||||
200);
|
||||
|
||||
public IReadOnlyList<CompanionPluginPage> Pages { get; } =
|
||||
[
|
||||
new CompanionPluginPage("SongOverlay", "Overview & settings", 10)
|
||||
];
|
||||
|
||||
public IReadOnlyList<CompanionPluginAction> Actions { get; }
|
||||
public CompanionPluginStatus Status { get; private set; } = new(CompanionPluginHealth.Ready, "Starting", "Waiting for initialization.");
|
||||
public SongOverlaySettings Settings => _settingsStore.Current;
|
||||
public bool IsInitialized => _initialized;
|
||||
public bool IsSpotifyEnrichmentConnected => _spotify?.IsConfigured == true;
|
||||
public bool UsesCompanionAuthentication => _transport.IsConfigured;
|
||||
public string ProviderStatus => Status.Detail ?? Status.Summary;
|
||||
public string? CurrentTrack => _lastObserved?.Track is null ? null : $"{_lastObserved.Track.Title} — {_lastObserved.Track.Artist}";
|
||||
public Uri? EffectiveLumiBaseUri => _transport.BaseUri;
|
||||
public event Action? Changed;
|
||||
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _settingsStore.LoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
Settings.Normalize();
|
||||
// Re-save through the current schema so legacy plugin-specific Lumi host
|
||||
// and connection-key fields are removed after the shared transport upgrade.
|
||||
await _settingsStore.SaveAsync(Settings, cancellationToken).ConfigureAwait(false);
|
||||
_spotify = new SpotifyWebApiEnricher(Settings, _secrets, SaveSettingsSync, Log);
|
||||
_initialized = true;
|
||||
await RestartProviderAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task SaveSettingsAsync(bool restartProvider, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Settings.Normalize();
|
||||
await _settingsStore.SaveAsync(Settings, cancellationToken).ConfigureAwait(false);
|
||||
if (restartProvider) await RestartProviderAsync(cancellationToken).ConfigureAwait(false);
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
public async Task ToggleEnabledAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Settings.Enabled = !Settings.Enabled;
|
||||
await SaveSettingsAsync(restartProvider: true, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ConnectSpotifyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureInitialized();
|
||||
await _spotify!.AuthorizeAsync(cancellationToken).ConfigureAwait(false);
|
||||
SaveSettingsSync();
|
||||
RaiseChanged();
|
||||
if (_transport.IsConfigured)
|
||||
await SendSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void DisconnectSpotify()
|
||||
{
|
||||
_spotify?.Disconnect();
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
public async Task SendSnapshotAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureInitialized();
|
||||
if (!_transport.IsConfigured) throw new InvalidOperationException("Pair Companion with Lumi before sending song updates.");
|
||||
if (_provider is null) throw new InvalidOperationException("No media provider is running.");
|
||||
var snapshot = await _provider.GetSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is null) throw new InvalidOperationException("None of the enabled media sources is currently exposing playback information.");
|
||||
await SendAsync("snapshot", snapshot, includeTrack: true, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RestartProviderAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await StopProviderAsync().ConfigureAwait(false);
|
||||
if (!Settings.Enabled)
|
||||
{
|
||||
SetStatus(CompanionPluginHealth.Ready, "Disabled", "Song Overlay monitoring is disabled.");
|
||||
return;
|
||||
}
|
||||
_lastObserved = null;
|
||||
_lastDelivered = null;
|
||||
_lifetime = new CancellationTokenSource();
|
||||
_provider = new WindowsMediaProvider(() => Settings, EnrichTrackAsync, Log);
|
||||
_provider.StateChanged += OnProviderStateChanged;
|
||||
_provider.AvailabilityChanged += OnAvailabilityChanged;
|
||||
try
|
||||
{
|
||||
await _provider.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
StartHeartbeat();
|
||||
var initial = await _provider.GetSnapshotAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!_transport.IsConfigured)
|
||||
SetStatus(CompanionPluginHealth.Warning, "Pairing required", "Media monitoring is ready, but Companion must be paired before Lumi can receive updates.");
|
||||
else if (initial is null)
|
||||
SetStatus(CompanionPluginHealth.Warning, "Waiting for media", "No enabled source is currently exposing a Windows media session.");
|
||||
else
|
||||
SetStatus(CompanionPluginHealth.Healthy, "Monitoring", Describe(initial));
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
SetStatus(CompanionPluginHealth.Error, "Provider failed", "Could not start Windows media discovery: " + error.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StopProviderAsync()
|
||||
{
|
||||
_lifetime?.Cancel();
|
||||
_heartbeat?.Dispose();
|
||||
if (_heartbeatTask is not null)
|
||||
{
|
||||
try { await _heartbeatTask.ConfigureAwait(false); } catch { }
|
||||
}
|
||||
_heartbeat = null;
|
||||
_heartbeatTask = null;
|
||||
if (_provider is not null)
|
||||
{
|
||||
_provider.StateChanged -= OnProviderStateChanged;
|
||||
_provider.AvailabilityChanged -= OnAvailabilityChanged;
|
||||
await _provider.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
_provider = null;
|
||||
_lifetime?.Dispose();
|
||||
_lifetime = null;
|
||||
}
|
||||
|
||||
private void StartHeartbeat()
|
||||
{
|
||||
_heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(Math.Clamp(Settings.HeartbeatSeconds, 15, 300)));
|
||||
var token = _lifetime?.Token ?? CancellationToken.None;
|
||||
_heartbeatTask = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (_heartbeat is not null && await _heartbeat.WaitForNextTickAsync(token).ConfigureAwait(false))
|
||||
{
|
||||
var current = _lastObserved;
|
||||
if (current is null || !_transport.IsConfigured) continue;
|
||||
await SendAsync("heartbeat", current, includeTrack: _lastDelivered?.Track?.Key != current.Track?.Key, token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception error) { Log("heartbeat_failed", "Song Overlay heartbeat failed", error); }
|
||||
}, token);
|
||||
}
|
||||
|
||||
private void OnAvailabilityChanged(object? sender, string message)
|
||||
{
|
||||
if (!_transport.IsConfigured)
|
||||
{
|
||||
SetStatus(CompanionPluginHealth.Warning, "Pairing required", message);
|
||||
return;
|
||||
}
|
||||
var waiting = message.Contains("not exposing", StringComparison.OrdinalIgnoreCase) ||
|
||||
message.Contains("No enabled source", StringComparison.OrdinalIgnoreCase);
|
||||
SetStatus(waiting ? CompanionPluginHealth.Warning : CompanionPluginHealth.Healthy,
|
||||
waiting ? "Waiting for media" : "Monitoring", message);
|
||||
}
|
||||
|
||||
private void OnProviderStateChanged(object? sender, ProviderStateChangedEventArgs args) =>
|
||||
_ = HandleStateChangeAsync(args.Reason, args.Snapshot, _lifetime?.Token ?? CancellationToken.None);
|
||||
|
||||
private async Task HandleStateChangeAsync(string reason, MediaSnapshot? snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
await _eventLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (snapshot is null)
|
||||
{
|
||||
if (_lastObserved is null) return;
|
||||
var stopped = _lastObserved with { Status = "stopped", PositionMilliseconds = 0, Track = null, CapturedAt = DateTimeOffset.UtcNow };
|
||||
_lastObserved = null;
|
||||
if (_transport.IsConfigured) await SendAsync("stop", stopped, false, cancellationToken).ConfigureAwait(false);
|
||||
else RaiseChanged();
|
||||
return;
|
||||
}
|
||||
var previous = _lastObserved;
|
||||
_lastObserved = snapshot;
|
||||
RaiseChanged();
|
||||
if (!_transport.IsConfigured)
|
||||
{
|
||||
SetStatus(CompanionPluginHealth.Warning, "Pairing required", Describe(snapshot));
|
||||
return;
|
||||
}
|
||||
var currentTrackKey = snapshot.Track?.Key;
|
||||
var trackChanged = !string.IsNullOrWhiteSpace(currentTrackKey)
|
||||
&& !string.Equals(currentTrackKey, previous?.Track?.Key, StringComparison.Ordinal);
|
||||
if (trackChanged)
|
||||
{
|
||||
await SendAsync(ClassifyTrackDirection(currentTrackKey!), snapshot, true, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
if (previous is null)
|
||||
{
|
||||
await SendAsync("snapshot", snapshot, true, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
if (reason == "metadata")
|
||||
{
|
||||
await SendAsync("snapshot", snapshot, true, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
if (!string.Equals(previous.Status, snapshot.Status, StringComparison.Ordinal))
|
||||
{
|
||||
var playbackEvent = snapshot.Status switch
|
||||
{
|
||||
"paused" => "pause",
|
||||
"stopped" or "closed" => "stop",
|
||||
"playing" when previous.Status == "paused" => "resume",
|
||||
"playing" => "play",
|
||||
_ => "snapshot"
|
||||
};
|
||||
await SendAsync(playbackEvent, snapshot, false, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
if (reason == "timeline")
|
||||
{
|
||||
var expected = ProjectPosition(previous, snapshot.CapturedAt);
|
||||
if (Math.Abs(snapshot.PositionMilliseconds - expected) >= Math.Clamp(Settings.SeekThresholdMilliseconds, 500, 10000))
|
||||
await SendAsync("seek", snapshot, false, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception error)
|
||||
{
|
||||
Log("event_processing_failed", "Could not process a Song Overlay event", error);
|
||||
SetStatus(CompanionPluginHealth.Warning, "Delivery delayed", "Playback was detected, but Lumi could not be updated: " + error.Message);
|
||||
}
|
||||
finally { _eventLock.Release(); }
|
||||
}
|
||||
|
||||
private async Task SendAsync(string eventName, MediaSnapshot snapshot, bool includeTrack, CancellationToken cancellationToken)
|
||||
{
|
||||
var track = includeTrack && snapshot.Track is not null
|
||||
? new TrackPayload(snapshot.Track.Key, snapshot.Track.Title, snapshot.Track.Artist, snapshot.Track.Album,
|
||||
snapshot.Track.ReleaseYear, snapshot.Track.Link, snapshot.Track.Cover)
|
||||
: null;
|
||||
var payload = new NowPlayingEventPayload(
|
||||
1,
|
||||
snapshot.Provider,
|
||||
_sessionId,
|
||||
Interlocked.Increment(ref _sequence),
|
||||
eventName,
|
||||
snapshot.CapturedAt.ToUnixTimeMilliseconds(),
|
||||
new PlaybackPayload(snapshot.Status, snapshot.PositionMilliseconds, snapshot.DurationMilliseconds, snapshot.PlaybackRate),
|
||||
track);
|
||||
var response = await _transport.SendJsonAsync("/plugins/now_playing/api/companion/state", payload, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"Lumi rejected the update with HTTP {response.StatusCode}: {Trim(response.Body, 240)}");
|
||||
}
|
||||
_lastDelivered = snapshot;
|
||||
SetStatus(CompanionPluginHealth.Healthy, snapshot.Status == "playing" ? "Playing" : "Connected", Describe(snapshot));
|
||||
}
|
||||
|
||||
private async Task<MediaTrack> EnrichTrackAsync(MediaTrack track, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_spotify?.IsConfigured != true) return track;
|
||||
SpotifyEnrichment? enriched = null;
|
||||
foreach (var delay in new[] { 0, 500, 1000 })
|
||||
{
|
||||
if (delay > 0) await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
enriched = await _spotify.EnrichAsync(track, cancellationToken).ConfigureAwait(false);
|
||||
if (enriched is not null) break;
|
||||
}
|
||||
if (enriched is null) return track;
|
||||
CoverPayload? cover = track.Cover;
|
||||
if (enriched.CoverBytes is { Length: > 0 } bytes && Settings.SendCoverArt)
|
||||
cover = WindowsMediaProvider.CreateCoverPayload(bytes, enriched.CoverMime) ?? cover;
|
||||
return track with
|
||||
{
|
||||
Link = string.IsNullOrWhiteSpace(enriched.Link) ? track.Link : enriched.Link,
|
||||
ReleaseYear = string.IsNullOrWhiteSpace(enriched.ReleaseYear) ? track.ReleaseYear : enriched.ReleaseYear,
|
||||
Cover = cover
|
||||
};
|
||||
}
|
||||
|
||||
private string ClassifyTrackDirection(string key)
|
||||
{
|
||||
if (_historyIndex > 0 && _trackHistory[_historyIndex - 1] == key) { _historyIndex--; return "previous"; }
|
||||
if (_historyIndex >= 0 && _historyIndex + 1 < _trackHistory.Count && _trackHistory[_historyIndex + 1] == key) { _historyIndex++; return "next"; }
|
||||
if (_historyIndex + 1 < _trackHistory.Count) _trackHistory.RemoveRange(_historyIndex + 1, _trackHistory.Count - _historyIndex - 1);
|
||||
_trackHistory.Add(key);
|
||||
if (_trackHistory.Count > 50) _trackHistory.RemoveAt(0);
|
||||
_historyIndex = _trackHistory.Count - 1;
|
||||
return "track_changed";
|
||||
}
|
||||
|
||||
private static long ProjectPosition(MediaSnapshot snapshot, DateTimeOffset at)
|
||||
{
|
||||
if (snapshot.Status != "playing") return snapshot.PositionMilliseconds;
|
||||
var elapsed = Math.Max(0, (at - snapshot.CapturedAt).TotalMilliseconds);
|
||||
return Math.Min(snapshot.DurationMilliseconds > 0 ? snapshot.DurationMilliseconds : long.MaxValue,
|
||||
snapshot.PositionMilliseconds + (long)(elapsed * snapshot.PlaybackRate));
|
||||
}
|
||||
|
||||
private void SaveSettingsSync() => _settingsStore.Save(Settings);
|
||||
private void EnsureInitialized()
|
||||
{
|
||||
if (!_initialized) throw new InvalidOperationException("The Song Overlay companion plugin has not initialized yet.");
|
||||
}
|
||||
|
||||
private void SetStatus(CompanionPluginHealth health, string summary, string detail)
|
||||
{
|
||||
Status = new CompanionPluginStatus(health, summary, detail);
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
private void RaiseChanged() => Changed?.Invoke();
|
||||
private void Log(string eventId, string message, Exception? error = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_logDirectory);
|
||||
PruneLogs();
|
||||
var normalizedEvent = CompanionLogSanitizer.NormalizeEvent(eventId);
|
||||
var line = JsonSerializer.Serialize(new
|
||||
{
|
||||
timestamp = DateTimeOffset.UtcNow,
|
||||
level = CompanionLogSanitizer.LevelForEvent(normalizedEvent),
|
||||
source = $"plugin:{PluginId}",
|
||||
category = "plugin",
|
||||
@event = normalizedEvent,
|
||||
message = CompanionLogSanitizer.Sanitize(message),
|
||||
error = error is null ? null : CompanionLogSanitizer.Sanitize(error.ToString())
|
||||
});
|
||||
File.AppendAllText(
|
||||
Path.Combine(_logDirectory, $"song-overlay-{DateTime.UtcNow:yyyy-MM-dd}.jsonl"),
|
||||
line + Environment.NewLine
|
||||
);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private void PruneLogs()
|
||||
{
|
||||
var files = new DirectoryInfo(_logDirectory)
|
||||
.GetFiles("song-overlay-*.*")
|
||||
.Where(file => file.Extension is ".jsonl" or ".log")
|
||||
.OrderBy(file => file.CreationTimeUtc)
|
||||
.ToList();
|
||||
foreach (var file in files.Where(file => file.CreationTimeUtc < DateTime.UtcNow.AddDays(-7))) file.Delete();
|
||||
const long cap = 32L * 1024 * 1024;
|
||||
var total = files.Where(file => file.Exists).Sum(file => file.Length);
|
||||
foreach (var file in files.Where(file => file.Exists))
|
||||
{
|
||||
if (total <= cap) break;
|
||||
total -= file.Length;
|
||||
file.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
private static string Describe(MediaSnapshot snapshot) => snapshot.Track is null
|
||||
? "Connected; no active song."
|
||||
: $"{snapshot.Status}: {snapshot.Track.Title} — {snapshot.Track.Artist}";
|
||||
private static string Trim(string value, int max) => value.Length <= max ? value : value[..max];
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopProviderAsync().ConfigureAwait(false);
|
||||
_spotify?.Dispose();
|
||||
_eventLock.Dispose();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Lumi.Companion.SongOverlay;
|
||||
|
||||
internal sealed class SongOverlaySecretProtector
|
||||
{
|
||||
private static readonly byte[] Entropy = "Lumi.Companion.SongOverlay.v1"u8.ToArray();
|
||||
|
||||
public string Protect(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "";
|
||||
return Convert.ToBase64String(ProtectedData.Protect(Encoding.UTF8.GetBytes(value), Entropy, DataProtectionScope.CurrentUser));
|
||||
}
|
||||
|
||||
public string Unprotect(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "";
|
||||
try
|
||||
{
|
||||
return Encoding.UTF8.GetString(ProtectedData.Unprotect(Convert.FromBase64String(value), Entropy, DataProtectionScope.CurrentUser));
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
namespace Lumi.Companion.SongOverlay;
|
||||
|
||||
public sealed class SongOverlaySettings
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string ProviderId { get; set; } = "spotify";
|
||||
public HashSet<string> EnabledSources { get; set; } = MediaSourceCatalog.Defaults();
|
||||
public int HeartbeatSeconds { get; set; } = 30;
|
||||
public int SeekThresholdMilliseconds { get; set; } = 1500;
|
||||
public bool SendCoverArt { get; set; } = true;
|
||||
public bool UseSearchLinkFallback { get; set; } = true;
|
||||
public string SpotifyClientId { get; set; } = "";
|
||||
public string ProtectedSpotifyRefreshToken { get; set; } = "";
|
||||
|
||||
public void Normalize()
|
||||
{
|
||||
ProviderId = "windows-media";
|
||||
EnabledSources ??= MediaSourceCatalog.Defaults();
|
||||
EnabledSources = EnabledSources
|
||||
.Where(id => MediaSourceCatalog.All.Any(source => source.Id == id))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
HeartbeatSeconds = Math.Clamp(HeartbeatSeconds, 15, 300);
|
||||
SeekThresholdMilliseconds = Math.Clamp(SeekThresholdMilliseconds, 500, 10000);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Lumi.Companion.SongOverlay;
|
||||
|
||||
internal sealed class SongOverlaySettingsStore(string path)
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
public SongOverlaySettings Current { get; private set; } = new();
|
||||
|
||||
public async Task LoadAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(path)) return;
|
||||
try
|
||||
{
|
||||
Current = JsonSerializer.Deserialize<SongOverlaySettings>(
|
||||
await File.ReadAllBytesAsync(path, cancellationToken),
|
||||
SongOverlayJson.Options) ?? new();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
Current = new();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(SongOverlaySettings settings)
|
||||
{
|
||||
_gate.Wait();
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
var temporary = $"{path}.{Environment.ProcessId}.tmp";
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(temporary, JsonSerializer.SerializeToUtf8Bytes(settings, SongOverlayJson.Options));
|
||||
File.Move(temporary, path, true);
|
||||
}
|
||||
finally { File.Delete(temporary); }
|
||||
Current = settings;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task SaveAsync(SongOverlaySettings settings, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
var temporary = $"{path}.{Environment.ProcessId}.tmp";
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(temporary, JsonSerializer.SerializeToUtf8Bytes(settings, SongOverlayJson.Options), cancellationToken);
|
||||
File.Move(temporary, path, true);
|
||||
}
|
||||
finally { File.Delete(temporary); }
|
||||
Current = settings;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
}
|
||||
|
||||
internal static class SongOverlayJson
|
||||
{
|
||||
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
using Lumi.Companion.Abstractions;
|
||||
|
||||
namespace Lumi.Companion.SongOverlay;
|
||||
|
||||
internal sealed class SongOverlayTransport(ICompanionPluginTransport host)
|
||||
{
|
||||
public Uri? BaseUri => host.LumiBaseUri;
|
||||
|
||||
public bool IsConfigured => host.IsAuthenticated;
|
||||
|
||||
public Task<CompanionPluginHttpResponse> SendJsonAsync(
|
||||
string relativePath,
|
||||
object payload,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
host.PostJsonAsync(relativePath, payload, cancellationToken);
|
||||
}
|
||||
@ -0,0 +1,256 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Lumi.Companion.SongOverlay.Spotify;
|
||||
|
||||
internal sealed record SpotifyEnrichment(string Link, string ReleaseYear, byte[]? CoverBytes, string CoverMime);
|
||||
|
||||
internal sealed class SpotifyWebApiEnricher : IDisposable
|
||||
{
|
||||
private const string Scope = "user-read-currently-playing";
|
||||
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
|
||||
private readonly SongOverlaySecretProtector _secrets;
|
||||
private readonly Action _save;
|
||||
private readonly Action<string, string, Exception?> _log;
|
||||
private readonly SongOverlaySettings _settings;
|
||||
private string _accessToken = "";
|
||||
private DateTimeOffset _accessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
|
||||
public SpotifyWebApiEnricher(SongOverlaySettings settings, SongOverlaySecretProtector secrets, Action save, Action<string, string, Exception?> log)
|
||||
{
|
||||
_settings = settings;
|
||||
_secrets = secrets;
|
||||
_save = save;
|
||||
_log = log;
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd("Lumi-Companion-NowPlaying/0.1.0");
|
||||
}
|
||||
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(_settings.SpotifyClientId) && !string.IsNullOrWhiteSpace(_settings.ProtectedSpotifyRefreshToken);
|
||||
|
||||
public async Task AuthorizeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var clientId = _settings.SpotifyClientId.Trim();
|
||||
if (string.IsNullOrWhiteSpace(clientId)) throw new InvalidOperationException("Enter your Spotify application Client ID first.");
|
||||
|
||||
var verifier = Base64Url(RandomNumberGenerator.GetBytes(64));
|
||||
var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)));
|
||||
var state = Base64Url(RandomNumberGenerator.GetBytes(24));
|
||||
var port = FindFreePort();
|
||||
var redirectUri = $"http://127.0.0.1:{port}/callback/";
|
||||
using var listener = new HttpListener();
|
||||
listener.Prefixes.Add(redirectUri);
|
||||
listener.Start();
|
||||
|
||||
var authorizationUrl = "https://accounts.spotify.com/authorize?" + BuildQuery(new Dictionary<string, string>
|
||||
{
|
||||
["client_id"] = clientId,
|
||||
["response_type"] = "code",
|
||||
["redirect_uri"] = redirectUri,
|
||||
["scope"] = Scope,
|
||||
["code_challenge_method"] = "S256",
|
||||
["code_challenge"] = challenge,
|
||||
["state"] = state
|
||||
});
|
||||
Process.Start(new ProcessStartInfo(authorizationUrl) { UseShellExecute = true });
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromMinutes(3));
|
||||
var contextTask = listener.GetContextAsync();
|
||||
var completed = await Task.WhenAny(contextTask, Task.Delay(Timeout.InfiniteTimeSpan, timeout.Token)).ConfigureAwait(false);
|
||||
if (completed != contextTask) throw new TimeoutException("Spotify authorization timed out.");
|
||||
var context = await contextTask.ConfigureAwait(false);
|
||||
var query = context.Request.QueryString;
|
||||
var responseText = "Spotify is connected to Lumi Companion. You can close this page.";
|
||||
try
|
||||
{
|
||||
if (!string.Equals(query["state"], state, StringComparison.Ordinal)) throw new InvalidOperationException("Spotify returned an invalid authorization state.");
|
||||
if (!string.IsNullOrWhiteSpace(query["error"])) throw new InvalidOperationException("Spotify authorization was declined: " + query["error"]);
|
||||
var code = query["code"] ?? throw new InvalidOperationException("Spotify did not return an authorization code.");
|
||||
var token = await ExchangeAsync(new Dictionary<string, string>
|
||||
{
|
||||
["client_id"] = clientId,
|
||||
["grant_type"] = "authorization_code",
|
||||
["code"] = code,
|
||||
["redirect_uri"] = redirectUri,
|
||||
["code_verifier"] = verifier
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
ApplyToken(token);
|
||||
if (string.IsNullOrWhiteSpace(token.RefreshToken)) throw new InvalidOperationException("Spotify did not provide a refresh token.");
|
||||
_settings.ProtectedSpotifyRefreshToken = _secrets.Protect(token.RefreshToken);
|
||||
_save();
|
||||
}
|
||||
catch
|
||||
{
|
||||
responseText = "Spotify could not be connected to Lumi Companion. Return to the app for details.";
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes($"<!doctype html><meta charset=\"utf-8\"><title>Lumi Companion</title><style>body{{font:18px system-ui;max-width:720px;margin:15vh auto;padding:24px;background:#111827;color:#f9fafb}}</style><h1>Lumi Companion</h1><p>{WebUtility.HtmlEncode(responseText)}</p>");
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
context.Response.ContentLength64 = bytes.Length;
|
||||
await context.Response.OutputStream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
|
||||
context.Response.Close();
|
||||
}
|
||||
}
|
||||
|
||||
public void Disconnect()
|
||||
{
|
||||
_accessToken = "";
|
||||
_accessTokenExpiresAt = DateTimeOffset.MinValue;
|
||||
_settings.ProtectedSpotifyRefreshToken = "";
|
||||
_save();
|
||||
}
|
||||
|
||||
public async Task<SpotifyEnrichment?> EnrichAsync(MediaTrack track, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsConfigured) return null;
|
||||
try
|
||||
{
|
||||
var token = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(token)) return null;
|
||||
using var response = await SendCurrentlyPlayingAsync(token, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NoContent) return null;
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
_accessToken = "";
|
||||
token = await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(token)) return null;
|
||||
using var retry = await SendCurrentlyPlayingAsync(token, cancellationToken).ConfigureAwait(false);
|
||||
if (retry.StatusCode == HttpStatusCode.NoContent || !retry.IsSuccessStatusCode) return null;
|
||||
return await ParseEnrichmentAsync(retry, track, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
return await ParseEnrichmentAsync(response, track, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
_log("spotify_metadata_enrichment_failed", "Spotify metadata enrichment failed", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task<HttpResponseMessage> SendCurrentlyPlayingAsync(string token, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.spotify.com/v1/me/player/currently-playing?additional_types=track");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
return await _http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<SpotifyEnrichment?> ParseEnrichmentAsync(HttpResponseMessage response, MediaTrack track, CancellationToken cancellationToken)
|
||||
{
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
|
||||
if (!document.RootElement.TryGetProperty("item", out var item) || item.ValueKind != JsonValueKind.Object) return null;
|
||||
var title = String(item, "name");
|
||||
var artists = item.TryGetProperty("artists", out var artistArray) && artistArray.ValueKind == JsonValueKind.Array
|
||||
? string.Join(", ", artistArray.EnumerateArray().Select(value => String(value, "name")).Where(value => value.Length > 0))
|
||||
: "";
|
||||
if (!LooseMatch(track.Title, title) || (!string.IsNullOrWhiteSpace(track.Artist) && !LooseMatch(track.Artist, artists))) return null;
|
||||
var link = item.TryGetProperty("external_urls", out var urls) ? String(urls, "spotify") : "";
|
||||
var releaseYear = "";
|
||||
string imageUrl = "";
|
||||
if (item.TryGetProperty("album", out var album))
|
||||
{
|
||||
var releaseDate = String(album, "release_date");
|
||||
if (releaseDate.Length >= 4) releaseYear = releaseDate[..4];
|
||||
if (album.TryGetProperty("images", out var images) && images.ValueKind == JsonValueKind.Array)
|
||||
imageUrl = images.EnumerateArray().Select(value => String(value, "url")).FirstOrDefault(value => value.Length > 0) ?? "";
|
||||
}
|
||||
byte[]? cover = null;
|
||||
var mime = "image/jpeg";
|
||||
if (!string.IsNullOrWhiteSpace(imageUrl))
|
||||
{
|
||||
using var imageResponse = await _http.GetAsync(imageUrl, cancellationToken).ConfigureAwait(false);
|
||||
if (imageResponse.IsSuccessStatusCode)
|
||||
{
|
||||
cover = await imageResponse.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (cover.Length > 3 * 1024 * 1024) cover = null;
|
||||
mime = imageResponse.Content.Headers.ContentType?.MediaType ?? mime;
|
||||
}
|
||||
}
|
||||
return new SpotifyEnrichment(link, releaseYear, cover, mime);
|
||||
}
|
||||
|
||||
private async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_accessToken) && _accessTokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(1)) return _accessToken;
|
||||
var refreshToken = _secrets.Unprotect(_settings.ProtectedSpotifyRefreshToken);
|
||||
if (string.IsNullOrWhiteSpace(refreshToken)) return "";
|
||||
try
|
||||
{
|
||||
var token = await ExchangeAsync(new Dictionary<string, string>
|
||||
{
|
||||
["client_id"] = _settings.SpotifyClientId.Trim(),
|
||||
["grant_type"] = "refresh_token",
|
||||
["refresh_token"] = refreshToken
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
ApplyToken(token);
|
||||
if (!string.IsNullOrWhiteSpace(token.RefreshToken))
|
||||
{
|
||||
_settings.ProtectedSpotifyRefreshToken = _secrets.Protect(token.RefreshToken);
|
||||
_save();
|
||||
}
|
||||
return _accessToken;
|
||||
}
|
||||
catch (SpotifyInvalidGrantException)
|
||||
{
|
||||
Disconnect();
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TokenResponse> ExchangeAsync(Dictionary<string, string> values, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await _http.PostAsync("https://accounts.spotify.com/api/token", new FormUrlEncodedContent(values), cancellationToken).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
if (body.Contains("invalid_grant", StringComparison.OrdinalIgnoreCase)) throw new SpotifyInvalidGrantException();
|
||||
throw new InvalidOperationException($"Spotify token exchange failed with HTTP {(int)response.StatusCode}.");
|
||||
}
|
||||
using var document = JsonDocument.Parse(body);
|
||||
return new TokenResponse(
|
||||
String(document.RootElement, "access_token"),
|
||||
String(document.RootElement, "refresh_token"),
|
||||
document.RootElement.TryGetProperty("expires_in", out var expires) ? expires.GetInt32() : 3600);
|
||||
}
|
||||
|
||||
private void ApplyToken(TokenResponse token)
|
||||
{
|
||||
_accessToken = token.AccessToken;
|
||||
_accessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, token.ExpiresIn));
|
||||
}
|
||||
|
||||
private static string String(JsonElement element, string property) =>
|
||||
element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : "";
|
||||
|
||||
private static bool LooseMatch(string left, string right)
|
||||
{
|
||||
static string Normalize(string value) => new(value.ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
|
||||
var a = Normalize(left); var b = Normalize(right);
|
||||
return a.Length > 0 && b.Length > 0 && (a == b || a.Contains(b, StringComparison.Ordinal) || b.Contains(a, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static int FindFreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
private static string BuildQuery(IEnumerable<KeyValuePair<string, string>> values) =>
|
||||
string.Join("&", values.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
|
||||
private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
public void Dispose() => _http.Dispose();
|
||||
private sealed record TokenResponse(string AccessToken, string RefreshToken, int ExpiresIn);
|
||||
private sealed class SpotifyInvalidGrantException : Exception { }
|
||||
}
|
||||
16
companion/plugins/Lumi.Companion.SongOverlay/plugin.json
Normal file
16
companion/plugins/Lumi.Companion.SongOverlay/plugin.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "now_playing",
|
||||
"name": "Song Overlay",
|
||||
"version": "0.1.2",
|
||||
"provider_api": 1,
|
||||
"providers": ["windows-media", "spotify", "wmp", "youtube", "vlc", "apple-music", "soundcloud", "tidal", "bandcamp", "qobuz"],
|
||||
"capabilities": [
|
||||
"windows.media-session.read",
|
||||
"network.lumi",
|
||||
"network.spotify.optional"
|
||||
],
|
||||
"navigation": {
|
||||
"root": "Song Overlay",
|
||||
"pages": ["SongOverlay"]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0-windows</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup><ItemGroup><ProjectReference Include="../../src/Lumi.Companion.Core/Lumi.Companion.Core.csproj" /><ProjectReference Include="../../src/Lumi.Companion.Protocol/Lumi.Companion.Protocol.csproj" /></ItemGroup></Project>
|
||||
117
companion/plugins/Lumi.Companion.Transcription/ObsBridgePipe.cs
Normal file
117
companion/plugins/Lumi.Companion.Transcription/ObsBridgePipe.cs
Normal file
@ -0,0 +1,117 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Pipes;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Lumi.Companion.Protocol;
|
||||
|
||||
namespace Lumi.Companion.Transcription;
|
||||
|
||||
public sealed class ObsBridgePipe : IAsyncDisposable
|
||||
{
|
||||
private static readonly HashSet<string> AllowedTypes = ["hello", "source_list", "source_state", "selection_state", "obs_state", "health", "stream_test_service_state", "stream_test_metrics"];
|
||||
private readonly string _pipeName;
|
||||
private readonly Func<JsonElement, Task> _onMessage;
|
||||
private readonly Func<ReadOnlyMemory<byte>, Task> _onAudio;
|
||||
private readonly CancellationTokenSource _lifetime = new();
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<JsonElement>> _requests = new();
|
||||
private Task? _loop;
|
||||
private Stream? _connection;
|
||||
|
||||
public ObsBridgePipe(string userSidHash, Func<JsonElement, Task> onMessage, Func<ReadOnlyMemory<byte>, Task> onAudio)
|
||||
{
|
||||
_pipeName = $"Lumi.Companion.ObsBridge.v1.{userSidHash}";
|
||||
_onMessage = onMessage; _onAudio = onAudio;
|
||||
}
|
||||
public event Action<bool>? ConnectionChanged;
|
||||
public void Start() => _loop ??= Task.Run(() => ListenAsync(_lifetime.Token));
|
||||
private async Task ListenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await using var pipe = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, 65536, 65536);
|
||||
await pipe.WaitForConnectionAsync(cancellationToken);
|
||||
_connection = pipe;
|
||||
ConnectionChanged?.Invoke(true);
|
||||
try { await ReadConnectionAsync(pipe, cancellationToken); }
|
||||
catch (Exception) when (!cancellationToken.IsCancellationRequested) { /* reconnect without taking down the shell */ }
|
||||
finally { _connection = null; ConnectionChanged?.Invoke(false); }
|
||||
}
|
||||
}
|
||||
private async Task ReadConnectionAsync(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
var lengthBytes = new byte[4];
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await ReadExactlyAsync(stream, lengthBytes, cancellationToken);
|
||||
var length = BinaryPrimitives.ReadUInt32LittleEndian(lengthBytes);
|
||||
if (length is 0 or > ProtocolV1.MaxJsonBytes) throw new InvalidDataException("OBS bridge message exceeds its limit.");
|
||||
var message = new byte[(int)length];
|
||||
await ReadExactlyAsync(stream, message, cancellationToken);
|
||||
if (length >= 4 && message.AsSpan(0, 4).SequenceEqual("LACP"u8))
|
||||
{
|
||||
if (length > ProtocolV1.AudioHeaderBytes + ProtocolV1.MaxAudioPayloadBytes) throw new InvalidDataException("OBS audio frame exceeds its limit.");
|
||||
await _onAudio(message); continue;
|
||||
}
|
||||
using var json = JsonDocument.Parse(message);
|
||||
var type = json.RootElement.TryGetProperty("type", out var property) ? property.GetString() : null;
|
||||
if (type is null || !AllowedTypes.Contains(type)) throw new InvalidDataException("OBS bridge message type is not allowed.");
|
||||
var clone = json.RootElement.Clone();
|
||||
if (clone.TryGetProperty("request_id", out var requestId) && requestId.ValueKind == JsonValueKind.String &&
|
||||
_requests.TryRemove(requestId.GetString()!, out var pending)) pending.TrySetResult(clone);
|
||||
await _onMessage(clone);
|
||||
}
|
||||
}
|
||||
public static async Task WriteAsync(Stream stream, object message, CancellationToken cancellationToken)
|
||||
{
|
||||
var body = JsonSerializer.SerializeToUtf8Bytes(message, ProtocolV1.JsonOptions);
|
||||
if (body.Length > ProtocolV1.MaxJsonBytes) throw new InvalidDataException("Companion IPC message exceeds its limit.");
|
||||
var prefix = new byte[4]; BinaryPrimitives.WriteUInt32LittleEndian(prefix, (uint)body.Length);
|
||||
await stream.WriteAsync(prefix, cancellationToken); await stream.WriteAsync(body, cancellationToken); await stream.FlushAsync(cancellationToken);
|
||||
}
|
||||
public async Task<bool> SendAsync(object message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var connection = _connection;
|
||||
if (connection is null) return false;
|
||||
await _writeLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (!ReferenceEquals(connection, _connection)) return false;
|
||||
await WriteAsync(connection, message, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch (IOException) { return false; }
|
||||
finally { _writeLock.Release(); }
|
||||
}
|
||||
public async Task<JsonElement> RequestAsync(string type, object payload, TimeSpan timeout, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var requestId = Guid.NewGuid().ToString();
|
||||
var completion = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
if (!_requests.TryAdd(requestId, completion)) throw new InvalidOperationException("OBS request identity could not be reserved.");
|
||||
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(timeout);
|
||||
using var registration = timeoutSource.Token.Register(() => completion.TrySetCanceled(timeoutSource.Token));
|
||||
try
|
||||
{
|
||||
if (!await SendAsync(new { type, request_id = requestId, payload }, timeoutSource.Token))
|
||||
throw new InvalidOperationException("OBS is not connected to Lumi Companion.");
|
||||
return await completion.Task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_requests.TryRemove(requestId, out _);
|
||||
}
|
||||
}
|
||||
private static async Task ReadExactlyAsync(Stream stream, Memory<byte> buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
var read = 0;
|
||||
while (read < buffer.Length)
|
||||
{
|
||||
var count = await stream.ReadAsync(buffer[read..], cancellationToken);
|
||||
if (count == 0) throw new EndOfStreamException();
|
||||
read += count;
|
||||
}
|
||||
}
|
||||
public async ValueTask DisposeAsync() { _lifetime.Cancel(); foreach (var request in _requests.Values) request.TrySetCanceled(); _requests.Clear(); if (_loop is not null) try { await _loop; } catch (OperationCanceledException) { } _writeLock.Dispose(); _lifetime.Dispose(); }
|
||||
}
|
||||
111
companion/scripts/build-obs-bridge.ps1
Normal file
111
companion/scripts/build-obs-bridge.ps1
Normal file
@ -0,0 +1,111 @@
|
||||
param(
|
||||
[string]$ObsVersion = "31.1.1",
|
||||
[string]$BridgeVersion = "0.2.5",
|
||||
[string]$CacheRoot = "$env:LOCALAPPDATA\LumiCompanionBuild"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
||||
$nativeRoot = Join-Path $repoRoot "companion\native\obs-bridge"
|
||||
$componentRoot = Join-Path $repoRoot "companion\src\Lumi.Companion.App\components\obs-bridge"
|
||||
$obsRoot = Join-Path $CacheRoot "obs-$ObsVersion"
|
||||
$runtimeArchive = Join-Path $obsRoot "obs-windows.zip"
|
||||
$sourceArchive = Join-Path $obsRoot "obs-source.zip"
|
||||
$runtimeRoot = Join-Path $obsRoot "runtime"
|
||||
$sourceParent = Join-Path $obsRoot "source"
|
||||
$sourceRoot = Join-Path $sourceParent "obs-studio-$ObsVersion"
|
||||
$importRoot = Join-Path $obsRoot "imports"
|
||||
$buildRoot = Join-Path $obsRoot "bridge-build"
|
||||
|
||||
function Invoke-Native([string]$FilePath, [string[]]$Arguments) {
|
||||
$argumentLine = ($Arguments | ForEach-Object { '"' + ([string]$_).Replace('"', '\"') + '"' }) -join ' '
|
||||
$process = Start-Process -FilePath $FilePath -ArgumentList $argumentLine -NoNewWindow -Wait -PassThru
|
||||
return $process.ExitCode
|
||||
}
|
||||
|
||||
$runtimeUrl = "https://github.com/obsproject/obs-studio/releases/download/$ObsVersion/OBS-Studio-$ObsVersion-Windows-x64.zip"
|
||||
$sourceUrl = "https://github.com/obsproject/obs-studio/archive/refs/tags/$ObsVersion.zip"
|
||||
$runtimeSha256 = "9d8dceb77acd8af04af23f877061f63c9bef78ca73d2093d0ccba1bb9104173f"
|
||||
$sourceSha256 = "2c8427c10b55ac6d68008df2e9a3e82f4647aaad18f105e30d4713c2de678ccf"
|
||||
|
||||
function Get-VerifiedArchive([string]$Url, [string]$Path, [string]$ExpectedSha256) {
|
||||
if (!(Test-Path $Path) -or (Get-FileHash $Path -Algorithm SHA256).Hash.ToLowerInvariant() -ne $ExpectedSha256) {
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $Path) | Out-Null
|
||||
$partial = "$Path.partial"
|
||||
Remove-Item $partial -Force -ErrorAction SilentlyContinue
|
||||
Invoke-WebRequest -Uri $Url -OutFile $partial
|
||||
if ((Get-FileHash $partial -Algorithm SHA256).Hash.ToLowerInvariant() -ne $ExpectedSha256) {
|
||||
Remove-Item $partial -Force -ErrorAction SilentlyContinue
|
||||
throw "Checksum mismatch while downloading $Url"
|
||||
}
|
||||
Move-Item $partial $Path -Force
|
||||
}
|
||||
}
|
||||
|
||||
function New-ImportLibrary([string]$Dll, [string]$Name, [string]$Dumpbin, [string]$LibExe) {
|
||||
$definition = Join-Path $importRoot "$Name.def"
|
||||
$dumpOutputPath = Join-Path $importRoot "$Name.exports.txt"
|
||||
$dump = Start-Process -FilePath $Dumpbin -ArgumentList @("/nologo", "/exports", "`"$Dll`"") -RedirectStandardOutput $dumpOutputPath -NoNewWindow -Wait -PassThru
|
||||
if ($dump.ExitCode) { throw "Could not inspect exports for $Name." }
|
||||
$dumpOutput = Get-Content $dumpOutputPath
|
||||
$exports = @($dumpOutput | ForEach-Object {
|
||||
if ($_ -match '^\s+\d+\s+[0-9A-F]+\s+[0-9A-F]+\s+(\S+)') { $Matches[1] }
|
||||
})
|
||||
@("LIBRARY $Name", "EXPORTS") + ($exports | ForEach-Object { " $_" }) | Set-Content -Encoding Ascii $definition
|
||||
$exitCode = Invoke-Native $LibExe @("/nologo", "/machine:x64", "/def:$definition", "/out:$(Join-Path $importRoot "$Name.lib")")
|
||||
if ($exitCode) { throw "Could not create the $Name import library." }
|
||||
}
|
||||
|
||||
Get-VerifiedArchive $runtimeUrl $runtimeArchive $runtimeSha256
|
||||
Get-VerifiedArchive $sourceUrl $sourceArchive $sourceSha256
|
||||
if (!(Test-Path (Join-Path $runtimeRoot "bin\64bit\obs.dll"))) {
|
||||
Remove-Item $runtimeRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Expand-Archive $runtimeArchive $runtimeRoot
|
||||
}
|
||||
if (!(Test-Path (Join-Path $sourceRoot "libobs\obs-module.h"))) {
|
||||
Remove-Item $sourceParent -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Expand-Archive $sourceArchive $sourceParent
|
||||
}
|
||||
|
||||
$vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
$vsRoot = if (Test-Path $vswhere) { & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath }
|
||||
if (!$vsRoot) { $vsRoot = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\2022\BuildTools" }
|
||||
if (!(Test-Path $vsRoot)) { throw "Visual Studio 2022 C++ Build Tools are required." }
|
||||
$vcTools = Get-ChildItem (Join-Path $vsRoot "VC\Tools\MSVC") -Directory | Sort-Object { [version]$_.Name } | Select-Object -Last 1
|
||||
$toolRoot = Join-Path $vcTools.FullName "bin\Hostx64\x64"
|
||||
$dumpbin = Join-Path $toolRoot "dumpbin.exe"
|
||||
$lib = Join-Path $toolRoot "lib.exe"
|
||||
$cmakeCommand = Get-Command cmake.exe -ErrorAction SilentlyContinue
|
||||
$cmake = if ($cmakeCommand) { $cmakeCommand.Source } else {
|
||||
Get-ChildItem (Join-Path $env:APPDATA "Python") -Filter cmake.exe -File -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName
|
||||
}
|
||||
if (!$cmake) { throw "CMake 3.28 or newer is required." }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $importRoot | Out-Null
|
||||
New-ImportLibrary (Join-Path $runtimeRoot "bin\64bit\obs.dll") "obs" $dumpbin $lib
|
||||
New-ImportLibrary (Join-Path $runtimeRoot "bin\64bit\obs-frontend-api.dll") "obs-frontend-api" $dumpbin $lib
|
||||
|
||||
$configureArguments = @(
|
||||
"-S", $nativeRoot,
|
||||
"-B", $buildRoot,
|
||||
"-G", "Visual Studio 17 2022",
|
||||
"-A", "x64",
|
||||
"-DOBS_SOURCE_DIR=$sourceRoot",
|
||||
"-DOBS_IMPORT_DIR=$importRoot",
|
||||
"-DLUMI_BRIDGE_VERSION=$BridgeVersion"
|
||||
)
|
||||
if ((Invoke-Native $cmake $configureArguments)) { throw "OBS bridge configuration failed." }
|
||||
if ((Invoke-Native $cmake @("--build", $buildRoot, "--config", "Release"))) { throw "OBS bridge build failed." }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $componentRoot | Out-Null
|
||||
$bridgeDll = Join-Path $buildRoot "Release\lumi-obs-bridge.dll"
|
||||
Copy-Item $bridgeDll (Join-Path $componentRoot "lumi-obs-bridge.dll") -Force
|
||||
Copy-Item (Join-Path $nativeRoot "data\locale\en-US.ini") (Join-Path $componentRoot "en-US.ini") -Force
|
||||
$manifest = [ordered]@{
|
||||
version = $BridgeVersion
|
||||
sha256 = (Get-FileHash $bridgeDll -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
obs_minimum_version = "31.0.0"
|
||||
}
|
||||
$manifestJson = $manifest | ConvertTo-Json
|
||||
[IO.File]::WriteAllText((Join-Path $componentRoot "manifest.json"), $manifestJson, [Text.UTF8Encoding]::new($false))
|
||||
Write-Host "Built OBS bridge $BridgeVersion at $componentRoot"
|
||||
98
companion/scripts/collect-nuget-notices.ps1
Normal file
98
companion/scripts/collect-nuget-notices.ps1
Normal file
@ -0,0 +1,98 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
[Parameter(Mandatory = $true)][string]$OutputRoot
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repo = (Resolve-Path $RepoRoot).Path
|
||||
$assetFiles = @(Get-ChildItem (Join-Path $repo "companion") -Filter project.assets.json -File -Recurse -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -notmatch '[\\/]installer[\\/]output[\\/]' })
|
||||
if ($assetFiles.Count -eq 0) { throw "No restored NuGet project.assets.json files were found after publish." }
|
||||
|
||||
Remove-Item $OutputRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $OutputRoot | Out-Null
|
||||
|
||||
|
||||
function Get-ChildText([System.Xml.XmlNode]$Node, [string]$Name) {
|
||||
if (!$Node) { return $null }
|
||||
$child = $Node.SelectSingleNode("*[local-name()='$Name']")
|
||||
if (!$child) { return $null }
|
||||
return $child.InnerText.Trim()
|
||||
}
|
||||
|
||||
$packages = @{}
|
||||
foreach ($assetFile in $assetFiles) {
|
||||
$assets = Get-Content $assetFile.FullName -Raw | ConvertFrom-Json
|
||||
$packageFolders = @($assets.packageFolders.PSObject.Properties.Name)
|
||||
foreach ($libraryProperty in $assets.libraries.PSObject.Properties) {
|
||||
if ($libraryProperty.Value.type -ne "package") { continue }
|
||||
$parts = $libraryProperty.Name -split '/', 2
|
||||
if ($parts.Count -ne 2) { continue }
|
||||
$id = $parts[0]
|
||||
$version = $parts[1]
|
||||
$key = "$($id.ToLowerInvariant())/$($version.ToLowerInvariant())"
|
||||
if ($packages.ContainsKey($key)) { continue }
|
||||
|
||||
$packageRoot = $null
|
||||
foreach ($folder in $packageFolders) {
|
||||
$candidate = Join-Path $folder ($key -replace '/', [IO.Path]::DirectorySeparatorChar)
|
||||
if (Test-Path $candidate) { $packageRoot = (Resolve-Path $candidate).Path; break }
|
||||
}
|
||||
if (!$packageRoot) { throw "Restored package files are missing for $id $version." }
|
||||
$packages[$key] = [ordered]@{ Id = $id; Version = $version; Root = $packageRoot }
|
||||
}
|
||||
}
|
||||
|
||||
$inventory = [Collections.Generic.List[string]]::new()
|
||||
$inventory.Add("LUMI COMPANION - EXACT NUGET PACKAGE LICENCE INVENTORY")
|
||||
$inventory.Add("Generated: $([DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss')) UTC")
|
||||
$inventory.Add("")
|
||||
$inventory.Add("This file is generated from the restored project.assets.json files used by the Companion build. Package-specific licence, notice, copying, and copyright files found in the restored packages are copied into the adjacent package directories.")
|
||||
$inventory.Add("")
|
||||
|
||||
foreach ($package in @($packages.Values | Sort-Object Id, Version)) {
|
||||
$safeName = ("$($package.Id)-$($package.Version)" -replace '[^A-Za-z0-9._-]', '_')
|
||||
$packageOutput = Join-Path $OutputRoot $safeName
|
||||
New-Item -ItemType Directory -Force -Path $packageOutput | Out-Null
|
||||
|
||||
$nuspec = Get-ChildItem $package.Root -Filter *.nuspec -File | Select-Object -First 1
|
||||
$metadata = $null
|
||||
if ($nuspec) {
|
||||
[xml]$nuspecXml = Get-Content $nuspec.FullName -Raw
|
||||
$metadata = $nuspecXml.SelectSingleNode("/*[local-name()='package']/*[local-name()='metadata']")
|
||||
}
|
||||
$licenseText = Get-ChildText $metadata "license"
|
||||
if (!$licenseText) { $licenseText = Get-ChildText $metadata "licenseUrl" }
|
||||
$authorsText = Get-ChildText $metadata "authors"
|
||||
$copyrightText = Get-ChildText $metadata "copyright"
|
||||
$projectUrlText = Get-ChildText $metadata "projectUrl"
|
||||
$license = if ($licenseText) { $licenseText } else { "Not declared in package metadata" }
|
||||
$authors = if ($authorsText) { $authorsText } else { "Not declared" }
|
||||
$copyright = if ($copyrightText) { $copyrightText } else { "Not declared" }
|
||||
$projectUrl = if ($projectUrlText) { $projectUrlText } else { "Not declared" }
|
||||
|
||||
$copied = [Collections.Generic.List[string]]::new()
|
||||
$noticeFiles = @(Get-ChildItem $package.Root -File -Recurse -ErrorAction SilentlyContinue | Where-Object {
|
||||
$_.Length -le 5MB -and $_.Name -match '^(license|licence|notice|third[-_. ]?party|thirdparty|copying|copyright)([._ -].*)?$'
|
||||
})
|
||||
foreach ($notice in $noticeFiles) {
|
||||
$rootPrefix = $package.Root.TrimEnd([char[]]"\/")
|
||||
$relative = $notice.FullName.Substring($rootPrefix.Length).TrimStart([char[]]"\/")
|
||||
$targetName = ($relative -replace '[\\/:*?"<>|]', '__')
|
||||
$target = Join-Path $packageOutput $targetName
|
||||
Copy-Item $notice.FullName $target -Force
|
||||
$copied.Add($targetName)
|
||||
}
|
||||
if ($copied.Count -eq 0) { Remove-Item $packageOutput -Force }
|
||||
|
||||
$inventory.Add("Package: $($package.Id) $($package.Version)")
|
||||
$inventory.Add("Authors: $authors")
|
||||
$inventory.Add("Copyright: $copyright")
|
||||
$inventory.Add("Licence declaration: $license")
|
||||
$inventory.Add("Project: $projectUrl")
|
||||
$inventory.Add("Copied notice files: $(if ($copied.Count) { $copied -join ', ' } else { 'none found in restored package' })")
|
||||
$inventory.Add("")
|
||||
}
|
||||
|
||||
[IO.File]::WriteAllLines((Join-Path $OutputRoot "NuGet-LICENCE-INVENTORY.txt"), $inventory, [Text.UTF8Encoding]::new($false))
|
||||
Write-Host "Collected legal metadata for $($packages.Count) restored NuGet packages."
|
||||
94
companion/scripts/generate-companion-icon.ps1
Normal file
94
companion/scripts/generate-companion-icon.ps1
Normal file
@ -0,0 +1,94 @@
|
||||
param(
|
||||
[string]$OutputPath = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||
$OutputPath = Join-Path $repoRoot "companion\src\Lumi.Companion.App\Assets\Lumi.Companion.ico"
|
||||
}
|
||||
$OutputPath = [System.IO.Path]::GetFullPath($OutputPath)
|
||||
$sizes = @(16, 32, 48, 256)
|
||||
|
||||
function New-LumiIconFrame {
|
||||
param([int]$Size)
|
||||
|
||||
$radius = [Math]::Max(3, [Math]::Round($Size * 0.22))
|
||||
$center = ($Size - 1) / 2.0
|
||||
$diamondRadius = $Size * 0.36
|
||||
$maskStride = [Math]::Ceiling($Size / 32.0) * 4
|
||||
$pixels = New-Object byte[] ($Size * $Size * 4)
|
||||
|
||||
for ($y = 0; $y -lt $Size; $y++) {
|
||||
for ($x = 0; $x -lt $Size; $x++) {
|
||||
$inside = $true
|
||||
if (!(($x -ge $radius -and $x -lt $Size - $radius) -or ($y -ge $radius -and $y -lt $Size - $radius))) {
|
||||
$cornerX = if ($x -lt $radius) { $radius } else { $Size - $radius - 1 }
|
||||
$cornerY = if ($y -lt $radius) { $radius } else { $Size - $radius - 1 }
|
||||
$dx = $x - $cornerX
|
||||
$dy = $y - $cornerY
|
||||
$inside = (($dx * $dx) + ($dy * $dy)) -le ($radius * $radius)
|
||||
}
|
||||
$diamond = (([Math]::Abs($x - $center) / $diamondRadius) + ([Math]::Abs($y - $center) / $diamondRadius)) -le 1
|
||||
$target = ((($Size - 1 - $y) * $Size) + $x) * 4
|
||||
$pixels[$target] = if ($diamond -and $inside) { 255 } else { 200 }
|
||||
$pixels[$target + 1] = if ($diamond -and $inside) { 255 } else { 183 }
|
||||
$pixels[$target + 2] = if ($diamond -and $inside) { 255 } else { 40 }
|
||||
$pixels[$target + 3] = if ($inside) { 255 } else { 0 }
|
||||
}
|
||||
}
|
||||
|
||||
$stream = [System.IO.MemoryStream]::new()
|
||||
$writer = [System.IO.BinaryWriter]::new($stream)
|
||||
$writer.Write([int]40)
|
||||
$writer.Write([int]$Size)
|
||||
$writer.Write([int]($Size * 2))
|
||||
$writer.Write([uint16]1)
|
||||
$writer.Write([uint16]32)
|
||||
$writer.Write([int]0)
|
||||
$writer.Write([int]$pixels.Length)
|
||||
$writer.Write([int]0)
|
||||
$writer.Write([int]0)
|
||||
$writer.Write([int]0)
|
||||
$writer.Write([int]0)
|
||||
$writer.Write($pixels)
|
||||
$writer.Write((New-Object byte[] ($maskStride * $Size)))
|
||||
$writer.Flush()
|
||||
$result = $stream.ToArray()
|
||||
$writer.Dispose()
|
||||
$stream.Dispose()
|
||||
return ,$result
|
||||
}
|
||||
|
||||
[byte[][]]$frames = @($sizes | ForEach-Object { ,(New-LumiIconFrame -Size $_) })
|
||||
$directorySize = 6 + ($frames.Count * 16)
|
||||
$offset = $directorySize
|
||||
$output = [System.IO.MemoryStream]::new()
|
||||
$binary = [System.IO.BinaryWriter]::new($output)
|
||||
$binary.Write([uint16]0)
|
||||
$binary.Write([uint16]1)
|
||||
$binary.Write([uint16]$frames.Count)
|
||||
|
||||
for ($index = 0; $index -lt $frames.Count; $index++) {
|
||||
$size = $sizes[$index]
|
||||
$frame = $frames[$index]
|
||||
$encodedSize = if ($size -eq 256) { 0 } else { $size }
|
||||
$binary.Write([byte]$encodedSize)
|
||||
$binary.Write([byte]$encodedSize)
|
||||
$binary.Write([byte]0)
|
||||
$binary.Write([byte]0)
|
||||
$binary.Write([uint16]1)
|
||||
$binary.Write([uint16]32)
|
||||
$binary.Write([int]$frame.Length)
|
||||
$binary.Write([int]$offset)
|
||||
$offset += $frame.Length
|
||||
}
|
||||
foreach ($frame in $frames) { $binary.Write($frame) }
|
||||
$binary.Flush()
|
||||
|
||||
$directory = Split-Path -Parent $OutputPath
|
||||
[System.IO.Directory]::CreateDirectory($directory) | Out-Null
|
||||
[System.IO.File]::WriteAllBytes($OutputPath, $output.ToArray())
|
||||
$binary.Dispose()
|
||||
$output.Dispose()
|
||||
Write-Host "Generated Lumi Companion icon: $OutputPath"
|
||||
142
companion/scripts/publish-companion.ps1
Normal file
142
companion/scripts/publish-companion.ps1
Normal file
@ -0,0 +1,142 @@
|
||||
param(
|
||||
[string]$Version = "0.2.8",
|
||||
[string]$BridgeVersion = "0.2.5",
|
||||
[string]$ObsVersion = "31.1.1"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
||||
$project = Join-Path $repoRoot "companion\src\Lumi.Companion.App\Lumi.Companion.App.csproj"
|
||||
$outputRoot = Join-Path $repoRoot "companion\installer\output"
|
||||
$publishRoot = Join-Path $outputRoot "publish"
|
||||
$stageRoot = Join-Path $outputRoot "package"
|
||||
$sourceStageRoot = Join-Path $outputRoot "obs-bridge-source"
|
||||
$archive = Join-Path $outputRoot "Lumi.Companion-win-x64.zip"
|
||||
$installer = Join-Path $outputRoot "Lumi.Companion-Setup.exe"
|
||||
$legalSourceRoot = Join-Path $repoRoot "companion\legal"
|
||||
$publishLegalRoot = Join-Path $publishRoot "legal"
|
||||
|
||||
function Invoke-Native([string]$FilePath, [string[]]$Arguments) {
|
||||
$argumentLine = ($Arguments | ForEach-Object { '"' + ([string]$_).Replace('"', '\"') + '"' }) -join ' '
|
||||
$process = Start-Process -FilePath $FilePath -ArgumentList $argumentLine -NoNewWindow -Wait -PassThru
|
||||
return $process.ExitCode
|
||||
}
|
||||
|
||||
function Compress-ArchiveWithRetry {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string]$DestinationPath,
|
||||
[int]$Attempts = 8
|
||||
)
|
||||
|
||||
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
|
||||
try {
|
||||
Remove-Item $DestinationPath -Force -ErrorAction SilentlyContinue
|
||||
Compress-Archive -Path $Path -DestinationPath $DestinationPath -CompressionLevel Optimal -ErrorAction Stop
|
||||
return
|
||||
} catch {
|
||||
if ($attempt -eq $Attempts) { throw }
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& (Join-Path $PSScriptRoot "generate-companion-icon.ps1")
|
||||
& (Join-Path $PSScriptRoot "build-obs-bridge.ps1") -BridgeVersion $BridgeVersion -ObsVersion $ObsVersion
|
||||
Remove-Item $publishRoot, $stageRoot, $sourceStageRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $publishRoot, $stageRoot | Out-Null
|
||||
$localDotnet = Join-Path $HOME ".dotnet-sdk\dotnet.exe"
|
||||
$dotnet = if (Test-Path $localDotnet) { $localDotnet } else { (Get-Command dotnet.exe -ErrorAction Stop).Source }
|
||||
$publishArguments = @("publish", $project, "-c", "Release", "-r", "win-x64", "--self-contained", "true", "--disable-build-servers", "-o", $publishRoot,
|
||||
"-p:PublishSingleFile=true", "-p:IncludeNativeLibrariesForSelfExtract=true", "-p:DebugType=None", "-p:Version=$Version")
|
||||
if ((Invoke-Native $dotnet $publishArguments)) { throw "Companion publish failed." }
|
||||
|
||||
$bridgeDiagnostic = Join-Path $outputRoot "obs-bridge-package-diagnostic.json"
|
||||
Remove-Item $bridgeDiagnostic -Force -ErrorAction SilentlyContinue
|
||||
if ((Invoke-Native (Join-Path $publishRoot "Lumi.Companion.App.exe") @("--diagnose-obs-bridge", $bridgeDiagnostic))) {
|
||||
$detail = if (Test-Path $bridgeDiagnostic) { Get-Content $bridgeDiagnostic -Raw } else { "No diagnostic result was written." }
|
||||
throw "Published OBS integration payload failed its executable-level check: $detail"
|
||||
}
|
||||
Remove-Item $bridgeDiagnostic -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Copy the release legal bundle beside the application so installed and portable
|
||||
# builds carry the same terms, privacy notice, and third-party attributions.
|
||||
New-Item -ItemType Directory -Force -Path $publishLegalRoot | Out-Null
|
||||
foreach ($name in @(
|
||||
"LUMI-COMPANION-LICENCE.txt",
|
||||
"PRIVACY-NOTICE.txt",
|
||||
"THIRD-PARTY-NOTICES.txt",
|
||||
"LEGAL-README.txt"
|
||||
)) {
|
||||
Copy-Item (Join-Path $legalSourceRoot $name) (Join-Path $publishLegalRoot $name) -Force
|
||||
}
|
||||
Copy-Item (Join-Path $legalSourceRoot "third-party") (Join-Path $publishLegalRoot "third-party") -Recurse -Force
|
||||
& (Join-Path $PSScriptRoot "collect-nuget-notices.ps1") -RepoRoot $repoRoot -OutputRoot (Join-Path $publishLegalRoot "third-party\nuget")
|
||||
|
||||
# The bundled OBS module links with GPL-licensed OBS/libobs. Ship the exact Lumi
|
||||
# bridge source and build materials with every binary distribution.
|
||||
$sourceCompanionRoot = Join-Path $sourceStageRoot "companion"
|
||||
$sourceNativeParent = Join-Path $sourceCompanionRoot "native"
|
||||
$sourceScriptsRoot = Join-Path $sourceCompanionRoot "scripts"
|
||||
New-Item -ItemType Directory -Force -Path $sourceNativeParent, $sourceScriptsRoot | Out-Null
|
||||
Copy-Item (Join-Path $repoRoot "companion\native\obs-bridge") (Join-Path $sourceNativeParent "obs-bridge") -Recurse -Force
|
||||
Copy-Item (Join-Path $repoRoot "companion\scripts\build-obs-bridge.ps1") (Join-Path $sourceScriptsRoot "build-obs-bridge.ps1") -Force
|
||||
$upstreamSourceRoot = Join-Path $sourceStageRoot "upstream-source"
|
||||
New-Item -ItemType Directory -Force -Path $upstreamSourceRoot | Out-Null
|
||||
$bridgeCacheRoot = Join-Path $env:LOCALAPPDATA "LumiCompanionBuild\obs-$ObsVersion"
|
||||
$obsSourceArchive = Join-Path $bridgeCacheRoot "obs-source.zip"
|
||||
$jsonSourceRoot = Join-Path $bridgeCacheRoot "bridge-build\_deps\json-src"
|
||||
if (!(Test-Path $obsSourceArchive)) { throw "The exact OBS source archive used for the bridge build is missing." }
|
||||
if (!(Test-Path $jsonSourceRoot)) { throw "The exact nlohmann/json source used for the bridge build is missing." }
|
||||
Copy-Item $obsSourceArchive (Join-Path $upstreamSourceRoot "OBS-Studio-$ObsVersion-source.zip") -Force
|
||||
Copy-Item $jsonSourceRoot (Join-Path $upstreamSourceRoot "nlohmann-json-3.12.0") -Recurse -Force
|
||||
@"
|
||||
Lumi OBS Bridge corresponding source
|
||||
Release version: $BridgeVersion
|
||||
|
||||
This archive contains the Lumi-authored source and build script used for the
|
||||
GPL-2.0-or-later OBS bridge distributed with Lumi Companion.
|
||||
|
||||
The archive includes the Lumi bridge source, its build script, the exact OBS Studio
|
||||
$ObsVersion source archive used for its interfaces, and the exact nlohmann/json
|
||||
3.12.0 source used in the binary. Checksums and upstream locations remain recorded
|
||||
in the build script. OBS Studio binaries are not distributed by Companion.
|
||||
|
||||
Licence: companion/native/obs-bridge/COPYING
|
||||
"@ | Set-Content -Encoding UTF8 (Join-Path $sourceStageRoot "SOURCE-README.txt")
|
||||
$bridgeSourceArchive = Join-Path $publishLegalRoot "Lumi-OBS-Bridge-Source.zip"
|
||||
Compress-ArchiveWithRetry -Path (Join-Path $sourceStageRoot "*") -DestinationPath $bridgeSourceArchive
|
||||
|
||||
Copy-Item (Join-Path $publishRoot "Lumi.Companion.App.exe") $stageRoot
|
||||
Copy-Item (Join-Path $publishRoot "components") $stageRoot -Recurse
|
||||
Copy-Item (Join-Path $publishRoot "legal") $stageRoot -Recurse
|
||||
Remove-Item $archive -Force -ErrorAction SilentlyContinue
|
||||
Compress-ArchiveWithRetry -Path (Join-Path $stageRoot "*") -DestinationPath $archive
|
||||
|
||||
$isccCandidates = @(
|
||||
(Join-Path $env:LOCALAPPDATA "Programs\Inno Setup 6\ISCC.exe"),
|
||||
(Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe"),
|
||||
(Join-Path $env:ProgramFiles "Inno Setup 6\ISCC.exe")
|
||||
)
|
||||
$iscc = $isccCandidates | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
|
||||
if (-not $iscc) { throw "Inno Setup 6 is required to build the durable per-user Companion installer." }
|
||||
Remove-Item $installer -Force -ErrorAction SilentlyContinue
|
||||
$compileArguments = @(
|
||||
"/DAppVersion=$Version",
|
||||
"/DSourceRoot=$publishRoot",
|
||||
"/DOutputRoot=$outputRoot",
|
||||
(Join-Path $repoRoot "companion\installer\Lumi.Companion.iss")
|
||||
)
|
||||
if ((Invoke-Native $iscc $compileArguments) -or -not (Test-Path $installer)) { throw "Companion installer compilation failed." }
|
||||
|
||||
$file = Get-Item $archive
|
||||
$sha = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$setupFile = Get-Item $installer
|
||||
$setupSha = (Get-FileHash $installer -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Host "Published Lumi Companion $Version"
|
||||
Write-Host "Artifact: $archive"
|
||||
Write-Host "Bytes: $($file.Length)"
|
||||
Write-Host "SHA256: $sha"
|
||||
Write-Host "Installer: $installer"
|
||||
Write-Host "Installer bytes: $($setupFile.Length)"
|
||||
Write-Host "Installer SHA256: $setupSha"
|
||||
72
companion/scripts/publish-dev-update.ps1
Normal file
72
companion/scripts/publish-dev-update.ps1
Normal file
@ -0,0 +1,72 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$OutputArchive,
|
||||
[Parameter(Mandatory = $true)][string]$ManifestPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
||||
$project = Join-Path $repoRoot "companion\src\Lumi.Companion.App\Lumi.Companion.App.csproj"
|
||||
$outputArchive = [System.IO.Path]::GetFullPath($OutputArchive)
|
||||
$manifest = [System.IO.Path]::GetFullPath($ManifestPath)
|
||||
$workRoot = Join-Path ([System.IO.Path]::GetDirectoryName($outputArchive)) "work"
|
||||
$publishRoot = Join-Path $workRoot "publish"
|
||||
$stageRoot = Join-Path $workRoot "package"
|
||||
|
||||
if (!(Test-Path $manifest)) { throw "The development build manifest is missing." }
|
||||
& (Join-Path $PSScriptRoot "generate-companion-icon.ps1")
|
||||
Remove-Item $workRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $publishRoot, $stageRoot | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path ([System.IO.Path]::GetDirectoryName($outputArchive)) | Out-Null
|
||||
|
||||
$localDotnet = Join-Path $HOME ".dotnet-sdk\dotnet.exe"
|
||||
$dotnet = if (Test-Path $localDotnet) { $localDotnet } else { (Get-Command dotnet.exe -ErrorAction Stop).Source }
|
||||
$arguments = @(
|
||||
"publish", "`"$project`"",
|
||||
"-c", "Release",
|
||||
"-r", "win-x64",
|
||||
"--self-contained", "true",
|
||||
"--disable-build-servers",
|
||||
"-o", "`"$publishRoot`"",
|
||||
"-p:PublishSingleFile=true",
|
||||
"-p:IncludeNativeLibrariesForSelfExtract=true",
|
||||
"-p:DebugType=None"
|
||||
)
|
||||
$published = Start-Process -FilePath $dotnet -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
if ($published.ExitCode -ne 0) { throw "Companion development publish failed." }
|
||||
|
||||
$app = Join-Path $publishRoot "Lumi.Companion.App.exe"
|
||||
if (!(Test-Path $app)) { throw "The Companion development publish did not create Lumi.Companion.App.exe." }
|
||||
Copy-Item $app $stageRoot -Force
|
||||
$componentsSource = Join-Path $publishRoot "components"
|
||||
if (Test-Path $componentsSource) { Copy-Item $componentsSource (Join-Path $stageRoot "components") -Recurse -Force }
|
||||
|
||||
$legalSource = Join-Path $repoRoot "companion\legal"
|
||||
$legalTarget = Join-Path $stageRoot "legal"
|
||||
if (Test-Path $legalSource) {
|
||||
New-Item -ItemType Directory -Force -Path $legalTarget | Out-Null
|
||||
foreach ($name in @("LUMI-COMPANION-LICENCE.txt", "PRIVACY-NOTICE.txt", "THIRD-PARTY-NOTICES.txt", "LEGAL-README.txt")) {
|
||||
$source = Join-Path $legalSource $name
|
||||
if (Test-Path $source) { Copy-Item $source (Join-Path $legalTarget $name) -Force }
|
||||
}
|
||||
$thirdParty = Join-Path $legalSource "third-party"
|
||||
if (Test-Path $thirdParty) { Copy-Item $thirdParty (Join-Path $legalTarget "third-party") -Recurse -Force }
|
||||
}
|
||||
Copy-Item $manifest (Join-Path $stageRoot ".lumi-dev-build.json") -Force
|
||||
Remove-Item $outputArchive -Force -ErrorAction SilentlyContinue
|
||||
$archiveItems = Get-ChildItem $stageRoot -Force | Select-Object -ExpandProperty FullName
|
||||
$archiveCreated = $false
|
||||
for ($attempt = 1; $attempt -le 20; $attempt++) {
|
||||
try {
|
||||
Compress-Archive -Path $archiveItems -DestinationPath $outputArchive -CompressionLevel Optimal -ErrorAction Stop
|
||||
$archiveCreated = $true
|
||||
break
|
||||
}
|
||||
catch {
|
||||
Remove-Item $outputArchive -Force -ErrorAction SilentlyContinue
|
||||
if ($attempt -eq 20) { throw }
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
}
|
||||
if (-not $archiveCreated) { throw "The Companion development archive could not be created." }
|
||||
Remove-Item $workRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "Created localhost development update: $outputArchive"
|
||||
20
companion/scripts/verify-lumi-overlay.ps1
Normal file
20
companion/scripts/verify-lumi-overlay.ps1
Normal file
@ -0,0 +1,20 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
||||
$sdk = Join-Path $env:LOCALAPPDATA "Lumi\dotnet-sdk-10\dotnet.exe"
|
||||
$dotnet = if (Test-Path $sdk) { $sdk } else { (Get-Command dotnet.exe -ErrorAction Stop).Source }
|
||||
$node = (Get-Command node.exe -ErrorAction Stop).Source
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
& $node "plugins/lumi_overlay/tests/verify.js"
|
||||
if ($LASTEXITCODE) { throw "Lumi Overlay server verification failed." }
|
||||
& $node "plugins/lumi_transcription/tests/verify.js"
|
||||
if ($LASTEXITCODE) { throw "Companion capability verification failed." }
|
||||
& $dotnet run --project "companion/tests/Lumi.Companion.Core.Tests/Lumi.Companion.Core.Tests.csproj" --configuration Release
|
||||
if ($LASTEXITCODE) { throw "Lumi Overlay Companion verification failed." }
|
||||
& $dotnet build "companion/Lumi.Companion.sln" --configuration Release
|
||||
if ($LASTEXITCODE) { throw "Lumi Companion build failed." }
|
||||
Write-Host "Lumi Overlay verification passed." -ForegroundColor Green
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
20
companion/scripts/verify-song-overlay.ps1
Normal file
20
companion/scripts/verify-song-overlay.ps1
Normal file
@ -0,0 +1,20 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$sdk = Join-Path $env:LOCALAPPDATA "Lumi\dotnet-sdk-10\dotnet.exe"
|
||||
$dotnet = if (Test-Path $sdk) { $sdk } else { (Get-Command dotnet.exe -ErrorAction Stop).Source }
|
||||
$node = (Get-Command node.exe -ErrorAction Stop).Source
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
Write-Host 'Building Lumi Companion and all registered Companion plugins...'
|
||||
& $dotnet build 'companion/Lumi.Companion.sln' -c Release
|
||||
if ($LASTEXITCODE) { throw 'Lumi Companion build failed.' }
|
||||
|
||||
Write-Host 'Verifying the existing Lumi Song Overlay server plugin...'
|
||||
& $node 'plugins/now_playing/tests/verify.js'
|
||||
if ($LASTEXITCODE) { throw 'Song Overlay server verification failed.' }
|
||||
|
||||
Write-Host 'Song Overlay Companion verification passed.' -ForegroundColor Green
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Lumi.Companion.Abstractions;
|
||||
|
||||
public static partial class CompanionLogSanitizer
|
||||
{
|
||||
public static string Sanitize(string? value, int maxLength = 4000)
|
||||
{
|
||||
var safeMaxLength = Math.Clamp(maxLength, 64, 65536);
|
||||
var text = value ?? "";
|
||||
text = AuthorizationPattern().Replace(text, "$1 [redacted]");
|
||||
text = CookiePattern().Replace(text, "$1[redacted]");
|
||||
text = QuerySecretPattern().Replace(text, "$1[redacted]");
|
||||
text = AssignedSecretPattern().Replace(text, "$1[redacted]");
|
||||
return text.Length <= safeMaxLength ? text : text[..safeMaxLength] + "\n[truncated]";
|
||||
}
|
||||
|
||||
public static string NormalizeEvent(string? value)
|
||||
{
|
||||
var normalized = InvalidEventCharacters().Replace((value ?? "").Trim().ToLowerInvariant(), "_");
|
||||
normalized = RepeatedUnderscores().Replace(normalized, "_").Trim('_');
|
||||
return string.IsNullOrWhiteSpace(normalized) ? "unknown_event" : normalized[..Math.Min(80, normalized.Length)];
|
||||
}
|
||||
|
||||
public static string LevelForEvent(string eventId)
|
||||
{
|
||||
var normalized = NormalizeEvent(eventId);
|
||||
if (normalized.EndsWith("_failed", StringComparison.Ordinal) ||
|
||||
normalized.EndsWith("_error", StringComparison.Ordinal))
|
||||
{
|
||||
return "error";
|
||||
}
|
||||
return normalized is "disconnected" or "audio_dropped" ? "warn" : "info";
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\b(Bearer|Basic|LumiDevice)\s+[A-Za-z0-9._~+/=-]+", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex AuthorizationPattern();
|
||||
|
||||
[GeneratedRegex(@"\b((?:set-)?cookie\s*:\s*)[^\r\n]+", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex CookiePattern();
|
||||
|
||||
[GeneratedRegex(@"([?&](?:token|key|secret|password|authorization)=)[^&#\s]+", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex QuerySecretPattern();
|
||||
|
||||
[GeneratedRegex(@"\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|passwd|secret|signature|authorization)\s*[:=]\s*)[^\s,;}]+", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex AssignedSecretPattern();
|
||||
|
||||
[GeneratedRegex(@"[^a-z0-9]+")]
|
||||
private static partial Regex InvalidEventCharacters();
|
||||
|
||||
[GeneratedRegex(@"_+")]
|
||||
private static partial Regex RepeatedUnderscores();
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
namespace Lumi.Companion.Abstractions;
|
||||
|
||||
public sealed record CompanionPluginHttpResponse(
|
||||
int StatusCode,
|
||||
string Body)
|
||||
{
|
||||
public bool IsSuccessStatusCode => StatusCode is >= 200 and <= 299;
|
||||
}
|
||||
|
||||
public enum CompanionPluginLiveState
|
||||
{
|
||||
Connecting,
|
||||
Connected,
|
||||
Reconnecting,
|
||||
WaitingForPairing,
|
||||
Stopped
|
||||
}
|
||||
|
||||
public sealed record CompanionPluginLiveStatus(
|
||||
CompanionPluginLiveState State,
|
||||
string Detail,
|
||||
int Attempt = 0);
|
||||
|
||||
/// <summary>
|
||||
/// An authenticated, plugin-scoped live channel. It intentionally exposes no
|
||||
/// credential or authorization header.
|
||||
/// </summary>
|
||||
public interface ICompanionPluginLiveChannel
|
||||
{
|
||||
Task SendJsonAsync(object payload, CancellationToken cancellationToken = default);
|
||||
IAsyncEnumerable<string> ReadAllAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A plugin-scoped view of the Companion shell's authenticated Lumi transport.
|
||||
/// Plugins never receive, store, or refresh the paired device credential.
|
||||
/// </summary>
|
||||
public interface ICompanionPluginTransport
|
||||
{
|
||||
Uri? LumiBaseUri { get; }
|
||||
bool IsAuthenticated { get; }
|
||||
Task<CompanionPluginHttpResponse> PostJsonAsync(
|
||||
string relativePath,
|
||||
object payload,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Runs a live plugin connection. The Companion shell owns authentication,
|
||||
/// TLS policy, reconnect backoff and socket lifetime.
|
||||
/// </summary>
|
||||
Task RunLiveJsonAsync(
|
||||
string relativePath,
|
||||
Func<ICompanionPluginLiveChannel, CancellationToken, Task> connected,
|
||||
Action<CompanionPluginLiveStatus>? status,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@ -0,0 +1,53 @@
|
||||
namespace Lumi.Companion.Abstractions;
|
||||
|
||||
public enum CompanionPluginHealth
|
||||
{
|
||||
Ready,
|
||||
Healthy,
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
||||
public sealed record CompanionPluginDescriptor(
|
||||
string Id,
|
||||
string Name,
|
||||
Version Version,
|
||||
string Description,
|
||||
int Order = 100);
|
||||
|
||||
public sealed record CompanionPluginPage(
|
||||
string Key,
|
||||
string Label,
|
||||
int Order = 100);
|
||||
|
||||
public sealed record CompanionPluginStatus(
|
||||
CompanionPluginHealth Health,
|
||||
string Summary,
|
||||
string? Detail = null);
|
||||
|
||||
public sealed record CompanionPluginAction(
|
||||
string Id,
|
||||
Func<string> Label,
|
||||
Func<CancellationToken, Task> Execute,
|
||||
Func<bool>? CanExecute = null,
|
||||
int Order = 100);
|
||||
|
||||
public static class CompanionPluginNavigation
|
||||
{
|
||||
public static bool UsesNestedNavigation(IReadOnlyCollection<CompanionPluginPage> pages) => pages.Count > 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A UI-neutral contribution contract. The Avalonia shell owns rendering and
|
||||
/// gives a single-page plugin one direct sidebar entry. Plugins with multiple
|
||||
/// pages receive one collapsible sidebar root instead of leaking loose pages
|
||||
/// into the global navigation.
|
||||
/// </summary>
|
||||
public interface ICompanionPluginContribution
|
||||
{
|
||||
CompanionPluginDescriptor Descriptor { get; }
|
||||
IReadOnlyList<CompanionPluginPage> Pages { get; }
|
||||
IReadOnlyList<CompanionPluginAction> Actions { get; }
|
||||
CompanionPluginStatus Status { get; }
|
||||
event Action? Changed;
|
||||
}
|
||||
97
companion/src/Lumi.Companion.App/App.axaml
Normal file
97
companion/src/Lumi.Companion.App/App.axaml
Normal file
@ -0,0 +1,97 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Lumi.Companion.App.App"
|
||||
RequestedThemeVariant="Light">
|
||||
<Application.Resources>
|
||||
<Color x:Key="LumiInk">#182026</Color>
|
||||
<Color x:Key="LumiMuted">#5A6872</Color>
|
||||
<Color x:Key="LumiSea">#176B75</Color>
|
||||
<Color x:Key="LumiSun">#E58B2B</Color>
|
||||
<Color x:Key="LumiSuccess">#23845B</Color>
|
||||
<Color x:Key="LumiWarning">#A96612</Color>
|
||||
<Color x:Key="LumiDanger">#BD4D4D</Color>
|
||||
<SolidColorBrush x:Key="LumiTextBrush" Color="{DynamicResource LumiInk}" />
|
||||
<SolidColorBrush x:Key="LumiMutedBrush" Color="{DynamicResource LumiMuted}" />
|
||||
<SolidColorBrush x:Key="LumiPrimaryBrush" Color="{DynamicResource LumiSea}" />
|
||||
<SolidColorBrush x:Key="LumiAccentBrush" Color="{DynamicResource LumiSun}" />
|
||||
<SolidColorBrush x:Key="LumiSuccessBrush" Color="{DynamicResource LumiSuccess}" />
|
||||
<SolidColorBrush x:Key="LumiWarningBrush" Color="{DynamicResource LumiWarning}" />
|
||||
<SolidColorBrush x:Key="LumiDangerBrush" Color="{DynamicResource LumiDanger}" />
|
||||
<SolidColorBrush x:Key="LumiSurfaceBrush" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="LumiSubtleBrush" Color="#F4F7F8" />
|
||||
<SolidColorBrush x:Key="LumiBorderBrush" Color="#D8E0E3" />
|
||||
</Application.Resources>
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<Style Selector="Window">
|
||||
<Setter Property="FontFamily" Value="Inter" />
|
||||
<Setter Property="Background" Value="{DynamicResource LumiSurfaceBrush}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource LumiTextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.eyebrow">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource LumiMutedBrush}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.pageTitle">
|
||||
<Setter Property="FontSize" Value="32" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="LineHeight" Value="36" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.sectionTitle">
|
||||
<Setter Property="FontSize" Value="18" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.muted">
|
||||
<Setter Property="Foreground" Value="{DynamicResource LumiMutedBrush}" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="LineHeight" Value="21" />
|
||||
</Style>
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="Background" Value="{DynamicResource LumiSurfaceBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource LumiBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="Padding" Value="20" />
|
||||
</Style>
|
||||
<Style Selector="Border.soft">
|
||||
<Setter Property="Background" Value="{DynamicResource LumiSubtleBrush}" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="16" />
|
||||
</Style>
|
||||
<Style Selector="Button.primary">
|
||||
<Setter Property="Background" Value="{DynamicResource LumiPrimaryBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Padding" Value="18,10" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="Button.secondary">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource LumiBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Padding" Value="16,9" />
|
||||
</Style>
|
||||
<Style Selector="Button.nav">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="Padding" Value="13,10" />
|
||||
<Setter Property="Margin" Value="0,2" />
|
||||
</Style>
|
||||
<Style Selector="Button.navRoot">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
<Style Selector="Button.nav.selected">
|
||||
<Setter Property="Background" Value="#DDEEF0" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource LumiPrimaryBrush}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="Button:focus-visible">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource LumiPrimaryBrush}" />
|
||||
<Setter Property="BorderThickness" Value="2" />
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
273
companion/src/Lumi.Companion.App/App.axaml.cs
Normal file
273
companion/src/Lumi.Companion.App/App.axaml.cs
Normal file
@ -0,0 +1,273 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using Lumi.Companion.Abstractions;
|
||||
using Lumi.Companion.SongOverlay;
|
||||
using Lumi.Companion.Overlay;
|
||||
|
||||
namespace Lumi.Companion.App;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private TrayIcon? _trayIcon;
|
||||
private MainWindow? _mainWindow;
|
||||
private SongOverlayRuntime? _songOverlay;
|
||||
private LumiOverlayRuntime? _lumiOverlay;
|
||||
private IReadOnlyList<ICompanionPluginContribution> _plugins = [];
|
||||
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
var paths = CompanionPaths.ForCurrentUser();
|
||||
var settings = new CompanionSettingsStore(paths.SettingsPath);
|
||||
var runtime = new CompanionRuntime(paths, settings);
|
||||
_songOverlay = new SongOverlayRuntime(
|
||||
Path.Combine(paths.Root, "plugins", "now_playing"),
|
||||
paths.LogsDirectory,
|
||||
runtime.CreatePluginTransport("now_playing"));
|
||||
_lumiOverlay = new LumiOverlayRuntime(
|
||||
Path.Combine(paths.Root, "plugins", "lumi_overlay"),
|
||||
runtime.CreatePluginTransport("lumi_overlay"));
|
||||
_plugins = [new TranscriptionPluginContribution(runtime), _lumiOverlay, _songOverlay];
|
||||
_mainWindow = new MainWindow(runtime, settings, _songOverlay, _lumiOverlay, _plugins);
|
||||
desktop.MainWindow = _mainWindow;
|
||||
ConfigureTray(desktop, runtime, _plugins);
|
||||
runtime.UpdateRestartRequested += async () =>
|
||||
{
|
||||
_mainWindow.AllowExit();
|
||||
_trayIcon?.Dispose();
|
||||
if (_songOverlay is not null) await _songOverlay.DisposeAsync();
|
||||
if (_lumiOverlay is not null) await _lumiOverlay.DisposeAsync();
|
||||
await runtime.DisposeAsync();
|
||||
desktop.Shutdown();
|
||||
};
|
||||
Program.InstanceCoordinator!.ActivationRequested += () => Dispatcher.UIThread.Post(ShowMainWindow);
|
||||
if (!Program.LaunchInBackground) _mainWindow.Show();
|
||||
_ = InitializeRuntimesAsync(runtime, _songOverlay, _lumiOverlay);
|
||||
}
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private static async Task InitializeRuntimesAsync(CompanionRuntime runtime, SongOverlayRuntime songOverlay, LumiOverlayRuntime lumiOverlay)
|
||||
{
|
||||
await runtime.InitializeAsync();
|
||||
try { await songOverlay.InitializeAsync(); }
|
||||
catch { /* The plugin publishes its own actionable status without taking down the shell. */ }
|
||||
try { await lumiOverlay.InitializeAsync(); }
|
||||
catch { /* Native overlay failures remain isolated and visible in the plugin status. */ }
|
||||
}
|
||||
|
||||
private void ConfigureTray(IClassicDesktopStyleApplicationLifetime desktop, CompanionRuntime runtime, IReadOnlyList<ICompanionPluginContribution> plugins)
|
||||
{
|
||||
var open = new NativeMenuItem("Open Lumi Companion");
|
||||
var web = new NativeMenuItem("Open Lumi WebUI");
|
||||
open.Click += (_, _) => ShowMainWindow();
|
||||
web.Click += (_, _) => runtime.OpenLumiWebUi();
|
||||
|
||||
var menu = new NativeMenu();
|
||||
menu.Items.Add(open);
|
||||
menu.Items.Add(web);
|
||||
menu.Items.Add(new NativeMenuItemSeparator());
|
||||
menu.Items.Add(new NativeMenuItem("Plugins") { IsEnabled = false });
|
||||
|
||||
var pluginViews = new List<PluginTrayView>();
|
||||
foreach (var plugin in plugins.OrderBy(item => item.Descriptor.Order).ThenBy(item => item.Descriptor.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var submenu = new NativeMenu();
|
||||
var firstPage = plugin.Pages.OrderBy(item => item.Order).FirstOrDefault();
|
||||
if (firstPage is not null)
|
||||
{
|
||||
var openPlugin = new NativeMenuItem($"Open {plugin.Descriptor.Name}");
|
||||
openPlugin.Click += (_, _) => ShowPluginPage(firstPage.Key);
|
||||
submenu.Items.Add(openPlugin);
|
||||
if (plugin.Actions.Count > 0) submenu.Items.Add(new NativeMenuItemSeparator());
|
||||
}
|
||||
|
||||
var actionViews = new List<(CompanionPluginAction Action, NativeMenuItem Item)>();
|
||||
foreach (var action in plugin.Actions.OrderBy(item => item.Order))
|
||||
{
|
||||
var actionItem = new NativeMenuItem(action.Label());
|
||||
actionItem.Click += async (_, _) => await RunPluginActionAsync(plugin, action);
|
||||
submenu.Items.Add(actionItem);
|
||||
actionViews.Add((action, actionItem));
|
||||
}
|
||||
|
||||
if (submenu.Items.Count > 0) submenu.Items.Add(new NativeMenuItemSeparator());
|
||||
var status = new NativeMenuItem($"Status: {plugin.Status.Summary}") { IsEnabled = false };
|
||||
submenu.Items.Add(status);
|
||||
var root = new NativeMenuItem(plugin.Descriptor.Name) { Menu = submenu };
|
||||
menu.Items.Add(root);
|
||||
var view = new PluginTrayView(plugin, root, status, actionViews);
|
||||
pluginViews.Add(view);
|
||||
plugin.Changed += () => Dispatcher.UIThread.Post(() => RefreshPluginTray(view));
|
||||
}
|
||||
|
||||
menu.Items.Add(new NativeMenuItemSeparator());
|
||||
var health = new NativeMenuItem("Health: Starting") { IsEnabled = false };
|
||||
var update = new NativeMenuItem("Updates: Checking…") { IsEnabled = false };
|
||||
var logs = new NativeMenuItem("Logs & diagnostics");
|
||||
var settings = new NativeMenuItem("Settings");
|
||||
var quit = new NativeMenuItem("Quit");
|
||||
logs.Click += (_, _) => { runtime.OpenLogsDirectory(); };
|
||||
settings.Click += (_, _) => { ShowMainWindow(); _mainWindow?.ShowPage(CompanionPage.Settings); };
|
||||
update.Click += (_, _) => { ShowMainWindow(); _mainWindow?.ShowPage(CompanionPage.Overview); };
|
||||
quit.Click += async (_, _) =>
|
||||
{
|
||||
if (runtime.State.RequiresQuitConfirmation) ShowMainWindow();
|
||||
if (_mainWindow is null || !await _mainWindow.ConfirmQuitAsync()) return;
|
||||
_mainWindow.AllowExit();
|
||||
_trayIcon?.Dispose();
|
||||
if (_songOverlay is not null) await _songOverlay.DisposeAsync();
|
||||
if (_lumiOverlay is not null) await _lumiOverlay.DisposeAsync();
|
||||
await runtime.DisposeAsync();
|
||||
desktop.Shutdown();
|
||||
};
|
||||
menu.Items.Add(health);
|
||||
menu.Items.Add(update);
|
||||
menu.Items.Add(logs);
|
||||
menu.Items.Add(settings);
|
||||
menu.Items.Add(new NativeMenuItemSeparator());
|
||||
menu.Items.Add(quit);
|
||||
|
||||
_trayIcon = new TrayIcon
|
||||
{
|
||||
Icon = LumiIconFactory.Create(TrayHealth.Ready),
|
||||
ToolTipText = "Lumi Companion — starting",
|
||||
IsVisible = true,
|
||||
Menu = menu
|
||||
};
|
||||
_trayIcon.Clicked += (_, _) => ShowMainWindow();
|
||||
TrayIcon.SetIcons(this, new TrayIcons { _trayIcon });
|
||||
var renderedHealth = TrayHealth.Ready;
|
||||
var renderedToolTip = _trayIcon.ToolTipText;
|
||||
var renderedHealthHeader = health.Header?.ToString();
|
||||
var renderedUpdateHeader = update.Header?.ToString();
|
||||
var renderedUpdateEnabled = update.IsEnabled;
|
||||
var refreshGate = new object();
|
||||
CompanionState? pendingState = null;
|
||||
var refreshQueued = false;
|
||||
|
||||
void ApplyTrayState(CompanionState state)
|
||||
{
|
||||
if (_trayIcon is null) return;
|
||||
if (state.Health != renderedHealth)
|
||||
{
|
||||
_trayIcon.Icon = LumiIconFactory.Create(state.Health);
|
||||
renderedHealth = state.Health;
|
||||
}
|
||||
|
||||
var nextToolTip = state.UpdateAvailable
|
||||
? $"Lumi Companion — update {state.AvailableVersion} available"
|
||||
: $"Lumi Companion — {state.Summary}";
|
||||
if (!string.Equals(nextToolTip, renderedToolTip, StringComparison.Ordinal))
|
||||
{
|
||||
_trayIcon.ToolTipText = nextToolTip;
|
||||
renderedToolTip = nextToolTip;
|
||||
}
|
||||
|
||||
var nextHealthHeader = $"Health: {state.Summary}";
|
||||
if (!string.Equals(nextHealthHeader, renderedHealthHeader, StringComparison.Ordinal))
|
||||
{
|
||||
health.Header = nextHealthHeader;
|
||||
renderedHealthHeader = nextHealthHeader;
|
||||
}
|
||||
|
||||
var nextUpdateHeader = state.UpdateAvailable
|
||||
? $"Update available: {state.AvailableVersion}"
|
||||
: "Updates: Current";
|
||||
if (!string.Equals(nextUpdateHeader, renderedUpdateHeader, StringComparison.Ordinal))
|
||||
{
|
||||
update.Header = nextUpdateHeader;
|
||||
renderedUpdateHeader = nextUpdateHeader;
|
||||
}
|
||||
if (state.UpdateAvailable != renderedUpdateEnabled)
|
||||
{
|
||||
update.IsEnabled = state.UpdateAvailable;
|
||||
renderedUpdateEnabled = state.UpdateAvailable;
|
||||
}
|
||||
}
|
||||
|
||||
void DrainTrayRefresh()
|
||||
{
|
||||
CompanionState? latest;
|
||||
lock (refreshGate)
|
||||
{
|
||||
latest = pendingState;
|
||||
pendingState = null;
|
||||
}
|
||||
if (latest is not null) ApplyTrayState(latest);
|
||||
|
||||
lock (refreshGate)
|
||||
{
|
||||
if (pendingState is null)
|
||||
{
|
||||
refreshQueued = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
Dispatcher.UIThread.Post(DrainTrayRefresh, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
runtime.StateChanged += state =>
|
||||
{
|
||||
lock (refreshGate)
|
||||
{
|
||||
pendingState = state;
|
||||
if (refreshQueued) return;
|
||||
refreshQueued = true;
|
||||
}
|
||||
Dispatcher.UIThread.Post(DrainTrayRefresh, DispatcherPriority.Background);
|
||||
};
|
||||
foreach (var view in pluginViews) RefreshPluginTray(view);
|
||||
}
|
||||
|
||||
private async Task RunPluginActionAsync(ICompanionPluginContribution plugin, CompanionPluginAction action)
|
||||
{
|
||||
try
|
||||
{
|
||||
await action.Execute(CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
var page = plugin.Pages.OrderBy(item => item.Order).FirstOrDefault();
|
||||
if (page is not null) ShowPluginPage(page.Key);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RefreshPluginTray(PluginTrayView view)
|
||||
{
|
||||
view.Root.Header = view.Plugin.Descriptor.Name;
|
||||
view.Status.Header = $"Status: {view.Plugin.Status.Summary}";
|
||||
foreach (var (action, item) in view.Actions)
|
||||
{
|
||||
item.Header = action.Label();
|
||||
item.IsEnabled = action.CanExecute?.Invoke() ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowPluginPage(string pageKey)
|
||||
{
|
||||
ShowMainWindow();
|
||||
if (Enum.TryParse<CompanionPage>(pageKey, out var page)) _mainWindow?.ShowPage(page);
|
||||
}
|
||||
|
||||
private void ShowMainWindow()
|
||||
{
|
||||
if (_mainWindow is null) return;
|
||||
_mainWindow.Show();
|
||||
_mainWindow.WindowState = WindowState.Normal;
|
||||
_mainWindow.Activate();
|
||||
}
|
||||
|
||||
private sealed record PluginTrayView(
|
||||
ICompanionPluginContribution Plugin,
|
||||
NativeMenuItem Root,
|
||||
NativeMenuItem Status,
|
||||
IReadOnlyList<(CompanionPluginAction Action, NativeMenuItem Item)> Actions);
|
||||
}
|
||||
BIN
companion/src/Lumi.Companion.App/Assets/Lumi.Companion.ico
Normal file
BIN
companion/src/Lumi.Companion.App/Assets/Lumi.Companion.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
11
companion/src/Lumi.Companion.App/CompanionPaths.cs
Normal file
11
companion/src/Lumi.Companion.App/CompanionPaths.cs
Normal file
@ -0,0 +1,11 @@
|
||||
namespace Lumi.Companion.App;
|
||||
|
||||
public sealed record CompanionPaths(string Root, string SettingsPath, string LogsDirectory, string BridgeDirectory, string UpdatesDirectory)
|
||||
{
|
||||
public static CompanionPaths ForCurrentUser()
|
||||
{
|
||||
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var root = Path.Combine(local, "Lumi", "Companion");
|
||||
return new CompanionPaths(root, Path.Combine(root, "settings.json"), Path.Combine(root, "logs"), Path.Combine(root, "obs-bridge"), Path.Combine(root, "updates"));
|
||||
}
|
||||
}
|
||||
1651
companion/src/Lumi.Companion.App/CompanionRuntime.cs
Normal file
1651
companion/src/Lumi.Companion.App/CompanionRuntime.cs
Normal file
File diff suppressed because it is too large
Load Diff
59
companion/src/Lumi.Companion.App/CompanionSettingsStore.cs
Normal file
59
companion/src/Lumi.Companion.App/CompanionSettingsStore.cs
Normal file
@ -0,0 +1,59 @@
|
||||
using System.Text.Json;
|
||||
using Lumi.Companion.Protocol;
|
||||
|
||||
namespace Lumi.Companion.App;
|
||||
|
||||
public sealed record CompanionSettings(
|
||||
bool AutoStartWithWindows = false,
|
||||
bool StartWithObs = true,
|
||||
bool TranscriptionEnabled = true,
|
||||
bool AdvancedMode = false,
|
||||
string? PrimarySourceUuid = null,
|
||||
string? PrimarySourceName = null,
|
||||
DateTimeOffset? PathTestPassedAt = null,
|
||||
string? PathTestFingerprint = null,
|
||||
int LastObsProcessId = 0,
|
||||
bool StreamTestObsRestartRequired = false);
|
||||
|
||||
public sealed class CompanionSettingsStore
|
||||
{
|
||||
private readonly string _path;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
public CompanionSettingsStore(string path) => _path = path;
|
||||
public CompanionSettings Current { get; private set; } = new();
|
||||
public event Action<CompanionSettings>? Changed;
|
||||
|
||||
public async Task LoadAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(_path)) return;
|
||||
try
|
||||
{
|
||||
Current = JsonSerializer.Deserialize<CompanionSettings>(await File.ReadAllBytesAsync(_path, cancellationToken), ProtocolV1.JsonOptions) ?? new();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
Current = new();
|
||||
}
|
||||
Changed?.Invoke(Current);
|
||||
}
|
||||
|
||||
public async Task SaveAsync(CompanionSettings settings, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||
var temporary = $"{_path}.{Environment.ProcessId}.tmp";
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(temporary, JsonSerializer.SerializeToUtf8Bytes(settings, ProtocolV1.JsonOptions), cancellationToken);
|
||||
File.Move(temporary, _path, true);
|
||||
}
|
||||
finally { File.Delete(temporary); }
|
||||
Current = settings;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
Changed?.Invoke(settings);
|
||||
}
|
||||
}
|
||||
74
companion/src/Lumi.Companion.App/CompanionState.cs
Normal file
74
companion/src/Lumi.Companion.App/CompanionState.cs
Normal file
@ -0,0 +1,74 @@
|
||||
namespace Lumi.Companion.App;
|
||||
|
||||
public enum TrayHealth { Ready, Operating, Degraded, Failed }
|
||||
public enum CompanionPage { Overview, StreamTesting, Transcription, Test, LumiOverlay, SongOverlay, Connection, Logs, Settings }
|
||||
public enum TestStageState { Waiting, Running, Passed, Blocked, Failed }
|
||||
|
||||
public sealed record CompanionState(
|
||||
bool Paired = false,
|
||||
bool Connected = false,
|
||||
bool ObsBridgeInstalled = false,
|
||||
bool ObsConnected = false,
|
||||
bool ObsStreaming = false,
|
||||
bool ObsRecording = false,
|
||||
bool TestRunning = false,
|
||||
bool BenchmarkRunning = false,
|
||||
bool StreamTestRunning = false,
|
||||
bool StreamTestRecoveryRequired = false,
|
||||
bool StreamTestObsRestartRequired = false,
|
||||
TrayHealth Health = TrayHealth.Degraded,
|
||||
string Detail = "Pair Lumi Companion to get started.",
|
||||
string? DeviceName = null,
|
||||
string? Host = null,
|
||||
DateTimeOffset? LastConnectedAt = null,
|
||||
bool UpdateAvailable = false,
|
||||
bool UpdateCheckRunning = false,
|
||||
string? AvailableVersion = null,
|
||||
string UpdateDetail = "Checking for updates…",
|
||||
string BenchmarkDetail = "Start a dedicated test, speak naturally, then end it when you have enough material.",
|
||||
string StreamTestDetail = "Start a private test only while OBS is not streaming or recording.",
|
||||
string? StreamTestSessionId = null,
|
||||
double StreamTestBitrateKbps = 0,
|
||||
long StreamTestDroppedFrames = 0,
|
||||
long StreamTestTotalFrames = 0,
|
||||
double StreamTestCongestion = 0,
|
||||
bool PathTestValid = false,
|
||||
string PathTestDetail = "Run once after setup or a relevant configuration change.",
|
||||
bool ObsBridgeRepairNeeded = false,
|
||||
bool ObsBridgeUpdateAvailable = false,
|
||||
bool ObsBridgePackageAvailable = false,
|
||||
string? ObsBridgeInstalledVersion = null,
|
||||
string? ObsBridgeLoadedVersion = null,
|
||||
string? ObsBridgeBundledVersion = null,
|
||||
string ObsBridgeDetail = "Checking the managed OBS integration…")
|
||||
{
|
||||
public string Summary => Health switch
|
||||
{
|
||||
TrayHealth.Operating => "Transcription active",
|
||||
TrayHealth.Ready => "Ready",
|
||||
TrayHealth.Failed => "Needs attention",
|
||||
_ when !Paired => "Setup required",
|
||||
_ when !Connected => "Lumi offline",
|
||||
_ when ObsBridgeUpdateAvailable => "OBS update required",
|
||||
_ when !ObsBridgeInstalled => "OBS setup required",
|
||||
_ => "Partially ready"
|
||||
};
|
||||
|
||||
public bool RequiresQuitConfirmation => ObsStreaming || ObsRecording || BenchmarkRunning || StreamTestRunning || StreamTestRecoveryRequired;
|
||||
public string QuitWarning => StreamTestRunning || StreamTestRecoveryRequired
|
||||
? "A private stream test or OBS recovery is active. Quitting will stop the test and restore the exact streaming service saved before it began."
|
||||
: BenchmarkRunning
|
||||
? "A transcription accuracy and latency test is running. Quitting will safely end and mark the test as aborted."
|
||||
: ObsStreaming
|
||||
? "OBS is streaming. Quitting Lumi Companion will stop transcription and closed captions, but it will not stop the OBS stream."
|
||||
: ObsRecording ? "OBS is recording. Quitting Lumi Companion will stop active companion features." : string.Empty;
|
||||
}
|
||||
|
||||
public sealed record TestStage(string Name, string Detail, TestStageState State);
|
||||
public sealed record BenchmarkWord(string Text, double LatencyMs, double? Confidence, bool Final);
|
||||
public sealed record MetricStatistics(int Count, double? Minimum, double? LowOnePercentAverage, double? Median, double? Average, double? P99, double? HighOnePercentAverage, double? Maximum);
|
||||
public sealed record BenchmarkSnapshot(string? Id, string Transcript, IReadOnlyList<BenchmarkWord> Words, MetricStatistics Latency, MetricStatistics Confidence, DateTimeOffset? StartedAt, string Status);
|
||||
public sealed record ObsSource(string Uuid, string Name, bool Active, bool Missing)
|
||||
{
|
||||
public override string ToString() => Missing ? $"{Name} (missing)" : Active ? $"{Name} (active)" : Name;
|
||||
}
|
||||
31
companion/src/Lumi.Companion.App/DecisionWindow.cs
Normal file
31
companion/src/Lumi.Companion.App/DecisionWindow.cs
Normal file
@ -0,0 +1,31 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
|
||||
namespace Lumi.Companion.App;
|
||||
|
||||
internal sealed class DecisionWindow : Window
|
||||
{
|
||||
public DecisionWindow(string title, string message, string confirmLabel)
|
||||
{
|
||||
Title = title;
|
||||
Width = 470;
|
||||
SizeToContent = SizeToContent.Height;
|
||||
CanResize = false;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
var cancel = new Button { Content = "Cancel", Classes = { "secondary" } };
|
||||
var confirm = new Button { Content = confirmLabel, Classes = { "primary" } };
|
||||
cancel.Click += (_, _) => Close(false);
|
||||
confirm.Click += (_, _) => Close(true);
|
||||
Content = new StackPanel
|
||||
{
|
||||
Margin = new Thickness(28), Spacing = 18,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = title, Classes = { "sectionTitle" } },
|
||||
new TextBlock { Text = message, Classes = { "muted" }, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
|
||||
new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Spacing = 10, Children = { cancel, confirm } }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
37
companion/src/Lumi.Companion.App/DevelopmentBuildManifest.cs
Normal file
37
companion/src/Lumi.Companion.App/DevelopmentBuildManifest.cs
Normal file
@ -0,0 +1,37 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Lumi.Companion.Protocol;
|
||||
|
||||
namespace Lumi.Companion.App;
|
||||
|
||||
public sealed record DevelopmentBuildManifest(
|
||||
[property: JsonPropertyName("schema_version")] int SchemaVersion,
|
||||
[property: JsonPropertyName("mode")] string Mode,
|
||||
[property: JsonPropertyName("build_checksum")] string BuildChecksum,
|
||||
[property: JsonPropertyName("generated_at")] string GeneratedAt,
|
||||
[property: JsonPropertyName("components")] IReadOnlyDictionary<string, string> Components)
|
||||
{
|
||||
public static DevelopmentBuildManifest? Load()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, ".lumi-dev-build.json");
|
||||
if (!File.Exists(path)) return null;
|
||||
try
|
||||
{
|
||||
var manifest = JsonSerializer.Deserialize<DevelopmentBuildManifest>(File.ReadAllText(path), ProtocolV1.JsonOptions);
|
||||
return manifest is { SchemaVersion: 1 } && IsSha256(manifest.BuildChecksum)
|
||||
? manifest with { Components = Sanitize(manifest.Components) }
|
||||
: null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> Sanitize(IReadOnlyDictionary<string, string>? input)
|
||||
{
|
||||
if (input is null) return new Dictionary<string, string>();
|
||||
return input
|
||||
.Where(entry => entry.Key.Length is > 0 and <= 160 && IsSha256(entry.Value))
|
||||
.ToDictionary(entry => entry.Key, entry => entry.Value.ToLowerInvariant(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsSha256(string? value) => value is { Length: 64 } && value.All(Uri.IsHexDigit);
|
||||
}
|
||||
134
companion/src/Lumi.Companion.App/HotkeyCaptureBinding.cs
Normal file
134
companion/src/Lumi.Companion.App/HotkeyCaptureBinding.cs
Normal file
@ -0,0 +1,134 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace Lumi.Companion.App;
|
||||
|
||||
public sealed class HotkeyCaptureBinding
|
||||
{
|
||||
private readonly Button _button;
|
||||
private readonly DispatcherTimer _timeout;
|
||||
private readonly HashSet<Key> _pressed = [];
|
||||
private string _hotkey = "";
|
||||
private string _previous = "";
|
||||
private KeyModifiers _modifiers;
|
||||
private Key? _primary;
|
||||
private bool _capturing;
|
||||
|
||||
public HotkeyCaptureBinding(Button button)
|
||||
{
|
||||
_button = button;
|
||||
_button.HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Left;
|
||||
_button.Focusable = true;
|
||||
_timeout = new DispatcherTimer { Interval = TimeSpan.FromSeconds(7) };
|
||||
_timeout.Tick += (_, _) => CancelCapture();
|
||||
_button.Click += (_, _) => BeginCapture();
|
||||
_button.KeyDown += OnKeyDown;
|
||||
_button.KeyUp += OnKeyUp;
|
||||
}
|
||||
|
||||
public string Hotkey
|
||||
{
|
||||
get => _hotkey;
|
||||
set
|
||||
{
|
||||
_hotkey = string.IsNullOrWhiteSpace(value) ? "Unassigned" : value.Trim();
|
||||
if (!_capturing) UpdateContent();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (!_capturing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
CancelCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
_pressed.Add(e.Key);
|
||||
_modifiers |= e.KeyModifiers;
|
||||
if (!IsModifier(e.Key)) _primary = e.Key;
|
||||
_button.Content = _primary is null ? "Hold modifiers, then press a key…" : $"{Format(_modifiers, _primary.Value)} · release to save";
|
||||
}
|
||||
|
||||
private void OnKeyUp(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (!_capturing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
_pressed.Remove(e.Key);
|
||||
if (_primary is not null && !_pressed.Any(key => !IsModifier(key)))
|
||||
{
|
||||
_hotkey = Format(_modifiers, _primary.Value);
|
||||
FinishCapture();
|
||||
}
|
||||
}
|
||||
|
||||
private void BeginCapture()
|
||||
{
|
||||
if (_capturing) return;
|
||||
_capturing = true;
|
||||
_previous = _hotkey;
|
||||
_pressed.Clear();
|
||||
_modifiers = KeyModifiers.None;
|
||||
_primary = null;
|
||||
_button.Content = "Press a shortcut…";
|
||||
_button.Focus();
|
||||
_timeout.Start();
|
||||
}
|
||||
|
||||
private void CancelCapture()
|
||||
{
|
||||
if (!_capturing) return;
|
||||
_hotkey = _previous;
|
||||
FinishCapture();
|
||||
}
|
||||
|
||||
private void FinishCapture()
|
||||
{
|
||||
_capturing = false;
|
||||
_timeout.Stop();
|
||||
_pressed.Clear();
|
||||
UpdateContent();
|
||||
}
|
||||
|
||||
private void UpdateContent() => _button.Content = $"{_hotkey} (Set hotkey)";
|
||||
|
||||
private static bool IsModifier(Key key) => key is
|
||||
Key.LeftCtrl or Key.RightCtrl or Key.LeftAlt or Key.RightAlt or
|
||||
Key.LeftShift or Key.RightShift or Key.LWin or Key.RWin;
|
||||
|
||||
internal static string Format(KeyModifiers modifiers, Key key)
|
||||
{
|
||||
var parts = new List<string>(5);
|
||||
if (modifiers.HasFlag(KeyModifiers.Control)) parts.Add("Ctrl");
|
||||
if (modifiers.HasFlag(KeyModifiers.Alt)) parts.Add("Alt");
|
||||
if (modifiers.HasFlag(KeyModifiers.Shift)) parts.Add("Shift");
|
||||
if (modifiers.HasFlag(KeyModifiers.Meta)) parts.Add("Win");
|
||||
parts.Add(KeyName(key));
|
||||
return string.Join("+", parts);
|
||||
}
|
||||
|
||||
private static string KeyName(Key key) => key switch
|
||||
{
|
||||
Key.PageUp => "PageUp",
|
||||
Key.PageDown => "PageDown",
|
||||
Key.Left => "Left",
|
||||
Key.Right => "Right",
|
||||
Key.Up => "Up",
|
||||
Key.Down => "Down",
|
||||
Key.Return => "Enter",
|
||||
Key.Space => "Space",
|
||||
Key.Back => "Backspace",
|
||||
_ => key.ToString()
|
||||
};
|
||||
}
|
||||
33
companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj
Normal file
33
companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj
Normal file
@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
|
||||
<Version>0.2.8</Version>
|
||||
<AssemblyVersion>0.2.8.0</AssemblyVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\Lumi.Companion.ico" />
|
||||
<ProjectReference Include="../Lumi.Companion.Core/Lumi.Companion.Core.csproj" />
|
||||
<ProjectReference Include="../Lumi.Companion.Protocol/Lumi.Companion.Protocol.csproj" />
|
||||
<ProjectReference Include="../Lumi.Companion.Abstractions/Lumi.Companion.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../plugins/Lumi.Companion.Transcription/Lumi.Companion.Transcription.csproj" />
|
||||
<ProjectReference Include="../../plugins/Lumi.Companion.SongOverlay/Lumi.Companion.SongOverlay.csproj" />
|
||||
<ProjectReference Include="../../plugins/Lumi.Companion.Overlay/Lumi.Companion.Overlay.csproj" />
|
||||
<PackageReference Include="Avalonia" Version="12.1.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.0" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="Exists('components/obs-bridge/lumi-obs-bridge.dll')">
|
||||
<Content Include="components/obs-bridge/lumi-obs-bridge.dll" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" ExcludeFromSingleFile="true" />
|
||||
<Content Include="components/obs-bridge/manifest.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
<Content Include="components/obs-bridge/en-US.ini" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
<EmbeddedResource Include="components/obs-bridge/lumi-obs-bridge.dll" LogicalName="Lumi.Companion.ObsBridge.dll" />
|
||||
<EmbeddedResource Include="components/obs-bridge/manifest.json" LogicalName="Lumi.Companion.ObsBridge.manifest.json" />
|
||||
<EmbeddedResource Include="components/obs-bridge/en-US.ini" LogicalName="Lumi.Companion.ObsBridge.en-US.ini" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
66
companion/src/Lumi.Companion.App/LumiIconFactory.cs
Normal file
66
companion/src/Lumi.Companion.App/LumiIconFactory.cs
Normal file
@ -0,0 +1,66 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Lumi.Companion.App;
|
||||
|
||||
internal static class LumiIconFactory
|
||||
{
|
||||
private static readonly Dictionary<TrayHealth, WindowIcon> Icons = new();
|
||||
private static readonly object IconGate = new();
|
||||
|
||||
public static WindowIcon Create(TrayHealth health)
|
||||
{
|
||||
lock (IconGate)
|
||||
{
|
||||
if (Icons.TryGetValue(health, out var icon)) return icon;
|
||||
icon = CreateUncached(health);
|
||||
Icons[health] = icon;
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
|
||||
private static WindowIcon CreateUncached(TrayHealth health)
|
||||
{
|
||||
const int size = 32;
|
||||
var color = health switch
|
||||
{
|
||||
TrayHealth.Operating => (R: (byte)35, G: (byte)132, B: (byte)91),
|
||||
TrayHealth.Degraded => (R: (byte)169, G: (byte)102, B: (byte)18),
|
||||
TrayHealth.Failed => (R: (byte)189, G: (byte)77, B: (byte)77),
|
||||
_ => (R: (byte)40, G: (byte)183, B: (byte)200)
|
||||
};
|
||||
var pixels = new byte[size * size * 4];
|
||||
for (var y = 0; y < size; y++)
|
||||
for (var x = 0; x < size; x++)
|
||||
{
|
||||
var target = ((size - 1 - y) * size + x) * 4;
|
||||
var rounded = InsideRoundedSquare(x, y, size, 7);
|
||||
var diamond = Math.Abs(x - 15.5) / 11.5 + Math.Abs(y - 15.5) / 11.5 <= 1;
|
||||
pixels[target] = diamond && rounded ? (byte)255 : color.B;
|
||||
pixels[target + 1] = diamond && rounded ? (byte)255 : color.G;
|
||||
pixels[target + 2] = diamond && rounded ? (byte)255 : color.R;
|
||||
pixels[target + 3] = rounded ? (byte)255 : (byte)0;
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true);
|
||||
writer.Write((ushort)0); writer.Write((ushort)1); writer.Write((ushort)1);
|
||||
writer.Write((byte)size); writer.Write((byte)size); writer.Write((byte)0); writer.Write((byte)0);
|
||||
writer.Write((ushort)1); writer.Write((ushort)32);
|
||||
var imageBytes = 40 + pixels.Length + size * 4;
|
||||
writer.Write(imageBytes); writer.Write(22);
|
||||
writer.Write(40); writer.Write(size); writer.Write(size * 2); writer.Write((ushort)1); writer.Write((ushort)32);
|
||||
writer.Write(0); writer.Write(pixels.Length); writer.Write(0); writer.Write(0); writer.Write(0); writer.Write(0);
|
||||
writer.Write(pixels); writer.Write(new byte[size * 4]);
|
||||
stream.Position = 0;
|
||||
return new WindowIcon(stream);
|
||||
}
|
||||
|
||||
private static bool InsideRoundedSquare(int x, int y, int size, int radius)
|
||||
{
|
||||
if (x >= radius && x < size - radius || y >= radius && y < size - radius) return true;
|
||||
var cx = x < radius ? radius : size - radius - 1;
|
||||
var cy = y < radius ? radius : size - radius - 1;
|
||||
var dx = x - cx; var dy = y - cy;
|
||||
return dx * dx + dy * dy <= radius * radius;
|
||||
}
|
||||
}
|
||||
605
companion/src/Lumi.Companion.App/MainWindow.axaml
Normal file
605
companion/src/Lumi.Companion.App/MainWindow.axaml
Normal file
@ -0,0 +1,605 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Lumi.Companion.App.MainWindow"
|
||||
Title="Lumi Companion"
|
||||
Icon="/Assets/Lumi.Companion.ico"
|
||||
Width="1120" Height="760" MinWidth="900" MinHeight="620"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<Grid ColumnDefinitions="248,*">
|
||||
<Border Grid.Column="0" Background="#F4F7F8" Padding="22,24">
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||
<StackPanel Orientation="Horizontal" Spacing="11">
|
||||
<Border Width="34" Height="34" CornerRadius="9" Background="#28B7C8">
|
||||
<Path Data="M 17,7 L 27,17 L 17,27 L 7,17 Z" Fill="White" Stretch="None" />
|
||||
</Border>
|
||||
<StackPanel VerticalAlignment="Center" Spacing="0">
|
||||
<TextBlock Text="LUMI" Classes="eyebrow" />
|
||||
<TextBlock Text="Companion" FontWeight="SemiBold" FontSize="16" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Row="1" Margin="0,25,0,20" Padding="11,9" CornerRadius="9" Background="#E6EEF0">
|
||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="9">
|
||||
<Border x:Name="StatusMark" Width="10" Height="10" CornerRadius="5" Background="#A96612" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Spacing="1">
|
||||
<TextBlock x:Name="StatusLabel" Text="Setup required" FontSize="13" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="StatusSymbol" Text="○ Incomplete" FontSize="11" Foreground="{DynamicResource LumiMutedBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="2" RowDefinitions="Auto,*,Auto">
|
||||
<StackPanel Spacing="4">
|
||||
<Button x:Name="OverviewNav" Classes="nav selected" Content="Overview" Tag="Overview" />
|
||||
<Button x:Name="StreamTestingNav" Classes="nav" Content="Stream testing" Tag="StreamTesting" />
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer Grid.Row="1" Margin="0,14,0,14" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="PLUGINS" Classes="eyebrow" Margin="12,0,0,4" />
|
||||
<StackPanel x:Name="PluginNavigation" Spacing="4" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<StackPanel Grid.Row="2" x:Name="TechnicalNavigation">
|
||||
<Border Height="1" Background="{DynamicResource LumiBorderBrush}" Margin="0,0,0,8" />
|
||||
<Button x:Name="ConnectionNav" Classes="nav" Content="Connection & device" Tag="Connection" />
|
||||
<Button x:Name="LogsNav" Classes="nav" Content="Logs & diagnostics" Tag="Logs" />
|
||||
<Button x:Name="SettingsNav" Classes="nav" Content="Settings" Tag="Settings" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Grid.Row="3" Text="Runs quietly in the notification area" Classes="muted" FontSize="11" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Column="1">
|
||||
<ScrollViewer x:Name="OverviewPage">
|
||||
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="24">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="AT A GLANCE" Classes="eyebrow" />
|
||||
<TextBlock Text="Overview" Classes="pageTitle" />
|
||||
<TextBlock x:Name="OverviewDetail" Text="Checking what is ready…" Classes="muted" FontSize="15" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="soft" Padding="22">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="24">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="NextActionTitle" Text="Pair this computer" Classes="sectionTitle" />
|
||||
<TextBlock x:Name="NextActionDetail" Text="Use a one-time pairing package from Lumi to connect this streaming computer." Classes="muted" MaxWidth="520" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" x:Name="NextActionButton" Classes="primary" Content="Choose pairing package" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="UpdatePanel" Classes="soft" Padding="18" IsVisible="False">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="20">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock x:Name="UpdateTitle" Text="Companion update available" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="UpdateDetail" Text="A verified update is ready." Classes="muted" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" x:Name="ApplyUpdateButton" Classes="primary" Content="Update Companion" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="OverviewBridgeUpdatePanel" Background="#FFF2D6" BorderBrush="#D58A1F" BorderThickness="1" CornerRadius="12" Padding="18" IsVisible="False">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="20">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Update OBS Bridge" FontWeight="Bold" Foreground="#764600" />
|
||||
<TextBlock x:Name="OverviewBridgeUpdateDetail" Text="The OBS integration loaded by OBS is older than the version bundled with Companion." TextWrapping="Wrap" Foreground="#764600" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" x:Name="OverviewUpdateBridgeButton" Classes="primary" Content="Update OBS Bridge" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Setup progress" Classes="sectionTitle" />
|
||||
<Grid ColumnDefinitions="*,*,*" ColumnSpacing="12">
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="7">
|
||||
<TextBlock x:Name="PairingStepSymbol" Text="○" FontSize="20" />
|
||||
<TextBlock Text="Lumi connection" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="PairingStepDetail" Text="Not paired" Classes="muted" FontSize="12" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="1" Classes="card">
|
||||
<StackPanel Spacing="7">
|
||||
<TextBlock x:Name="ObsStepSymbol" Text="○" FontSize="20" />
|
||||
<TextBlock Text="OBS integration" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="ObsStepDetail" Text="Not installed" Classes="muted" FontSize="12" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="2" Classes="card">
|
||||
<StackPanel Spacing="7">
|
||||
<TextBlock x:Name="TestStepSymbol" Text="○" FontSize="20" />
|
||||
<TextBlock Text="Full path test" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="TestStepDetail" Text="Not run" Classes="muted" FontSize="12" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="soft">
|
||||
<TextBlock Text="Lumi Companion never runs speech recognition on this streaming computer. Audio stays in bounded memory and is sent to your paired Lumi host." Classes="muted" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<ScrollViewer x:Name="StreamTestingPage" IsVisible="False">
|
||||
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="24">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="PRIVATE OUTPUT" Classes="eyebrow" />
|
||||
<TextBlock Text="Stream testing" Classes="pageTitle" />
|
||||
<TextBlock Text="Send the real OBS stream to Lumi's private test receiver, inspect it in the Admin page, then restore the exact normal streaming service automatically." Classes="muted" FontSize="15" />
|
||||
</StackPanel>
|
||||
<Border Background="#FFF2D6" BorderBrush="#D58A1F" BorderThickness="1" CornerRadius="12" Padding="18">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="Never start this while publicly live" FontWeight="Bold" Foreground="#764600" />
|
||||
<TextBlock Text="Companion refuses to redirect active streaming or recording. While the test is active, the status below remains unmistakable and the prior OBS service is kept in an encrypted current-user recovery file." TextWrapping="Wrap" Foreground="#764600" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="16">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock x:Name="StreamTestStatusTitle" Text="Ready to check" Classes="sectionTitle" />
|
||||
<TextBlock x:Name="StreamTestStatusDetail" Text="Start only while OBS is idle." Classes="muted" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="*,*,*" ColumnSpacing="12">
|
||||
<Border Classes="soft"><StackPanel Spacing="4"><TextBlock Text="OBS bitrate" Classes="muted" FontSize="11" /><TextBlock x:Name="StreamTestBitrateText" Text="—" FontWeight="SemiBold" /></StackPanel></Border>
|
||||
<Border Grid.Column="1" Classes="soft"><StackPanel Spacing="4"><TextBlock Text="Dropped frames" Classes="muted" FontSize="11" /><TextBlock x:Name="StreamTestDroppedText" Text="—" FontWeight="SemiBold" /></StackPanel></Border>
|
||||
<Border Grid.Column="2" Classes="soft"><StackPanel Spacing="4"><TextBlock Text="Congestion" Classes="muted" FontSize="11" /><TextBlock x:Name="StreamTestCongestionText" Text="—" FontWeight="SemiBold" /></StackPanel></Border>
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button x:Name="StartStreamTestButton" Classes="primary" Content="Start private stream test" />
|
||||
<Button x:Name="StopStreamTestButton" Classes="secondary" Content="End test and restore OBS" IsEnabled="False" />
|
||||
<Button x:Name="OpenStreamTestWebButton" Classes="secondary" Content="Open stream viewer" IsVisible="False" />
|
||||
<Button x:Name="RepairStreamTestButton" Classes="secondary" Content="Repair restoration" IsVisible="False" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Classes="soft">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="Where to watch" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Open Admin → Stream testing in Lumi WebUI for the private player, quality control, receiver facts, warnings, and recovery timeline." Classes="muted" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<ScrollViewer x:Name="TranscriptionPage" IsVisible="False">
|
||||
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="25">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="CAPTURE" Classes="eyebrow" />
|
||||
<TextBlock Text="Transcription" Classes="pageTitle" />
|
||||
<TextBlock Text="Choose the OBS microphone Lumi should caption. Source identity remains stable if you rename it." Classes="muted" FontSize="15" />
|
||||
</StackPanel>
|
||||
<Border Classes="card">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="24">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Generate and include captions" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="TranscriptionEnabledDetail" Text="Speech recognition and caption inclusion are enabled." Classes="muted" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="TranscriptionEnabledToggle" OffContent="Off" OnContent="On" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Primary microphone" FontWeight="SemiBold" />
|
||||
<ComboBox x:Name="SourcePicker" IsEnabled="False" PlaceholderText="Open OBS after installing the managed integration" MinWidth="420" HorizontalAlignment="Left" />
|
||||
<TextBlock x:Name="SourceHelp" Text="No OBS source list is available yet." Classes="muted" />
|
||||
</StackPanel>
|
||||
<Border Classes="soft">
|
||||
<StackPanel Spacing="7">
|
||||
<TextBlock Text="Optional second microphone" FontWeight="SemiBold" />
|
||||
<TextBlock Text="The protocol supports more than one source, but source overlap policy remains disabled until the primary path is validated." Classes="muted" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<ScrollViewer x:Name="TestPage" IsVisible="False">
|
||||
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="24">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="SAFE CHECK" Classes="eyebrow" />
|
||||
<TextBlock Text="Transcription test" Classes="pageTitle" />
|
||||
<TextBlock Text="Checks the selected OBS source, secure connection, model readiness, and safe caption return path. You do not need to speak, and nothing is sent to Twitch." Classes="muted" FontSize="15" />
|
||||
</StackPanel>
|
||||
<Button x:Name="RunTestButton" Classes="primary" Content="Run full path test" HorizontalAlignment="Left" />
|
||||
<StackPanel x:Name="TestStagesPanel" Spacing="8" />
|
||||
<Border Classes="soft">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="Simulated outgoing caption" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="SimulatedCaption" Text="A real caption will appear here only after every upstream stage passes." Classes="muted" FontStyle="Italic" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="16">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="ACCURACY & LATENCY" Classes="eyebrow" />
|
||||
<TextBlock Text="Dedicated transcription test" Classes="sectionTitle" />
|
||||
<TextBlock Text="Speak naturally for as long as needed. End the test yourself, or Lumi will finalize it after 10 seconds of silence. Nothing is sent to Twitch." Classes="muted" />
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="10">
|
||||
<Button x:Name="StartBenchmarkButton" Classes="primary" Content="Start dedicated test" />
|
||||
<Button Grid.Column="1" x:Name="StopBenchmarkButton" Classes="secondary" Content="End test" IsEnabled="False" />
|
||||
<ProgressBar Grid.Column="2" x:Name="VoiceLevelMeter" Minimum="-60" Maximum="0" Value="-60" Height="12" MinWidth="220" VerticalAlignment="Center" Foreground="{DynamicResource LumiDangerBrush}" />
|
||||
<TextBlock Grid.Column="3" x:Name="VoiceLevelText" Text="-60 dBFS" Classes="muted" FontSize="11" VerticalAlignment="Center" MinWidth="58" />
|
||||
</Grid>
|
||||
<TextBlock Text="Input level: green from -30 to -12 dBFS; orange within 10 dB outside that range; red otherwise." Classes="muted" FontSize="11" />
|
||||
<TextBlock x:Name="BenchmarkStatusText" Text="Not started." Classes="muted" />
|
||||
<Border Classes="soft">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="Measured transcript" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="BenchmarkTranscript" Text="Finalized speech will appear here." Classes="muted" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Latency" FontWeight="SemiBold" />
|
||||
<WrapPanel x:Name="LatencyStatsPanel" Orientation="Horizontal" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Final confidence" FontWeight="SemiBold" />
|
||||
<WrapPanel x:Name="ConfidenceStatsPanel" Orientation="Horizontal" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="Low and high 1% values are tail averages. Confidence is included only after an utterance is finalized." Classes="muted" FontSize="12" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
|
||||
<ScrollViewer x:Name="SongOverlayPage" IsVisible="False">
|
||||
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="24">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="MEDIA" Classes="eyebrow" />
|
||||
<TextBlock Text="Song Overlay" Classes="pageTitle" />
|
||||
<TextBlock Text="Choose which Windows media sessions Lumi may discover. Song delivery continues to use the Companion's shared secure connection." Classes="muted" FontSize="15" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="soft">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="20">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock x:Name="SongOverlayStatusTitle" Text="Starting" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="SongOverlayStatusDetail" Text="Waiting for the media provider…" Classes="muted" />
|
||||
<TextBlock x:Name="SongOverlayCurrentTrack" Text="No active song" Classes="muted" FontSize="12" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="SongOverlayEnabledToggle" OffContent="Off" OnContent="On" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Lumi authentication" Classes="sectionTitle" />
|
||||
<Border Classes="soft">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock x:Name="SongOverlayLumiAuthenticationText" Text="Uses the Companion device pairing" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Song Overlay inherits the protected Companion credential automatically. Revoking or forgetting this Companion also revokes plugin access; no separate plugin key is needed." Classes="muted" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Playback sources" Classes="sectionTitle" />
|
||||
<TextBlock Text="Only enabled sources are observed. Browser sessions without an exposed service name are treated as YouTube." Classes="muted" FontSize="12" TextWrapping="Wrap" />
|
||||
<Border Classes="soft">
|
||||
<StackPanel x:Name="SongOverlaySourcesPanel" Spacing="9" />
|
||||
</Border>
|
||||
<Grid ColumnDefinitions="150,*" RowDefinitions="Auto,Auto" RowSpacing="10" ColumnSpacing="12">
|
||||
<TextBlock Text="Recovery heartbeat" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Column="1" x:Name="SongOverlayHeartbeatBox" Minimum="15" Maximum="300" Increment="5" FormatString="0 seconds" HorizontalAlignment="Left" Width="160" />
|
||||
<TextBlock Grid.Row="1" Text="Seek sensitivity" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="1" x:Name="SongOverlaySeekBox" Minimum="500" Maximum="10000" Increment="250" FormatString="0 ms" HorizontalAlignment="Left" Width="160" />
|
||||
</Grid>
|
||||
<CheckBox x:Name="SongOverlayCoverToggle" Content="Send cover art only with track metadata" />
|
||||
<CheckBox x:Name="SongOverlaySearchLinkToggle" Content="Use a Spotify search link when an exact song link is unavailable" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="12">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="SPOTIFY-SPECIFIC" Classes="eyebrow" />
|
||||
<TextBlock Text="Optional metadata enrichment" Classes="sectionTitle" />
|
||||
<TextBlock Text="Windows normally supplies title, artist, album, timeline and artwork. Connecting your own Spotify application adds an exact link, release year and official artwork when available. Spotify is contacted only when track metadata changes or needs recovery." Classes="muted" />
|
||||
</StackPanel>
|
||||
<TextBox x:Name="SongOverlaySpotifyClientIdBox" PlaceholderText="Spotify application Client ID" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button x:Name="ConnectSongOverlaySpotifyButton" Classes="secondary" Content="Connect Spotify metadata" />
|
||||
<Button x:Name="DisconnectSongOverlaySpotifyButton" Classes="secondary" Content="Disconnect" />
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="SongOverlaySpotifyStatus" Text="Not connected. Core playback detection still works through Windows." Classes="muted" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button x:Name="SaveSongOverlayButton" Classes="primary" Content="Save Song Overlay" />
|
||||
<Button x:Name="SendSongOverlayStateButton" Classes="secondary" Content="Send current song now" />
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="SongOverlayFeedback" Classes="muted" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<ScrollViewer x:Name="LumiOverlayPage" IsVisible="False">
|
||||
<StackPanel Margin="44,38,52,48" MaxWidth="820" HorizontalAlignment="Left" Spacing="24">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="NATIVE MONITOR OVERLAY" Classes="eyebrow" />
|
||||
<TextBlock Text="Lumi Overlay" Classes="pageTitle" />
|
||||
<TextBlock Text="Show Lumi's normalized chat and stream events directly on one Windows monitor. This is separate from browser-source overlays and never receives provider credentials." Classes="muted" FontSize="15" TextWrapping="Wrap" />
|
||||
<TextBlock Text="Changes on this page stay as an unsaved draft until you select Save Lumi Overlay. Live feed refreshes will not replace the draft." Classes="muted" FontSize="12" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
|
||||
<Border Classes="soft">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Feed" Classes="muted" FontSize="11" />
|
||||
<TextBlock x:Name="LumiOverlayFeedStatus" Text="Starting" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="LumiOverlayFeedDetail" Text="Loading the native overlay…" Classes="muted" TextWrapping="Wrap" />
|
||||
<TextBlock x:Name="LumiOverlayProviderStatus" Text="Provider status will appear after Lumi connects." Classes="muted" FontSize="11" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Grid.Column="1" Classes="soft">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Capture exclusion" Classes="muted" FontSize="11" />
|
||||
<TextBlock x:Name="LumiOverlayCaptureStatus" Text="Pending" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="LumiOverlayCaptureDetail" Text="Waiting for the overlay window." Classes="muted" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
<TextBlock x:Name="LumiOverlayMonitorWarning" Foreground="{DynamicResource LumiWarningBrush}" TextWrapping="Wrap" IsVisible="False" />
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Text="Display & visibility" Classes="sectionTitle" />
|
||||
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="10" ColumnSpacing="12">
|
||||
<TextBlock Text="Monitor" Classes="muted" VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Column="1" x:Name="LumiOverlayMonitorPicker" />
|
||||
<TextBlock Grid.Row="1" Text="Visibility" Classes="muted" VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Row="1" Grid.Column="1" x:Name="LumiOverlayVisibilityPicker" />
|
||||
<TextBlock Grid.Row="2" Text="Layout" Classes="muted" VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Row="2" Grid.Column="1" x:Name="LumiOverlayLayoutPicker" />
|
||||
<TextBlock Grid.Row="3" x:Name="LumiOverlayChatShareLabel" Text="Chat share in divided layout" Classes="muted" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Row="3" Grid.Column="1" x:Name="LumiOverlayChatShareEditor" Orientation="Horizontal" Spacing="7">
|
||||
<NumericUpDown x:Name="LumiOverlayChatPercentageBox" Minimum="10" Maximum="90" Increment="5" FormatString="0" HorizontalAlignment="Left" Width="112" />
|
||||
<TextBlock Text="%" Classes="muted" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<CheckBox x:Name="LumiOverlayEnabledToggle" Content="Run Lumi Overlay automatically with Companion" />
|
||||
<TextBlock x:Name="LumiOverlayVisibilityState" Text="Current display: checking…" Classes="muted" FontSize="12" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button x:Name="LumiOverlayPreviewButton" Classes="secondary" Content="Start sample preview" />
|
||||
<Button x:Name="LumiOverlayVisibilityButton" Classes="secondary" Content="Show temporarily" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="16">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Sources" Classes="sectionTitle" />
|
||||
<TextBlock Text="Only checked sources are sent over this device feed. Saved unavailable sources stay listed." Classes="muted" FontSize="12" TextWrapping="Wrap" />
|
||||
<Border Classes="soft"><StackPanel x:Name="LumiOverlaySourcesPanel" Spacing="8"><TextBlock Text="Waiting for Lumi…" Classes="muted" /></StackPanel></Border>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Spacing="10">
|
||||
<TextBlock Text="Event types" Classes="sectionTitle" />
|
||||
<TextBlock Text="Chat is controlled by Sources. Choose which non-chat events should appear." Classes="muted" FontSize="12" TextWrapping="Wrap" />
|
||||
<Border Classes="soft"><StackPanel x:Name="LumiOverlayEventsPanel" Spacing="8"><TextBlock Text="Waiting for Lumi…" Classes="muted" /></StackPanel></Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border Classes="card">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="20">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="Interactive layout editor" Classes="sectionTitle" />
|
||||
<TextBlock Text="Edit the real monitor overlay in place. Drag a highlighted container to move it, or drag its lower-right corner to resize it. The preview uses the same renderer, typography, media and animations as the live overlay." Classes="muted" TextWrapping="Wrap" />
|
||||
<TextBlock x:Name="LumiOverlayEditStatus" Text="Edit mode is off. Changes remain a draft until saved." Classes="muted" FontSize="12" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" x:Name="LumiOverlayEditButton" Classes="primary" Content="Start edit mode" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Expander Header="Advanced appearance & lifecycle">
|
||||
<Border Classes="card" Margin="0,10,0,0">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock Text="Container styling & lifecycle" Classes="sectionTitle" />
|
||||
<ComboBox x:Name="LumiOverlayContainerPicker" HorizontalAlignment="Left" Width="240">
|
||||
<ComboBoxItem x:Name="LumiOverlayUnifiedContainerOption" Content="Unified container" Tag="unified" />
|
||||
<ComboBoxItem x:Name="LumiOverlayChatContainerOption" Content="Chat container" Tag="chat" />
|
||||
<ComboBoxItem x:Name="LumiOverlayEventContainerOption" Content="Event container" Tag="events" />
|
||||
</ComboBox>
|
||||
<Grid ColumnDefinitions="170,180,170,180" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto" RowSpacing="10" ColumnSpacing="10">
|
||||
<TextBlock Text="Anchor" Classes="muted" VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Column="1" x:Name="LumiOverlayAnchorPicker" />
|
||||
<TextBlock Grid.Column="2" Text="Newest items" Classes="muted" VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Column="3" x:Name="LumiOverlayDirectionPicker" />
|
||||
<TextBlock Grid.Row="1" Text="Width (physical px)" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="1" x:Name="LumiOverlayWidthBox" Minimum="160" Maximum="16000" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="2" Text="Height (physical px)" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="3" x:Name="LumiOverlayHeightBox" Minimum="100" Maximum="16000" />
|
||||
<TextBlock Grid.Row="2" Text="Card RGBA" Classes="muted" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="2" Grid.Column="1" x:Name="LumiOverlayCardColorBox" />
|
||||
<TextBlock Grid.Row="2" Grid.Column="2" Text="Text RGB" Classes="muted" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Row="2" Grid.Column="3" x:Name="LumiOverlayTextColorBox" />
|
||||
<TextBlock Grid.Row="3" Text="Font" Classes="muted" VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Row="3" Grid.Column="1" x:Name="LumiOverlayFontBox" />
|
||||
<TextBlock Grid.Row="3" Grid.Column="2" Text="Font size" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Row="3" Grid.Column="3" x:Name="LumiOverlayFontSizeBox" Minimum="8" Maximum="120" />
|
||||
<TextBlock Grid.Row="4" Text="Entry animation" Classes="muted" VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Row="4" Grid.Column="1" x:Name="LumiOverlayEntryPicker" />
|
||||
<TextBlock Grid.Row="4" Grid.Column="2" Text="Exit animation" Classes="muted" VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Row="4" Grid.Column="3" x:Name="LumiOverlayExitPicker" />
|
||||
<TextBlock Grid.Row="5" Text="Lifetime (ms)" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Row="5" Grid.Column="1" x:Name="LumiOverlayLifetimeBox" Minimum="1000" Maximum="300000" Increment="1000" />
|
||||
<TextBlock Grid.Row="5" Grid.Column="2" Text="Queue limit" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Row="5" Grid.Column="3" x:Name="LumiOverlayQueueBox" Minimum="1" Maximum="100" />
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Spacing="14">
|
||||
<CheckBox x:Name="LumiOverlayAvatarToggle" Content="Avatar" />
|
||||
<CheckBox x:Name="LumiOverlayUsernameToggle" Content="Username" />
|
||||
<CheckBox x:Name="LumiOverlayPlatformToggle" Content="Platform identity" />
|
||||
</StackPanel>
|
||||
<Expander Header="Position, shadow & animation details">
|
||||
<StackPanel Spacing="12" Margin="0,12,0,0">
|
||||
<Grid ColumnDefinitions="130,120,130,120" RowDefinitions="Auto,Auto,Auto" RowSpacing="8" ColumnSpacing="10">
|
||||
<TextBlock Text="Top padding" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Column="1" x:Name="LumiOverlayPaddingTopBox" Minimum="0" Maximum="4000" />
|
||||
<TextBlock Grid.Column="2" Text="Right padding" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Column="3" x:Name="LumiOverlayPaddingRightBox" Minimum="0" Maximum="4000" />
|
||||
<TextBlock Grid.Row="1" Text="Bottom padding" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="1" x:Name="LumiOverlayPaddingBottomBox" Minimum="0" Maximum="4000" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="2" Text="Left padding" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="3" x:Name="LumiOverlayPaddingLeftBox" Minimum="0" Maximum="4000" />
|
||||
<TextBlock Grid.Row="2" Text="Horizontal align" Classes="muted" VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Row="2" Grid.Column="1" x:Name="LumiOverlayHorizontalAlignPicker" />
|
||||
<TextBlock Grid.Row="2" Grid.Column="2" Text="Vertical align" Classes="muted" VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Row="2" Grid.Column="3" x:Name="LumiOverlayVerticalAlignPicker" />
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="130,120,130,120" RowDefinitions="Auto,Auto" RowSpacing="8" ColumnSpacing="10">
|
||||
<TextBlock Text="Shadow RGBA" Classes="muted" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" x:Name="LumiOverlayShadowColorBox" />
|
||||
<TextBlock Grid.Column="2" Text="Shadow blur" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Column="3" x:Name="LumiOverlayShadowBlurBox" Minimum="0" Maximum="40" />
|
||||
<TextBlock Grid.Row="1" Text="Shadow X / Y" Classes="muted" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Spacing="6">
|
||||
<NumericUpDown x:Name="LumiOverlayShadowXBox" Minimum="-40" Maximum="40" Width="56" />
|
||||
<NumericUpDown x:Name="LumiOverlayShadowYBox" Minimum="-40" Maximum="40" Width="56" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Row="1" Grid.Column="2" Text="Easing" Classes="muted" VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Row="1" Grid.Column="3" x:Name="LumiOverlayEasingPicker" />
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="130,120,130,120" RowDefinitions="Auto,Auto" RowSpacing="8" ColumnSpacing="10">
|
||||
<TextBlock Text="Entry duration" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Column="1" x:Name="LumiOverlayEntryDurationBox" Minimum="0" Maximum="5000" Increment="20" FormatString="0 ms" />
|
||||
<TextBlock Grid.Column="2" Text="Exit duration" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Column="3" x:Name="LumiOverlayExitDurationBox" Minimum="0" Maximum="5000" Increment="20" FormatString="0 ms" />
|
||||
<TextBlock Grid.Row="1" Text="Slide distance" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="1" x:Name="LumiOverlaySlideDistanceBox" Minimum="0" Maximum="1000" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="2" Text="Scale amount" Classes="muted" VerticalAlignment="Center" />
|
||||
<NumericUpDown Grid.Row="1" Grid.Column="3" x:Name="LumiOverlayScaleAmountBox" Minimum="0.1" Maximum="2" Increment="0.05" FormatString="0.00" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
<TextBlock Text="Disabled padding edges are always stored as zero. Width and height are physical pixels and clamp to the selected monitor." Classes="muted" FontSize="12" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Expander>
|
||||
|
||||
<Border Classes="soft">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="Global hotkeys" Classes="sectionTitle" />
|
||||
<Grid ColumnDefinitions="180,*" RowDefinitions="Auto,Auto" RowSpacing="10">
|
||||
<TextBlock Text="Visibility override" Classes="muted" />
|
||||
<Button Grid.Column="1" x:Name="LumiOverlayVisibilityHotkeyBox" Classes="secondary" />
|
||||
<TextBlock Grid.Row="1" Text="Preview override" Classes="muted" />
|
||||
<Button Grid.Row="1" Grid.Column="1" x:Name="LumiOverlayPreviewHotkeyBox" Classes="secondary" />
|
||||
</Grid>
|
||||
<TextBlock x:Name="LumiOverlayHotkeyStatus" Text="Hotkeys are not registered yet." Classes="muted" FontSize="12" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button x:Name="SaveLumiOverlayButton" Classes="primary" Content="Save Lumi Overlay" />
|
||||
</StackPanel>
|
||||
<TextBlock x:Name="LumiOverlayFeedback" Classes="muted" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<ScrollViewer x:Name="ConnectionPage" IsVisible="False">
|
||||
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="24">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="DEVICE" Classes="eyebrow" />
|
||||
<TextBlock Text="Connection & device" Classes="pageTitle" />
|
||||
<TextBlock Text="The device credential is protected for your Windows account and never shared with OBS." Classes="muted" FontSize="15" />
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="150,*" RowDefinitions="Auto,Auto,Auto" RowSpacing="12">
|
||||
<TextBlock Text="Computer" Classes="muted" /><TextBlock Grid.Column="1" x:Name="DeviceNameText" Text="Not paired" FontWeight="SemiBold" />
|
||||
<TextBlock Grid.Row="1" Text="Lumi host" Classes="muted" /><TextBlock Grid.Row="1" Grid.Column="1" x:Name="HostText" Text="—" />
|
||||
<TextBlock Grid.Row="2" Text="Last connected" Classes="muted" /><TextBlock Grid.Row="2" Grid.Column="1" x:Name="LastConnectedText" Text="Never" />
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button x:Name="ReconnectButton" Classes="secondary" Content="Retry connection" />
|
||||
<Button x:Name="PairButton" Classes="secondary" Content="Choose pairing package" />
|
||||
<Button x:Name="OpenWebButton" Classes="secondary" Content="Open Lumi WebUI" />
|
||||
</StackPanel>
|
||||
<Border x:Name="ConnectionBridgeUpdatePanel" Background="#FFF2D6" BorderBrush="#D58A1F" BorderThickness="1" CornerRadius="12" Padding="18" IsVisible="False">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="20">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="OBS Bridge update required" FontWeight="Bold" Foreground="#764600" />
|
||||
<TextBlock x:Name="ConnectionBridgeUpdateDetail" Text="Close OBS before updating the managed integration." TextWrapping="Wrap" Foreground="#764600" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" x:Name="ConnectionUpdateBridgeButton" Classes="primary" Content="Update OBS Bridge" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border Classes="soft">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Managed OBS integration" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="BridgeStatusText" Text="Checking the bundled integration…" Classes="muted" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button x:Name="InstallBridgeButton" Classes="primary" Content="Install integration" />
|
||||
<Button x:Name="RemoveBridgeButton" Classes="secondary" Content="Remove integration" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="Close OBS before install, repair, or removal. Restart OBS afterward so it can load the verified plugin." Classes="muted" FontSize="12" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Classes="soft">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Remove this device" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Removes the local credential. You can also revoke this computer from Lumi." Classes="muted" />
|
||||
<Button x:Name="ForgetButton" Classes="secondary" Content="Forget this device" HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<ScrollViewer x:Name="LogsPage" IsVisible="False">
|
||||
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="24">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="DIAGNOSTICS" Classes="eyebrow" />
|
||||
<TextBlock Text="Logs & diagnostics" Classes="pageTitle" />
|
||||
<TextBlock Text="Short-lived diagnostic logs help explain connection and delivery failures. Raw audio is never written to disk." Classes="muted" FontSize="15" />
|
||||
</StackPanel>
|
||||
<Button x:Name="OpenLogsButton" Classes="secondary" Content="Open logs folder" HorizontalAlignment="Left" />
|
||||
<Border Background="#182026" CornerRadius="12" Padding="16" MinHeight="230">
|
||||
<StackPanel x:Name="RecentLogsPanel" Spacing="6">
|
||||
<TextBlock Text="Waiting for companion events…" Foreground="#CFDADD" FontFamily="Consolas" FontSize="12" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<TextBlock Text="Logs are retained for 7 days and capped at 256 MiB. Caption text may appear in diagnostics during testing or troubleshooting." Classes="muted" FontSize="12" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<ScrollViewer x:Name="SettingsPage" IsVisible="False">
|
||||
<StackPanel Margin="44,38,52,48" MaxWidth="760" HorizontalAlignment="Left" Spacing="25">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="PREFERENCES" Classes="eyebrow" />
|
||||
<TextBlock Text="Settings" Classes="pageTitle" />
|
||||
<TextBlock Text="Only controls useful on the streaming computer live here. Model and server administration stay in Lumi." Classes="muted" FontSize="15" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="16">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="24">
|
||||
<StackPanel Spacing="3"><TextBlock Text="Start with Windows" FontWeight="SemiBold" /><TextBlock Text="Keep Lumi Companion ready in the notification area after sign-in." Classes="muted" /></StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="AutoStartToggle" OffContent="Off" OnContent="On" />
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="24">
|
||||
<StackPanel Spacing="3"><TextBlock Text="Start companion features with OBS" FontWeight="SemiBold" /><TextBlock Text="Prepare selected features when OBS becomes available." Classes="muted" /></StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="StartWithObsToggle" OffContent="Off" OnContent="On" />
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="24">
|
||||
<StackPanel Spacing="3"><TextBlock Text="Advanced mode" FontWeight="SemiBold" /><TextBlock Text="Reveal protocol and component details useful for troubleshooting." Classes="muted" /></StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="AdvancedToggle" OffContent="Off" OnContent="On" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
<Border x:Name="AdvancedPanel" Classes="soft" IsVisible="False">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Advanced details" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Protocol v1 · bounded in-memory audio · secure WebSocket transport · server-hosted inference" Classes="muted" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Classes="soft">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="20">
|
||||
<StackPanel Spacing="4"><TextBlock Text="Companion updates" FontWeight="SemiBold" /><TextBlock x:Name="UpdateStatusText" Text="Checking for updates…" Classes="muted" /><TextBlock Text="Current version" Classes="muted" FontSize="11" /><TextBlock x:Name="CurrentVersionText" Text="—" FontSize="12" /></StackPanel>
|
||||
<Button Grid.Column="1" x:Name="CheckUpdateButton" Classes="secondary" Content="Check now" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<Button x:Name="SaveSettingsButton" Classes="primary" Content="Save preferences" HorizontalAlignment="Left" />
|
||||
<TextBlock x:Name="SettingsFeedback" Classes="muted" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
1053
companion/src/Lumi.Companion.App/MainWindow.axaml.cs
Normal file
1053
companion/src/Lumi.Companion.App/MainWindow.axaml.cs
Normal file
File diff suppressed because it is too large
Load Diff
255
companion/src/Lumi.Companion.App/ObsBridgeManager.cs
Normal file
255
companion/src/Lumi.Companion.App/ObsBridgeManager.cs
Normal file
@ -0,0 +1,255 @@
|
||||
using System.Diagnostics;
|
||||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Principal;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Lumi.Companion.Protocol;
|
||||
|
||||
namespace Lumi.Companion.App;
|
||||
|
||||
public sealed class ObsBridgeManager
|
||||
{
|
||||
private readonly string _installRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "obs-studio", "plugins", "lumi-obs-bridge");
|
||||
private string? _packageFailure;
|
||||
private string InstalledDll => Path.Combine(_installRoot, "bin", "64bit", "lumi-obs-bridge.dll");
|
||||
private string InstalledManifest => Path.Combine(_installRoot, "lumi-managed.json");
|
||||
|
||||
public ObsBridgeStatus Inspect()
|
||||
{
|
||||
var package = LoadPackage();
|
||||
var installed = ReadManifest(InstalledManifest);
|
||||
var packageAvailable = package is not null;
|
||||
var fileInstalled = File.Exists(InstalledDll);
|
||||
var valid = packageAvailable && fileInstalled && HashFile(InstalledDll) == package!.Manifest.Sha256 && installed?.Version == package.Manifest.Version;
|
||||
var updateAvailable = packageAvailable && fileInstalled && installed is not null && installed.Version != package!.Manifest.Version;
|
||||
return new ObsBridgeStatus(packageAvailable, fileInstalled, valid, updateAvailable, installed?.Version, package?.Manifest.Version,
|
||||
valid ? $"OBS integration {package!.Manifest.Version} is installed. Restart OBS if it was open during the last repair." :
|
||||
!packageAvailable ? $"The bundled OBS integration could not be verified. {_packageFailure ?? "The packaged component was not found."}" :
|
||||
updateAvailable ? $"OBS Bridge {installed!.Version} is installed; Companion includes {package!.Manifest.Version}. Close OBS, then update the bridge." :
|
||||
fileInstalled ? "The OBS integration is outdated or damaged. Repair it while OBS is closed." : "The OBS integration is ready to install.");
|
||||
}
|
||||
|
||||
public async Task<ObsBridgeStatus> InstallOrRepairAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureObsClosed();
|
||||
var status = Inspect();
|
||||
if (!status.PackageAvailable) throw new InvalidOperationException(status.Detail);
|
||||
if (!IsElevated())
|
||||
{
|
||||
await RunElevatedAsync("install", cancellationToken);
|
||||
var elevatedResult = Inspect();
|
||||
if (!elevatedResult.Valid) throw new InvalidDataException("The OBS integration did not pass verification after installation.");
|
||||
return elevatedResult;
|
||||
}
|
||||
return await InstallDirectAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<ObsBridgeStatus> InstallDirectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var package = LoadPackage() ?? throw new InvalidDataException("The bundled OBS integration could not be verified.");
|
||||
var manifest = package.Manifest;
|
||||
var bin = Path.GetDirectoryName(InstalledDll)!;
|
||||
var locale = Path.Combine(_installRoot, "data", "locale");
|
||||
Directory.CreateDirectory(bin);
|
||||
Directory.CreateDirectory(locale);
|
||||
await WriteAtomicAsync(package.Dll, InstalledDll, cancellationToken);
|
||||
if (package.Locale is not null) await WriteAtomicAsync(package.Locale, Path.Combine(locale, "en-US.ini"), cancellationToken);
|
||||
var marker = JsonSerializer.SerializeToUtf8Bytes(manifest, ProtocolV1.JsonOptions);
|
||||
var temporary = $"{InstalledManifest}.{Environment.ProcessId}.tmp";
|
||||
await File.WriteAllBytesAsync(temporary, marker, cancellationToken);
|
||||
File.Move(temporary, InstalledManifest, true);
|
||||
var result = Inspect();
|
||||
if (!result.Valid) throw new InvalidDataException("The OBS integration did not pass verification after installation.");
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task RemoveAsync()
|
||||
{
|
||||
EnsureObsClosed();
|
||||
return RemoveCoreAsync();
|
||||
}
|
||||
|
||||
private async Task RemoveCoreAsync()
|
||||
{
|
||||
if (!IsElevated()) await RunElevatedAsync("remove", CancellationToken.None);
|
||||
else if (Directory.Exists(_installRoot)) Directory.Delete(_installRoot, true);
|
||||
}
|
||||
|
||||
public static bool IsMaintenanceRequest(string[] args) => args.Length is 2 or 3 && args[0].Equals("--manage-obs-bridge", StringComparison.OrdinalIgnoreCase);
|
||||
public static bool IsDiagnosticRequest(string[] args) => args.Length == 2 && args[0].Equals("--diagnose-obs-bridge", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static int RunDiagnostic(string[] args)
|
||||
{
|
||||
if (!IsDiagnosticRequest(args)) return 10;
|
||||
try
|
||||
{
|
||||
var status = new ObsBridgeManager().Inspect();
|
||||
File.WriteAllBytes(args[1], JsonSerializer.SerializeToUtf8Bytes(status, ProtocolV1.JsonOptions));
|
||||
return status.PackageAvailable ? 0 : 9;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
try { File.WriteAllText(args[1], JsonSerializer.Serialize(new { packageAvailable = false, detail = error.Message }, ProtocolV1.JsonOptions)); } catch { }
|
||||
return 9;
|
||||
}
|
||||
}
|
||||
|
||||
public static int RunMaintenance(string[] args)
|
||||
{
|
||||
if (!IsMaintenanceRequest(args) || !IsElevated()) return 6;
|
||||
try
|
||||
{
|
||||
var manager = new ObsBridgeManager();
|
||||
EnsureObsClosed();
|
||||
if (args[1].Equals("install", StringComparison.OrdinalIgnoreCase)) manager.InstallDirectAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
else if (args[1].Equals("remove", StringComparison.OrdinalIgnoreCase)) { if (Directory.Exists(manager._installRoot)) Directory.Delete(manager._installRoot, true); }
|
||||
else return 7;
|
||||
return 0;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
if (args.Length == 3) try { File.WriteAllText(args[2], error.Message); } catch { }
|
||||
return 8;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsElevated()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return false;
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
|
||||
private static async Task RunElevatedAsync(string action, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Environment.ProcessPath)) throw new InvalidOperationException("The packaged Companion application is required to manage OBS integration.");
|
||||
var resultPath = Path.Combine(Path.GetTempPath(), $"lumi-obs-maintenance-{Guid.NewGuid():N}.txt");
|
||||
var start = new ProcessStartInfo(Environment.ProcessPath) { UseShellExecute = true, Verb = "runas", WindowStyle = ProcessWindowStyle.Hidden };
|
||||
start.ArgumentList.Add("--manage-obs-bridge");
|
||||
start.ArgumentList.Add(action);
|
||||
start.ArgumentList.Add(resultPath);
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(start) ?? throw new InvalidOperationException("The OBS integration maintenance process could not start.");
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
string? detail = null;
|
||||
try { if (File.Exists(resultPath)) detail = File.ReadAllText(resultPath).Trim(); } catch { }
|
||||
throw new InvalidOperationException(string.IsNullOrWhiteSpace(detail) ? "OBS integration maintenance did not complete." : detail);
|
||||
}
|
||||
}
|
||||
catch (Win32Exception error) when (error.NativeErrorCode == 1223)
|
||||
{
|
||||
throw new InvalidOperationException("Administrator approval was cancelled. Lumi only requests it to manage the OBS plugin under ProgramData.");
|
||||
}
|
||||
finally { try { File.Delete(resultPath); } catch { } }
|
||||
}
|
||||
|
||||
private static void EnsureObsClosed()
|
||||
{
|
||||
var processes = Process.GetProcessesByName("obs64");
|
||||
foreach (var process in processes) process.Dispose();
|
||||
if (processes.Length > 0)
|
||||
throw new InvalidOperationException("Close OBS before installing, repairing, or removing the managed integration. Companion will never modify a loaded OBS plugin.");
|
||||
}
|
||||
private static ObsBridgeManifest? ReadManifest(string path) { try { return JsonSerializer.Deserialize<ObsBridgeManifest>(File.ReadAllBytes(path), ProtocolV1.JsonOptions); } catch { return null; } }
|
||||
private ObsBridgePackage? LoadPackage()
|
||||
{
|
||||
var failures = new List<string>();
|
||||
var roots = CandidatePackageRoots();
|
||||
foreach (var root in roots)
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifestBytes = File.ReadAllBytes(Path.Combine(root, "manifest.json"));
|
||||
var dll = File.ReadAllBytes(Path.Combine(root, "lumi-obs-bridge.dll"));
|
||||
var manifest = ParseManifest(manifestBytes);
|
||||
var actualHash = HashBytes(dll);
|
||||
if (!actualHash.Equals(manifest.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException($"OBS bridge checksum mismatch (expected {manifest.Sha256}, got {actualHash}).");
|
||||
_packageFailure = null;
|
||||
return new ObsBridgePackage(manifest, dll, File.Exists(Path.Combine(root, "en-US.ini")) ? File.ReadAllBytes(Path.Combine(root, "en-US.ini")) : null);
|
||||
}
|
||||
catch (Exception error) { failures.Add($"{root}: {error.Message}"); }
|
||||
}
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var manifestBytes = ReadResource(assembly, "Lumi.Companion.ObsBridge.manifest.json");
|
||||
var dll = ReadResource(assembly, "Lumi.Companion.ObsBridge.dll");
|
||||
var manifest = ParseManifest(manifestBytes);
|
||||
var actualHash = HashBytes(dll);
|
||||
if (!actualHash.Equals(manifest.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException($"Embedded OBS bridge checksum mismatch (expected {manifest.Sha256}, got {actualHash}).");
|
||||
_packageFailure = null;
|
||||
return new ObsBridgePackage(manifest, dll, TryReadResource(assembly, "Lumi.Companion.ObsBridge.en-US.ini"));
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
failures.Add($"embedded payload: {error.Message}");
|
||||
_packageFailure = string.Join(" ", failures);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
private static IReadOnlyList<string> CandidatePackageRoots()
|
||||
{
|
||||
var bases = new List<string?>
|
||||
{
|
||||
Path.GetDirectoryName(Environment.ProcessPath),
|
||||
AppContext.BaseDirectory,
|
||||
Path.GetDirectoryName(Environment.GetCommandLineArgs().FirstOrDefault()),
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Lumi Companion")
|
||||
};
|
||||
try { bases.Add(Path.GetDirectoryName(Process.GetCurrentProcess().MainModule?.FileName)); } catch { }
|
||||
return bases.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => Path.Combine(value!, "components", "obs-bridge"))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
private static ObsBridgeManifest ParseManifest(byte[] value)
|
||||
{
|
||||
var start = value.Length >= 3 && value[0] == 0xEF && value[1] == 0xBB && value[2] == 0xBF ? 3 : 0;
|
||||
using var document = JsonDocument.Parse(value.AsMemory(start));
|
||||
var root = document.RootElement;
|
||||
var version = root.GetProperty("version").GetString();
|
||||
var sha256 = root.GetProperty("sha256").GetString();
|
||||
var minimum = root.GetProperty("obs_minimum_version").GetString();
|
||||
if (string.IsNullOrWhiteSpace(version) || string.IsNullOrWhiteSpace(minimum) || sha256 is null || sha256.Length != 64 || !sha256.All(Uri.IsHexDigit))
|
||||
throw new InvalidDataException("The OBS bridge manifest is invalid.");
|
||||
return new ObsBridgeManifest(version, sha256.ToLowerInvariant(), minimum);
|
||||
}
|
||||
private static byte[] ReadResource(Assembly assembly, string name) { using var stream = assembly.GetManifestResourceStream(name) ?? throw new FileNotFoundException($"Embedded resource {name} is missing."); using var body = new MemoryStream(); stream.CopyTo(body); return body.ToArray(); }
|
||||
private static byte[]? TryReadResource(Assembly assembly, string name) { try { return ReadResource(assembly, name); } catch { return null; } }
|
||||
private static string HashFile(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); }
|
||||
private static string HashBytes(byte[] value) => Convert.ToHexString(SHA256.HashData(value)).ToLowerInvariant();
|
||||
private static async Task WriteAtomicAsync(byte[] value, string target, CancellationToken cancellationToken)
|
||||
{
|
||||
var temporary = $"{target}.{Environment.ProcessId}.tmp";
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(temporary, value, cancellationToken);
|
||||
File.Move(temporary, target, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { File.Delete(temporary); } catch { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ObsBridgeManifest(
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("sha256")] string Sha256,
|
||||
[property: JsonPropertyName("obs_minimum_version")] string ObsMinimumVersion);
|
||||
public sealed record ObsBridgeStatus(
|
||||
bool PackageAvailable,
|
||||
bool Installed,
|
||||
bool Valid,
|
||||
bool UpdateAvailable,
|
||||
string? InstalledVersion,
|
||||
string? Version,
|
||||
string Detail);
|
||||
internal sealed record ObsBridgePackage(ObsBridgeManifest Manifest, byte[] Dll, byte[]? Locale);
|
||||
46
companion/src/Lumi.Companion.App/Program.cs
Normal file
46
companion/src/Lumi.Companion.App/Program.cs
Normal file
@ -0,0 +1,46 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace Lumi.Companion.App;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
internal static SingleInstanceCoordinator? InstanceCoordinator { get; private set; }
|
||||
internal static bool LaunchInBackground { get; private set; }
|
||||
|
||||
[STAThread]
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
Console.Error.WriteLine("The first Lumi Companion release supports Windows x64 only.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (UpdateApplier.IsApplyRequest(args)) return UpdateApplier.Apply(args);
|
||||
if (ObsBridgeManager.IsDiagnosticRequest(args)) return ObsBridgeManager.RunDiagnostic(args);
|
||||
if (ObsBridgeManager.IsMaintenanceRequest(args)) return ObsBridgeManager.RunMaintenance(args);
|
||||
|
||||
LaunchInBackground = args.Contains("--background", StringComparer.OrdinalIgnoreCase);
|
||||
InstanceCoordinator = new SingleInstanceCoordinator();
|
||||
if (!InstanceCoordinator.IsPrimary)
|
||||
{
|
||||
InstanceCoordinator.SignalPrimary();
|
||||
InstanceCoordinator.Dispose();
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
finally
|
||||
{
|
||||
InstanceCoordinator.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Lumi.Companion.App;
|
||||
|
||||
internal sealed class SingleInstanceCoordinator : IDisposable
|
||||
{
|
||||
private readonly Mutex _mutex;
|
||||
private readonly CancellationTokenSource _lifetime = new();
|
||||
private readonly string _pipeName;
|
||||
private Task? _listener;
|
||||
|
||||
public SingleInstanceCoordinator()
|
||||
{
|
||||
var userKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{Environment.UserDomainName}\\{Environment.UserName}"))).Substring(0, 16);
|
||||
_pipeName = $"Lumi.Companion.Activate.v1.{userKey}";
|
||||
_mutex = new Mutex(true, $"Local\\Lumi.Companion.v1.{userKey}", out var created);
|
||||
IsPrimary = created;
|
||||
if (created) _listener = Task.Run(() => ListenAsync(_lifetime.Token));
|
||||
}
|
||||
|
||||
public bool IsPrimary { get; }
|
||||
public event Action? ActivationRequested;
|
||||
|
||||
public void SignalPrimary()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var pipe = new NamedPipeClientStream(".", _pipeName, PipeDirection.Out, PipeOptions.Asynchronous);
|
||||
pipe.Connect(1000);
|
||||
pipe.WriteByte(1);
|
||||
pipe.Flush();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private async Task ListenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var pipe = new NamedPipeServerStream(_pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, 1, 1);
|
||||
await pipe.WaitForConnectionAsync(cancellationToken);
|
||||
if (pipe.ReadByte() == 1) ActivationRequested?.Invoke();
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch when (!cancellationToken.IsCancellationRequested) { await Task.Delay(200, cancellationToken); }
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_lifetime.Cancel();
|
||||
try { _listener?.Wait(500); } catch { }
|
||||
if (IsPrimary) try { _mutex.ReleaseMutex(); } catch { }
|
||||
_mutex.Dispose();
|
||||
_lifetime.Dispose();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
using Lumi.Companion.Abstractions;
|
||||
|
||||
namespace Lumi.Companion.App;
|
||||
|
||||
internal sealed class TranscriptionPluginContribution : ICompanionPluginContribution
|
||||
{
|
||||
private readonly CompanionRuntime _runtime;
|
||||
|
||||
public TranscriptionPluginContribution(CompanionRuntime runtime)
|
||||
{
|
||||
_runtime = runtime;
|
||||
Actions =
|
||||
[
|
||||
new CompanionPluginAction(
|
||||
"toggle",
|
||||
() => _runtime.TranscriptionEnabled ? "Turn captions off" : "Turn captions on",
|
||||
async cancellationToken =>
|
||||
{
|
||||
await _runtime.SetTranscriptionEnabledAsync(!_runtime.TranscriptionEnabled, cancellationToken);
|
||||
Changed?.Invoke();
|
||||
},
|
||||
() => !_runtime.State.TestRunning && !_runtime.State.BenchmarkRunning,
|
||||
5),
|
||||
new CompanionPluginAction(
|
||||
"test",
|
||||
() => _runtime.State.TestRunning || _runtime.State.BenchmarkRunning ? "Transcription test running…" : "Run transcription test",
|
||||
cancellationToken => _runtime.RunTestAsync(cancellationToken),
|
||||
() => _runtime.TranscriptionEnabled && !_runtime.State.TestRunning && !_runtime.State.BenchmarkRunning,
|
||||
10)
|
||||
];
|
||||
_runtime.StateChanged += _ => Changed?.Invoke();
|
||||
}
|
||||
|
||||
public CompanionPluginDescriptor Descriptor { get; } = new(
|
||||
"transcription",
|
||||
"Transcription",
|
||||
new Version(0, 1, 0),
|
||||
"Captures selected OBS sources and delivers closed captions through Lumi.",
|
||||
100);
|
||||
|
||||
public IReadOnlyList<CompanionPluginPage> Pages { get; } =
|
||||
[
|
||||
new CompanionPluginPage("Transcription", "Capture", 10),
|
||||
new CompanionPluginPage("Test", "Test & benchmark", 20)
|
||||
];
|
||||
|
||||
public IReadOnlyList<CompanionPluginAction> Actions { get; }
|
||||
|
||||
public CompanionPluginStatus Status => new(
|
||||
!_runtime.TranscriptionEnabled
|
||||
? CompanionPluginHealth.Healthy
|
||||
: _runtime.State.Health switch
|
||||
{
|
||||
TrayHealth.Ready => CompanionPluginHealth.Healthy,
|
||||
TrayHealth.Operating => CompanionPluginHealth.Healthy,
|
||||
TrayHealth.Failed => CompanionPluginHealth.Error,
|
||||
_ => CompanionPluginHealth.Warning
|
||||
},
|
||||
_runtime.TranscriptionEnabled ? _runtime.State.Summary : "Captions off",
|
||||
_runtime.TranscriptionEnabled ? _runtime.State.Detail : "Microphone audio is not sent for speech recognition, and captions are not included.");
|
||||
|
||||
public event Action? Changed;
|
||||
}
|
||||
63
companion/src/Lumi.Companion.App/UpdateApplier.cs
Normal file
63
companion/src/Lumi.Companion.App/UpdateApplier.cs
Normal file
@ -0,0 +1,63 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Lumi.Companion.App;
|
||||
|
||||
internal static class UpdateApplier
|
||||
{
|
||||
public static bool IsApplyRequest(string[] args) => args.Length > 0 && args[0].Equals("--apply-update", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static int Apply(string[] args)
|
||||
{
|
||||
if (args.Length != 7 || !int.TryParse(args[1], out var processId)) return 3;
|
||||
var stageRoot = Path.GetFullPath(args[2]);
|
||||
var staged = Path.GetFullPath(args[3]);
|
||||
var target = Path.GetFullPath(args[4]);
|
||||
var expectedHash = args[5];
|
||||
var helper = Path.GetFullPath(args[6]);
|
||||
try
|
||||
{
|
||||
try { Process.GetProcessById(processId).WaitForExit(30000); } catch (ArgumentException) { }
|
||||
if (!File.Exists(staged) || !staged.StartsWith(stageRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||
!UpdateService.HashFile(staged).Equals(expectedHash, StringComparison.OrdinalIgnoreCase)) return 4;
|
||||
var targetRoot = Path.GetDirectoryName(target)!;
|
||||
var stagedComponents = Path.Combine(stageRoot, "components");
|
||||
if (Directory.Exists(stagedComponents)) ReplaceDirectory(stagedComponents, Path.Combine(targetRoot, "components"));
|
||||
var stagedLegal = Path.Combine(stageRoot, "legal");
|
||||
if (Directory.Exists(stagedLegal)) ReplaceDirectory(stagedLegal, Path.Combine(targetRoot, "legal"));
|
||||
var stagedDevelopmentManifest = Path.Combine(stageRoot, ".lumi-dev-build.json");
|
||||
var targetDevelopmentManifest = Path.Combine(targetRoot, ".lumi-dev-build.json");
|
||||
if (File.Exists(stagedDevelopmentManifest)) File.Copy(stagedDevelopmentManifest, targetDevelopmentManifest, true);
|
||||
else try { File.Delete(targetDevelopmentManifest); } catch { }
|
||||
var temporary = $"{target}.{Environment.ProcessId}.update";
|
||||
var backup = $"{target}.previous";
|
||||
File.Copy(staged, temporary, true);
|
||||
if (File.Exists(target)) File.Copy(target, backup, true);
|
||||
File.Move(temporary, target, true);
|
||||
Process.Start(new ProcessStartInfo(target) { UseShellExecute = true });
|
||||
try { Directory.Delete(stageRoot, true); } catch { }
|
||||
try { File.Delete(helper); } catch { }
|
||||
return 0;
|
||||
}
|
||||
catch { return 5; }
|
||||
}
|
||||
|
||||
private static void ReplaceDirectory(string source, string target)
|
||||
{
|
||||
var temporary = $"{target}.{Environment.ProcessId}.update";
|
||||
var backup = $"{target}.previous";
|
||||
if (Directory.Exists(temporary)) Directory.Delete(temporary, true);
|
||||
CopyDirectory(source, temporary);
|
||||
if (Directory.Exists(backup)) Directory.Delete(backup, true);
|
||||
if (Directory.Exists(target)) Directory.Move(target, backup);
|
||||
Directory.Move(temporary, target);
|
||||
}
|
||||
|
||||
private static void CopyDirectory(string source, string target)
|
||||
{
|
||||
Directory.CreateDirectory(target);
|
||||
foreach (var directory in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories))
|
||||
Directory.CreateDirectory(Path.Combine(target, Path.GetRelativePath(source, directory)));
|
||||
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
|
||||
File.Copy(file, Path.Combine(target, Path.GetRelativePath(source, file)), true);
|
||||
}
|
||||
}
|
||||
181
companion/src/Lumi.Companion.App/UpdateService.cs
Normal file
181
companion/src/Lumi.Companion.App/UpdateService.cs
Normal file
@ -0,0 +1,181 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Lumi.Companion.Core;
|
||||
using Lumi.Companion.Protocol;
|
||||
|
||||
namespace Lumi.Companion.App;
|
||||
|
||||
public sealed class UpdateService(HttpClient http, CompanionPaths paths)
|
||||
{
|
||||
public async Task<CompanionUpdate?> CheckAsync(DeviceCredential credential, string currentVersion, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var host = new Uri(credential.Host);
|
||||
var endpoint = new Uri(host, "/plugins/lumi_transcription/api/companion/update");
|
||||
var localBuild = DevelopmentBuildManifest.Load();
|
||||
var deadline = DateTimeOffset.UtcNow.AddMinutes(10);
|
||||
while (true)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("LumiDevice", $"{credential.DeviceId}.{credential.DeviceSecret}");
|
||||
request.Content = JsonContent.Create(new CompanionUpdateCheck(
|
||||
currentVersion,
|
||||
localBuild?.BuildChecksum,
|
||||
localBuild?.Components ?? new Dictionary<string, string>(),
|
||||
true), options: ProtocolV1.JsonOptions);
|
||||
using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
if (response.StatusCode is System.Net.HttpStatusCode.NotFound or System.Net.HttpStatusCode.MethodNotAllowed)
|
||||
return await CheckLegacyAsync(credential, currentVersion, host, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var update = await JsonSerializer.DeserializeAsync<CompanionUpdate>(
|
||||
await response.Content.ReadAsStreamAsync(cancellationToken),
|
||||
ProtocolV1.JsonOptions,
|
||||
cancellationToken);
|
||||
if (update is not { Ok: true, UpdateAvailable: true }) return null;
|
||||
if (update.Artifact is not null) return update;
|
||||
if (!update.Development || !update.BuildPending)
|
||||
throw new InvalidDataException("Lumi announced a Companion update without a downloadable artifact.");
|
||||
if (DateTimeOffset.UtcNow >= deadline)
|
||||
throw new TimeoutException("Lumi did not finish preparing the local Companion update within 10 minutes.");
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(Math.Clamp(update.RetryAfterMilliseconds ?? 2000, 500, 10000)), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CompanionUpdate?> CheckLegacyAsync(DeviceCredential credential, string currentVersion, Uri host, CancellationToken cancellationToken)
|
||||
{
|
||||
var endpoint = new Uri(host, $"/plugins/lumi_transcription/api/companion/update?current_version={Uri.EscapeDataString(currentVersion)}");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, endpoint);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("LumiDevice", $"{credential.DeviceId}.{credential.DeviceSecret}");
|
||||
using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var legacy = await JsonSerializer.DeserializeAsync<LegacyCompanionUpdate>(await response.Content.ReadAsStreamAsync(cancellationToken), ProtocolV1.JsonOptions, cancellationToken);
|
||||
if (legacy is not { Ok: true, UpdateAvailable: true }) return null;
|
||||
return new CompanionUpdate(legacy.Ok, legacy.Version, legacy.UpdateAvailable, legacy.Artifact, legacy.Signed, legacy.ReleaseNotes,
|
||||
false, null, null, null, false, null);
|
||||
}
|
||||
|
||||
public async Task<StagedUpdate> StageAsync(CompanionUpdate update, DeviceCredential? credential = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var artifact = update.Artifact ?? throw new InvalidDataException("The update manifest does not contain an artifact.");
|
||||
if (artifact.Bytes is <= 0 or > 300 * 1024 * 1024 || !IsSha256(artifact.Sha256)) throw new InvalidDataException("The update manifest is invalid.");
|
||||
var pairedHost = credential is null ? null : new Uri(credential.Host);
|
||||
if (!Uri.TryCreate(artifact.Url, UriKind.Absolute, out var artifactUri) ||
|
||||
!IsAllowedArtifactUri(artifactUri, update.Development, pairedHost))
|
||||
throw new InvalidDataException("The update manifest does not use an allowed artifact URL.");
|
||||
var safeVersion = SafeVersion(update.Version);
|
||||
if (string.IsNullOrWhiteSpace(safeVersion)) throw new InvalidDataException("The update manifest has an invalid version.");
|
||||
Directory.CreateDirectory(paths.UpdatesDirectory);
|
||||
var buildSuffix = update.Development && IsSha256(update.BuildChecksum ?? string.Empty) ? $"-{update.BuildChecksum![..12]}" : string.Empty;
|
||||
var archivePath = Path.Combine(paths.UpdatesDirectory, $"companion-{safeVersion}{buildSuffix}.zip.partial");
|
||||
var stageRoot = Path.Combine(paths.UpdatesDirectory, $"{safeVersion}{buildSuffix}");
|
||||
try
|
||||
{
|
||||
using var download = new HttpRequestMessage(HttpMethod.Get, artifactUri);
|
||||
if (update.Development && credential is not null && CompanionTransportPolicy.IsSameOrigin(artifactUri, pairedHost!))
|
||||
download.Headers.Authorization = new AuthenticationHeaderValue("LumiDevice", $"{credential.DeviceId}.{credential.DeviceSecret}");
|
||||
using var response = await http.SendAsync(download, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken))
|
||||
await using (var target = new FileStream(archivePath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true))
|
||||
{
|
||||
var buffer = new byte[81920];
|
||||
long total = 0;
|
||||
while (true)
|
||||
{
|
||||
var read = await source.ReadAsync(buffer, cancellationToken);
|
||||
if (read == 0) break;
|
||||
total = checked(total + read);
|
||||
if (total > artifact.Bytes) throw new InvalidDataException("The Companion update download exceeded its declared size.");
|
||||
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
|
||||
}
|
||||
}
|
||||
if (new FileInfo(archivePath).Length != artifact.Bytes || !HashFile(archivePath).Equals(artifact.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException("The downloaded Companion update did not match its trusted checksum.");
|
||||
if (Directory.Exists(stageRoot)) Directory.Delete(stageRoot, true);
|
||||
Directory.CreateDirectory(stageRoot);
|
||||
using var archive = ZipFile.OpenRead(archivePath);
|
||||
if (archive.Entries.Count is 0 or > 256) throw new InvalidDataException("The Companion update contains an invalid number of files.");
|
||||
long extractedBytes = 0;
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
var relative = entry.FullName.Replace('\\', '/').TrimStart('/');
|
||||
if (string.IsNullOrEmpty(relative) || relative.EndsWith('/')) continue;
|
||||
if (relative.Split('/').Any(segment => segment is "" or "." or "..")) throw new InvalidDataException("The Companion update contains an unsafe path.");
|
||||
extractedBytes = checked(extractedBytes + entry.Length);
|
||||
if (extractedBytes > 500 * 1024 * 1024) throw new InvalidDataException("The Companion update expands beyond its safety limit.");
|
||||
var destination = Path.GetFullPath(Path.Combine(stageRoot, relative.Replace('/', Path.DirectorySeparatorChar)));
|
||||
if (!destination.StartsWith(stageRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("The Companion update contains an unsafe path.");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
||||
entry.ExtractToFile(destination, true);
|
||||
}
|
||||
var staged = Path.GetFullPath(Path.Combine(stageRoot, artifact.Entrypoint.Replace('/', Path.DirectorySeparatorChar)));
|
||||
if (!File.Exists(staged) || !staged.StartsWith(stageRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException("The Companion update is missing its application entrypoint.");
|
||||
return new StagedUpdate(stageRoot, staged, HashFile(staged));
|
||||
}
|
||||
finally { try { File.Delete(archivePath); } catch { } }
|
||||
}
|
||||
|
||||
public void LaunchApplier(StagedUpdate update)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() || string.IsNullOrWhiteSpace(Environment.ProcessPath)) throw new PlatformNotSupportedException("In-place updates require the packaged Windows application.");
|
||||
var target = Path.GetFullPath(Environment.ProcessPath);
|
||||
var helper = Path.Combine(paths.UpdatesDirectory, $"Lumi.Companion.UpdateHelper.{Environment.ProcessId}.exe");
|
||||
File.Copy(target, helper, true);
|
||||
var start = new ProcessStartInfo(helper) { UseShellExecute = false, CreateNoWindow = true };
|
||||
foreach (var argument in new[] { "--apply-update", Environment.ProcessId.ToString(), update.StageRoot, update.ExecutablePath, target, update.Sha256, helper }) start.ArgumentList.Add(argument);
|
||||
Process.Start(start)?.Dispose();
|
||||
}
|
||||
|
||||
private static bool IsAllowedArtifactUri(Uri uri, bool development, Uri? pairedHost)
|
||||
{
|
||||
if (CompanionTransportPolicy.IsSecureOrigin(uri)) return true;
|
||||
return development && CompanionTransportPolicy.IsEndpointAllowed(uri, pairedHost);
|
||||
}
|
||||
|
||||
internal static string HashFile(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); }
|
||||
private static bool IsSha256(string value) => value.Length == 64 && value.All(Uri.IsHexDigit);
|
||||
private static string SafeVersion(string value) => string.Concat(value.Where(character => char.IsLetterOrDigit(character) || character is '.' or '-')).Trim('.');
|
||||
}
|
||||
|
||||
public sealed record LegacyCompanionUpdate(
|
||||
[property: JsonPropertyName("ok")] bool Ok,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("update_available")] bool UpdateAvailable,
|
||||
[property: JsonPropertyName("artifact")] CompanionUpdateArtifact Artifact,
|
||||
[property: JsonPropertyName("signed")] bool Signed,
|
||||
[property: JsonPropertyName("release_notes")] string ReleaseNotes);
|
||||
|
||||
public sealed record CompanionUpdateCheck(
|
||||
[property: JsonPropertyName("current_version")] string CurrentVersion,
|
||||
[property: JsonPropertyName("build_checksum")] string? BuildChecksum,
|
||||
[property: JsonPropertyName("component_checksums")] IReadOnlyDictionary<string, string> ComponentChecksums,
|
||||
[property: JsonPropertyName("supports_pending_build")] bool SupportsPendingBuild);
|
||||
|
||||
public sealed record CompanionUpdate(
|
||||
[property: JsonPropertyName("ok")] bool Ok,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("update_available")] bool UpdateAvailable,
|
||||
[property: JsonPropertyName("artifact")] CompanionUpdateArtifact? Artifact,
|
||||
[property: JsonPropertyName("signed")] bool Signed,
|
||||
[property: JsonPropertyName("release_notes")] string ReleaseNotes,
|
||||
[property: JsonPropertyName("development")] bool Development,
|
||||
[property: JsonPropertyName("build_checksum")] string? BuildChecksum,
|
||||
[property: JsonPropertyName("component_checksums")] IReadOnlyDictionary<string, string>? ComponentChecksums,
|
||||
[property: JsonPropertyName("changed_components")] IReadOnlyList<CompanionUpdateComponent>? ChangedComponents,
|
||||
[property: JsonPropertyName("build_pending")] bool BuildPending,
|
||||
[property: JsonPropertyName("retry_after_ms")] int? RetryAfterMilliseconds);
|
||||
public sealed record CompanionUpdateComponent(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("checksum")] string Checksum);
|
||||
public sealed record CompanionUpdateArtifact(
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("sha256")] string Sha256,
|
||||
[property: JsonPropertyName("bytes")] long Bytes,
|
||||
[property: JsonPropertyName("entrypoint")] string Entrypoint);
|
||||
public sealed record StagedUpdate(string StageRoot, string ExecutablePath, string Sha256);
|
||||
15
companion/src/Lumi.Companion.App/app.manifest
Normal file
15
companion/src/Lumi.Companion.App/app.manifest
Normal file
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="Lumi.Companion.App" />
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
188
companion/src/Lumi.Companion.Core/CompanionPluginTransport.cs
Normal file
188
companion/src/Lumi.Companion.Core/CompanionPluginTransport.cs
Normal file
@ -0,0 +1,188 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Net.WebSockets;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Lumi.Companion.Abstractions;
|
||||
using Lumi.Companion.Protocol;
|
||||
|
||||
namespace Lumi.Companion.Core;
|
||||
|
||||
public sealed class CompanionPluginTransport(
|
||||
HttpClient http,
|
||||
Func<DeviceCredential?> credentialProvider,
|
||||
string pluginId,
|
||||
Action<string, string>? log = null) : ICompanionPluginTransport
|
||||
{
|
||||
private readonly string _pluginId = ValidatePluginId(pluginId);
|
||||
|
||||
public Uri? LumiBaseUri
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
var credential = credentialProvider();
|
||||
return credential is null ? null : new Uri(credential.Host);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsAuthenticated => LumiBaseUri is not null;
|
||||
|
||||
public async Task<CompanionPluginHttpResponse> PostJsonAsync(
|
||||
string relativePath,
|
||||
object payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var credential = credentialProvider()
|
||||
?? throw new InvalidOperationException("Pair Companion with Lumi before using this plugin.");
|
||||
var host = new Uri(credential.Host);
|
||||
if (!CompanionTransportPolicy.IsPairedHostAllowed(host))
|
||||
throw new InvalidDataException("Companion transport requires HTTPS unless this device was paired with localhost.");
|
||||
|
||||
var expectedPrefix = $"/plugins/{_pluginId}/";
|
||||
if (!relativePath.StartsWith(expectedPrefix, StringComparison.Ordinal) ||
|
||||
!Uri.TryCreate(relativePath, UriKind.Relative, out var relative))
|
||||
throw new InvalidDataException($"Plugin '{_pluginId}' attempted to use an endpoint outside its Lumi plugin scope.");
|
||||
|
||||
var endpoint = new Uri(host, relative);
|
||||
if (!CompanionTransportPolicy.IsEndpointAllowed(endpoint, host))
|
||||
throw new InvalidDataException("The plugin endpoint does not match the paired Lumi host.");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
|
||||
{
|
||||
Content = JsonContent.Create(payload, options: ProtocolV1.JsonOptions)
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||
"LumiDevice",
|
||||
$"{credential.DeviceId}.{credential.DeviceSecret}");
|
||||
request.Headers.UserAgent.ParseAdd("Lumi-Companion/1.0");
|
||||
request.Headers.Add("X-Lumi-Companion-Plugin", _pluginId);
|
||||
|
||||
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new CompanionPluginHttpResponse((int)response.StatusCode, body);
|
||||
}
|
||||
|
||||
public async Task RunLiveJsonAsync(
|
||||
string relativePath,
|
||||
Func<ICompanionPluginLiveChannel, CancellationToken, Task> connected,
|
||||
Action<CompanionPluginLiveStatus>? status,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connected);
|
||||
var attempt = 0;
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var credential = credentialProvider();
|
||||
if (credential is null)
|
||||
{
|
||||
status?.Invoke(new(CompanionPluginLiveState.WaitingForPairing, "Pair Companion with Lumi to use this plugin.", attempt));
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var endpoint = ValidateLiveEndpoint(credential, relativePath);
|
||||
status?.Invoke(new(attempt == 0 ? CompanionPluginLiveState.Connecting : CompanionPluginLiveState.Reconnecting,
|
||||
attempt == 0 ? "Connecting to Lumi…" : "Lumi is unavailable; reconnecting silently.", attempt));
|
||||
using var socket = new ClientWebSocket();
|
||||
socket.Options.SetRequestHeader("Authorization", $"LumiDevice {credential.DeviceId}.{credential.DeviceSecret}");
|
||||
socket.Options.SetRequestHeader("User-Agent", "Lumi-Companion/1.0");
|
||||
socket.Options.SetRequestHeader("X-Lumi-Companion-Plugin", _pluginId);
|
||||
socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(15);
|
||||
await socket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false);
|
||||
attempt = 0;
|
||||
log?.Invoke("plugin_live_connected", $"Plugin {_pluginId} connected to its authenticated Lumi live feed.");
|
||||
status?.Invoke(new(CompanionPluginLiveState.Connected, "Live feed connected."));
|
||||
await connected(new LiveChannel(socket), cancellationToken).ConfigureAwait(false);
|
||||
if (socket.State == WebSocketState.Open)
|
||||
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "reconnect", cancellationToken).ConfigureAwait(false);
|
||||
if (!cancellationToken.IsCancellationRequested) throw new WebSocketException("The Lumi live feed closed.");
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { break; }
|
||||
catch (Exception error)
|
||||
{
|
||||
attempt += 1;
|
||||
var detail = error.Message.Contains("403", StringComparison.Ordinal)
|
||||
? "Lumi has not granted this paired device access to the plugin feed."
|
||||
: error.Message.Contains("401", StringComparison.Ordinal)
|
||||
? "Lumi no longer accepts this device pairing."
|
||||
: "Lumi is unavailable; reconnecting silently.";
|
||||
if (attempt == 1 || attempt % 10 == 0)
|
||||
log?.Invoke("plugin_live_reconnecting", $"Plugin {_pluginId} live feed is reconnecting after {error.GetType().Name}.");
|
||||
status?.Invoke(new(CompanionPluginLiveState.Reconnecting, detail, attempt));
|
||||
var delay = TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, Math.Min(attempt, 5))));
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
status?.Invoke(new(CompanionPluginLiveState.Stopped, "Live feed stopped."));
|
||||
}
|
||||
|
||||
private Uri ValidateLiveEndpoint(DeviceCredential credential, string relativePath)
|
||||
{
|
||||
var host = new Uri(credential.Host);
|
||||
if (!CompanionTransportPolicy.IsPairedHostAllowed(host))
|
||||
throw new InvalidDataException("Companion transport requires HTTPS unless this device was paired with localhost.");
|
||||
var expectedPrefix = $"/plugins/{_pluginId}/";
|
||||
if (!relativePath.StartsWith(expectedPrefix, StringComparison.Ordinal) ||
|
||||
!Uri.TryCreate(relativePath, UriKind.Relative, out var relative))
|
||||
throw new InvalidDataException($"Plugin '{_pluginId}' attempted to use an endpoint outside its Lumi plugin scope.");
|
||||
var httpEndpoint = new Uri(host, relative);
|
||||
if (!CompanionTransportPolicy.IsEndpointAllowed(httpEndpoint, host))
|
||||
throw new InvalidDataException("The plugin endpoint does not match the paired Lumi host.");
|
||||
var builder = new UriBuilder(httpEndpoint)
|
||||
{
|
||||
Scheme = httpEndpoint.Scheme == Uri.UriSchemeHttps ? "wss" : "ws",
|
||||
Port = httpEndpoint.IsDefaultPort ? -1 : httpEndpoint.Port
|
||||
};
|
||||
return builder.Uri;
|
||||
}
|
||||
|
||||
private sealed class LiveChannel(ClientWebSocket socket) : ICompanionPluginLiveChannel
|
||||
{
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
|
||||
public async Task SendJsonAsync(object payload, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(payload, ProtocolV1.JsonOptions);
|
||||
if (bytes.Length > 64 * 1024) throw new InvalidDataException("Plugin live message exceeds the 64 KiB limit.");
|
||||
await _sendLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try { await socket.SendAsync(bytes, WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false); }
|
||||
finally { _sendLock.Release(); }
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<string> ReadAllAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var buffer = new byte[16 * 1024];
|
||||
using var message = new MemoryStream();
|
||||
while (!cancellationToken.IsCancellationRequested && socket.State == WebSocketState.Open)
|
||||
{
|
||||
var result = await socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
if (result.MessageType == WebSocketMessageType.Close) yield break;
|
||||
if (result.MessageType != WebSocketMessageType.Text) continue;
|
||||
if (message.Length + result.Count > 64 * 1024) throw new InvalidDataException("Lumi sent an oversized plugin live message.");
|
||||
message.Write(buffer, 0, result.Count);
|
||||
if (!result.EndOfMessage) continue;
|
||||
yield return Encoding.UTF8.GetString(message.GetBuffer(), 0, (int)message.Length);
|
||||
message.SetLength(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ValidatePluginId(string value)
|
||||
{
|
||||
var clean = (value ?? string.Empty).Trim();
|
||||
if (clean.Length is < 1 or > 100 ||
|
||||
clean.Any(character => !char.IsAsciiLetterOrDigit(character) && character is not '_' and not '-' and not '.'))
|
||||
throw new ArgumentException("A valid Companion plugin identifier is required.", nameof(value));
|
||||
return clean;
|
||||
}
|
||||
}
|
||||
171
companion/src/Lumi.Companion.Core/CompanionSocket.cs
Normal file
171
companion/src/Lumi.Companion.Core/CompanionSocket.cs
Normal file
@ -0,0 +1,171 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net.WebSockets;
|
||||
using System.Threading.Channels;
|
||||
using Lumi.Companion.Protocol;
|
||||
|
||||
namespace Lumi.Companion.Core;
|
||||
|
||||
public sealed class CompanionSocket : IAsyncDisposable
|
||||
{
|
||||
private const int NetworkAudioPayloadBytes = 640; // 20 ms of 16 kHz mono PCM16.
|
||||
private readonly Channel<ReadOnlyMemory<byte>> _audio = Channel.CreateBounded<ReadOnlyMemory<byte>>(new BoundedChannelOptions(250) { FullMode = BoundedChannelFullMode.Wait, SingleReader = true });
|
||||
private readonly object _bridgeAudioGate = new();
|
||||
private readonly byte[] _bridgeAudioBuffer = new byte[ProtocolV1.MaxAudioPayloadBytes + NetworkAudioPayloadBytes];
|
||||
private readonly byte[] _bridgeAudioHeader = new byte[ProtocolV1.AudioHeaderBytes];
|
||||
private int _bridgeAudioBuffered;
|
||||
private uint _bridgeAudioSequence;
|
||||
private ClientWebSocket? _socket;
|
||||
private CancellationTokenSource? _lifetime;
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
private readonly List<Task> _backgroundTasks = [];
|
||||
private long _droppedFrames;
|
||||
private int _disconnectNotified;
|
||||
public long DroppedFrames => Interlocked.Read(ref _droppedFrames);
|
||||
public Guid SessionId { get; private set; }
|
||||
public event Func<ServerEnvelope, Task>? MessageReceived;
|
||||
public event Action<Exception?>? Disconnected;
|
||||
|
||||
public async Task ConnectAsync(DeviceCredential credential, string companionVersion, string pluginVersion, string? obsVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
_disconnectNotified = 0;
|
||||
_socket = new ClientWebSocket();
|
||||
_socket.Options.SetRequestHeader("Authorization", $"LumiDevice {credential.DeviceId}.{credential.DeviceSecret}");
|
||||
var host = new Uri(credential.Host);
|
||||
if (!CompanionTransportPolicy.IsPairedHostAllowed(host))
|
||||
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;
|
||||
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 } });
|
||||
await _socket.SendAsync(hello, WebSocketMessageType.Text, true, cancellationToken);
|
||||
var acknowledgement = await ReceiveMessageAsync(_socket, cancellationToken);
|
||||
if (acknowledgement.Type != "hello_ack" || acknowledgement.Version != 1 || acknowledgement.SessionId is null)
|
||||
throw new InvalidDataException("Lumi returned an incompatible companion handshake.");
|
||||
SessionId = acknowledgement.SessionId.Value;
|
||||
_lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_backgroundTasks.Add(Task.Run(() => RunGuardedAsync(SendAudioAsync, _lifetime.Token), _lifetime.Token));
|
||||
_backgroundTasks.Add(Task.Run(() => RunGuardedAsync(ReceiveAsync, _lifetime.Token), _lifetime.Token));
|
||||
_backgroundTasks.Add(Task.Run(() => RunGuardedAsync(SendHeartbeatsAsync, _lifetime.Token), _lifetime.Token));
|
||||
}
|
||||
|
||||
public bool QueueAudio(AudioFrame frame)
|
||||
{
|
||||
return QueueEncodedAudio(ProtocolV1.EncodeAudio(frame));
|
||||
}
|
||||
public bool QueueEncodedAudio(ReadOnlyMemory<byte> encoded)
|
||||
{
|
||||
if (encoded.Length < ProtocolV1.AudioHeaderBytes || encoded.Length > ProtocolV1.AudioHeaderBytes + ProtocolV1.MaxAudioPayloadBytes ||
|
||||
!encoded.Span[..4].SequenceEqual("LACP"u8)) return false;
|
||||
if (_audio.Writer.TryWrite(encoded)) return true;
|
||||
_audio.Reader.TryRead(out _);
|
||||
Interlocked.Increment(ref _droppedFrames);
|
||||
return _audio.Writer.TryWrite(encoded);
|
||||
}
|
||||
public bool QueueBridgeAudio(ReadOnlyMemory<byte> encoded)
|
||||
{
|
||||
var input = encoded.Span;
|
||||
if (input.Length < ProtocolV1.AudioHeaderBytes || input.Length > ProtocolV1.AudioHeaderBytes + ProtocolV1.MaxAudioPayloadBytes ||
|
||||
!input[..4].SequenceEqual("LACP"u8) || BinaryPrimitives.ReadUInt16LittleEndian(input[6..8]) != ProtocolV1.AudioHeaderBytes) return false;
|
||||
var payloadBytes = (int)BinaryPrimitives.ReadUInt32LittleEndian(input[60..64]);
|
||||
if (payloadBytes < 0 || payloadBytes % 2 != 0 || input.Length != ProtocolV1.AudioHeaderBytes + payloadBytes) return false;
|
||||
lock (_bridgeAudioGate)
|
||||
{
|
||||
var sourceChanged = !_bridgeAudioHeader.AsSpan(36, 16).SequenceEqual(input[36..52]);
|
||||
var stateChanged = _bridgeAudioHeader[5] != input[5];
|
||||
if (_bridgeAudioBuffered > 0 && (sourceChanged || stateChanged)) _bridgeAudioBuffered = 0;
|
||||
input[..ProtocolV1.AudioHeaderBytes].CopyTo(_bridgeAudioHeader);
|
||||
input[ProtocolV1.AudioHeaderBytes..].CopyTo(_bridgeAudioBuffer.AsSpan(_bridgeAudioBuffered));
|
||||
_bridgeAudioBuffered += payloadBytes;
|
||||
var accepted = true;
|
||||
while (_bridgeAudioBuffered >= NetworkAudioPayloadBytes)
|
||||
{
|
||||
var normalized = new byte[ProtocolV1.AudioHeaderBytes + NetworkAudioPayloadBytes];
|
||||
_bridgeAudioHeader.CopyTo(normalized, 0);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(normalized.AsSpan(8, 4), _bridgeAudioSequence++);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(normalized.AsSpan(12, 8), (ulong)DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1000UL);
|
||||
SessionId.TryWriteBytes(normalized.AsSpan(20, 16), bigEndian: true, out _);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(normalized.AsSpan(60, 4), NetworkAudioPayloadBytes);
|
||||
_bridgeAudioBuffer.AsSpan(0, NetworkAudioPayloadBytes).CopyTo(normalized.AsSpan(ProtocolV1.AudioHeaderBytes));
|
||||
_bridgeAudioBuffered -= NetworkAudioPayloadBytes;
|
||||
if (_bridgeAudioBuffered > 0)
|
||||
_bridgeAudioBuffer.AsSpan(NetworkAudioPayloadBytes, _bridgeAudioBuffered).CopyTo(_bridgeAudioBuffer);
|
||||
accepted &= QueueEncodedAudio(normalized);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
}
|
||||
public async Task SendAsync(string type, object payload, Guid? sessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_socket?.State != WebSocketState.Open) throw new InvalidOperationException("Lumi is not connected.");
|
||||
await SendLockedAsync(ProtocolV1.EncodeEnvelope(type, payload, sessionId), WebSocketMessageType.Text, cancellationToken);
|
||||
}
|
||||
private async Task SendAudioAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var frame in _audio.Reader.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
if (_socket?.State != WebSocketState.Open) continue;
|
||||
await SendLockedAsync(frame, WebSocketMessageType.Binary, cancellationToken);
|
||||
}
|
||||
}
|
||||
private async Task SendLockedAsync(ReadOnlyMemory<byte> body, WebSocketMessageType type, CancellationToken cancellationToken)
|
||||
{
|
||||
await _sendLock.WaitAsync(cancellationToken);
|
||||
try { if (_socket?.State == WebSocketState.Open) await _socket.SendAsync(body, type, true, cancellationToken); }
|
||||
finally { _sendLock.Release(); }
|
||||
}
|
||||
private async Task ReceiveAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (_socket?.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var message = await ReceiveMessageAsync(_socket, cancellationToken);
|
||||
if (MessageReceived is { } handler) await handler(message);
|
||||
}
|
||||
}
|
||||
private async Task SendHeartbeatsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(8));
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken))
|
||||
await SendAsync("ping", new { }, SessionId, cancellationToken);
|
||||
}
|
||||
private async Task RunGuardedAsync(Func<CancellationToken, Task> operation, CancellationToken cancellationToken)
|
||||
{
|
||||
try { await operation(cancellationToken); }
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
|
||||
catch (Exception error)
|
||||
{
|
||||
_lifetime?.Cancel();
|
||||
if (Interlocked.Exchange(ref _disconnectNotified, 1) == 0) Disconnected?.Invoke(error);
|
||||
}
|
||||
}
|
||||
private static async Task<ServerEnvelope> ReceiveMessageAsync(ClientWebSocket socket, CancellationToken cancellationToken)
|
||||
{
|
||||
using var body = new MemoryStream();
|
||||
var buffer = new byte[8192];
|
||||
WebSocketReceiveResult result;
|
||||
do
|
||||
{
|
||||
result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
if (socket.State == WebSocketState.CloseReceived)
|
||||
try { await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "close_ack", CancellationToken.None); } catch (WebSocketException) { }
|
||||
throw new EndOfStreamException($"Lumi closed the companion connection ({result.CloseStatus?.ToString() ?? "no status"}: {result.CloseStatusDescription ?? "no reason"}).");
|
||||
}
|
||||
body.Write(buffer, 0, result.Count);
|
||||
if (body.Length > ProtocolV1.MaxJsonBytes) throw new InvalidDataException("Lumi message exceeds the protocol limit.");
|
||||
} while (!result.EndOfMessage);
|
||||
if (result.MessageType != WebSocketMessageType.Text) throw new InvalidDataException("Unexpected binary message from Lumi.");
|
||||
return System.Text.Json.JsonSerializer.Deserialize<ServerEnvelope>(body.ToArray(), ProtocolV1.JsonOptions)
|
||||
?? throw new InvalidDataException("Lumi message is invalid.");
|
||||
}
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_lifetime?.Cancel();
|
||||
if (_socket?.State == WebSocketState.Open)
|
||||
{
|
||||
try { await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "companion_exit", CancellationToken.None); }
|
||||
catch (WebSocketException) { }
|
||||
}
|
||||
try { await Task.WhenAll(_backgroundTasks); } catch (OperationCanceledException) { }
|
||||
_socket?.Dispose(); _lifetime?.Dispose(); _sendLock.Dispose();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
namespace Lumi.Companion.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical transport boundary for every Companion feature that sends
|
||||
/// credentials or private stream data to Lumi.
|
||||
/// </summary>
|
||||
public static class CompanionTransportPolicy
|
||||
{
|
||||
public static bool IsSecureOrigin(Uri uri) =>
|
||||
uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static bool IsLoopbackHttpOrigin(Uri uri) =>
|
||||
uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
|
||||
uri.IsLoopback;
|
||||
|
||||
public static bool IsSameOrigin(Uri left, Uri right) =>
|
||||
left.Scheme.Equals(right.Scheme, StringComparison.OrdinalIgnoreCase) &&
|
||||
left.Host.Equals(right.Host, StringComparison.OrdinalIgnoreCase) &&
|
||||
left.Port == right.Port;
|
||||
|
||||
public static bool IsPairingExchangeAllowed(Uri exchangeUri, Uri hostUri) =>
|
||||
IsSameOrigin(exchangeUri, hostUri) &&
|
||||
((IsSecureOrigin(exchangeUri) && IsSecureOrigin(hostUri)) ||
|
||||
(IsLoopbackHttpOrigin(exchangeUri) && IsLoopbackHttpOrigin(hostUri)));
|
||||
|
||||
public static bool IsPairedHostAllowed(Uri hostUri) =>
|
||||
IsSecureOrigin(hostUri) || IsLoopbackHttpOrigin(hostUri);
|
||||
|
||||
public static bool IsEndpointAllowed(Uri endpoint, Uri? pairedHost)
|
||||
{
|
||||
if (IsSecureOrigin(endpoint)) return true;
|
||||
return pairedHost is not null &&
|
||||
IsLoopbackHttpOrigin(endpoint) &&
|
||||
IsLoopbackHttpOrigin(pairedHost) &&
|
||||
IsSameOrigin(endpoint, pairedHost);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0-windows</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup><ItemGroup><ProjectReference Include="../Lumi.Companion.Abstractions/Lumi.Companion.Abstractions.csproj" /><ProjectReference Include="../Lumi.Companion.Protocol/Lumi.Companion.Protocol.csproj" /><PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" /></ItemGroup></Project>
|
||||
30
companion/src/Lumi.Companion.Core/PairingClient.cs
Normal file
30
companion/src/Lumi.Companion.Core/PairingClient.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Lumi.Companion.Protocol;
|
||||
|
||||
namespace Lumi.Companion.Core;
|
||||
|
||||
public sealed class PairingClient(HttpClient http)
|
||||
{
|
||||
public async Task<DeviceCredential> PairAsync(string packagePath, object device, CancellationToken cancellationToken)
|
||||
{
|
||||
var bootstrap = JsonSerializer.Deserialize<PairingBootstrap>(await File.ReadAllBytesAsync(packagePath, cancellationToken), ProtocolV1.JsonOptions)
|
||||
?? throw new InvalidDataException("Pairing package is invalid.");
|
||||
if (bootstrap.Format != "lumi-companion-bootstrap-v1" || bootstrap.ProtocolVersion != 1)
|
||||
throw new InvalidDataException("Pairing package is incompatible with this companion.");
|
||||
if (DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() >= bootstrap.ExpiresAt)
|
||||
throw new InvalidDataException("Pairing package has expired. Download a new package from Lumi.");
|
||||
var exchangeUri = new Uri(bootstrap.ExchangeUrl);
|
||||
var hostUri = new Uri(bootstrap.Host);
|
||||
if (!CompanionTransportPolicy.IsPairingExchangeAllowed(exchangeUri, hostUri))
|
||||
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);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"Pairing failed: {body}");
|
||||
using var json = JsonDocument.Parse(body);
|
||||
return new DeviceCredential(
|
||||
json.RootElement.GetProperty("device_id").GetString()!, json.RootElement.GetProperty("device_secret").GetString()!,
|
||||
json.RootElement.GetProperty("host").GetString()!, json.RootElement.GetProperty("capabilities").EnumerateArray().Select(item => item.GetString()!).ToArray(),
|
||||
json.RootElement.GetProperty("protocol_version").GetInt32());
|
||||
}
|
||||
}
|
||||
33
companion/src/Lumi.Companion.Core/SecureCredentialStore.cs
Normal file
33
companion/src/Lumi.Companion.Core/SecureCredentialStore.cs
Normal file
@ -0,0 +1,33 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Lumi.Companion.Protocol;
|
||||
|
||||
namespace Lumi.Companion.Core;
|
||||
|
||||
public sealed class SecureCredentialStore(string root)
|
||||
{
|
||||
private readonly string _path = Path.Combine(root, "device.credential");
|
||||
private readonly string _installIdPath = Path.Combine(root, "install.id");
|
||||
public void Save(DeviceCredential credential)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||
var clear = JsonSerializer.SerializeToUtf8Bytes(credential, ProtocolV1.JsonOptions);
|
||||
var protectedBytes = ProtectedData.Protect(clear, "Lumi.Companion.Device.v1"u8.ToArray(), DataProtectionScope.CurrentUser);
|
||||
var temporary = $"{_path}.{Environment.ProcessId}.tmp";
|
||||
try { File.WriteAllBytes(temporary, protectedBytes); File.Move(temporary, _path, true); }
|
||||
finally { File.Delete(temporary); }
|
||||
}
|
||||
public DeviceCredential? Load()
|
||||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
var clear = ProtectedData.Unprotect(File.ReadAllBytes(_path), "Lumi.Companion.Device.v1"u8.ToArray(), DataProtectionScope.CurrentUser);
|
||||
return JsonSerializer.Deserialize<DeviceCredential>(clear, ProtocolV1.JsonOptions);
|
||||
}
|
||||
public void Remove() => File.Delete(_path);
|
||||
public string GetOrCreateInstallId()
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_installIdPath)!);
|
||||
if (File.Exists(_installIdPath) && Guid.TryParse(File.ReadAllText(_installIdPath).Trim(), out var existing)) return existing.ToString();
|
||||
var created = Guid.NewGuid().ToString(); File.WriteAllText(_installIdPath, created); return created;
|
||||
}
|
||||
}
|
||||
46
companion/src/Lumi.Companion.Core/SingleFlightOperation.cs
Normal file
46
companion/src/Lumi.Companion.Core/SingleFlightOperation.cs
Normal file
@ -0,0 +1,46 @@
|
||||
namespace Lumi.Companion.Core;
|
||||
|
||||
public sealed class SingleFlightOperation
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private Task? _active;
|
||||
private long _generation;
|
||||
|
||||
public bool IsRunning
|
||||
{
|
||||
get { lock (_gate) return _active is { IsCompleted: false }; }
|
||||
}
|
||||
|
||||
public Task RunAsync(Func<CancellationToken, Task> operation, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(operation);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_active is { IsCompleted: false }) return _active;
|
||||
var generation = ++_generation;
|
||||
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var task = completion.Task;
|
||||
_active = task;
|
||||
_ = RunCoreAsync(generation, operation, cancellationToken, completion);
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunCoreAsync(long generation, Func<CancellationToken, Task> operation, CancellationToken cancellationToken, TaskCompletionSource completion)
|
||||
{
|
||||
try
|
||||
{
|
||||
await operation(cancellationToken);
|
||||
completion.TrySetResult();
|
||||
}
|
||||
catch (OperationCanceledException error) { completion.TrySetCanceled(error.CancellationToken); }
|
||||
catch (Exception error) { completion.TrySetException(error); }
|
||||
finally
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_generation == generation) _active = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
companion/src/Lumi.Companion.Core/StreamTestRecoveryStore.cs
Normal file
47
companion/src/Lumi.Companion.Core/StreamTestRecoveryStore.cs
Normal file
@ -0,0 +1,47 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Lumi.Companion.Protocol;
|
||||
|
||||
namespace Lumi.Companion.Core;
|
||||
|
||||
public sealed record StreamTestRecoveryState(
|
||||
string SessionId,
|
||||
string ServiceType,
|
||||
string ServiceSettingsJson,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed class StreamTestRecoveryStore(string root)
|
||||
{
|
||||
private readonly string _path = Path.Combine(root, "stream-test.recovery");
|
||||
private static readonly byte[] Entropy = "Lumi.Companion.StreamTestRecovery.v1"u8.ToArray();
|
||||
|
||||
public void Save(StreamTestRecoveryState state)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||
var clear = JsonSerializer.SerializeToUtf8Bytes(state, ProtocolV1.JsonOptions);
|
||||
var protectedBytes = ProtectedData.Protect(clear, Entropy, DataProtectionScope.CurrentUser);
|
||||
var temporary = $"{_path}.{Environment.ProcessId}.tmp";
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(temporary, protectedBytes);
|
||||
File.Move(temporary, _path, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(temporary);
|
||||
CryptographicOperations.ZeroMemory(clear);
|
||||
}
|
||||
}
|
||||
|
||||
public StreamTestRecoveryState? Load()
|
||||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
var protectedBytes = File.ReadAllBytes(_path);
|
||||
var clear = ProtectedData.Unprotect(protectedBytes, Entropy, DataProtectionScope.CurrentUser);
|
||||
try { return JsonSerializer.Deserialize<StreamTestRecoveryState>(clear, ProtocolV1.JsonOptions); }
|
||||
finally { CryptographicOperations.ZeroMemory(clear); }
|
||||
}
|
||||
|
||||
public bool Exists => File.Exists(_path);
|
||||
public void Remove() => File.Delete(_path);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0-windows</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup></Project>
|
||||
23
companion/src/Lumi.Companion.PluginHost/PluginWorker.cs
Normal file
23
companion/src/Lumi.Companion.PluginHost/PluginWorker.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Lumi.Companion.PluginHost;
|
||||
|
||||
public sealed class PluginWorker(string executable, string arguments) : IAsyncDisposable
|
||||
{
|
||||
private Process? _process;
|
||||
public void Start()
|
||||
{
|
||||
if (_process is { HasExited: false }) return;
|
||||
_process = Process.Start(new ProcessStartInfo(executable, arguments) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true })
|
||||
?? throw new InvalidOperationException("Companion plugin worker could not start.");
|
||||
}
|
||||
public bool Healthy => _process is { HasExited: false };
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_process is not { HasExited: false }) return;
|
||||
_process.CloseMainWindow();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
try { await _process.WaitForExitAsync(timeout.Token); } catch (OperationCanceledException) { _process.Kill(entireProcessTree: true); }
|
||||
_process.Dispose();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup></Project>
|
||||
66
companion/src/Lumi.Companion.Protocol/ProtocolV1.cs
Normal file
66
companion/src/Lumi.Companion.Protocol/ProtocolV1.cs
Normal file
@ -0,0 +1,66 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Lumi.Companion.Protocol;
|
||||
|
||||
public static class ProtocolV1
|
||||
{
|
||||
public const int Version = 1;
|
||||
public const int AudioHeaderBytes = 64;
|
||||
public const int MaxAudioPayloadBytes = 6400;
|
||||
public const int MaxJsonBytes = 64 * 1024;
|
||||
|
||||
public static byte[] EncodeAudio(AudioFrame frame)
|
||||
{
|
||||
if (frame.Pcm.Length > MaxAudioPayloadBytes || frame.Pcm.Length % 2 != 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(frame), "PCM payload must be even and at most 6,400 bytes.");
|
||||
var output = new byte[AudioHeaderBytes + frame.Pcm.Length];
|
||||
"LACP"u8.CopyTo(output);
|
||||
output[4] = Version;
|
||||
output[5] = (byte)((frame.Active ? 1 : 0) | (frame.Muted ? 2 : 0));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(output.AsSpan(6), AudioHeaderBytes);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(output.AsSpan(8), frame.Sequence);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(output.AsSpan(12), frame.CaptureTimestampUs);
|
||||
frame.SessionId.TryWriteBytes(output.AsSpan(20), bigEndian: true, out _);
|
||||
frame.SourceUuid.TryWriteBytes(output.AsSpan(36), bigEndian: true, out _);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(output.AsSpan(52), 16000);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(output.AsSpan(56), 1);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(output.AsSpan(58), 16);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(output.AsSpan(60), (uint)frame.Pcm.Length);
|
||||
frame.Pcm.Span.CopyTo(output.AsSpan(AudioHeaderBytes));
|
||||
return output;
|
||||
}
|
||||
|
||||
public static byte[] EncodeEnvelope(string type, object payload, Guid? sessionId = null) =>
|
||||
JsonSerializer.SerializeToUtf8Bytes(new Envelope(Version, type, Guid.NewGuid(), DateTimeOffset.UtcNow, sessionId, payload), JsonOptions);
|
||||
|
||||
public static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record AudioFrame(Guid SessionId, Guid SourceUuid, uint Sequence, ulong CaptureTimestampUs, bool Active, bool Muted, ReadOnlyMemory<byte> Pcm);
|
||||
public sealed record Envelope(
|
||||
[property: JsonPropertyName("version")] int Version,
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("id")] Guid Id,
|
||||
[property: JsonPropertyName("sent_at")] DateTimeOffset SentAt,
|
||||
[property: JsonPropertyName("session_id")] Guid? SessionId,
|
||||
[property: JsonPropertyName("payload")] object Payload);
|
||||
public sealed record ServerEnvelope(
|
||||
[property: JsonPropertyName("version")] int Version,
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("id")] Guid Id,
|
||||
[property: JsonPropertyName("sent_at")] DateTimeOffset SentAt,
|
||||
[property: JsonPropertyName("session_id")] Guid? SessionId,
|
||||
[property: JsonPropertyName("payload")] JsonElement Payload);
|
||||
public sealed record PairingBootstrap(
|
||||
[property: JsonPropertyName("format")] string Format,
|
||||
[property: JsonPropertyName("token")] string Token,
|
||||
[property: JsonPropertyName("host")] string Host,
|
||||
[property: JsonPropertyName("exchange_url")] string ExchangeUrl,
|
||||
[property: JsonPropertyName("expires_at")] long ExpiresAt,
|
||||
[property: JsonPropertyName("protocol_version")] int ProtocolVersion);
|
||||
public sealed record DeviceCredential(string DeviceId, string DeviceSecret, string Host, string[] Capabilities, int ProtocolVersion);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user