From 02bdec7584301f0dce2bb86a55e86b3a07267f75 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Wed, 22 Jul 2026 15:47:56 +0200 Subject: [PATCH] Add Companion updates and managed OBS bridge --- .gitignore | 1 + TODO.md | 14 +- companion/README.md | 14 +- companion/native/obs-bridge/CMakeLists.txt | 34 +- .../native/obs-bridge/data/locale/en-US.ini | 2 + companion/native/obs-bridge/src/plugin.cpp | 475 ++++++++++++++++-- companion/scripts/build-obs-bridge.ps1 | 98 ++++ companion/scripts/publish-companion.ps1 | 32 ++ companion/src/Lumi.Companion.App/App.axaml.cs | 15 +- .../src/Lumi.Companion.App/CompanionPaths.cs | 4 +- .../Lumi.Companion.App/CompanionRuntime.cs | 119 ++++- .../src/Lumi.Companion.App/CompanionState.cs | 8 +- .../Lumi.Companion.App.csproj | 7 +- .../src/Lumi.Companion.App/MainWindow.axaml | 27 + .../Lumi.Companion.App/MainWindow.axaml.cs | 40 +- .../Lumi.Companion.App/ObsBridgeManager.cs | 152 ++++++ companion/src/Lumi.Companion.App/Program.cs | 3 + .../src/Lumi.Companion.App/UpdateApplier.cs | 57 +++ .../src/Lumi.Companion.App/UpdateService.cs | 107 ++++ .../companion_manifest.json | 10 +- plugins/lumi_transcription/index.js | 40 ++ plugins/lumi_transcription/tests/verify.js | 8 + 22 files changed, 1198 insertions(+), 69 deletions(-) create mode 100644 companion/native/obs-bridge/data/locale/en-US.ini create mode 100644 companion/scripts/build-obs-bridge.ps1 create mode 100644 companion/scripts/publish-companion.ps1 create mode 100644 companion/src/Lumi.Companion.App/ObsBridgeManager.cs create mode 100644 companion/src/Lumi.Companion.App/UpdateApplier.cs create mode 100644 companion/src/Lumi.Companion.App/UpdateService.cs diff --git a/.gitignore b/.gitignore index cfb2a77..2ab289f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/TODO.md b/TODO.md index cfcec07..2b9fc63 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/companion/README.md b/companion/README.md index 6e54963..ebbb40c 100644 --- a/companion/README.md +++ b/companion/README.md @@ -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. diff --git a/companion/native/obs-bridge/CMakeLists.txt b/companion/native/obs-bridge/CMakeLists.txt index 9753dcd..0894baf 100644 --- a/companion/native/obs-bridge/CMakeLists.txt +++ b/companion/native/obs-bridge/CMakeLists.txt @@ -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") diff --git a/companion/native/obs-bridge/data/locale/en-US.ini b/companion/native/obs-bridge/data/locale/en-US.ini new file mode 100644 index 0000000..33a298b --- /dev/null +++ b/companion/native/obs-bridge/data/locale/en-US.ini @@ -0,0 +1,2 @@ +Module.Name="Lumi Companion OBS Integration" +Module.Description="Connects selected OBS audio sources and native captions to Lumi Companion." diff --git a/companion/native/obs-bridge/src/plugin.cpp b/companion/native/obs-bridge/src/plugin.cpp index 852c25c..5f9fb87 100644 --- a/companion/native/obs-bridge/src/plugin.cpp +++ b/companion/native/obs-bridge/src/plugin.cpp @@ -1,58 +1,467 @@ #include #include -#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include + +#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 mono{}; + std::array 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 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(value.size()), nullptr, 0, nullptr, nullptr); + std::string output(static_cast(bytes), '\0'); + WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast(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 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(&object_size), sizeof(object_size), &result_size, 0); + std::vector object(object_size); + const bool ok = BCryptCreateHash(algorithm, &hash, object.data(), object_size, nullptr, 0, 0) >= 0 && + BCryptHashData(hash, reinterpret_cast(const_cast(identity.data())), static_cast(identity.size()), 0) >= 0 && + BCryptFinishHash(hash, digest.data(), static_cast(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 &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((high << 4) | low); + } + return true; +} + +static void write_u16(std::vector &value, size_t offset, uint16_t input) +{ + value[offset] = static_cast(input); + value[offset + 1] = static_cast(input >> 8); +} + +static void write_u32(std::vector &value, size_t offset, uint32_t input) +{ + for (size_t index = 0; index < 4; ++index) value[offset + index] = static_cast(input >> (index * 8)); +} + +static void write_u64(std::vector &value, size_t offset, uint64_t input) +{ + for (size_t index = 0; index < 8; ++index) value[offset + index] = static_cast(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(std::min(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(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 prefix{ + static_cast(size), static_cast(size >> 8), + static_cast(size >> 16), static_cast(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(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 &active, std::set &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::set *> *>(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 program_sources() +{ + std::set 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 *, const std::set *> *>(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 enumerate_sources() +{ + const auto active = program_sources(); + std::vector 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(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(audio->data[channel])[frame]; + } + packet.mono[frame] = sample / static_cast(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 encode_audio(const raw_audio_packet &packet) +{ + const auto output_frames = std::min(3200, static_cast((static_cast(packet.frames) * output_sample_rate) / packet.sample_rate)); + std::vector frame(64 + static_cast(output_frames) * 2, 0); + std::memcpy(frame.data(), "LACP", 4); + frame[4] = protocol_version; + frame[5] = static_cast((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(packet.frames - 1, static_cast((static_cast(index) * packet.sample_rate) / output_sample_rate)); + const auto sample = static_cast(std::lrint(std::clamp(packet.mono[source_position], -1.0f, 1.0f) * 32767.0f)); + write_u16(frame, 64 + static_cast(index) * 2, static_cast(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 prefix{}; + if (!read_exact(pipe, prefix.data(), prefix.size())) return false; + const uint32_t size = static_cast(prefix[0]) | (static_cast(prefix[1]) << 8) | + (static_cast(prefix[2]) << 16) | (static_cast(prefix[3]) << 24); + if (size == 0 || size > max_json_bytes) return false; + std::vector 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"); } diff --git a/companion/scripts/build-obs-bridge.ps1 b/companion/scripts/build-obs-bridge.ps1 new file mode 100644 index 0000000..a3c9e22 --- /dev/null +++ b/companion/scripts/build-obs-bridge.ps1 @@ -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" diff --git a/companion/scripts/publish-companion.ps1 b/companion/scripts/publish-companion.ps1 new file mode 100644 index 0000000..a2cdca9 --- /dev/null +++ b/companion/scripts/publish-companion.ps1 @@ -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" diff --git a/companion/src/Lumi.Companion.App/App.axaml.cs b/companion/src/Lumi.Companion.App/App.axaml.cs index 0b10384..42b79d7 100644 --- a/companion/src/Lumi.Companion.App/App.axaml.cs +++ b/companion/src/Lumi.Companion.App/App.axaml.cs @@ -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; }); diff --git a/companion/src/Lumi.Companion.App/CompanionPaths.cs b/companion/src/Lumi.Companion.App/CompanionPaths.cs index e22c623..aee1c4b 100644 --- a/companion/src/Lumi.Companion.App/CompanionPaths.cs +++ b/companion/src/Lumi.Companion.App/CompanionPaths.cs @@ -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")); } } diff --git a/companion/src/Lumi.Companion.App/CompanionRuntime.cs b/companion/src/Lumi.Companion.App/CompanionRuntime.cs index ac30dae..809bbad 100644 --- a/companion/src/Lumi.Companion.App/CompanionRuntime.cs +++ b/companion/src/Lumi.Companion.App/CompanionRuntime.cs @@ -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? _audioSignal; private TaskCompletionSource? _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>? ObsSourcesChanged; public event Action? CaptionReceived; public event Action? LogAdded; + public event Func? 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 SyncBridgeSelectionAsync(CancellationToken cancellationToken = default) => _obsBridge?.SendAsync(new + { + type = "select_sources", + protocol_version = 1, + source_uuids = string.IsNullOrWhiteSpace(_settings.Current.PrimarySourceUuid) ? Array.Empty() : 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()?.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(); } } diff --git a/companion/src/Lumi.Companion.App/CompanionState.cs b/companion/src/Lumi.Companion.App/CompanionState.cs index e993039..5be8e52 100644 --- a/companion/src/Lumi.Companion.App/CompanionState.cs +++ b/companion/src/Lumi.Companion.App/CompanionState.cs @@ -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 { diff --git a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj index a3bc841..4cd106f 100644 --- a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj +++ b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj @@ -5,7 +5,7 @@ enable enable app.manifest - 0.1.0-experimental.2 + 0.1.0-experimental.3 0.1.0.0 @@ -17,4 +17,9 @@ + + + + + diff --git a/companion/src/Lumi.Companion.App/MainWindow.axaml b/companion/src/Lumi.Companion.App/MainWindow.axaml index 87f34d7..2100afc 100644 --- a/companion/src/Lumi.Companion.App/MainWindow.axaml +++ b/companion/src/Lumi.Companion.App/MainWindow.axaml @@ -59,6 +59,16 @@ + + + + + + +