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,339 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[cfg(target_os = "linux")]
use std::{
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread::{self, JoinHandle},
time::Duration,
};
#[cfg(target_os = "linux")]
use futures_lite::{FutureExt, StreamExt, future};
#[cfg(target_os = "linux")]
use zbus::{
MatchRule, MessageStream,
blocking::{Connection as BlockingConnection, Proxy as BlockingProxy},
message::Type as MessageType,
zvariant::{OwnedValue, Value},
};
pub const PORTAL_DESTINATION: &str = "org.freedesktop.portal.Desktop";
pub const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
pub const SETTINGS_INTERFACE: &str = "org.freedesktop.portal.Settings";
pub const APPEARANCE_NAMESPACE: &str = "org.freedesktop.appearance";
#[cfg(target_os = "linux")]
pub const READ_TIMEOUT: Duration = Duration::from_millis(1_500);
#[cfg(target_os = "linux")]
pub const SIGNAL_POLL_INTERVAL: Duration = Duration::from_millis(200);
#[cfg(target_os = "linux")]
pub const SIGNAL_THREAD_START_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorScheme {
NoPreference,
PreferDark,
PreferLight,
}
impl ColorScheme {
pub fn from_u32(value: u32) -> Self {
match value {
1 => Self::PreferDark,
2 => Self::PreferLight,
_ => Self::NoPreference,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::NoPreference => "no-preference",
Self::PreferDark => "prefer-dark",
Self::PreferLight => "prefer-light",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Contrast {
NoPreference,
High,
}
impl Contrast {
pub fn from_u32(value: u32) -> Self {
match value {
1 => Self::High,
_ => Self::NoPreference,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::NoPreference => "no-preference",
Self::High => "high",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AccentColor {
pub r: f64,
pub g: f64,
pub b: f64,
}
pub fn classify_accent_color(r: f64, g: f64, b: f64) -> Option<AccentColor> {
if r < 0.0 || g < 0.0 || b < 0.0 {
return None;
}
Some(AccentColor { r, g, b })
}
#[derive(Debug, Clone, PartialEq)]
pub struct ChangeEvent {
pub namespace: String,
pub key: String,
pub payload: ChangePayload,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ChangePayload {
Uint32(u32),
Accent(AccentColor),
Unknown,
}
#[cfg(target_os = "linux")]
fn open_blocking_connection() -> zbus::Result<BlockingConnection> {
zbus::blocking::connection::Builder::session()?
.method_timeout(READ_TIMEOUT)
.build()
}
#[cfg(target_os = "linux")]
fn read_uint32_setting(key: &str) -> Option<u32> {
let conn = open_blocking_connection().ok()?;
let proxy =
BlockingProxy::new(&conn, PORTAL_DESTINATION, PORTAL_PATH, SETTINGS_INTERFACE).ok()?;
let value: OwnedValue = proxy.call("Read", &(APPEARANCE_NAMESPACE, key)).ok()?;
crate::kwin::integer_from_variant(crate::kwin::value_of_owned(&value))
}
#[cfg(target_os = "linux")]
pub fn read_color_scheme() -> ColorScheme {
read_uint32_setting("color-scheme")
.map(ColorScheme::from_u32)
.unwrap_or(ColorScheme::NoPreference)
}
#[cfg(target_os = "linux")]
pub fn read_contrast() -> Contrast {
read_uint32_setting("contrast")
.map(Contrast::from_u32)
.unwrap_or(Contrast::NoPreference)
}
#[cfg(target_os = "linux")]
pub fn read_accent_color() -> Option<AccentColor> {
let conn = open_blocking_connection().ok()?;
let proxy =
BlockingProxy::new(&conn, PORTAL_DESTINATION, PORTAL_PATH, SETTINGS_INTERFACE).ok()?;
let value: OwnedValue = proxy
.call("Read", &(APPEARANCE_NAMESPACE, "accent-color"))
.ok()?;
extract_accent_from_variant(crate::kwin::value_of_owned(&value))
}
#[cfg(target_os = "linux")]
fn extract_accent_from_variant(value: &Value<'_>) -> Option<AccentColor> {
let inner: &Value<'_> = match value {
Value::Value(b) => b.as_ref(),
other => other,
};
let Value::Structure(structure) = inner else {
return None;
};
let fields = structure.fields();
if fields.len() < 3 {
return None;
}
let r = double_from_value(&fields[0])?;
let g = double_from_value(&fields[1])?;
let b = double_from_value(&fields[2])?;
classify_accent_color(r, g, b)
}
#[cfg(target_os = "linux")]
fn double_from_value(value: &Value<'_>) -> Option<f64> {
match value {
Value::F64(v) => Some(*v),
Value::Value(b) => double_from_value(b.as_ref()),
_ => None,
}
}
#[cfg(not(target_os = "linux"))]
pub fn read_color_scheme() -> ColorScheme {
ColorScheme::NoPreference
}
#[cfg(not(target_os = "linux"))]
pub fn read_contrast() -> Contrast {
Contrast::NoPreference
}
#[cfg(not(target_os = "linux"))]
pub fn read_accent_color() -> Option<AccentColor> {
None
}
#[cfg(target_os = "linux")]
type ChangeCallback = Arc<dyn Fn(ChangeEvent) + Send + Sync + 'static>;
#[cfg(target_os = "linux")]
pub struct Subscription {
stop_flag: Arc<AtomicBool>,
thread: Mutex<Option<JoinHandle<()>>>,
}
#[cfg(target_os = "linux")]
impl Subscription {
pub fn new(callback: ChangeCallback) -> Result<Self, String> {
let stop_flag = Arc::new(AtomicBool::new(false));
let (ready_tx, ready_rx) = mpsc::sync_channel(1);
let stop_for_thread = stop_flag.clone();
let thread = thread::Builder::new()
.name("fluxer-linux-portals-settings".to_string())
.spawn(move || {
let setup = future::block_on(async {
let conn = zbus::Connection::session().await?;
let rule = MatchRule::builder()
.msg_type(MessageType::Signal)
.interface(SETTINGS_INTERFACE)?
.member("SettingChanged")?
.build();
let stream = MessageStream::for_match_rule(rule, &conn, Some(32)).await?;
zbus::Result::Ok((conn, stream))
});
let (_conn, mut stream) = match setup {
Ok(parts) => {
let _ = ready_tx.send(Ok(()));
parts
}
Err(err) => {
let _ = ready_tx.send(Err(err.to_string()));
return;
}
};
while !stop_for_thread.load(Ordering::Acquire) {
let timeout = async {
async_io::Timer::after(SIGNAL_POLL_INTERVAL).await;
None::<zbus::Result<zbus::Message>>
};
match future::block_on(stream.next().or(timeout)) {
Some(Ok(message)) => {
if let Some(event) = parse_setting_changed(&message)
&& event.namespace == APPEARANCE_NAMESPACE
{
callback(event);
}
}
Some(Err(_)) => break,
None => {}
}
}
})
.map_err(|err| err.to_string())?;
match ready_rx.recv_timeout(SIGNAL_THREAD_START_TIMEOUT) {
Ok(Ok(())) => Ok(Self {
stop_flag,
thread: Mutex::new(Some(thread)),
}),
Ok(Err(err)) => {
let _ = thread.join();
Err(err)
}
Err(err) => {
stop_flag.store(true, Ordering::Release);
if matches!(err, mpsc::RecvTimeoutError::Disconnected) {
let _ = thread.join();
}
Err(err.to_string())
}
}
}
pub fn close(&self) {
self.stop_flag.store(true, Ordering::Release);
if let Ok(mut thread) = self.thread.lock()
&& let Some(t) = thread.take()
{
let _ = t.join();
}
}
}
#[cfg(target_os = "linux")]
impl Drop for Subscription {
fn drop(&mut self) {
self.close();
}
}
#[cfg(target_os = "linux")]
fn parse_setting_changed(message: &zbus::Message) -> Option<ChangeEvent> {
let body = message.body();
let (namespace, key, value): (String, String, OwnedValue) = body.deserialize().ok()?;
let value_ref = crate::kwin::value_of_owned(&value);
let payload = classify_payload(&key, value_ref);
Some(ChangeEvent {
namespace,
key,
payload,
})
}
#[cfg(target_os = "linux")]
fn classify_payload(_key: &str, value: &Value<'_>) -> ChangePayload {
if let Some(n) = crate::kwin::integer_from_variant(value) {
return ChangePayload::Uint32(n);
}
if let Some(accent) = extract_accent_from_variant(value) {
return ChangePayload::Accent(accent);
}
ChangePayload::Unknown
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn color_scheme_maps_to_strings_per_typescript_union() {
assert_eq!(ColorScheme::from_u32(0).as_str(), "no-preference");
assert_eq!(ColorScheme::from_u32(1).as_str(), "prefer-dark");
assert_eq!(ColorScheme::from_u32(2).as_str(), "prefer-light");
assert_eq!(ColorScheme::from_u32(99).as_str(), "no-preference");
}
#[test]
fn contrast_maps_to_strings_per_typescript_union() {
assert_eq!(Contrast::from_u32(0).as_str(), "no-preference");
assert_eq!(Contrast::from_u32(1).as_str(), "high");
assert_eq!(Contrast::from_u32(99).as_str(), "no-preference");
}
#[test]
fn classify_accent_color_treats_negative_as_no_preference() {
assert_eq!(classify_accent_color(-1.0, -1.0, -1.0), None);
assert_eq!(classify_accent_color(-0.0001, 0.5, 0.5), None);
let accent = classify_accent_color(0.1, 0.2, 0.3).unwrap();
assert_eq!(accent.r, 0.1);
assert_eq!(accent.g, 0.2);
assert_eq!(accent.b, 0.3);
}
}