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/**/obj/
companion/installer/output/
companion/src/Lumi.Companion.App/components/obs-bridge/
companion/**/logs/
*.lumi-pairing.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
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.
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:
- Package the native whisper.cpp worker as the Windows CUDA runtime, then benchmark
`small.en`, quantized `small.en`, and `base.en` on the RTX 3060.
- Complete the signed companion installer, DPAPI migration UX, and managed OBS
bridge install/repair/uninstall service.
- Complete native selected-source capture, nested Program-scene evaluation,
resampling/IPC, bridge health, and source rename/missing recovery.
- Complete the signed companion installer and DPAPI migration UX; 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

View File

@ -1,6 +1,6 @@
# 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.
@ -14,6 +14,14 @@ Build all managed projects from the repository root:
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)
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_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)
target_include_directories(lumi-obs-bridge PRIVATE include)
target_link_libraries(lumi-obs-bridge PRIVATE OBS::libobs OBS::obs-frontend-api)
set_target_properties(lumi-obs-bridge PROPERTIES PREFIX "")
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")

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-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 <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::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)
{
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)
{
const char *version = obs_get_version_string();
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_enum_sources(enumerate_source, nullptr);
blog(LOG_INFO, "[Lumi Companion] bridge loaded; waiting for the companion-managed IPC transport");
worker_thread = std::thread(run_pipe_worker);
blog(LOG_INFO, "[Lumi Companion] bridge %s loaded", bridge_version);
return true;
}
void obs_module_unload(void)
{
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");
}

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

View File

