use std::ffi::{OsStr, OsString};
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::cli::CompositorChoice;
use crate::linux::launcher::command_in_path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AppProfile {
pub name: &'static str,
pub binary_names: &'static [&'static str],
pub env: &'static [(&'static str, &'static str)],
pub args: &'static [&'static str],
pub compositor: CompositorChoice,
pub fullscreen: bool,
pub snap_aware: bool,
pub private_dbus: bool,
pub app_id: &'static str,
}
pub const PROFILES: &[AppProfile] = &[
AppProfile {
name: "google-chrome",
binary_names: &[
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
],
env: &[],
args: &[
"--ozone-platform=wayland",
"--start-maximized",
"--no-first-run",
"--no-default-browser-check",
"--disable-breakpad",
"--disable-crash-reporter",
"--disable-metrics",
"--disable-metrics-repo-reporting",
"--disable-component-update",
"--disable-background-networking",
"--disable-sync",
"--disable-features=PassageEmbeddings,HistoryEmbeddings,OptimizationGuideModelDownloading",
],
compositor: CompositorChoice::Sway,
fullscreen: true,
snap_aware: true,
private_dbus: false,
app_id: "google-chrome",
},
AppProfile {
name: "thunar",
binary_names: &["thunar", "Thunar"],
env: &[("GDK_BACKEND", "wayland")],
args: &[],
compositor: CompositorChoice::Sway,
fullscreen: true,
snap_aware: false,
private_dbus: true,
app_id: "thunar",
},
];
pub fn profile(name: &str) -> io::Result<&'static AppProfile> {
PROFILES
.iter()
.find(|profile| profile.name == name)
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"unknown --app profile {name:?}; known profiles: {}",
known()
),
)
})
}
pub fn known() -> String {
PROFILES
.iter()
.map(|profile| profile.name)
.collect::<Vec<_>>()
.join(", ")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppLaunch {
pub program: Vec<OsString>,
pub binary: PathBuf,
pub env: Vec<(OsString, OsString)>,
pub snap_confined: bool,
pub fullscreen: bool,
pub app_id: &'static str,
}
const PRIVATE_BUS_LAUNCHER: &str = "dbus-run-session";
impl AppProfile {
pub fn launch(&self, passthrough: &[OsString]) -> io::Result<AppLaunch> {
let binary = self.discover().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!(
"--app {} found none of: {}",
self.name,
self.binary_names.join(", ")
),
)
})?;
Ok(self.launch_with(&binary, passthrough))
}
pub fn launch_with(&self, binary: &Path, passthrough: &[OsString]) -> AppLaunch {
self.launch_parts(
binary,
passthrough,
self.private_dbus && private_bus_available(),
)
}
fn launch_parts(
&self,
binary: &Path,
passthrough: &[OsString],
private_bus: bool,
) -> AppLaunch {
let mut program = Vec::with_capacity(3 + self.args.len() + passthrough.len());
if private_bus {
program.push(OsString::from(PRIVATE_BUS_LAUNCHER));
program.push(OsString::from("--"));
}
program.push(binary.as_os_str().to_owned());
program.extend(self.args.iter().map(OsString::from));
program.extend(passthrough.iter().cloned());
AppLaunch {
program,
binary: binary.to_owned(),
env: self
.env
.iter()
.map(|(name, value)| (OsString::from(name), OsString::from(value)))
.collect(),
snap_confined: self.snap_aware && is_snap_binary(binary),
fullscreen: self.fullscreen,
app_id: self.app_id,
}
}
pub fn discover(&self) -> Option<PathBuf> {
prefer_non_snap(
self.binary_names
.iter()
.filter(|name| command_in_path(name))
.filter_map(|name| which_path(name))
.collect(),
)
}
}
fn private_bus_available() -> bool {
command_in_path(PRIVATE_BUS_LAUNCHER)
}
fn prefer_non_snap(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates
.iter()
.find(|path| !is_snap_binary(path))
.or_else(|| candidates.first())
.cloned()
}
pub fn is_snap_path(path: &Path) -> bool {
let path = path.to_string_lossy();
path.starts_with("/snap/") || path.contains("/snap/bin/")
}
fn is_snap_binary(path: &Path) -> bool {
if is_snap_path(path) {
return true;
}
path.file_name()
.and_then(OsStr::to_str)
.is_some_and(snap_package_installed)
}
fn snap_package_installed(package: &str) -> bool {
Command::new("snap")
.args(["list", package])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
pub const SNAP_WAYLAND_GAP: &str = "is snap-confined; snap's sandbox only allows Wayland sockets at $XDG_RUNTIME_DIR/wayland-N, \
so it cannot connect to vvland's private compositor socket. Install the non-snap (deb or \
flatpak) build, or run it under --xwayland";
pub fn is_unix_pulse_server(server: &OsStr) -> bool {
server.to_string_lossy().starts_with("unix:")
}
fn which_path(name: &str) -> Option<PathBuf> {
std::env::var_os("PATH").and_then(|paths| {
std::env::split_paths(&paths)
.map(|directory| directory.join(name))
.find(|candidate| candidate.is_file())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_profiles_list_the_known_ones() {
let error = profile("emacs").unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
let message = error.to_string();
assert!(message.contains("emacs"));
for name in ["google-chrome", "thunar"] {
assert!(message.contains(name), "{message}");
assert_eq!(profile(name).unwrap().name, name);
}
}
#[test]
fn profile_arguments_come_before_the_passthrough() {
let chrome = profile("google-chrome").unwrap();
let launch = chrome.launch_with(
Path::new("/usr/bin/google-chrome"),
&[OsString::from("--user-data-dir=/tmp/x")],
);
assert_eq!(
launch.program,
[
"/usr/bin/google-chrome",
"--ozone-platform=wayland",
"--start-maximized",
"--no-first-run",
"--no-default-browser-check",
"--disable-breakpad",
"--disable-crash-reporter",
"--disable-metrics",
"--disable-metrics-repo-reporting",
"--disable-component-update",
"--disable-background-networking",
"--disable-sync",
"--disable-features=PassageEmbeddings,HistoryEmbeddings,OptimizationGuideModelDownloading",
"--user-data-dir=/tmp/x",
]
.map(OsString::from)
);
}
#[test]
fn profiles_force_the_wayland_backend() {
let thunar = profile("thunar")
.unwrap()
.launch_with(Path::new("/usr/bin/thunar"), &[]);
assert!(
thunar
.env
.contains(&(OsString::from("GDK_BACKEND"), OsString::from("wayland")))
);
assert_eq!(thunar.app_id, "thunar");
assert!(profile("google-chrome").unwrap().env.is_empty());
}
#[test]
fn a_dbus_single_instance_application_gets_its_own_bus() {
let thunar = profile("thunar").unwrap();
assert!(thunar.private_dbus);
let wrapped = thunar.launch_parts(Path::new("/usr/bin/thunar"), &[], true);
assert_eq!(
wrapped.program,
["dbus-run-session", "--", "/usr/bin/thunar"].map(OsString::from)
);
assert_eq!(wrapped.binary, Path::new("/usr/bin/thunar"));
let bare = thunar.launch_parts(Path::new("/usr/bin/thunar"), &[], false);
assert_eq!(bare.program, ["/usr/bin/thunar"].map(OsString::from));
let chrome = profile("google-chrome").unwrap();
assert!(!chrome.private_dbus);
assert_eq!(
chrome
.launch_with(Path::new("/usr/bin/google-chrome"), &[])
.program
.first()
.unwrap(),
&OsString::from("/usr/bin/google-chrome")
);
}
#[test]
fn the_private_bus_wrapper_precedes_profile_and_passthrough_arguments() {
let thunar = profile("thunar").unwrap();
let launch = thunar.launch_parts(
Path::new("/usr/bin/thunar"),
&[OsString::from("/tmp")],
true,
);
assert_eq!(
launch.program,
["dbus-run-session", "--", "/usr/bin/thunar", "/tmp"].map(OsString::from)
);
}
#[test]
fn discovery_prefers_a_non_snap_binary() {
let snap = PathBuf::from("/snap/bin/chromium");
let plain = PathBuf::from("/usr/bin/google-chrome");
assert_eq!(
prefer_non_snap(vec![snap.clone(), plain.clone()]),
Some(plain.clone())
);
assert_eq!(
prefer_non_snap(vec![plain.clone(), snap.clone()]),
Some(plain)
);
assert_eq!(prefer_non_snap(vec![snap.clone()]), Some(snap));
assert_eq!(prefer_non_snap(Vec::new()), None);
}
#[test]
fn snap_paths_are_detected_without_touching_snapd() {
assert!(is_snap_path(Path::new("/snap/bin/chromium")));
assert!(is_snap_path(Path::new("/snap/chromium/current/chromium")));
assert!(is_snap_path(Path::new("/usr/local/snap/bin/chromium")));
assert!(!is_snap_path(Path::new("/usr/bin/google-chrome")));
assert!(!is_snap_path(Path::new("/opt/google/chrome/chrome")));
let launch = profile("thunar")
.unwrap()
.launch_with(Path::new("/snap/bin/thunar"), &[]);
assert!(!launch.snap_confined, "thunar is not snap-aware");
let launch = profile("google-chrome")
.unwrap()
.launch_with(Path::new("/snap/bin/chromium"), &[]);
assert!(launch.snap_confined);
}
#[test]
fn raw_unix_pulse_servers_are_recognized() {
assert!(is_unix_pulse_server(OsStr::new(
"unix:/run/user/1000/pulse/native"
)));
assert!(!is_unix_pulse_server(OsStr::new("tcp:127.0.0.1:4713")));
}
#[test]
fn help_text_lists_every_built_in_profile() {
use clap::CommandFactory;
let help = crate::cli::Config::command().render_long_help().to_string();
for profile in PROFILES {
assert!(help.contains(profile.name), "help omits {}", profile.name);
}
}
#[test]
fn every_profile_prefers_a_compositor_that_can_isolate_one_window() {
for profile in PROFILES {
assert_ne!(
profile.compositor,
CompositorChoice::Weston,
"{}",
profile.name
);
assert!(!profile.binary_names.is_empty(), "{}", profile.name);
assert!(!profile.app_id.is_empty(), "{}", profile.name);
}
}
}