#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
#[cfg(target_os = "linux")]
mod bus;
#[cfg(test)]
mod tests;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex, PoisonError};
use std::task::{Context, Waker};
use std::thread::JoinHandle;
use std::time::{Duration, Instant, SystemTime};
use teksilo_core::AppEventPoster;
use teksilo_kde_scheme::KdeConfigLocations;
pub const DEBOUNCE: Duration = Duration::from_millis(150);
pub const RECONNECT_EVERY: Duration = Duration::from_secs(1);
const PORTAL_SETTINGS: &str = "org.freedesktop.portal.Settings";
const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
const SETTING_CHANGED: &str = "SettingChanged";
const GLOBAL_SETTINGS: &str = "org.kde.KGlobalSettings";
const GLOBAL_SETTINGS_PATH: &str = "/KGlobalSettings";
const NOTIFY_CHANGE: &str = "notifyChange";
const KCONFIG_NOTIFY: &str = "org.kde.kconfig.notify";
const CONFIG_CHANGED: &str = "ConfigChanged";
const KDEGLOBALS_PATH: &str = "/kdeglobals";
const BREEZERC_PATH: &str = "/breezerc";
const LISTENED: [(&str, &str, &str); 4] = [
(PORTAL_SETTINGS, SETTING_CHANGED, PORTAL_PATH),
(GLOBAL_SETTINGS, NOTIFY_CHANGE, GLOBAL_SETTINGS_PATH),
(KCONFIG_NOTIFY, CONFIG_CHANGED, KDEGLOBALS_PATH),
(KCONFIG_NOTIFY, CONFIG_CHANGED, BREEZERC_PATH),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OsThemeChanged;
pub struct OsThemeWatch {
_running: Running,
files: ConfigStamps,
}
impl std::fmt::Debug for OsThemeWatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OsThemeWatch")
.field("files", &self.files.files.len())
.finish()
}
}
impl OsThemeWatch {
pub fn new(poster: impl AppEventPoster) -> Option<Self> {
#[cfg(target_os = "linux")]
{
Self::on_bus(None, poster, |_| {})
}
#[cfg(not(target_os = "linux"))]
{
let _ = poster;
None
}
}
#[cfg(target_os = "linux")]
fn on_bus(
address: Option<String>,
poster: impl AppEventPoster,
mut opened: impl FnMut(bool) + Send + 'static,
) -> Option<Self> {
let locations = KdeConfigLocations::from_env(super::kde::process_env);
let files = ConfigStamps::read(config_files(&locations), modified);
let running = Running::start(
DEBOUNCE,
RECONNECT_EVERY,
move |stop| {
let source = bus::BusSource::connect(address.as_deref(), stop);
opened(source.is_some());
source
},
move || poster.post_external(Box::new(OsThemeChanged)),
)?;
Some(Self {
_running: running,
files,
})
}
pub fn files_changed(&mut self) -> bool {
self.files.refresh(modified)
}
}
fn signal_matters(interface: &str, member: &str, path: &str, namespace: Option<&str>) -> bool {
match (interface, member, path) {
(PORTAL_SETTINGS, SETTING_CHANGED, PORTAL_PATH) => {
namespace.is_some_and(portal_namespace_matters)
}
(GLOBAL_SETTINGS, NOTIFY_CHANGE, GLOBAL_SETTINGS_PATH) => true,
(KCONFIG_NOTIFY, CONFIG_CHANGED, KDEGLOBALS_PATH | BREEZERC_PATH) => true,
_ => false,
}
}
fn portal_namespace_matters(namespace: &str) -> bool {
namespace == "org.freedesktop.appearance"
|| namespace
.strip_prefix("org.kde.kdeglobals.")
.is_some_and(|group| !group.is_empty())
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Debounce {
window: Duration,
due: Option<Instant>,
}
impl Debounce {
fn new(window: Duration) -> Self {
Self { window, due: None }
}
fn heard(&mut self, now: Instant) {
if self.due.is_none() {
self.due = Some(now + self.window);
}
}
fn deadline(&self) -> Option<Instant> {
self.due
}
fn take_open(&mut self) -> bool {
self.due.take().is_some()
}
fn take_due(&mut self, now: Instant) -> bool {
match self.due {
Some(due) if now >= due => {
self.due = None;
true
}
_ => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Heard {
Change,
Deadline,
Closed,
}
trait ChangeSource {
fn wait(&mut self, deadline: Option<Instant>) -> Heard;
}
fn run(
source: &mut impl ChangeSource,
stop: &Stop,
debounce: &mut Debounce,
clock: &impl Fn() -> Instant,
post: &impl Fn(),
) {
loop {
match source.wait(debounce.deadline()) {
Heard::Change => debounce.heard(clock()),
Heard::Deadline => {}
Heard::Closed => {
if !stop.is_stopped() && debounce.take_open() {
post();
}
return;
}
}
if stop.is_stopped() {
return;
}
if debounce.take_due(clock()) {
post();
}
}
}
#[derive(Debug, Default)]
struct Stop {
stopped: AtomicBool,
waker: Mutex<Option<Waker>>,
parked: Condvar,
}
impl Stop {
fn stop(&self) {
self.stopped.store(true, Ordering::SeqCst);
let waker = {
let mut slot = self.waker.lock().unwrap_or_else(PoisonError::into_inner);
self.parked.notify_all();
slot.take()
};
if let Some(waker) = waker {
waker.wake();
}
}
fn pause(&self, duration: Duration) -> bool {
let slot = self.waker.lock().unwrap_or_else(PoisonError::into_inner);
let _ = self
.parked
.wait_timeout_while(slot, duration, |_| !self.is_stopped())
.unwrap_or_else(PoisonError::into_inner);
!self.is_stopped()
}
fn is_stopped(&self) -> bool {
self.stopped.load(Ordering::SeqCst)
}
fn poll_stopped(&self, cx: &Context<'_>) -> bool {
*self.waker.lock().unwrap_or_else(PoisonError::into_inner) = Some(cx.waker().clone());
self.is_stopped()
}
}
struct Running {
stop: Arc<Stop>,
thread: Option<JoinHandle<()>>,
}
impl Running {
fn start<S: ChangeSource>(
window: Duration,
retry: Duration,
mut open: impl FnMut(Arc<Stop>) -> Option<S> + Send + 'static,
post: impl Fn() + Send + 'static,
) -> Option<Running> {
let stop = Arc::new(Stop::default());
let thread_stop = Arc::clone(&stop);
let thread = std::thread::Builder::new()
.name("teksilo-os-theme-watch".to_owned())
.spawn(move || {
let Some(mut source) = open(Arc::clone(&thread_stop)) else {
return;
};
let mut debounce = Debounce::new(window);
loop {
run(
&mut source,
&thread_stop,
&mut debounce,
&Instant::now,
&post,
);
drop(source);
let Some(again) = reopen(&mut open, &thread_stop, retry) else {
return;
};
source = again;
debounce.heard(Instant::now());
}
})
.ok()?;
Some(Running {
stop,
thread: Some(thread),
})
}
}
fn reopen<S>(
open: &mut impl FnMut(Arc<Stop>) -> Option<S>,
stop: &Arc<Stop>,
retry: Duration,
) -> Option<S> {
loop {
if !stop.pause(retry) {
return None;
}
if let Some(source) = open(Arc::clone(stop)) {
return Some(source);
}
}
}
impl Drop for Running {
fn drop(&mut self) {
self.stop.stop();
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ConfigStamps {
files: Vec<(PathBuf, Option<SystemTime>)>,
}
impl ConfigStamps {
fn read(
paths: impl IntoIterator<Item = PathBuf>,
modified: impl Fn(&Path) -> Option<SystemTime>,
) -> Self {
let files = paths
.into_iter()
.map(|path| {
let stamp = modified(&path);
(path, stamp)
})
.collect();
Self { files }
}
fn refresh(&mut self, modified: impl Fn(&Path) -> Option<SystemTime>) -> bool {
let mut changed = false;
for (path, stamp) in &mut self.files {
let now = modified(path);
if now != *stamp {
*stamp = now;
changed = true;
}
}
changed
}
}
fn config_files(locations: &KdeConfigLocations) -> Vec<PathBuf> {
locations
.kdeglobals_cascade()
.into_iter()
.chain(locations.breezerc_cascade())
.map(|layer| layer.path)
.collect()
}
fn modified(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).and_then(|m| m.modified()).ok()
}