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:
2026-07-01 18:22:43 -04:00
commit 682afacd30
1763 changed files with 613720 additions and 0 deletions
@@ -0,0 +1,418 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::ffi::{CStr, CString};
use std::fs::File;
use std::path::Path;
use std::ptr;
use std::sync::{Mutex, MutexGuard};
use fluxer_desktop_native::system_hunspell::dictionaries::{EnvSnapshot, discover_dictionaries};
use fluxer_desktop_native::system_hunspell::encoding::is_utf8_encoding;
use fluxer_desktop_native::system_hunspell::hashing::hash_file_to_hex;
use napi::Task;
use napi::bindgen_prelude::{AsyncTask, Env, Error, Result, Status};
use napi_derive::napi;
static HUNSPELL_LOCK: Mutex<()> = Mutex::new(());
#[napi(object)]
pub struct SystemDictionary {
pub tag: String,
#[napi(js_name = "affPath")]
pub aff_path: String,
#[napi(js_name = "dicPath")]
pub dic_path: String,
}
#[napi]
pub struct Hunspell {
dict: Option<Dictionary>,
}
#[napi]
impl Hunspell {
#[napi(constructor)]
pub fn new(aff_path: String, dic_path: String) -> Result<Self> {
Ok(Self {
dict: Some(Dictionary::load(&aff_path, &dic_path)?),
})
}
#[napi]
pub fn spell(&self, word: String) -> Result<bool> {
validate_text_arg(&word, "word")?;
Ok(self
.dict
.as_ref()
.is_some_and(|dict| dict.spell(&word).unwrap_or(true)))
}
#[napi]
pub fn suggest(&self, word: String, max: Option<u32>) -> Result<Vec<String>> {
validate_text_arg(&word, "word")?;
let Some(dict) = &self.dict else {
return Ok(Vec::new());
};
let limit = normalize_suggestion_limit(max);
dict.suggest(&word, limit)
}
#[napi]
pub fn add(&mut self, word: String) -> Result<()> {
validate_text_arg(&word, "word")?;
if let Some(dict) = &mut self.dict {
let _ = dict.add(&word);
}
Ok(())
}
#[napi]
pub fn remove(&mut self, word: String) -> Result<()> {
validate_text_arg(&word, "word")?;
if let Some(dict) = &mut self.dict {
let _ = dict.remove(&word);
}
Ok(())
}
#[napi]
pub fn close(&mut self) {
self.dict = None;
}
}
#[napi(js_name = "discoverSystemDictionaries")]
pub fn discover_system_dictionaries() -> Vec<SystemDictionary> {
let snapshot = EnvSnapshot {
hunspell_dict_dir: std::env::var("HUNSPELL_DICT_DIR").ok(),
xdg_data_home: std::env::var("XDG_DATA_HOME").ok(),
home: std::env::var("HOME").ok(),
xdg_data_dirs: std::env::var("XDG_DATA_DIRS").ok(),
};
discover_dictionaries(&snapshot)
.into_iter()
.map(|dict| SystemDictionary {
tag: dict.tag,
aff_path: dict.aff_path.display().to_string(),
dic_path: dict.dic_path.display().to_string(),
})
.collect()
}
pub struct HashFileTask {
path: String,
}
#[napi(js_name = "hashFile")]
pub fn hash_file(path: String) -> Result<AsyncTask<HashFileTask>> {
validate_path_arg(&path)?;
Ok(AsyncTask::new(HashFileTask { path }))
}
impl Task for HashFileTask {
type Output = String;
type JsValue = String;
fn compute(&mut self) -> Result<Self::Output> {
hash_file_to_hex(&self.path).map_err(hash_error)
}
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
#[derive(Debug)]
struct Dictionary {
handle: *mut hunspell_sys::Hunhandle,
}
impl Dictionary {
fn load(aff_path: &str, dic_path: &str) -> Result<Self> {
validate_path_arg(aff_path)?;
validate_path_arg(dic_path)?;
if File::open(Path::new(aff_path)).is_err() || File::open(Path::new(dic_path)).is_err() {
return Err(hunspell_load_error("LoadFailed"));
}
let aff = cstring_arg(aff_path, "affPath")?;
let dic = cstring_arg(dic_path, "dicPath")?;
let _guard = hunspell_lock();
let handle = unsafe { hunspell_sys::Hunspell_create(aff.as_ptr(), dic.as_ptr()) };
if handle.is_null() {
return Err(hunspell_load_error("LoadFailed"));
}
let encoding_ptr = unsafe { hunspell_sys::Hunspell_get_dic_encoding(handle) };
if encoding_ptr.is_null() {
unsafe { hunspell_sys::Hunspell_destroy(handle) };
return Err(hunspell_load_error("LoadFailed"));
}
let encoding = unsafe { CStr::from_ptr(encoding_ptr) }
.to_str()
.map_err(|_| hunspell_load_error("DictionaryNotUtf8"))?;
if !is_utf8_encoding(encoding) {
unsafe { hunspell_sys::Hunspell_destroy(handle) };
return Err(hunspell_load_error("DictionaryNotUtf8"));
}
Ok(Self { handle })
}
fn spell(&self, word: &str) -> Result<bool> {
let word = cstring_arg(word, "word")?;
let _guard = hunspell_lock();
Ok(unsafe { hunspell_sys::Hunspell_spell(self.handle, word.as_ptr()) } != 0)
}
fn suggest(&self, word: &str, max: usize) -> Result<Vec<String>> {
let word = cstring_arg(word, "word")?;
let mut raw: *mut *mut std::os::raw::c_char = ptr::null_mut();
let _hunspell_guard = hunspell_lock();
let count = unsafe { hunspell_sys::Hunspell_suggest(self.handle, &mut raw, word.as_ptr()) };
let _guard = SuggestionList {
handle: self.handle,
raw,
count,
};
if count <= 0 || raw.is_null() {
return Ok(Vec::new());
}
let limit = (count as usize).min(max);
let mut out = Vec::with_capacity(limit);
for index in 0..limit {
let item = unsafe { *raw.add(index) };
if item.is_null() {
out.push(String::new());
continue;
}
let suggestion = unsafe { CStr::from_ptr(item) };
if let Ok(text) = suggestion.to_str() {
out.push(text.to_owned());
}
}
Ok(out)
}
fn add(&mut self, word: &str) -> Result<()> {
let word = cstring_arg(word, "word")?;
let _guard = hunspell_lock();
let rc = unsafe { hunspell_sys::Hunspell_add(self.handle, word.as_ptr()) };
if rc == 0 {
Ok(())
} else {
Err(hunspell_load_error("LoadFailed"))
}
}
fn remove(&mut self, word: &str) -> Result<()> {
let word = cstring_arg(word, "word")?;
let _guard = hunspell_lock();
unsafe { hunspell_sys::Hunspell_remove(self.handle, word.as_ptr()) };
Ok(())
}
}
impl Drop for Dictionary {
fn drop(&mut self) {
if !self.handle.is_null() {
let _guard = hunspell_lock();
unsafe { hunspell_sys::Hunspell_destroy(self.handle) };
self.handle = ptr::null_mut();
}
}
}
struct SuggestionList {
handle: *mut hunspell_sys::Hunhandle,
raw: *mut *mut std::os::raw::c_char,
count: i32,
}
impl Drop for SuggestionList {
fn drop(&mut self) {
if self.count > 0 && !self.raw.is_null() {
unsafe { hunspell_sys::Hunspell_free_list(self.handle, &mut self.raw, self.count) };
}
}
}
fn normalize_suggestion_limit(max: Option<u32>) -> usize {
match max {
Some(value @ 1..=64) => value as usize,
_ => 8,
}
}
fn hunspell_lock() -> MutexGuard<'static, ()> {
HUNSPELL_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn validate_path_arg(path: &str) -> Result<()> {
if path.is_empty() {
return Err(Error::new(Status::InvalidArg, "path must be non-empty"));
}
if path.as_bytes().contains(&0) {
return Err(Error::new(
Status::InvalidArg,
"path must not contain NUL bytes",
));
}
Ok(())
}
fn validate_text_arg(value: &str, label: &str) -> Result<()> {
if value.as_bytes().contains(&0) {
return Err(Error::new(
Status::InvalidArg,
format!("{label} must not contain NUL bytes"),
));
}
Ok(())
}
fn cstring_arg(value: &str, label: &str) -> Result<CString> {
CString::new(value).map_err(|_| {
Error::new(
Status::InvalidArg,
format!("{label} must not contain NUL bytes"),
)
})
}
fn hunspell_load_error(reason: &str) -> Error {
Error::new(
Status::GenericFailure,
format!("Hunspell load failed: {reason}"),
)
}
fn hash_error(error: std::io::Error) -> Error {
let message = match error.kind() {
std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied => {
"could not open file for hashing"
}
_ => "read failed while hashing file",
};
Error::new(Status::GenericFailure, message)
}
#[cfg(test)]
mod tests {
use std::io::Write;
use super::*;
#[test]
fn suggestion_limit_defaults_and_bounds_match_legacy_contract() {
assert_eq!(8, normalize_suggestion_limit(None));
assert_eq!(8, normalize_suggestion_limit(Some(0)));
assert_eq!(1, normalize_suggestion_limit(Some(1)));
assert_eq!(64, normalize_suggestion_limit(Some(64)));
assert_eq!(8, normalize_suggestion_limit(Some(65)));
}
#[test]
fn path_validation_rejects_empty_and_nul_paths() {
assert_eq!(
"path must be non-empty",
validate_path_arg("")
.expect_err("empty path should fail")
.reason
);
assert_eq!(
"path must not contain NUL bytes",
validate_path_arg("/tmp/a\0b")
.expect_err("NUL path should fail")
.reason
);
}
#[test]
fn text_validation_rejects_nul_words() {
assert_eq!(
"word must not contain NUL bytes",
validate_text_arg("a\0b", "word")
.expect_err("NUL word should fail")
.reason
);
}
#[test]
fn hash_error_maps_open_failures_to_existing_js_message() {
let err = hash_file_to_hex("/nonexistent/path/that/should/not/exist.bin")
.map_err(hash_error)
.expect_err("missing file should fail");
assert_eq!(Status::GenericFailure, err.status);
assert_eq!("could not open file for hashing", err.reason);
}
#[test]
fn hash_file_task_streams_file_to_expected_hex() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("payload");
let mut file = File::create(&path).unwrap();
file.write_all(b"abc").unwrap();
let mut task = HashFileTask {
path: path.display().to_string(),
};
assert_eq!(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
task.compute().unwrap()
);
}
#[test]
fn dictionary_fixture_spells_and_accepts_runtime_words() {
let (dir, aff_path, dic_path) = write_fixture_dictionary("UTF-8");
let mut dict =
Dictionary::load(&aff_path, &dic_path).expect("fixture dictionary should load");
assert!(dict.spell("cat").unwrap());
assert!(dict.spell("cats").unwrap());
assert!(!dict.spell("Fluxer").unwrap());
dict.add("Fluxer").unwrap();
assert!(dict.spell("Fluxer").unwrap());
dict.remove("Fluxer").unwrap();
assert!(!dict.spell("Fluxer").unwrap());
drop(dir);
}
#[test]
fn dictionary_rejects_non_utf8_dictionaries() {
let (_dir, aff_path, dic_path) = write_fixture_dictionary("ISO-8859-1");
let err = Dictionary::load(&aff_path, &dic_path)
.expect_err("non-UTF-8 dictionary should be rejected");
assert_eq!(Status::GenericFailure, err.status);
assert_eq!("Hunspell load failed: DictionaryNotUtf8", err.reason);
}
fn write_fixture_dictionary(encoding: &str) -> (tempfile::TempDir, String, String) {
let dir = tempfile::tempdir().unwrap();
let aff_path = dir.path().join("fixture.aff");
let dic_path = dir.path().join("fixture.dic");
std::fs::write(
&aff_path,
format!("SET {encoding}\n\nSFX S Y 1\nSFX S 0 s [^sxzhy]\n"),
)
.unwrap();
std::fs::write(&dic_path, "2\ncat/S\nprogram/S\n").unwrap();
(
dir,
aff_path.display().to_string(),
dic_path.display().to_string(),
)
}
}