Add Companion updates and managed OBS bridge

This commit is contained in:
Franz Rolfsvaag 2026-07-22 15:47:56 +02:00
parent 37dda538b3
commit 02bdec7584
22 changed files with 1198 additions and 69 deletions

1
.gitignore vendored
View File

@ -19,6 +19,7 @@ npm-debug.log
companion/**/bin/ companion/**/bin/
companion/**/obj/ companion/**/obj/
companion/installer/output/ companion/installer/output/
companion/src/Lumi.Companion.App/components/obs-bridge/
companion/**/logs/ companion/**/logs/
*.lumi-pairing.json *.lumi-pairing.json
security-audit-*.json security-audit-*.json

14
TODO.md
View File

@ -20,16 +20,20 @@ Companion health/download section beneath its shortcut cards. A checksum-pinned
self-contained Windows bundle includes a one-time pairing file and pairs after 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 extraction without a separate import workflow. The pinned CPU whisper worker now
has a reproducible Windows build/install script and is discovered automatically; has a reproducible Windows build/install script and is discovered automatically;
the production CUDA build and target benchmark remain pending below. 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.
Release-blocking work remains: Release-blocking work remains:
- Package the native whisper.cpp worker as the Windows CUDA runtime, then benchmark - Package the native whisper.cpp worker as the Windows CUDA runtime, then benchmark
`small.en`, quantized `small.en`, and `base.en` on the RTX 3060. `small.en`, quantized `small.en`, and `base.en` on the RTX 3060.
- Complete the signed companion installer, DPAPI migration UX, and managed OBS - Complete the signed companion installer and DPAPI migration UX; add a rollback
bridge install/repair/uninstall service. policy after the experimental updater has target-machine acceptance evidence.
- Complete native selected-source capture, nested Program-scene evaluation, - Complete target-machine bridge acceptance and source rename/missing recovery.
resampling/IPC, bridge health, and source rename/missing recovery.
- Run the OBS 31+/Twitch compatibility spike and prove toggleable closed captions; - Run the OBS 31+/Twitch compatibility spike and prove toggleable closed captions;
tune replacement/display duration from player behavior without open-caption fallback. tune replacement/display duration from player behavior without open-caption fallback.
- Complete the remaining live source/setup steps, conflict-resolution UI, and - Complete the remaining live source/setup steps, conflict-resolution UI, and

View File

@ -1,6 +1,6 @@
# Lumi Companion (experimental transcription milestone) # Lumi Companion (experimental transcription milestone)
This directory is the single Lumi Companion product boundary. The current milestone supplies the versioned protocol client, Windows credential protection, bounded outbound transport, plugin-process supervision, same-user OBS bridge IPC boundary, and an Avalonia tray shell. The shell is single-instance, closes to the tray, confirms quit during OBS streaming/recording, supports explicit pairing, and reports real setup/test blockers without presenting simulated success. This directory is the single Lumi Companion product boundary. The current milestone supplies the versioned protocol client, Windows credential protection, bounded outbound transport, plugin-process supervision, same-user OBS bridge IPC, native selected-source capture, and an Avalonia tray shell. The shell is single-instance, closes to the tray, confirms quit during OBS streaming/recording, supports explicit pairing, and reports real setup/test blockers without presenting simulated success.
Build prerequisites: the current .NET SDK with the .NET 8 targeting pack on Windows x64. The app targets .NET 8; .NET SDK 10 is recommended for Avalonia 12 source-generator compatibility. The native bridge additionally requires CMake, Visual Studio C++ tools, and the OBS 31+ SDK. Build prerequisites: the current .NET SDK with the .NET 8 targeting pack on Windows x64. The app targets .NET 8; .NET SDK 10 is recommended for Avalonia 12 source-generator compatibility. The native bridge additionally requires CMake, Visual Studio C++ tools, and the OBS 31+ SDK.
@ -14,6 +14,14 @@ Build all managed projects from the repository root:
dotnet build companion/Lumi.Companion.sln -p:EnableWindowsTargeting=true dotnet build companion/Lumi.Companion.sln -p:EnableWindowsTargeting=true
``` ```
The signed installer, managed OBS bridge installation/repair service, and a target-machine OBS/Twitch acceptance run are still required. Until those pass, the UI deliberately stops its test at the unavailable real boundary. Build the pinned OBS 31.1.1 bridge and a self-contained Windows package from PowerShell:
The Admin **Download Companion** action currently distributes a checksum-pinned, self-contained Windows x64 ZIP. It is intentionally marked experimental and is not code-signed yet. ```powershell
companion/scripts/publish-companion.ps1
```
This verifies both official OBS archives by SHA-256, builds the native module with Visual Studio 2022/CMake, and places the bridge beside the published app as a managed component. In Companion, **Install/Repair** requests Windows administrator approval only to copy that verified component into OBS's shared ProgramData plugin directory. OBS must be closed; Companion refuses plugin maintenance while `obs64.exe` is running.
Experimental.3 checks for updates after connecting and every six hours. Updates remain user-approved, are checksum-verified, refuse to run while OBS is streaming or recording, replace the app and bundled components, then restart Companion. Pairing credentials and settings remain in the per-user data directory. Experimental.2 requires one final manual download to bootstrap this updater. A signed installer, rollback policy, and target-machine OBS/Twitch acceptance run are still required.
The Admin **Download Companion** action distributes a checksum-pinned, self-contained Windows x64 ZIP. It is intentionally marked experimental and is not code-signed yet.

View File

@ -1,10 +1,36 @@
cmake_minimum_required(VERSION 3.28) cmake_minimum_required(VERSION 3.28)
project(lumi-obs-bridge VERSION 0.1.0 LANGUAGES CXX) project(lumi-obs-bridge VERSION 0.1.0 LANGUAGES CXX)
set(LUMI_BRIDGE_VERSION "0.1.0-development" CACHE STRING "Lumi Companion bridge release version")
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(libobs REQUIRED)
find_package(obs-frontend-api REQUIRED) 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) add_library(lumi-obs-bridge MODULE src/plugin.cpp)
target_include_directories(lumi-obs-bridge PRIVATE include) target_include_directories(lumi-obs-bridge PRIVATE include)
target_link_libraries(lumi-obs-bridge PRIVATE OBS::libobs OBS::obs-frontend-api) target_compile_definitions(lumi-obs-bridge PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN LUMI_BRIDGE_VERSION="${LUMI_BRIDGE_VERSION}")
set_target_properties(lumi-obs-bridge PROPERTIES PREFIX "") 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")

View File