@ -1,11 +1,11 @@
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()
{
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"));
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 SecureCredentialStore _credentials;
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 ObsBridgePipe? _obsBridge;
private TaskCompletionSource<bool>? _audioSignal;
private TaskCompletionSource<bool>? _captionSignal;
private bool _disposed;
private CompanionUpdate? _availableUpdate;
public CompanionRuntime(CompanionPaths paths, CompanionSettingsStore settings)
{
_paths = paths;
_settings = settings;
_credentials = new SecureCredentialStore(paths.Root);
_updates = new UpdateService(_http, paths);
State = new CompanionState();
TestStages = CreateInitialTestStages();
ObsSources = [];
@ -41,6 +46,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
public event Action<IReadOnlyList<ObsSource>>? ObsSourcesChanged;
public event Action<string, bool>? CaptionReceived;
public event Action<string>? LogAdded;
public event Func<Task>? UpdateRestartRequested;
public async Task InitializeAsync()
{
@ -56,6 +62,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
{
await _settings.LoadAsync();
StartObsBridgeBoundary();
_ = RunUpdateChecksAsync(_maintenanceLifetime.Token);
ApplyAutoStart(_settings.Current.AutoStartWithWindows);
DeviceCredential? credential;
try { credential = _credentials.Load(); }
@ -74,10 +81,10 @@ public sealed class CompanionRuntime : IAsyncDisposable
try { File.Delete(bundledPairing); } catch { }
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;
}
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);
}
@ -142,6 +149,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
if (State.ObsConnected)
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 CheckForUpdatesAsync(cancellationToken);
}
catch (Exception error)
{
@ -221,9 +229,92 @@ public sealed class CompanionRuntime : IAsyncDisposable
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)
{
await _settings.SaveAsync(_settings.Current with { PrimarySourceUuid = source.Uuid, PrimarySourceName = source.Name }, cancellationToken);
await SyncBridgeSelectionAsync(cancellationToken);
foreach (var item in ObsSources) await SendSourceUpdateAsync(item);
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; }
_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.");
}
@ -279,6 +370,7 @@ public sealed class CompanionRuntime : IAsyncDisposable
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 });
_ = WriteLogAsync("obs_connection", connected ? "OBS bridge connected." : "OBS bridge disconnected.");
if (connected) _ = SyncBridgeSelectionAsync();
};
_obsBridge.Start();
}
@ -341,6 +433,14 @@ public sealed class CompanionRuntime : IAsyncDisposable
}, _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 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;
@ -354,12 +454,8 @@ public sealed class CompanionRuntime : IAsyncDisposable
return true;
}
private bool DetectBridgeInstallation()
{
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 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 }; }
private static string? FindBundledPairingPackage()
{
@ -425,14 +521,15 @@ public sealed class CompanionRuntime : IAsyncDisposable
new("Delivery adapter", "Waiting for safe simulation mode.", 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()
{
if (_disposed) return;
_disposed = true;
_maintenanceLifetime.Cancel();
if (_socket is not null) await _socket.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? DeviceName = 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
{

View File

@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Version>0.1.0-experimental.2</Version>
<Version>0.1.0-experimental.3</Version>
<AssemblyVersion>0.1.0.0</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
@ -17,4 +17,9 @@
<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" />
</ItemGroup>
</Project>

View File

@ -59,6 +59,16 @@
</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>
<StackPanel Spacing="12">
<TextBlock Text="Setup progress" Classes="sectionTitle" />
<Grid ColumnDefinitions="*,*,*" ColumnSpacing="12">
@ -148,6 +158,17 @@
<Button x:Name="PairButton" Classes="secondary" Content="Choose pairing package" />
<Button x:Name="OpenWebButton" Classes="secondary" Content="Open Lumi WebUI" />
</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">
<StackPanel Spacing="8">
<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" />
</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>

View File

@ -51,6 +51,10 @@ public partial class MainWindow : Window
RunTestButton.Click += async (_, _) => await RunUiActionAsync(() => _runtime.RunTestAsync(), RunTestButton);
ForgetButton.Click += async (_, _) => await ForgetDeviceAsync();
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;
SourcePicker.SelectionChanged += async (_, _) =>
{
@ -104,7 +108,7 @@ public partial class MainWindow : Window
button.IsEnabled = false;
try { await action(); }
catch (Exception error) { AddLog($"{DateTime.Now:HH:mm:ss} {error.Message}"); }
finally { button.IsEnabled = true; }
finally { RenderState(_runtime.State); }
}
private async Task SaveSettingsAsync()
@ -132,6 +136,26 @@ public partial class MainWindow : Window
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)
{
StatusLabel.Text = state.Summary;
@ -152,6 +176,16 @@ public partial class MainWindow : Window
ForgetButton.IsEnabled = state.Paired;
RunTestButton.Content = state.TestRunning ? "Testing…" : "Run full path test";
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)
{
@ -168,8 +202,8 @@ public partial class MainWindow : Window
else if (!state.ObsBridgeInstalled)
{
NextActionTitle.Text = "Install the OBS integration";
NextActionDetail.Text = "The native bridge package is not installed yet. The signed installer and repair service remain required.";
NextActionButton.Content = "View connection details";
NextActionDetail.Text = state.ObsBridgeDetail;
NextActionButton.Content = "Manage OBS integration";
}
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;
}
if (UpdateApplier.IsApplyRequest(args)) return UpdateApplier.Apply(args);
if (ObsBridgeManager.IsMaintenanceRequest(args)) return ObsBridgeManager.RunMaintenance(args);
LaunchInBackground = args.Contains("--background", StringComparer.OrdinalIgnoreCase);
InstanceCoordinator = new SingleInstanceCoordinator();
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,
"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": [
{
"id": "windows-x64-self-contained",
@ -8,9 +10,9 @@
"architecture": "x64",
"label": "Windows x64 self-contained",
"filename": "Lumi.Companion-win-x64.zip",
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.2/Lumi.Companion-win-x64.zip",
"sha256": "2884522cb368fa990ff6475c99324a41a9a7ce4871029078808f2f9a2902c6e5",
"bytes": 41568887,
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.1.0-experimental.3/Lumi.Companion-win-x64.zip",
"sha256": "913bc719653b7f5b5bbd1b96f136e503654aa6913e8225c12a46ce19d9d91811",
"bytes": 43034535,
"entrypoint": "Lumi.Companion.App.exe"
}
]

View File

@ -21,6 +21,7 @@ const PLUGIN_ID = "lumi_transcription";
module.exports = {
id: PLUGIN_ID,
compareVersions,
init({ web, db, logger }) {
ensureDataDirs();
const devices = new DeviceStore(db);
@ -102,6 +103,21 @@ module.exports = {
res.send(bundle.buffer);
} 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) => {
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 }); }
@ -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 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 }) {
const inference = await provider.health();
const activeDevices = devices.list().filter((device) => !device.revoked_at);

View File

@ -31,6 +31,7 @@ async function run() {
verifyProtocol();
verifyPairingAndRevocation();
verifyLocalhostTransportPolicy();
verifyCompanionVersionOrdering();
verifyWorkerResolution(temp);
verifyRevisions();
verifyQueues();
@ -96,6 +97,13 @@ function verifyLocalhostTransportPolicy() {
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) {
const executable = path.join(temp, process.platform === "win32" ? "worker.exe" : "worker");
fs.writeFileSync(executable, "worker");