Add native self-hosted instance connection to fluxer_desktop
Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_inject_helper"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "fluxer_inject_helper"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[[bin]]
|
||||
name = "fluxer-inject-helper"
|
||||
path = "src/main.rs"
|
||||
|
||||
[profile.release]
|
||||
# Keep the helper tiny: it is a one-shot injector exe shipped alongside the
|
||||
# hook DLLs, so optimise hard for size and strip symbols.
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = {version = "0.61.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Threading",
|
||||
]}
|
||||
@@ -0,0 +1,242 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![deny(clippy::all)]
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn main() {
|
||||
eprintln!("fluxer-inject-helper is only supported on Windows");
|
||||
std::process::exit(Stage::Unsupported as i32);
|
||||
}
|
||||
|
||||
#[repr(i32)]
|
||||
#[derive(Clone, Copy)]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
enum Stage {
|
||||
Success = 0,
|
||||
BadArgs = 2,
|
||||
HookMissing = 3,
|
||||
OpenProcess = 4,
|
||||
Alloc = 5,
|
||||
Write = 6,
|
||||
Kernel32 = 7,
|
||||
LoadLibraryAddr = 8,
|
||||
CreateThread = 9,
|
||||
WaitTimeout = 10,
|
||||
LoadLibraryFailed = 11,
|
||||
#[cfg_attr(target_os = "windows", allow(dead_code))]
|
||||
Unsupported = 64,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn main() {
|
||||
let code = win::run();
|
||||
std::process::exit(code as i32);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod win {
|
||||
use super::Stage;
|
||||
use core::ffi::c_void;
|
||||
use std::ptr::{null, null_mut};
|
||||
use windows_sys::Win32::Foundation::{
|
||||
CloseHandle, GetLastError, HANDLE, INVALID_HANDLE_VALUE, WAIT_ABANDONED, WAIT_OBJECT_0,
|
||||
};
|
||||
use windows_sys::Win32::System::Diagnostics::Debug::{OutputDebugStringW, WriteProcessMemory};
|
||||
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress};
|
||||
use windows_sys::Win32::System::Memory::{
|
||||
MEM_COMMIT, MEM_RELEASE, MEM_RESERVE, PAGE_READWRITE, VirtualAllocEx, VirtualFreeEx,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
CreateRemoteThread, GetExitCodeThread, INFINITE, OpenProcess, PROCESS_CREATE_THREAD,
|
||||
PROCESS_QUERY_INFORMATION, PROCESS_VM_OPERATION, PROCESS_VM_READ, PROCESS_VM_WRITE,
|
||||
WaitForSingleObject,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: u32 = 10_000;
|
||||
|
||||
struct OwnedHandle(HANDLE);
|
||||
|
||||
impl OwnedHandle {
|
||||
fn raw(&self) -> HANDLE {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OwnedHandle {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE {
|
||||
unsafe {
|
||||
CloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_wide(value: &str) -> Vec<u16> {
|
||||
value.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
fn log(message: &str) {
|
||||
let text = format!("[fluxer-inject-helper] {message}");
|
||||
let wide = to_wide(&text);
|
||||
unsafe {
|
||||
OutputDebugStringW(wide.as_ptr());
|
||||
}
|
||||
eprintln!("{text}");
|
||||
}
|
||||
|
||||
fn fail(stage: Stage, context: &str) -> Stage {
|
||||
let err = unsafe { GetLastError() };
|
||||
log(&format!(
|
||||
"FAILED stage={} ({context}); GetLastError={err}",
|
||||
stage as i32
|
||||
));
|
||||
stage
|
||||
}
|
||||
|
||||
pub(super) fn run() -> Stage {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.len() < 2 || args.len() > 3 {
|
||||
log(&format!(
|
||||
"bad args: expected <pid> <hook-dll-path> [timeout-ms], got {} arg(s)",
|
||||
args.len()
|
||||
));
|
||||
return Stage::BadArgs;
|
||||
}
|
||||
let Ok(target_pid) = args[0].parse::<u32>() else {
|
||||
log(&format!("bad args: unparseable pid {:?}", args[0]));
|
||||
return Stage::BadArgs;
|
||||
};
|
||||
if target_pid == 0 {
|
||||
log("bad args: pid must be non-zero");
|
||||
return Stage::BadArgs;
|
||||
}
|
||||
let hook_path = args[1].as_str();
|
||||
let timeout_ms = match args.get(2) {
|
||||
None => DEFAULT_TIMEOUT_MS,
|
||||
Some(raw) => match raw.parse::<u32>() {
|
||||
Ok(0) => INFINITE,
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
log(&format!("bad args: unparseable timeout {raw:?}"));
|
||||
return Stage::BadArgs;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if !std::path::Path::new(hook_path).exists() {
|
||||
log(&format!("hook DLL missing: {hook_path}"));
|
||||
return Stage::HookMissing;
|
||||
}
|
||||
|
||||
log(&format!(
|
||||
"injecting (pid={target_pid}, hook={hook_path}, timeout_ms={timeout_ms}, \
|
||||
helper_bits={})",
|
||||
usize::BITS
|
||||
));
|
||||
|
||||
inject(target_pid, hook_path, timeout_ms)
|
||||
}
|
||||
|
||||
fn inject(target_pid: u32, hook_path: &str, timeout_ms: u32) -> Stage {
|
||||
let wide_path = to_wide(hook_path);
|
||||
let path_bytes = wide_path.len() * std::mem::size_of::<u16>();
|
||||
|
||||
unsafe {
|
||||
let process = OpenProcess(
|
||||
PROCESS_CREATE_THREAD
|
||||
| PROCESS_VM_OPERATION
|
||||
| PROCESS_VM_WRITE
|
||||
| PROCESS_VM_READ
|
||||
| PROCESS_QUERY_INFORMATION,
|
||||
0,
|
||||
target_pid,
|
||||
);
|
||||
if process.is_null() {
|
||||
return fail(Stage::OpenProcess, "OpenProcess returned null");
|
||||
}
|
||||
let process = OwnedHandle(process);
|
||||
|
||||
let remote_path = VirtualAllocEx(
|
||||
process.raw(),
|
||||
null(),
|
||||
path_bytes,
|
||||
MEM_COMMIT | MEM_RESERVE,
|
||||
PAGE_READWRITE,
|
||||
);
|
||||
if remote_path.is_null() {
|
||||
return fail(Stage::Alloc, "VirtualAllocEx returned null");
|
||||
}
|
||||
|
||||
let mut written: usize = 0;
|
||||
let write_ok = WriteProcessMemory(
|
||||
process.raw(),
|
||||
remote_path,
|
||||
wide_path.as_ptr().cast(),
|
||||
path_bytes,
|
||||
&mut written,
|
||||
) != 0;
|
||||
if !write_ok || written != path_bytes {
|
||||
let stage = fail(Stage::Write, "WriteProcessMemory failed/short");
|
||||
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
|
||||
return stage;
|
||||
}
|
||||
|
||||
let kernel32_name = to_wide("kernel32.dll");
|
||||
let kernel32 = GetModuleHandleW(kernel32_name.as_ptr());
|
||||
if kernel32.is_null() {
|
||||
let stage = fail(Stage::Kernel32, "GetModuleHandleW(kernel32.dll)");
|
||||
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
|
||||
return stage;
|
||||
}
|
||||
let load_library = GetProcAddress(kernel32, c"LoadLibraryW".as_ptr().cast());
|
||||
let Some(load_library) = load_library else {
|
||||
let stage = fail(Stage::LoadLibraryAddr, "GetProcAddress(LoadLibraryW)");
|
||||
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
|
||||
return stage;
|
||||
};
|
||||
let start_routine: unsafe extern "system" fn(*mut c_void) -> u32 =
|
||||
std::mem::transmute(load_library);
|
||||
|
||||
let thread = CreateRemoteThread(
|
||||
process.raw(),
|
||||
null(),
|
||||
0,
|
||||
Some(start_routine),
|
||||
remote_path,
|
||||
0,
|
||||
null_mut(),
|
||||
);
|
||||
if thread.is_null() {
|
||||
let stage = fail(Stage::CreateThread, "CreateRemoteThread returned null");
|
||||
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
|
||||
return stage;
|
||||
}
|
||||
let thread = OwnedHandle(thread);
|
||||
|
||||
let wait = WaitForSingleObject(thread.raw(), timeout_ms);
|
||||
if wait != WAIT_OBJECT_0 && wait != WAIT_ABANDONED {
|
||||
let stage = fail(Stage::WaitTimeout, "WaitForSingleObject did not signal");
|
||||
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
|
||||
return stage;
|
||||
}
|
||||
|
||||
let mut exit_code: u32 = 0;
|
||||
let got_exit = GetExitCodeThread(thread.raw(), &mut exit_code) != 0;
|
||||
VirtualFreeEx(process.raw(), remote_path, 0, MEM_RELEASE);
|
||||
|
||||
if !got_exit {
|
||||
return fail(Stage::LoadLibraryFailed, "GetExitCodeThread failed");
|
||||
}
|
||||
if exit_code == 0 {
|
||||
log("remote LoadLibraryW returned NULL -- DLL failed to load in target");
|
||||
return Stage::LoadLibraryFailed;
|
||||
}
|
||||
|
||||
log(&format!(
|
||||
"injection succeeded (remote LoadLibraryW HMODULE low bits={exit_code:#010x})"
|
||||
));
|
||||
Stage::Success
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user