@ -0,0 +1,2 @@
Module.Name="Lumi Companion OBS Integration"
Module.Description="Connects selected OBS audio sources and native captions to Lumi Companion."

View File

@ -1,58 +1,467 @@
#include <obs-module.h> #include <obs-module.h>
#include <obs-frontend-api.h> #include <obs-frontend-api.h>
#include <cstdlib> #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 <set>
#include <string> #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_DECLARE_MODULE()
OBS_MODULE_USE_DEFAULT_LOCALE("lumi-obs-bridge", "en-US") 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::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()
{
return {{"type", "obs_state"}, {"protocol_version", protocol_version}, {"version", obs_get_version_string()},
{"streaming", obs_frontend_streaming_active()}, {"recording", obs_frontend_recording_active()}};
}
static bool output_caption(const std::string &text, double display_seconds)
{
if (!obs_frontend_streaming_active() || text.empty()) return false;
obs_output_t *output = obs_frontend_get_streaming_output();
if (!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 void 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);
source_list_dirty.store(true, std::memory_order_release);
blog(installed ? LOG_INFO : LOG_WARNING, "[Lumi Companion] %s selected OBS audio source %s",
installed ? "Attached" : "Could not attach", uuid.c_str());
} 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);
}
}
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 { handle_command(json::parse(body.begin(), body.end())); }
catch (const std::exception &error) { blog(LOG_WARNING, "[Lumi Companion] 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] 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}});
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();
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;
}
std::unique_lock lock(worker_signal_mutex);
worker_signal.wait_for(lock, std::chrono::milliseconds(10));
}
CloseHandle(pipe);
blog(LOG_INFO, "[Lumi Companion] 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) const char *obs_module_description(void)
{ {
return "Companion-managed Lumi audio and native-caption bridge"; return "Companion-managed Lumi audio and native-caption bridge";
} }
static bool enumerate_source(void *, obs_source_t *source)
{
if (!source) return true;
blog(LOG_DEBUG, "[Lumi Companion] OBS source available: uuid=%s name=%s active=%s",
obs_source_get_uuid(source), obs_source_get_name(source), obs_source_active(source) ? "true" : "false");
return true;
}
static void frontend_event(enum obs_frontend_event event, void *)
{
if (event == OBS_FRONTEND_EVENT_STREAMING_STARTED || event == OBS_FRONTEND_EVENT_STREAMING_STOPPED ||
event == OBS_FRONTEND_EVENT_RECORDING_STARTED || event == OBS_FRONTEND_EVENT_RECORDING_STOPPED) {
blog(LOG_INFO, "[Lumi Companion] OBS state changed: streaming=%s recording=%s",
obs_frontend_streaming_active() ? "true" : "false", obs_frontend_recording_active() ? "true" : "false");
}
}
// Called only by the bridge IPC worker, never by an OBS audio/render callback.
// Returning false keeps delivery failure isolated from the active OBS output.
static bool output_caption(const std::string &text, double display_seconds)
{
if (!obs_frontend_streaming_active() || text.empty()) return false;
obs_output_t *output = obs_frontend_get_streaming_output();
if (!output) return false;
obs_output_output_caption_text2(output, text.c_str(), display_seconds);
obs_output_release(output);
return true;
}
bool obs_module_load(void) bool obs_module_load(void)
{ {
const char *version = obs_get_version_string(); const char *version = obs_get_version_string();
const int major = version ? std::atoi(version) : 0; const int major = version ? std::atoi(version) : 0;
if (major < 31) blog(LOG_WARNING, "[Lumi Companion] OBS %s is outside the supported 31+ range", version ? version : "unknown"); if (major < 31) {
blog(LOG_ERROR, "[Lumi Companion] 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); obs_frontend_add_event_callback(frontend_event, nullptr);
obs_enum_sources(enumerate_source, nullptr); worker_thread = std::thread(run_pipe_worker);
blog(LOG_INFO, "[Lumi Companion] bridge loaded; waiting for the companion-managed IPC transport"); blog(LOG_INFO, "[Lumi Companion] bridge %s loaded", bridge_version);
return true; return true;
} }
void obs_module_unload(void) void obs_module_unload(void)
{ {
obs_frontend_remove_event_callback(frontend_event, nullptr); obs_frontend_remove_event_callback(frontend_event, nullptr);
detach_source();
stopping.store(true, std::memory_order_release);
worker_signal.notify_all();
if (worker_thread.joinable()) worker_thread.join();
blog(LOG_INFO, "[Lumi Companion] bridge unloaded"); blog(LOG_INFO, "[Lumi Companion] bridge unloaded");
} }

View File

@ -0,0 +1,98 @@
param(
[string]$ObsVersion = "31.1.1",
[string]$BridgeVersion = "0.1.0-experimental.3",
[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"
$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
& $LibExe /nologo /machine:x64 "/def:$definition" "/out:$(Join-Path $importRoot "$Name.lib")"
if ($LASTEXITCODE) { 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`"")
$configured = Start-Process -FilePath $cmake -ArgumentList $configureArguments -NoNewWindow -Wait -PassThru
if ($configured.ExitCode) { throw "OBS bridge configuration failed." }
$built = Start-Process -FilePath $cmake -ArgumentList @("--build", "`"$buildRoot`"", "--config", "Release") -NoNewWindow -Wait -PassThru
if ($built.ExitCode) { 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"
}
$manifest | ConvertTo-Json | Set-Content -Encoding utf8 (Join-Path $componentRoot "manifest.json")
Write-Host "Built OBS bridge $BridgeVersion at $componentRoot"

View File

@ -0,0 +1,32 @@
param(
[string]$Version = "0.1.0-experimental.3"
)
$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"
$archive = Join-Path $outputRoot "Lumi.Companion-win-x64.zip"
& (Join-Path $PSScriptRoot "build-obs-bridge.ps1") -BridgeVersion $Version
Remove-Item $publishRoot, $stageRoot -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", "-o", "`"$publishRoot`"",
"-p:PublishSingleFile=true", "-p:IncludeNativeLibrariesForSelfExtract=true", "-p:DebugType=None", "-p:Version=$Version")
$published = Start-Process -FilePath $dotnet -ArgumentList $publishArguments -NoNewWindow -Wait -PassThru
if ($published.ExitCode) { throw "Companion publish failed." }
Copy-Item (Join-Path $publishRoot "Lumi.Companion.App.exe") $stageRoot
Copy-Item (Join-Path $publishRoot "components") $stageRoot -Recurse
Remove-Item $archive -Force -ErrorAction SilentlyContinue
Compress-Archive -Path (Join-Path $stageRoot "*") -DestinationPath $archive -CompressionLevel Optimal
$file = Get-Item $archive
$sha = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Host "Published Lumi Companion $Version"
Write-Host "Artifact: $archive"
Write-Host "Bytes: $($file.Length)"
Write-Host "SHA256: $sha"

View File

@ -24,6 +24,13 @@ public partial class App : Application
_mainWindow = new MainWindow(runtime, settings); _mainWindow = new MainWindow(runtime, settings);
desktop.MainWindow = _mainWindow; desktop.MainWindow = _mainWindow;
ConfigureTray(desktop, runtime); ConfigureTray(desktop, runtime);
runtime.UpdateRestartRequested += async () =>
{
_mainWindow.AllowExit();
_trayIcon?.Dispose();
await runtime.DisposeAsync();
desktop.Shutdown();
};
Program.InstanceCoordinator!.ActivationRequested += () => Dispatcher.UIThread.Post(ShowMainWindow); Program.InstanceCoordinator!.ActivationRequested += () => Dispatcher.UIThread.Post(ShowMainWindow);
if (!Program.LaunchInBackground) _mainWindow.Show(); if (!Program.LaunchInBackground) _mainWindow.Show();
_ = runtime.InitializeAsync(); _ = runtime.InitializeAsync();
@ -36,6 +43,7 @@ public partial class App : Application
var open = new NativeMenuItem("Open Lumi Companion"); var open = new NativeMenuItem("Open Lumi Companion");
var test = new NativeMenuItem("Run transcription test"); var test = new NativeMenuItem("Run transcription test");
var health = new NativeMenuItem("Health: Starting") { IsEnabled = false }; var health = new NativeMenuItem("Health: Starting") { IsEnabled = false };
var update = new NativeMenuItem("Updates: Checking…") { IsEnabled = false };
var web = new NativeMenuItem("Open Lumi WebUI"); var web = new NativeMenuItem("Open Lumi WebUI");
var quit = new NativeMenuItem("Quit"); var quit = new NativeMenuItem("Quit");
open.Click += (_, _) => ShowMainWindow(); open.Click += (_, _) => ShowMainWindow();
@ -46,6 +54,7 @@ public partial class App : Application
await runtime.RunTestAsync(); await runtime.RunTestAsync();
}; };
web.Click += (_, _) => runtime.OpenLumiWebUi(); web.Click += (_, _) => runtime.OpenLumiWebUi();
update.Click += (_, _) => { ShowMainWindow(); _mainWindow?.ShowPage(CompanionPage.Overview); };
quit.Click += async (_, _) => quit.Click += async (_, _) =>
{ {
if (runtime.State.RequiresQuitConfirmation) ShowMainWindow(); if (runtime.State.RequiresQuitConfirmation) ShowMainWindow();
@ -61,7 +70,7 @@ public partial class App : Application
Icon = LumiIconFactory.Create(TrayHealth.Ready), Icon = LumiIconFactory.Create(TrayHealth.Ready),
ToolTipText = "Lumi Companion — starting", ToolTipText = "Lumi Companion — starting",
IsVisible = true, IsVisible = true,
Menu = new NativeMenu { Items = { open, test, web, health, new NativeMenuItemSeparator(), quit } } Menu = new NativeMenu { Items = { open, test, web, health, update, new NativeMenuItemSeparator(), quit } }
}; };
_trayIcon.Clicked += (_, _) => ShowMainWindow(); _trayIcon.Clicked += (_, _) => ShowMainWindow();
TrayIcon.SetIcons(this, new TrayIcons { _trayIcon }); TrayIcon.SetIcons(this, new TrayIcons { _trayIcon });
@ -69,8 +78,10 @@ public partial class App : Application
{ {
if (_trayIcon is null) return; if (_trayIcon is null) return;
_trayIcon.Icon = LumiIconFactory.Create(state.Health); _trayIcon.Icon = LumiIconFactory.Create(state.Health);
_trayIcon.ToolTipText = $"Lumi Companion — {state.Summary}"; _trayIcon.ToolTipText = state.UpdateAvailable ? $"Lumi Companion — update {state.AvailableVersion} available" : $"Lumi Companion — {state.Summary}";
health.Header = $"Health: {state.Summary}"; health.Header = $"Health: {state.Summary}";
update.Header = state.UpdateAvailable ? $"Update available: {state.AvailableVersion}" : "Updates: Current";
update.IsEnabled = state.UpdateAvailable;
test.Header = state.TestRunning ? "Transcription test running…" : "Run transcription test"; test.Header = state.TestRunning ? "Transcription test running…" : "Run transcription test";
test.IsEnabled = !state.TestRunning; test.IsEnabled = !state.TestRunning;
}); });

View File

@ -1,11 +1,11 @@
namespace Lumi.Companion.App; namespace Lumi.Companion.App;
public sealed record CompanionPaths(string Root, string SettingsPath, string LogsDirectory, string BridgeDirectory) public sealed record CompanionPaths(string Root, string SettingsPath, string LogsDirectory, string BridgeDirectory, string UpdatesDirectory)
{ {
public static CompanionPaths ForCurrentUser() public static CompanionPaths ForCurrentUser()
{ {
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var root = Path.Combine(local, "Lumi", "Companion"); 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")); return new CompanionPaths(root, Path.Combine(root, "settings.json"), Path.Combine(root, "logs"), Path.Combine(root, "obs-bridge"), Path.Combine(root, "updates"));
} }
} }

View File

@ -17,17 +17,22 @@ public sealed class CompanionRuntime : IAsyncDisposable
private readonly CompanionSettingsStore _settings; private readonly CompanionSettingsStore _settings;
private readonly SecureCredentialStore _credentials; private readonly SecureCredentialStore _credentials;
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) }; private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
private readonly UpdateService _updates;
private readonly ObsBridgeManager _bridgeManager = new();
private readonly CancellationTokenSource _maintenanceLifetime = new();
private CompanionSocket? _socket; private CompanionSocket? _socket;
private ObsBridgePipe? _obsBridge; private ObsBridgePipe? _obsBridge;
private TaskCompletionSource<bool>? _audioSignal; private TaskCompletionSource<bool>? _audioSignal;
private TaskCompletionSource<bool>? _captionSignal; private TaskCompletionSource<bool>? _captionSignal;
private bool _disposed; private bool _disposed;
private CompanionUpdate? _availableUpdate;
public CompanionRuntime(CompanionPaths paths, CompanionSettingsStore settings) public CompanionRuntime(CompanionPaths paths, CompanionSettingsStore settings)
{ {
_paths = paths; _paths = paths;
_settings = settings; _settings = settings;
_credentials = new SecureCredentialStore(paths.Root); _credentials = new SecureCredentialStore(paths.Root);
_updates = new UpdateService(_http, paths);
State = new CompanionState(); State = new CompanionState();
TestStages = CreateInitialTestStages(); TestStages = CreateInitialTestStages();
ObsSources = []; ObsSources = [];
@ -41,6 +46,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
public event Action<IReadOnlyList<ObsSource>>? ObsSourcesChanged; public event Action<IReadOnlyList<ObsSource>>? ObsSourcesChanged;
public event Action<string, bool>? CaptionReceived; public event Action<string, bool>? CaptionReceived;
public event Action<string>? LogAdded; public event Action<string>? LogAdded;
public event Func<Task>? UpdateRestartRequested;
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
@ -56,6 +62,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
{ {
await _settings.LoadAsync(); await _settings.LoadAsync();
StartObsBridgeBoundary(); StartObsBridgeBoundary();
_ = RunUpdateChecksAsync(_maintenanceLifetime.Token);
ApplyAutoStart(_settings.Current.AutoStartWithWindows); ApplyAutoStart(_settings.Current.AutoStartWithWindows);
DeviceCredential? credential; DeviceCredential? credential;
try { credential = _credentials.Load(); } try { credential = _credentials.Load(); }
@ -74,10 +81,10 @@ public sealed class CompanionRuntime : IAsyncDisposable
try { File.Delete(bundledPairing); } catch { } try { File.Delete(bundledPairing); } catch { }
return; return;
} }
SetState(State with { ObsBridgeInstalled = DetectBridgeInstallation(), Detail = "Download a pairing package from Lumi, then open it here." }); SetState(WithBridgeState(State with { Detail = "Download a pairing package from Lumi, then open it here." }));
return; return;
} }
SetState(State with { Paired = true, DeviceName = Environment.MachineName, Host = credential.Host, ObsBridgeInstalled = DetectBridgeInstallation(), Detail = "Connecting securely to Lumi…" }); SetState(WithBridgeState(State with { Paired = true, DeviceName = Environment.MachineName, Host = credential.Host, Detail = "Connecting securely to Lumi…" }));
await ConnectAsync(credential); await ConnectAsync(credential);
} }
@ -142,6 +149,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
if (State.ObsConnected) if (State.ObsConnected)
await _socket.SendAsync("obs_state", new { streaming = State.ObsStreaming, recording = State.ObsRecording, auto_start = _settings.Current.StartWithObs }, _socket.SessionId, cancellationToken); await _socket.SendAsync("obs_state", new { streaming = State.ObsStreaming, recording = State.ObsRecording, auto_start = _settings.Current.StartWithObs }, _socket.SessionId, cancellationToken);
await WriteLogAsync("connected", $"Connected to {credential.Host}."); await WriteLogAsync("connected", $"Connected to {credential.Host}.");
await CheckForUpdatesAsync(cancellationToken);
} }
catch (Exception error) catch (Exception error)
{ {
@ -221,9 +229,92 @@ public sealed class CompanionRuntime : IAsyncDisposable
await WriteLogAsync("settings_saved", "Local companion preferences updated."); await WriteLogAsync("settings_saved", "Local companion preferences updated.");
} }
public async Task CheckForUpdatesAsync(CancellationToken cancellationToken = default)
{
var credential = _credentials.Load();
if (credential is null) { SetState(State with { UpdateDetail = "Pair this computer before checking for updates." }); return; }
try
{
_availableUpdate = await _updates.CheckAsync(credential, Version, cancellationToken);
SetState(State with
{
UpdateAvailable = _availableUpdate is not null,
AvailableVersion = _availableUpdate?.Version,
UpdateDetail = _availableUpdate is null ? $"Lumi Companion {Version} is current." : $"Lumi Companion {_availableUpdate.Version} is ready to install when OBS is idle."
});
}
catch (Exception error)
{
SetState(State with { UpdateDetail = $"Update check could not finish. {Friendly(error)}" });
await WriteLogAsync("update_check_failed", error.Message);
}
}
public async Task ApplyUpdateAsync(CancellationToken cancellationToken = default)
{
if (State.ObsStreaming || State.ObsRecording) throw new InvalidOperationException("Stop streaming and recording before updating Lumi Companion.");
var update = _availableUpdate ?? throw new InvalidOperationException("No Companion update is ready to install.");
try
{
SetState(State with { UpdateDetail = $"Downloading and verifying {update.Version}…" });
var staged = await _updates.StageAsync(update, cancellationToken);
if (State.ObsStreaming || State.ObsRecording) throw new InvalidOperationException("OBS started output while the update was downloading. Stop streaming and recording, then try again.");
_updates.LaunchApplier(staged);
SetState(State with { UpdateDetail = "Update verified. Restarting Lumi Companion…" });
await WriteLogAsync("update_staged", $"Verified Companion {update.Version}; restarting to apply it.");
if (UpdateRestartRequested is { } restart) await restart();
}
catch (Exception error)
{
SetState(State with { UpdateDetail = $"Update could not finish. {Friendly(error)}" });
await WriteLogAsync("update_failed", error.Message);
throw;
}
}
public async Task InstallOrRepairObsBridgeAsync(CancellationToken cancellationToken = default)
{
try
{
var result = await _bridgeManager.InstallOrRepairAsync(cancellationToken);
SetState(WithBridgeState(State with { Detail = "OBS integration installed. Start or restart OBS to connect it to Companion." }));
await WriteLogAsync("obs_bridge_installed", $"Installed managed OBS integration {result.Version}.");
}
catch (Exception error)
{
SetState(State with { ObsBridgeDetail = $"OBS integration maintenance could not finish. {Friendly(error)}" });
await WriteLogAsync("obs_bridge_install_failed", error.Message);
throw;
}
}
public async Task RemoveObsBridgeAsync()
{
try
{
await _bridgeManager.RemoveAsync();
SetState(WithBridgeState(State with { ObsConnected = false, Detail = "OBS integration removed. Other Companion features remain installed." }));
await WriteLogAsync("obs_bridge_removed", "Removed the managed OBS integration.");
}
catch (Exception error)
{
SetState(State with { ObsBridgeDetail = $"OBS integration removal could not finish. {Friendly(error)}" });
await WriteLogAsync("obs_bridge_remove_failed", error.Message);
throw;
}
}
private async Task RunUpdateChecksAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(6));
try { while (await timer.WaitForNextTickAsync(cancellationToken)) await CheckForUpdatesAsync(cancellationToken); }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
}
public async Task SelectSourceAsync(ObsSource source, CancellationToken cancellationToken = default) public async Task SelectSourceAsync(ObsSource source, CancellationToken cancellationToken = default)
{ {
await _settings.SaveAsync(_settings.Current with { PrimarySourceUuid = source.Uuid, PrimarySourceName = source.Name }, cancellationToken); await _settings.SaveAsync(_settings.Current with { PrimarySourceUuid = source.Uuid, PrimarySourceName = source.Name }, cancellationToken);
await SyncBridgeSelectionAsync(cancellationToken);
foreach (var item in ObsSources) await SendSourceUpdateAsync(item); foreach (var item in ObsSources) await SendSourceUpdateAsync(item);
await WriteLogAsync("source_selected", $"Selected OBS source {source.Name}."); await WriteLogAsync("source_selected", $"Selected OBS source {source.Name}.");
} }
@ -244,7 +335,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
{ {
if (_socket is not null) { await _socket.DisposeAsync(); _socket = null; } if (_socket is not null) { await _socket.DisposeAsync(); _socket = null; }
_credentials.Remove(); _credentials.Remove();
SetState(new CompanionState(ObsBridgeInstalled: DetectBridgeInstallation(), Detail: "Device removed. Pair this computer to reconnect.")); SetState(WithBridgeState(new CompanionState(Detail: "Device removed. Pair this computer to reconnect.")));
await WriteLogAsync("device_removed", "Saved device credential removed locally."); await WriteLogAsync("device_removed", "Saved device credential removed locally.");
} }
@ -279,6 +370,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
var health = connected && State.Connected ? TrayHealth.Ready : State.Health; var health = connected && State.Connected ? TrayHealth.Ready : State.Health;
SetState(State with { ObsBridgeInstalled = connected || State.ObsBridgeInstalled, ObsConnected = connected, Health = health, Detail = connected ? "OBS and Lumi are connected. Choose a microphone and run a safe test." : State.Detail }); SetState(State with { ObsBridgeInstalled = connected || State.ObsBridgeInstalled, ObsConnected = connected, Health = health, Detail = connected ? "OBS and Lumi are connected. Choose a microphone and run a safe test." : State.Detail });
_ = WriteLogAsync("obs_connection", connected ? "OBS bridge connected." : "OBS bridge disconnected."); _ = WriteLogAsync("obs_connection", connected ? "OBS bridge connected." : "OBS bridge disconnected.");
if (connected) _ = SyncBridgeSelectionAsync();
}; };
_obsBridge.Start(); _obsBridge.Start();
} }
@ -341,6 +433,14 @@ public sealed class CompanionRuntime : IAsyncDisposable
}, _socket.SessionId, _lifetimeToken()); }, _socket.SessionId, _lifetimeToken());
} }
private Task<bool> SyncBridgeSelectionAsync(CancellationToken cancellationToken = default) => _obsBridge?.SendAsync(new
{
type = "select_sources",
protocol_version = 1,
source_uuids = string.IsNullOrWhiteSpace(_settings.Current.PrimarySourceUuid) ? Array.Empty<string>() : new[] { _settings.Current.PrimarySourceUuid },
primary_source_uuid = _settings.Current.PrimarySourceUuid
}, cancellationToken) ?? Task.FromResult(false);
private static bool ReadBoolean(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.True; private static bool ReadBoolean(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.True;
private static string? ReadString(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String ? property.GetString() : null; private static string? ReadString(JsonElement value, string name) => value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String ? property.GetString() : null;
private CancellationToken _lifetimeToken() => _disposed ? new CancellationToken(true) : CancellationToken.None; private CancellationToken _lifetimeToken() => _disposed ? new CancellationToken(true) : CancellationToken.None;
@ -354,12 +454,8 @@ public sealed class CompanionRuntime : IAsyncDisposable
return true; return true;
} }
private bool DetectBridgeInstallation() private bool DetectBridgeInstallation() => _bridgeManager.Inspect().Valid;
{ private CompanionState WithBridgeState(CompanionState state) { var bridge = _bridgeManager.Inspect(); return state with { ObsBridgeInstalled = bridge.Valid, ObsBridgeRepairNeeded = bridge.Installed && !bridge.Valid, ObsBridgePackageAvailable = bridge.PackageAvailable, ObsBridgeDetail = bridge.Detail }; }
if (File.Exists(Path.Combine(_paths.BridgeDirectory, "lumi-obs-bridge.dll"))) return true;
var obsData = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "obs-studio", "plugins", "lumi-obs-bridge", "bin", "64bit", "lumi-obs-bridge.dll");
return File.Exists(obsData);
}
private static string? FindBundledPairingPackage() private static string? FindBundledPairingPackage()
{ {
@ -425,14 +521,15 @@ public sealed class CompanionRuntime : IAsyncDisposable
new("Delivery adapter", "Waiting for safe simulation mode.", TestStageState.Waiting), new("Delivery adapter", "Waiting for safe simulation mode.", TestStageState.Waiting),
new("Simulated output", "Nothing is sent to Twitch during this test.", TestStageState.Waiting) new("Simulated output", "Nothing is sent to Twitch during this test.", TestStageState.Waiting)
]; ];
public static string Version => Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.1.0"; public static string Version => (Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "0.1.0").Split('+')[0];
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
_maintenanceLifetime.Cancel();
if (_socket is not null) await _socket.DisposeAsync(); if (_socket is not null) await _socket.DisposeAsync();
if (_obsBridge is not null) await _obsBridge.DisposeAsync(); if (_obsBridge is not null) await _obsBridge.DisposeAsync();
_http.Dispose(); _http.Dispose(); _maintenanceLifetime.Dispose();
} }
} }

