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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user