Add Companion updates and managed OBS bridge
This commit is contained in:
parent
37dda538b3
commit
02bdec7584
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
14
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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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")
|
||||
|
||||
2
companion/native/obs-bridge/data/locale/en-US.ini
Normal file
2
companion/native/obs-bridge/data/locale/en-US.ini
Normal file
@ -0,0 +1,2 @@
|
||||
Module.Name="Lumi Companion OBS Integration"
|
||||
Module.Description="Connects selected OBS audio sources and native captions to Lumi Companion."
|
||||
@ -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");
|
||||
}
|
||||
|
||||
98
companion/scripts/build-obs-bridge.ps1
Normal file
98
companion/scripts/build-obs-bridge.ps1
Normal 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"
|
||||
32
companion/scripts/publish-companion.ps1
Normal file
32
companion/scripts/publish-companion.ps1
Normal 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"
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
152
companion/src/Lumi.Companion.App/ObsBridgeManager.cs
Normal file
152
companion/src/Lumi.Companion.App/ObsBridgeManager.cs
Normal 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);
|
||||
@ -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)
|
||||
|
||||
57
companion/src/Lumi.Companion.App/UpdateApplier.cs
Normal file
57
companion/src/Lumi.Companion.App/UpdateApplier.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
107
companion/src/Lumi.Companion.App/UpdateService.cs
Normal file
107
companion/src/Lumi.Companion.App/UpdateService.cs
Normal 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);
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user