View File

@ -16,7 +16,13 @@ public sealed record CompanionState(
string Detail = "Pair Lumi Companion to get started.", string Detail = "Pair Lumi Companion to get started.",
string? DeviceName = null, string? DeviceName = null,
string? Host = null, string? Host = null,
DateTimeOffset? LastConnectedAt = null) DateTimeOffset? LastConnectedAt = null,
bool UpdateAvailable = false,
string? AvailableVersion = null,
string UpdateDetail = "Checking for updates…",
bool ObsBridgeRepairNeeded = false,
bool ObsBridgePackageAvailable = false,
string ObsBridgeDetail = "Checking the managed OBS integration…")
{ {
public string Summary => Health switch public string Summary => Health switch
{ {

View File

@ -5,7 +5,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<Version>0.1.0-experimental.2</Version> <Version>0.1.0-experimental.3</Version>
<AssemblyVersion>0.1.0.0</AssemblyVersion> <AssemblyVersion>0.1.0.0</AssemblyVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -17,4 +17,9 @@
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.0" /> <PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.0" /> <PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.0" />
</ItemGroup> </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" />
</ItemGroup>
</Project> </Project>

View File

@ -59,6 +59,16 @@
</Grid> </Grid>
</Border> </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>
<StackPanel Spacing="12"> <StackPanel Spacing="12">
<TextBlock Text="Setup progress" Classes="sectionTitle" /> <TextBlock Text="Setup progress" Classes="sectionTitle" />
<Grid ColumnDefinitions="*,*,*" ColumnSpacing="12"> <Grid ColumnDefinitions="*,*,*" ColumnSpacing="12">
@ -148,6 +158,17 @@
<Button x:Name="PairButton" Classes="secondary" Content="Choose pairing package" /> <Button x:Name="PairButton" Classes="secondary" Content="Choose pairing package" />
<Button x:Name="OpenWebButton" Classes="secondary" Content="Open Lumi WebUI" /> <Button x:Name="OpenWebButton" Classes="secondary" Content="Open Lumi WebUI" />
</StackPanel> </StackPanel>
<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"> <Border Classes="soft">
<StackPanel Spacing="8"> <StackPanel Spacing="8">
<TextBlock Text="Remove this device" FontWeight="SemiBold" /> <TextBlock Text="Remove this device" FontWeight="SemiBold" />
@ -202,6 +223,12 @@
<TextBlock Text="Protocol v1 · bounded in-memory audio · secure WebSocket transport · server-hosted inference" Classes="muted" /> <TextBlock Text="Protocol v1 · bounded in-memory audio · secure WebSocket transport · server-hosted inference" Classes="muted" />
</StackPanel> </StackPanel>
</Border> </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" /> <Button x:Name="SaveSettingsButton" Classes="primary" Content="Save preferences" HorizontalAlignment="Left" />
<TextBlock x:Name="SettingsFeedback" Classes="muted" /> <TextBlock x:Name="SettingsFeedback" Classes="muted" />
</StackPanel> </StackPanel>

View File

@ -51,6 +51,10 @@ public partial class MainWindow : Window
RunTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.RunTestAsync(), RunTestButton); RunTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.RunTestAsync(), RunTestButton);
ForgetButton.Click += async (_, _) => await ForgetDeviceAsync(); ForgetButton.Click += async (_, _) => await ForgetDeviceAsync();
SaveSettingsButton.Click += async (_, _) => await SaveSettingsAsync(); SaveSettingsButton.Click += async (_, _) => await SaveSettingsAsync();
CheckUpdateButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.CheckForUpdatesAsync(), CheckUpdateButton);
ApplyUpdateButton.Click += async (_, _) => await ApplyUpdateAsync();
InstallBridgeButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.InstallOrRepairObsBridgeAsync(), InstallBridgeButton);
RemoveBridgeButton.Click += async (_, _) => await RemoveBridgeAsync();
AdvancedToggle.IsCheckedChanged += (_, _) => AdvancedPanel.IsVisible = AdvancedToggle.IsChecked == true; AdvancedToggle.IsCheckedChanged += (_, _) => AdvancedPanel.IsVisible = AdvancedToggle.IsChecked == true;
SourcePicker.SelectionChanged += async (_, _) => SourcePicker.SelectionChanged += async (_, _) =>
{ {
@ -104,7 +108,7 @@ public partial class MainWindow : Window
button.IsEnabled = false; button.IsEnabled = false;
try { await action(); } try { await action(); }
catch (Exception error) { AddLog($"{DateTime.Now:HH:mm:ss} {error.Message}"); } catch (Exception error) { AddLog($"{DateTime.Now:HH:mm:ss} {error.Message}"); }
finally { button.IsEnabled = true; } finally { RenderState(_runtime.State); }
} }
private async Task SaveSettingsAsync() private async Task SaveSettingsAsync()
@ -132,6 +136,26 @@ public partial class MainWindow : Window
await _runtime.ForgetDeviceAsync(); await _runtime.ForgetDeviceAsync();
} }
private async Task ApplyUpdateAsync()
{
if (_runtime.State.RequiresQuitConfirmation)
{
var blocked = new DecisionWindow("Update after the stream?", "Companion updates never interrupt streaming or recording. Stop OBS output, then choose Update Companion again.", "Got it");
await blocked.ShowDialog<bool>(this);
return;
}
var dialog = new DecisionWindow("Update Lumi Companion?", $"Download, verify, and install {_runtime.State.AvailableVersion}. Companion will restart and keep your pairing and settings.", "Update Companion");
if (!await dialog.ShowDialog<bool>(this)) return;
await RunUiActionAsync(() => _runtime.ApplyUpdateAsync(), ApplyUpdateButton);
}
private async Task RemoveBridgeAsync()
{
var dialog = new DecisionWindow("Remove the OBS integration?", "This removes only the Companion-managed OBS plugin. Pairing, preferences, and Lumi Companion remain installed.", "Remove integration");
if (!await dialog.ShowDialog<bool>(this)) return;
await RunUiActionAsync(() => _runtime.RemoveObsBridgeAsync(), RemoveBridgeButton);
}
private void RenderState(CompanionState state) private void RenderState(CompanionState state)
{ {
StatusLabel.Text = state.Summary; StatusLabel.Text = state.Summary;
@ -152,6 +176,16 @@ public partial class MainWindow : Window
ForgetButton.IsEnabled = state.Paired; ForgetButton.IsEnabled = state.Paired;
RunTestButton.Content = state.TestRunning ? "Testing…" : "Run full path test"; RunTestButton.Content = state.TestRunning ? "Testing…" : "Run full path test";
RunTestButton.IsEnabled = !state.TestRunning; RunTestButton.IsEnabled = !state.TestRunning;
UpdatePanel.IsVisible = state.UpdateAvailable;
UpdateTitle.Text = state.UpdateAvailable ? $"Lumi Companion {state.AvailableVersion} is available" : "Lumi Companion is current";
UpdateDetail.Text = state.UpdateDetail;
ApplyUpdateButton.IsEnabled = state.UpdateAvailable && !state.ObsStreaming && !state.ObsRecording;
CurrentVersionText.Text = CompanionRuntime.Version;
UpdateStatusText.Text = state.UpdateDetail;
BridgeStatusText.Text = state.ObsBridgeDetail;
InstallBridgeButton.Content = state.ObsBridgeRepairNeeded ? "Repair integration" : state.ObsBridgeInstalled ? "Reinstall integration" : "Install integration";
InstallBridgeButton.IsEnabled = state.ObsBridgePackageAvailable && !state.ObsConnected;
RemoveBridgeButton.IsEnabled = state.ObsBridgeInstalled || state.ObsBridgeRepairNeeded;
if (!state.Paired) if (!state.Paired)
{ {
@ -168,8 +202,8 @@ public partial class MainWindow : Window
else if (!state.ObsBridgeInstalled) else if (!state.ObsBridgeInstalled)
{ {
NextActionTitle.Text = "Install the OBS integration"; NextActionTitle.Text = "Install the OBS integration";
NextActionDetail.Text = "The native bridge package is not installed yet. The signed installer and repair service remain required."; NextActionDetail.Text = state.ObsBridgeDetail;
NextActionButton.Content = "View connection details"; NextActionButton.Content = "Manage OBS integration";
} }
else else
{ {

View File

@ -0,0 +1,152 @@
using System.Diagnostics;
using System.ComponentModel;
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 _componentRoot = Path.Combine(AppContext.BaseDirectory, "components", "obs-bridge");
private readonly string _installRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "obs-studio", "plugins", "lumi-obs-bridge");
private string SourceDll => Path.Combine(_componentRoot, "lumi-obs-bridge.dll");
private string SourceManifest => Path.Combine(_componentRoot, "manifest.json");
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 = ReadManifest(SourceManifest);
var installed = ReadManifest(InstalledManifest);
var packageAvailable = package is not null && File.Exists(SourceDll) && HashFile(SourceDll) == package.Sha256;
var fileInstalled = File.Exists(InstalledDll);
var valid = packageAvailable && fileInstalled && HashFile(InstalledDll) == package!.Sha256 && installed?.Version == package.Version;
return new ObsBridgeStatus(packageAvailable, fileInstalled, valid, package?.Version,
valid ? $"OBS integration {package!.Version} is installed. Restart OBS if it was open during the last repair." :
!packageAvailable ? "This Companion build does not contain a valid OBS integration package." :
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 manifest = ReadManifest(SourceManifest)!;
var bin = Path.GetDirectoryName(InstalledDll)!;
var locale = Path.Combine(_installRoot, "data", "locale");
Directory.CreateDirectory(bin);
Directory.CreateDirectory(locale);
await CopyAtomicAsync(SourceDll, InstalledDll, cancellationToken);
var sourceLocale = Path.Combine(_componentRoot, "en-US.ini");
if (File.Exists(sourceLocale)) await CopyAtomicAsync(sourceLocale, 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 == 2 && args[0].Equals("--manage-obs-bridge", StringComparison.OrdinalIgnoreCase);
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 { 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 start = new ProcessStartInfo(Environment.ProcessPath) { UseShellExecute = true, Verb = "runas", WindowStyle = ProcessWindowStyle.Hidden };
start.ArgumentList.Add("--manage-obs-bridge");
start.ArgumentList.Add(action);
try
{
using var process = Process.Start(start) ?? throw new InvalidOperationException("The OBS integration maintenance process could not start.");
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode != 0) throw new InvalidOperationException("OBS integration maintenance did not complete.");
}
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.");
}
}
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 static string HashFile(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); }
private static async Task CopyAtomicAsync(string source, string target, CancellationToken cancellationToken)
{
var temporary = $"{target}.{Environment.ProcessId}.tmp";
try
{
await using (var input = File.OpenRead(source))
await using (var output = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true))
await input.CopyToAsync(output, 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, string? Version, string Detail);

View File

@ -16,6 +16,9 @@ internal static class Program
return 2; return 2;
} }
if (UpdateApplier.IsApplyRequest(args)) return UpdateApplier.Apply(args);
if (ObsBridgeManager.IsMaintenanceRequest(args)) return ObsBridgeManager.RunMaintenance(args);
LaunchInBackground = args.Contains("--background", StringComparer.OrdinalIgnoreCase); LaunchInBackground = args.Contains("--background", StringComparer.OrdinalIgnoreCase);
InstanceCoordinator = new SingleInstanceCoordinator(); InstanceCoordinator = new SingleInstanceCoordinator();
if (!InstanceCoordinator.IsPrimary) if (!InstanceCoordinator.IsPrimary)

View File

@ -0,0 +1,57 @@
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 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);
}
}

