use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use async_trait::async_trait;
use futures::future::join_all;
use crate::desktop_file::{DESKTOP_ENTRY_GROUP, DesktopFile};
use crate::model::{AutostartEntry, Scope};
use crate::provider::AutostartProvider;
const PROVIDER_ID: &str = "xdg-autostart";
pub struct XdgAutostartProvider {
autostart_dir: PathBuf,
}
impl XdgAutostartProvider {
pub fn new() -> Self {
Self::with_autostart_dir(default_autostart_dir())
}
pub fn with_autostart_dir(autostart_dir: PathBuf) -> Self {
Self { autostart_dir }
}
fn entry_path(&self, id: &str) -> PathBuf {
self.autostart_dir.join(format!("{id}.desktop"))
}
async fn read_entry_file(&self, id: &str) -> Result<DesktopFile> {
let path = self.entry_path(id);
let text = tokio::fs::read_to_string(&path)
.await
.with_context(|| format!("no autostart entry '{id}' at {}", path.display()))?;
Ok(DesktopFile::parse(&text))
}
}
impl Default for XdgAutostartProvider {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl AutostartProvider for XdgAutostartProvider {
fn id(&self) -> &'static str {
PROVIDER_ID
}
fn scope(&self) -> Scope {
Scope::User
}
async fn is_available(&self) -> bool {
if tokio::fs::metadata(&self.autostart_dir).await.is_ok() {
return true;
}
match self.autostart_dir.parent() {
Some(parent) => tokio::fs::metadata(parent).await.is_ok(),
None => false,
}
}
async fn entries(&self) -> Result<Vec<AutostartEntry>> {
let mut read_dir = match tokio::fs::read_dir(&self.autostart_dir).await {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(e).with_context(|| {
format!("reading autostart dir {}", self.autostart_dir.display())
});
}
};
let mut paths = Vec::new();
while let Some(dent) = read_dir.next_entry().await? {
let path = dent.path();
if path.extension().and_then(|e| e.to_str()) == Some("desktop") {
paths.push(path);
}
}
let reads = paths
.iter()
.map(|p| async move { (p, tokio::fs::read_to_string(p).await) });
let results = join_all(reads).await;
let mut entries = Vec::new();
for (path, read) in results {
let Ok(text) = read else { continue };
let Some(id) = file_stem(path) else { continue };
entries.push(entry_from_desktop(&id, &DesktopFile::parse(&text)));
}
Ok(entries)
}
async fn enable(&self, id: &str) -> Result<()> {
let mut file = self.read_entry_file(id).await?;
file.remove(DESKTOP_ENTRY_GROUP, "Hidden");
self.write_entry_file(id, &file).await
}
async fn disable(&self, id: &str) -> Result<()> {
let mut file = self.read_entry_file(id).await?;
file.set(DESKTOP_ENTRY_GROUP, "Hidden", "true");
self.write_entry_file(id, &file).await
}
async fn add(&self, entry: &AutostartEntry) -> Result<()> {
let path = self.entry_path(&entry.id);
if tokio::fs::try_exists(&path).await.unwrap_or(false) {
bail!("autostart entry '{}' already exists", entry.id);
}
tokio::fs::create_dir_all(&self.autostart_dir)
.await
.with_context(|| format!("creating autostart dir {}", self.autostart_dir.display()))?;
let mut file = DesktopFile::default();
file.set(DESKTOP_ENTRY_GROUP, "Type", "Application");
file.set(DESKTOP_ENTRY_GROUP, "Name", &entry.display_name);
file.set(DESKTOP_ENTRY_GROUP, "Exec", &entry.command);
if let Some(icon) = &entry.icon {
file.set(DESKTOP_ENTRY_GROUP, "Icon", icon);
}
if let Some(desc) = &entry.description {
file.set(DESKTOP_ENTRY_GROUP, "Comment", desc);
}
if !entry.enabled {
file.set(DESKTOP_ENTRY_GROUP, "Hidden", "true");
}
self.write_entry_file(&entry.id, &file).await
}
async fn remove(&self, id: &str) -> Result<()> {
let path = self.entry_path(id);
tokio::fs::remove_file(&path)
.await
.with_context(|| format!("removing autostart entry '{id}' at {}", path.display()))
}
}
impl XdgAutostartProvider {
async fn write_entry_file(&self, id: &str, file: &DesktopFile) -> Result<()> {
let path = self.entry_path(id);
tokio::fs::write(&path, file.to_text())
.await
.with_context(|| format!("writing autostart entry '{id}' at {}", path.display()))
}
}
fn default_autostart_dir() -> PathBuf {
let config_home = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_default();
home.join(".config")
});
config_home.join("autostart")
}
fn file_stem(path: &Path) -> Option<String> {
path.file_stem().and_then(|s| s.to_str()).map(String::from)
}
fn entry_from_desktop(id: &str, file: &DesktopFile) -> AutostartEntry {
let get = |key: &str| file.get(DESKTOP_ENTRY_GROUP, key).map(str::to_string);
let hidden = file
.get(DESKTOP_ENTRY_GROUP, "Hidden")
.map(|v| v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
AutostartEntry {
id: id.to_string(),
display_name: get("Name").unwrap_or_else(|| id.to_string()),
description: get("Comment"),
command: get("Exec").unwrap_or_default(),
icon: get("Icon"),
enabled: !hidden,
source: PROVIDER_ID.to_string(),
scope: Scope::User,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn provider() -> (XdgAutostartProvider, TempDir) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("autostart");
std::fs::create_dir_all(&dir).unwrap();
(XdgAutostartProvider::with_autostart_dir(dir), tmp)
}
async fn write_desktop(p: &XdgAutostartProvider, id: &str, body: &str) {
tokio::fs::write(p.entry_path(id), body).await.unwrap();
}
#[tokio::test]
async fn parses_and_lists_entry() {
let (p, _guard) = provider();
write_desktop(
&p,
"example",
"[Desktop Entry]\nType=Application\nName=Example App\nComment=Does things\nExec=example --run\nIcon=example-icon\n",
)
.await;
let entries = p.entries().await.unwrap();
assert_eq!(entries.len(), 1);
let e = &entries[0];
assert_eq!(e.id, "example");
assert_eq!(e.display_name, "Example App");
assert_eq!(e.description.as_deref(), Some("Does things"));
assert_eq!(e.command, "example --run");
assert_eq!(e.icon.as_deref(), Some("example-icon"));
assert_eq!(e.source, "xdg-autostart");
assert_eq!(e.scope, Scope::User);
assert!(e.enabled);
}
#[tokio::test]
async fn disable_sets_hidden_true() {
let (p, _guard) = provider();
write_desktop(
&p,
"example",
"[Desktop Entry]\nName=Example\nExec=example\n",
)
.await;
p.disable("example").await.unwrap();
let text = tokio::fs::read_to_string(p.entry_path("example"))
.await
.unwrap();
assert!(text.contains("Hidden=true"), "file was: {text}");
assert!(text.contains("Name=Example"));
let entries = p.entries().await.unwrap();
assert!(!entries[0].enabled);
}
#[tokio::test]
async fn enable_removes_hidden_key() {
let (p, _guard) = provider();
write_desktop(
&p,
"example",
"[Desktop Entry]\nName=Example\nExec=example\nHidden=true\n",
)
.await;
assert!(!p.entries().await.unwrap()[0].enabled);
p.enable("example").await.unwrap();
let text = tokio::fs::read_to_string(p.entry_path("example"))
.await
.unwrap();
assert!(
!text.contains("Hidden"),
"Hidden key should be gone: {text}"
);
assert!(p.entries().await.unwrap()[0].enabled);
}
#[tokio::test]
async fn add_then_remove_round_trip() {
let (p, _guard) = provider();
let entry = AutostartEntry {
id: "newthing".into(),
display_name: "New Thing".into(),
description: None,
command: "newthing --start".into(),
icon: None,
enabled: true,
source: "xdg-autostart".into(),
scope: Scope::User,
};
p.add(&entry).await.unwrap();
assert!(p.entry_path("newthing").exists());
let listed = p.entries().await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].command, "newthing --start");
assert!(p.add(&entry).await.is_err());
p.remove("newthing").await.unwrap();
assert!(!p.entry_path("newthing").exists());
assert!(p.entries().await.unwrap().is_empty());
}
#[tokio::test]
async fn missing_dir_lists_empty() {
let tmp = TempDir::new().unwrap();
let p = XdgAutostartProvider::with_autostart_dir(tmp.path().join("nope"));
assert!(p.entries().await.unwrap().is_empty());
}
}