#![cfg_attr(
not(all(
target_os = "linux",
any(feature = "linux-gnome", feature = "linux-kde")
)),
allow(dead_code)
)]
use crate::config::{ProxyConfig, ProxyConfigSource};
use crate::error::Error;
use crate::mode::ProxyMode;
pub(crate) const XDG_CURRENT_DESKTOP: &str = "XDG_CURRENT_DESKTOP";
pub(crate) const DESKTOP_SESSION: &str = "DESKTOP_SESSION";
pub(crate) const GNOME_DESKTOP_SESSION_ID: &str = "GNOME_DESKTOP_SESSION_ID";
pub(crate) const KDE_FULL_SESSION: &str = "KDE_FULL_SESSION";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum Store {
GSettings,
Kioslaverc,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Desktop {
Gnome,
Kde,
Unknown,
}
const KDE_TOKENS: [&str; 2] = ["kde", "plasma"];
const GNOME_TOKENS: [&str; 8] = [
"gnome",
"gnome-classic",
"gnome-flashback",
"unity",
"x-cinnamon",
"cinnamon",
"mate",
"pantheon",
];
pub(crate) fn classify(value: Option<&str>) -> Desktop {
let Some(value) = value else {
return Desktop::Unknown;
};
let tokens: Vec<String> = value
.split(':')
.map(|token| token.trim().to_ascii_lowercase())
.collect();
if tokens.iter().any(|t| KDE_TOKENS.contains(&t.as_str())) {
return Desktop::Kde;
}
if tokens.iter().any(|t| GNOME_TOKENS.contains(&t.as_str())) {
return Desktop::Gnome;
}
Desktop::Unknown
}
pub(crate) fn classify_with_fallback(
xdg_current_desktop: Option<&str>,
desktop_session: Option<&str>,
gnome_desktop_session_id_present: bool,
kde_full_session_present: bool,
) -> Desktop {
let primary = classify(xdg_current_desktop);
if !matches!(primary, Desktop::Unknown) {
return primary;
}
let unset_or_empty = xdg_current_desktop.is_none_or(|value| value.trim().is_empty());
if !unset_or_empty {
return Desktop::Unknown;
}
let from_session = classify_desktop_session(desktop_session.unwrap_or(""));
if !matches!(from_session, Desktop::Unknown) {
return from_session;
}
if gnome_desktop_session_id_present {
return Desktop::Gnome;
}
if kde_full_session_present {
return Desktop::Kde;
}
Desktop::Unknown
}
fn classify_desktop_session(value: &str) -> Desktop {
match value.trim().to_ascii_lowercase().as_str() {
"gnome" | "mate" => Desktop::Gnome,
"kde4" | "kde-plasma" | "kde" => Desktop::Kde,
_ => Desktop::Unknown,
}
}
pub(crate) fn order(desktop: Desktop) -> [Store; 2] {
match desktop {
Desktop::Kde => [Store::Kioslaverc, Store::GSettings],
Desktop::Gnome | Desktop::Unknown => [Store::GSettings, Store::Kioslaverc],
}
}
pub(crate) fn is_compiled_in(store: Store) -> bool {
match store {
Store::GSettings => cfg!(feature = "linux-gnome"),
Store::Kioslaverc => cfg!(feature = "linux-kde"),
}
}
pub(crate) fn current(env: &super::Env) -> Desktop {
classify_with_fallback(
text_if_set(env.var_os(XDG_CURRENT_DESKTOP)).as_deref(),
text_if_set(env.var_os(DESKTOP_SESSION)).as_deref(),
env.var_os(GNOME_DESKTOP_SESSION_ID).is_some(),
env.var_os(KDE_FULL_SESSION).is_some(),
)
}
fn text_if_set(value: Option<std::ffi::OsString>) -> Option<String> {
value.map(|value| value.to_string_lossy().into_owned())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Reading {
Absent,
Unset,
Configured {
source: ProxyConfigSource,
mode: ProxyMode,
},
}
impl Reading {
pub(crate) fn configured(source: ProxyConfigSource, mode: ProxyMode) -> Self {
Self::Configured { source, mode }
}
}
pub(crate) fn read_in_order(
desktop: Desktop,
gnome: impl Fn() -> Result<Reading, Error>,
kde: impl Fn() -> Result<Reading, Error>,
fallbacks: &mut Vec<ProxyConfigSource>,
) -> Result<(Reading, Reading), Error> {
let [leading_store, trailing_store] = order(desktop);
let leading = match leading_store {
Store::GSettings => read_store_fail_soft(leading_store, None, &gnome, fallbacks),
Store::Kioslaverc => read_store_fail_soft(leading_store, None, &kde, fallbacks),
}?;
let trailing = match trailing_store {
Store::GSettings => read_store_fail_soft(trailing_store, Some(&leading), &gnome, fallbacks),
Store::Kioslaverc => read_store_fail_soft(trailing_store, Some(&leading), &kde, fallbacks),
}?;
Ok(match leading_store {
Store::GSettings => (leading, trailing),
Store::Kioslaverc => (trailing, leading),
})
}
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
fn read_store_fail_soft(
store: Store,
leading: Option<&Reading>,
read: impl FnOnce() -> Result<Reading, Error>,
fallbacks: &mut Vec<ProxyConfigSource>,
) -> Result<Reading, Error> {
match read() {
Ok(reading) => Ok(reading),
Err(error) if !matches!(leading, Some(Reading::Configured { .. })) => Err(error),
Err(error) => {
crate::trace::warning!(
store = ?store,
error = %crate::trace::SafeError(&error),
"a Linux desktop store failed to read, but the leading store is already \
configured and is the effective one; treating the failure as absent \
instead of failing the whole read"
);
fallbacks.push(match store {
Store::GSettings => ProxyConfigSource::GSettings,
Store::Kioslaverc => ProxyConfigSource::Kioslaverc,
});
Ok(Reading::Absent)
}
}
}
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
pub(crate) fn note_if_the_leading_store_was_compiled_out(
desktop: Desktop,
gsettings: &Reading,
kioslaverc: &Reading,
compiled_in: impl Fn(Store) -> bool,
fallbacks: &mut Vec<ProxyConfigSource>,
) {
let leading = order(desktop)[0];
if compiled_in(leading) {
return;
}
let other = match leading {
Store::GSettings => kioslaverc,
Store::Kioslaverc => gsettings,
};
if !matches!(other, Reading::Absent) {
fallbacks.push(match leading {
Store::GSettings => ProxyConfigSource::GSettings,
Store::Kioslaverc => ProxyConfigSource::Kioslaverc,
});
}
match other {
Reading::Configured { .. } => crate::trace::warning!(
desktop = ?desktop,
leading_store = ?order(desktop)[0],
effective_store = ?order(desktop)[1],
"the desktop store this session's proxy settings would normally come from is not \
compiled into this build, so the configuration being reported is \
the other desktop's, which may be stale. Enable the feature for the desktop \
this process actually runs under."
),
Reading::Unset => crate::trace::warning!(
desktop = ?desktop,
leading_store = ?order(desktop)[0],
effective_store = ?order(desktop)[1],
"the desktop store this session's proxy settings would normally come from is not \
compiled into this build, and the other desktop's store holds nothing, so the \
configuration being reported is `Direct` by default rather than a reading that \
found no proxy. Enable the feature for the desktop this process actually runs \
under."
),
Reading::Absent => {}
}
}
pub(crate) fn assemble(
desktop: Desktop,
gsettings: &Reading,
kioslaverc: &Reading,
) -> Option<ProxyConfig> {
if matches!(gsettings, Reading::Absent) && matches!(kioslaverc, Reading::Absent) {
return None;
}
let mut sources = Vec::with_capacity(2);
for store in order(desktop) {
let reading = match store {
Store::GSettings => gsettings,
Store::Kioslaverc => kioslaverc,
};
if let Reading::Configured { source, mode } = reading {
sources.push((*source, mode.clone()));
}
}
Some(ProxyConfig::from_ordered_sources(sources))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_desktop_comes_from_the_copied_environment() {
let env = super::super::Env::from_pairs(&[("XDG_CURRENT_DESKTOP", "KDE")]);
assert_eq!(current(&env), Desktop::Kde);
assert_eq!(current(&super::super::Env::default()), Desktop::Unknown);
}
#[test]
fn the_common_spellings_are_recognised() {
for (value, expected) in [
("GNOME", Desktop::Gnome),
("ubuntu:GNOME", Desktop::Gnome),
("GNOME-Classic:GNOME", Desktop::Gnome),
("X-Cinnamon", Desktop::Gnome),
("MATE", Desktop::Gnome),
("Unity:Unity7:ubuntu", Desktop::Gnome),
("KDE", Desktop::Kde),
("plasma", Desktop::Kde),
("KDE:GNOME", Desktop::Kde),
("sway", Desktop::Unknown),
("", Desktop::Unknown),
(" ", Desktop::Unknown),
] {
assert_eq!(classify(Some(value)), expected, "for {value:?}");
}
assert_eq!(classify(None), Desktop::Unknown);
}
#[test]
fn matching_is_case_insensitive() {
assert_eq!(classify(Some("gnome")), Desktop::Gnome);
assert_eq!(classify(Some("kDe")), Desktop::Kde);
}
#[test]
fn a_present_xdg_current_desktop_never_consults_the_fallback_chain() {
assert_eq!(
classify_with_fallback(Some("GNOME"), Some("kde"), true, true),
Desktop::Gnome
);
}
#[test]
fn an_unset_xdg_current_desktop_falls_back_to_desktop_session() {
assert_eq!(
classify_with_fallback(None, Some("gnome"), false, false),
Desktop::Gnome
);
assert_eq!(
classify_with_fallback(None, Some("kde-plasma"), false, false),
Desktop::Kde
);
}
#[test]
fn a_session_file_name_is_recognised_only_in_the_desktop_session_fallback() {
for session in ["kde4", "kde-plasma"] {
assert_eq!(
classify(Some(session)),
Desktop::Unknown,
"{session} is not an XDG_CURRENT_DESKTOP token"
);
assert_eq!(
classify_with_fallback(None, Some(session), false, false),
Desktop::Kde,
"{session} is a DESKTOP_SESSION value"
);
}
}
#[test]
fn a_token_with_spaces_around_it_still_names_its_desktop() {
assert_eq!(classify(Some(" plasma ")), Desktop::Kde);
assert_eq!(
classify_with_fallback(None, Some(" kde-plasma "), false, false),
Desktop::Kde
);
}
#[test]
fn an_empty_xdg_current_desktop_also_falls_back() {
assert_eq!(
classify_with_fallback(Some(""), Some("kde"), false, false),
Desktop::Kde
);
assert_eq!(
classify_with_fallback(Some(" "), Some("mate"), false, false),
Desktop::Gnome,
"whitespace-only counts as empty, same as classify()"
);
}
#[test]
fn a_present_but_unrecognised_xdg_current_desktop_does_not_fall_back() {
assert_eq!(
classify_with_fallback(Some("sway"), Some("gnome"), true, true),
Desktop::Unknown
);
}
#[cfg(any(windows, unix))]
#[test]
fn a_variable_that_is_not_unicode_still_counts_as_set() {
#[cfg(windows)]
let raw = {
use std::os::windows::ffi::OsStringExt;
std::ffi::OsString::from_wide(&[0xD800])
};
#[cfg(unix)]
let raw = {
use std::os::unix::ffi::OsStringExt;
std::ffi::OsString::from_vec(vec![0xFF])
};
let text = text_if_set(Some(raw)).expect("a variable that is set must not read as unset");
assert!(
!text.trim().is_empty(),
"an unreadable value must not read as empty either: {text:?}"
);
assert_eq!(
classify_with_fallback(Some(&text), Some("kde"), true, true),
Desktop::Unknown,
"a mangled XDG_CURRENT_DESKTOP must stop here, not fall through to the older \
variables"
);
}
#[test]
fn an_unrecognised_desktop_session_falls_through_to_the_presence_only_variables() {
assert_eq!(
classify_with_fallback(None, Some("default"), true, false),
Desktop::Gnome
);
assert_eq!(
classify_with_fallback(None, Some("default"), false, true),
Desktop::Kde
);
}
#[test]
fn gnome_desktop_session_id_is_checked_before_kde_full_session() {
assert_eq!(
classify_with_fallback(None, None, true, true),
Desktop::Gnome
);
}
#[test]
fn nothing_recognised_anywhere_is_still_unknown() {
assert_eq!(
classify_with_fallback(None, None, false, false),
Desktop::Unknown
);
assert_eq!(
classify_with_fallback(Some(""), Some(""), false, false),
Desktop::Unknown
);
}
#[test]
fn every_gnome_token_stops_the_fallback_chain() {
const TOKENS: [&str; 8] = [
"gnome",
"gnome-classic",
"gnome-flashback",
"unity",
"x-cinnamon",
"cinnamon",
"mate",
"pantheon",
];
assert_eq!(TOKENS.len(), GNOME_TOKENS.len());
for token in TOKENS {
assert_eq!(
classify_with_fallback(Some(token), Some("kde"), true, true),
Desktop::Gnome,
"{token} is a GNOME token"
);
assert_eq!(
order(classify(Some(token))),
[Store::GSettings, Store::Kioslaverc],
"{token} reads GSettings first"
);
}
}
#[test]
fn kde_wins_when_both_are_advertised() {
assert_eq!(
order(classify(Some("KDE:GNOME"))),
[Store::Kioslaverc, Store::GSettings]
);
}
#[test]
fn gsettings_leads_everywhere_else() {
for value in ["GNOME", "sway", ""] {
assert_eq!(
order(classify(Some(value))),
[Store::GSettings, Store::Kioslaverc],
"for {value:?}"
);
}
}
#[cfg(all(feature = "linux-kde", not(feature = "linux-gnome")))]
#[test]
fn a_kde_only_build_can_read_kioslaverc_and_not_gsettings() {
assert!(is_compiled_in(Store::Kioslaverc));
assert!(!is_compiled_in(Store::GSettings));
}
#[cfg(all(feature = "linux-gnome", not(feature = "linux-kde")))]
#[test]
fn a_gnome_only_build_can_read_gsettings_and_not_kioslaverc() {
assert!(is_compiled_in(Store::GSettings));
assert!(!is_compiled_in(Store::Kioslaverc));
}
fn manual(authority: &str) -> ProxyMode {
crate::parse::windows_manual(authority, "")
}
fn gnome(mode: ProxyMode) -> Reading {
Reading::configured(ProxyConfigSource::GSettings, mode)
}
fn kde(mode: ProxyMode) -> Reading {
Reading::configured(ProxyConfigSource::Kioslaverc, mode)
}
#[test]
fn an_explicit_direct_in_the_leading_store_wins() {
let assembled = assemble(
Desktop::Gnome,
&gnome(ProxyMode::Direct),
&kde(manual("stale.corp:8080")),
)
.expect("both stores exist");
assert_eq!(
assembled.effective,
ProxyMode::Direct,
"a proxy the user switched off in their own desktop must stay off"
);
assert_eq!(
assembled.sources,
vec![
(ProxyConfigSource::GSettings, ProxyMode::Direct),
(ProxyConfigSource::Kioslaverc, manual("stale.corp:8080")),
]
);
}
#[test]
fn the_leading_store_is_the_running_desktops() {
let assembled = assemble(
Desktop::Kde,
&gnome(manual("gnome.corp:8080")),
&kde(ProxyMode::Direct),
)
.expect("both stores exist");
assert_eq!(assembled.effective, ProxyMode::Direct);
assert_eq!(assembled.sources[0].0, ProxyConfigSource::Kioslaverc);
}
#[test]
fn an_unset_leading_store_falls_through_to_the_other() {
let assembled = assemble(Desktop::Gnome, &Reading::Unset, &kde(manual("p.corp:3128")))
.expect("kioslaverc exists");
assert_eq!(assembled.effective, manual("p.corp:3128"));
assert_eq!(
assembled.sources,
vec![(ProxyConfigSource::Kioslaverc, manual("p.corp:3128"))],
"an unconfigured store contributes no source at all"
);
}
#[test]
fn both_configured_means_the_leading_one_wins() {
let assembled = assemble(
Desktop::Gnome,
&gnome(manual("gnome.corp:8080")),
&kde(manual("kde.corp:3128")),
)
.expect("both stores exist");
assert_eq!(assembled.effective, manual("gnome.corp:8080"));
assert_eq!(assembled.sources.len(), 2);
}
#[test]
fn both_unset_is_direct_with_no_sources() {
let assembled =
assemble(Desktop::Unknown, &Reading::Unset, &Reading::Unset).expect("a store exists");
assert_eq!(assembled.effective, ProxyMode::Direct);
assert!(
assembled.sources.is_empty(),
"a store nobody configured must not be reported as a source"
);
}
#[test]
fn only_two_absent_stores_have_no_answer() {
assert!(assemble(Desktop::Unknown, &Reading::Absent, &Reading::Absent).is_none());
for reading in [Reading::Unset, gnome(ProxyMode::Direct)] {
assert!(
assemble(Desktop::Unknown, &reading, &Reading::Absent).is_some(),
"{reading:?} is a readable store"
);
}
}
#[test]
fn a_relabelled_source_still_occupies_its_stores_slot() {
let assembled = assemble(
Desktop::Kde,
&gnome(manual("gnome.corp:8080")),
&Reading::configured(ProxyConfigSource::KioslavercEnv, manual("env.corp:3128")),
)
.expect("both stores exist");
assert_eq!(assembled.effective, manual("env.corp:3128"));
assert_eq!(assembled.sources[0].0, ProxyConfigSource::KioslavercEnv);
}
fn some_error() -> Error {
Error::Unsupported
}
#[test]
fn a_successful_read_passes_its_value_through_unchanged() {
let reading = gnome(ProxyMode::Direct);
let leading = [None, Some(Reading::Unset), Some(reading.clone())];
let passed_through: Vec<Reading> = leading
.iter()
.map(|leading| {
let value = reading.clone();
read_store_fail_soft(
Store::GSettings,
leading.as_ref(),
move || Ok(value),
&mut Vec::new(),
)
.expect("the closure returned Ok")
})
.collect();
assert_eq!(passed_through, vec![reading; leading.len()]);
}
#[test]
fn a_second_store_error_is_softened_only_when_the_leading_store_is_configured() {
let leading = [Reading::Absent, Reading::Unset, gnome(ProxyMode::Direct)];
let mut recorded = Vec::new();
let softened: Vec<bool> = leading
.iter()
.map(|leading| {
match read_store_fail_soft(
Store::Kioslaverc,
Some(leading),
|| Err(some_error()),
&mut recorded,
) {
Ok(Reading::Absent) => true,
Err(Error::Unsupported) => false,
other => panic!("leading = {leading:?}: {other:?}"),
}
})
.collect();
assert_eq!(softened, [false, false, true], "against {leading:?}");
assert_eq!(recorded, [ProxyConfigSource::Kioslaverc]);
}
#[test]
fn a_leading_store_error_propagates() {
let result = read_store_fail_soft(
Store::GSettings,
None,
|| Err(some_error()),
&mut Vec::new(),
);
assert!(
matches!(result, Err(Error::Unsupported)),
"the running desktop's own store must not be silenced: {result:?}"
);
}
#[test]
fn the_store_that_cannot_be_softened_is_the_one_the_desktop_leads_with() {
let answer = kde(manual("kde.corp:8080"));
let mut recorded = Vec::new();
let softened: Vec<bool> = [Desktop::Gnome, Desktop::Kde]
.iter()
.map(|desktop| {
match read_in_order(
*desktop,
|| Err(some_error()),
|| Ok(answer.clone()),
&mut recorded,
) {
Err(Error::Unsupported) => false,
Ok((Reading::Absent, kioslaverc)) if kioslaverc == answer => true,
other => panic!("{desktop:?}: {other:?}"),
}
})
.collect();
assert_eq!(softened, [false, true]);
assert_eq!(recorded, [ProxyConfigSource::GSettings]);
}
#[test]
fn a_leading_store_this_build_left_out_is_recorded_unless_nothing_is_left_to_report() {
let configured = Reading::configured(ProxyConfigSource::GSettings, ProxyMode::Direct);
let mut recorded = Vec::new();
note_if_the_leading_store_was_compiled_out(
Desktop::Kde,
&configured,
&Reading::Absent,
|store| store == Store::GSettings,
&mut recorded,
);
note_if_the_leading_store_was_compiled_out(
Desktop::Kde,
&Reading::Absent,
&Reading::Absent,
|store| store == Store::GSettings,
&mut recorded,
);
note_if_the_leading_store_was_compiled_out(
Desktop::Kde,
&configured,
&Reading::Unset,
|_| true,
&mut recorded,
);
note_if_the_leading_store_was_compiled_out(
Desktop::Gnome,
&Reading::Absent,
&Reading::Unset,
|store| store == Store::Kioslaverc,
&mut recorded,
);
assert_eq!(
recorded,
[ProxyConfigSource::Kioslaverc, ProxyConfigSource::GSettings]
);
}
#[test]
fn the_two_readings_come_back_in_store_order_whichever_store_led() {
let from_gsettings = gnome(manual("gnome.corp:8080"));
let from_kioslaverc = kde(manual("kde.corp:3128"));
let pairs: Vec<(Reading, Reading)> = [Desktop::Gnome, Desktop::Kde]
.iter()
.map(|desktop| {
read_in_order(
*desktop,
|| Ok(from_gsettings.clone()),
|| Ok(from_kioslaverc.clone()),
&mut Vec::new(),
)
.expect("both stores answered")
})
.collect();
assert_eq!(
pairs,
[
(from_gsettings.clone(), from_kioslaverc.clone()),
(from_gsettings, from_kioslaverc)
]
);
}
}