View File

@ -0,0 +1,107 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
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?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 update = await JsonSerializer.DeserializeAsync<CompanionUpdate>(await response.Content.ReadAsStreamAsync(cancellationToken), ProtocolV1.JsonOptions, cancellationToken);
return update is { Ok: true, UpdateAvailable: true } ? update : null;
}
public async Task<StagedUpdate> StageAsync(CompanionUpdate update, CancellationToken cancellationToken = default)
{
if (update.Artifact.Bytes is <= 0 or > 300 * 1024 * 1024 || !IsSha256(update.Artifact.Sha256)) throw new InvalidDataException("The update manifest is invalid.");
if (!Uri.TryCreate(update.Artifact.Url, UriKind.Absolute, out var artifactUri) || artifactUri.Scheme != Uri.UriSchemeHttps) throw new InvalidDataException("The update manifest does not use a secure 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 archivePath = Path.Combine(paths.UpdatesDirectory, $"companion-{safeVersion}.zip.partial");
var stageRoot = Path.Combine(paths.UpdatesDirectory, safeVersion);
try
{
using var response = await http.GetAsync(update.Artifact.Url, 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 > update.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 != update.Artifact.Bytes || !HashFile(archivePath).Equals(update.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, update.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();
}
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 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);
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);

View File

@ -1,6 +1,8 @@
{ {
"schema_version": 1, "schema_version": 1,
"version": "0.1.0-experimental.2", "version": "0.1.0-experimental.3",
"signed": false,
"release_notes": "Adds user-approved in-place updates and Companion-managed OBS integration maintenance.",
"artifacts": [ "artifacts": [
{ {
"id": "windows-x64-self-contained", "id": "windows-x64-self-contained",
@ -8,9 +10,9 @@
"architecture": "x64", "architecture": "x64",
"label": "Windows x64 self-contained", "label": "Windows x64 self-contained",
"filename": "Lumi.Companion-win-x64.zip", "filename": "Lumi.Companion-win-x64.zip",
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.2/Lumi.Companion-win-x64.zip", "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.3/Lumi.Companion-win-x64.zip",
"sha256": "2884522cb368fa990ff6475c99324a41a9a7ce4871029078808f2f9a2902c6e5", "sha256": "913bc719653b7f5b5bbd1b96f136e503654aa6913e8225c12a46ce19d9d91811",
"bytes": 41568887, "bytes": 43034535,
"entrypoint": "Lumi.Companion.App.exe" "entrypoint": "Lumi.Companion.App.exe"
} }
] ]

View File

@ -21,6 +21,7 @@ const PLUGIN_ID = "lumi_transcription";
module.exports = { module.exports = {
id: PLUGIN_ID, id: PLUGIN_ID,
compareVersions,
init({ web, db, logger }) { init({ web, db, logger }) {
ensureDataDirs(); ensureDataDirs();
const devices = new DeviceStore(db); const devices = new DeviceStore(db);
@ -102,6 +103,21 @@ module.exports = {
res.send(bundle.buffer); res.send(bundle.buffer);
} catch (error) { res.status(503).json({ ok: false, error: error.message }); } } catch (error) { res.status(503).json({ ok: false, error: error.message }); }
}); });
router.get("/api/companion/update", requireDeviceAccess(devices), (req, res) => {
const updateManifest = companionPackages.currentManifest();
const entry = companionPackages.entry(updateManifest);
if (!entry) return res.status(503).json({ ok: false, error: "No Windows Companion update is configured." });
res.set("Cache-Control", "no-store");
res.json({
ok: true,
version: updateManifest.version,
current_version: String(req.query.current_version || "").slice(0, 40),
update_available: compareVersions(updateManifest.version, req.query.current_version) > 0,
artifact: { url: entry.url, sha256: entry.sha256, bytes: entry.bytes, entrypoint: entry.entrypoint },
signed: updateManifest.signed === true,
release_notes: String(updateManifest.release_notes || "Companion reliability and integration improvements.").slice(0, 500)
});
});
router.post("/api/pair", requirePairingTransport(devices), (req, res) => { router.post("/api/pair", requirePairingTransport(devices), (req, res) => {
try { res.set("Cache-Control", "no-store"); res.status(201).json({ ok: true, ...devices.exchange(req.body || {}) }); } try { res.set("Cache-Control", "no-store"); res.status(201).json({ ok: true, ...devices.exchange(req.body || {}) }); }
catch (error) { res.status(error.code === "PAIRING_ALREADY_USED" ? 409 : 400).json({ ok: false, code: error.code, error: error.message }); } catch (error) { res.status(error.code === "PAIRING_ALREADY_USED" ? 409 : 400).json({ ok: false, code: error.code, error: error.message }); }
@ -174,6 +190,30 @@ module.exports = {
}; };
function requireAdmin(req, res, next) { if (req.session?.user?.isAdmin) return next(); return res.status(403).json({ error: "Administrator access is required." }); } function requireAdmin(req, res, next) { if (req.session?.user?.isAdmin) return next(); return res.status(403).json({ error: "Administrator access is required." }); }
function requireDeviceAccess(devices) { return (req, res, next) => { const auth = devices.authenticate(req.headers.authorization); if (!auth.allowed) return res.status(401).json({ error: "A paired device credential is required." }); if (!req.secure && !insecureDeviceAllowed(auth.device, requestHost(req), req.socket.remoteAddress)) return res.status(426).json({ error: "Companion update checks require HTTPS unless this device was paired from the same localhost origin." }); req.lumiDevice = auth.device; next(); }; }
function compareVersions(left, right) {
const parse = (value) => {
const [version] = String(value || "0").split("+", 1);
const separator = version.indexOf("-");
const core = (separator < 0 ? version : version.slice(0, separator)).split(".").map((part) => Number(part) || 0);
const prerelease = separator < 0 ? null : version.slice(separator + 1).split(/[.\-]/).filter(Boolean);
return { core, prerelease };
};
const a = parse(left); const b = parse(right);
for (let index = 0; index < Math.max(a.core.length, b.core.length); index += 1) {
if ((a.core[index] || 0) !== (b.core[index] || 0)) return (a.core[index] || 0) > (b.core[index] || 0) ? 1 : -1;
}
if (a.prerelease === null || b.prerelease === null) return a.prerelease === b.prerelease ? 0 : a.prerelease === null ? 1 : -1;
for (let index = 0; index < Math.max(a.prerelease.length, b.prerelease.length); index += 1) {
if (a.prerelease[index] === undefined || b.prerelease[index] === undefined) return a.prerelease[index] === b.prerelease[index] ? 0 : a.prerelease[index] === undefined ? -1 : 1;
const numericA = /^\d+$/.test(a.prerelease[index]); const numericB = /^\d+$/.test(b.prerelease[index]);
if (numericA && numericB && Number(a.prerelease[index]) !== Number(b.prerelease[index])) return Number(a.prerelease[index]) > Number(b.prerelease[index]) ? 1 : -1;
if (numericA !== numericB) return numericA ? -1 : 1;
const compared = a.prerelease[index].localeCompare(b.prerelease[index]);
if (compared) return compared > 0 ? 1 : -1;
}
return 0;
}
async function buildDashboardSummary({ provider, devices, sessions, companionPackages }) { async function buildDashboardSummary({ provider, devices, sessions, companionPackages }) {
const inference = await provider.health(); const inference = await provider.health();
const activeDevices = devices.list().filter((device) => !device.revoked_at); const activeDevices = devices.list().filter((device) => !device.revoked_at);

View File

@ -31,6 +31,7 @@ async function run() {
verifyProtocol(); verifyProtocol();
verifyPairingAndRevocation(); verifyPairingAndRevocation();
verifyLocalhostTransportPolicy(); verifyLocalhostTransportPolicy();
verifyCompanionVersionOrdering();
verifyWorkerResolution(temp); verifyWorkerResolution(temp);
verifyRevisions(); verifyRevisions();
verifyQueues(); verifyQueues();
@ -96,6 +97,13 @@ function verifyLocalhostTransportPolicy() {
db.close(); db.close();
} }
function verifyCompanionVersionOrdering() {
assert.equal(plugin.compareVersions("0.1.0-experimental.3", "0.1.0-experimental.2"), 1);
assert.equal(plugin.compareVersions("0.1.0-experimental.2", "0.1.0-experimental.2"), 0);
assert.equal(plugin.compareVersions("0.1.0", "0.1.0-experimental.9"), 1);
assert.equal(plugin.compareVersions("0.1.1", "0.1.0"), 1);
}
function verifyWorkerResolution(temp) { function verifyWorkerResolution(temp) {
const executable = path.join(temp, process.platform === "win32" ? "worker.exe" : "worker"); const executable = path.join(temp, process.platform === "win32" ? "worker.exe" : "worker");
fs.writeFileSync(executable, "worker"); fs.writeFileSync(executable, "worker");