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.
340 lines
10 KiB
Rust
340 lines
10 KiB
Rust
// 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);
|
|
}
|
|
}
|