cosmic-applets/cosmic-app-list/src/launcher_edit.rs
Lionel DARNIS cc501c7637
Some checks are pending
Continuous Integration / formatting (push) Waiting to run
Continuous Integration / linting (push) Waiting to run
feat: add editable dock launchers
2026-05-26 10:39:01 +02:00

378 lines
11 KiB
Rust

// Copyright 2026 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use std::{
env,
ffi::OsString,
io::ErrorKind,
path::{Path, PathBuf},
};
use tokio::fs;
#[derive(Clone, Debug)]
pub struct LauncherEditRequest {
pub current_app_id: String,
pub source_path: PathBuf,
pub name: String,
pub exec: String,
pub icon: String,
pub terminal: bool,
pub replace_localized_name: bool,
pub disable_dbus_activation: bool,
}
#[derive(Clone, Debug)]
pub struct LauncherEditResult {
pub old_app_id: String,
pub new_app_id: String,
pub path: PathBuf,
}
#[derive(Clone, Debug)]
struct ValidLauncherEdit {
current_app_id: String,
source_path: PathBuf,
name: String,
exec: String,
icon: String,
terminal: bool,
replace_localized_name: bool,
disable_dbus_activation: bool,
}
pub fn validate_launcher_fields(name: &str, exec: &str, icon: &str) -> Result<(), String> {
validate_required("Name", name)?;
validate_required("Command", exec)?;
validate_optional("Icon", icon)?;
Ok(())
}
pub async fn save_launcher_edit(
request: LauncherEditRequest,
) -> Result<LauncherEditResult, String> {
let request = validate_request(request)?;
let (new_app_id, target_path) =
target_launcher_path(&request.current_app_id, &request.source_path)?;
let source = read_source_desktop_entry(&request).await?;
let rendered = render_editable_desktop_entry(&source, &request);
let Some(parent) = target_path.parent() else {
return Err("Desktop file target has no parent directory".to_string());
};
fs::create_dir_all(parent)
.await
.map_err(|err| format!("Could not create applications directory: {err}"))?;
let tmp_path = temporary_path(&target_path)?;
fs::write(&tmp_path, rendered)
.await
.map_err(|err| format!("Could not write temporary desktop file: {err}"))?;
if let Err(err) = fs::rename(&tmp_path, &target_path).await {
let _ = fs::remove_file(&tmp_path).await;
return Err(format!("Could not install desktop file: {err}"));
}
Ok(LauncherEditResult {
old_app_id: request.current_app_id,
new_app_id,
path: target_path,
})
}
fn validate_request(request: LauncherEditRequest) -> Result<ValidLauncherEdit, String> {
let name = validate_required("Name", &request.name)?;
let exec = validate_required("Command", &request.exec)?;
let icon = validate_optional("Icon", &request.icon)?;
Ok(ValidLauncherEdit {
current_app_id: request.current_app_id,
source_path: request.source_path,
name,
exec,
icon,
terminal: request.terminal,
replace_localized_name: request.replace_localized_name,
disable_dbus_activation: request.disable_dbus_activation,
})
}
fn validate_required(label: &str, value: &str) -> Result<String, String> {
let value = validate_optional(label, value)?;
if value.is_empty() {
return Err(format!("{label} cannot be empty"));
}
Ok(value)
}
fn validate_optional(label: &str, value: &str) -> Result<String, String> {
if value.contains('\0') || value.contains('\n') || value.contains('\r') {
return Err(format!("{label} cannot contain line breaks"));
}
Ok(value.trim().to_string())
}
async fn read_source_desktop_entry(request: &ValidLauncherEdit) -> Result<String, String> {
if request.source_path.as_os_str().is_empty() {
return Ok(minimal_desktop_entry());
}
match fs::read_to_string(&request.source_path).await {
Ok(source) => Ok(source),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(minimal_desktop_entry()),
Err(err) => Err(format!("Could not read source desktop file: {err}")),
}
}
fn minimal_desktop_entry() -> String {
"[Desktop Entry]\nType=Application\n".to_string()
}
fn user_applications_dir() -> Result<PathBuf, String> {
if let Some(xdg_data_home) = env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
return Ok(PathBuf::from(xdg_data_home).join("applications"));
}
let Some(home) = env::var_os("HOME").filter(|value| !value.is_empty()) else {
return Err("Neither XDG_DATA_HOME nor HOME is set".to_string());
};
Ok(PathBuf::from(home).join(".local/share/applications"))
}
fn target_launcher_path(app_id: &str, source_path: &Path) -> Result<(String, PathBuf), String> {
let applications_dir = user_applications_dir()?;
if source_path.starts_with(&applications_dir) {
return Ok((app_id.to_string(), source_path.to_path_buf()));
}
let desktop_id = normalize_desktop_id(app_id)?;
Ok((
desktop_id.clone(),
applications_dir.join(format!("{desktop_id}.desktop")),
))
}
fn normalize_desktop_id(app_id: &str) -> Result<String, String> {
if app_id.contains('\0') || app_id.contains('\n') || app_id.contains('\r') {
return Err("Desktop ID cannot contain line breaks".to_string());
}
let desktop_id = app_id
.chars()
.map(|c| if c == '/' { '-' } else { c })
.collect::<String>();
if desktop_id.trim().is_empty() {
return Err("Desktop ID cannot be empty".to_string());
}
Ok(desktop_id)
}
fn temporary_path(target_path: &Path) -> Result<PathBuf, String> {
let Some(file_name) = target_path.file_name() else {
return Err("Desktop file target has no filename".to_string());
};
let mut tmp_file_name = OsString::from(".");
tmp_file_name.push(file_name);
tmp_file_name.push(format!(".tmp-{}", std::process::id()));
Ok(target_path.with_file_name(tmp_file_name))
}
fn render_editable_desktop_entry(source: &str, request: &ValidLauncherEdit) -> String {
let mut updates = vec![
("Type", "Application".to_string()),
("Name", request.name.clone()),
("Exec", request.exec.clone()),
("Icon", request.icon.clone()),
(
"Terminal",
if request.terminal { "true" } else { "false" }.to_string(),
),
("X-COSMIC-UserEditable", "true".to_string()),
("X-COSMIC-SourceDesktopId", request.current_app_id.clone()),
];
if !request.source_path.as_os_str().is_empty() {
updates.push((
"X-COSMIC-SourceDesktopPath",
request.source_path.to_string_lossy().to_string(),
));
}
if request.disable_dbus_activation {
updates.push(("DBusActivatable", "false".to_string()));
}
set_desktop_entry_keys(
source,
&updates,
request.replace_localized_name,
request.disable_dbus_activation,
)
}
fn set_desktop_entry_keys(
source: &str,
updates: &[(&str, String)],
replace_localized_name: bool,
remove_try_exec: bool,
) -> String {
let mut out = Vec::new();
let mut seen = vec![false; updates.len()];
let mut in_desktop_entry = false;
let mut saw_desktop_entry = false;
for line in source.lines() {
if let Some(section) = section_name(line) {
if in_desktop_entry {
insert_missing_keys(&mut out, updates, &seen);
}
in_desktop_entry = section == "Desktop Entry";
saw_desktop_entry |= in_desktop_entry;
if in_desktop_entry {
seen.fill(false);
}
out.push(line.to_string());
continue;
}
if in_desktop_entry && let Some(key) = desktop_entry_key(line) {
if replace_localized_name && key.starts_with("Name[") {
continue;
}
if remove_try_exec && key == "TryExec" {
continue;
}
if let Some(index) = updates
.iter()
.position(|(update_key, _)| *update_key == key)
{
out.push(format!("{}={}", updates[index].0, updates[index].1));
seen[index] = true;
continue;
}
}
out.push(line.to_string());
}
if in_desktop_entry {
insert_missing_keys(&mut out, updates, &seen);
}
if !saw_desktop_entry {
let mut with_desktop_entry = Vec::with_capacity(out.len() + updates.len() + 2);
with_desktop_entry.push("[Desktop Entry]".to_string());
insert_missing_keys(
&mut with_desktop_entry,
updates,
&vec![false; updates.len()],
);
if !out.is_empty() {
with_desktop_entry.push(String::new());
with_desktop_entry.extend(out);
}
out = with_desktop_entry;
}
let mut rendered = out.join("\n");
rendered.push('\n');
rendered
}
fn insert_missing_keys(out: &mut Vec<String>, updates: &[(&str, String)], seen: &[bool]) {
for (index, (key, value)) in updates.iter().enumerate() {
if !seen[index] {
out.push(format!("{key}={value}"));
}
}
}
fn section_name(line: &str) -> Option<&str> {
let trimmed = line.trim();
trimmed
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
}
fn desktop_entry_key(line: &str) -> Option<&str> {
let line = line.trim_start();
if line.starts_with('#') || line.starts_with(';') {
return None;
}
line.split_once('=').map(|(key, _)| key.trim_end())
}
#[cfg(test)]
mod tests {
use super::*;
fn request() -> ValidLauncherEdit {
ValidLauncherEdit {
current_app_id: "org.example.App".to_string(),
source_path: PathBuf::from("/usr/share/applications/org.example.App.desktop"),
name: "Example".to_string(),
exec: "example --new".to_string(),
icon: "example-custom".to_string(),
terminal: false,
replace_localized_name: true,
disable_dbus_activation: true,
}
}
#[test]
fn updates_desktop_entry_without_dropping_action_groups() {
let source = "\
[Desktop Entry]
Type=Application
Name=Old
Name[fr]=Ancien
Exec=old
TryExec=old
Icon=old
DBusActivatable=true
Actions=new-window;
[Desktop Action new-window]
Name=New Window
Exec=old --new-window
";
let rendered = render_editable_desktop_entry(source, &request());
assert!(rendered.contains("Name=Example\n"));
assert!(!rendered.contains("Name[fr]="));
assert!(rendered.contains("Exec=example --new\n"));
assert!(rendered.contains("Icon=example-custom\n"));
assert!(rendered.contains("DBusActivatable=false\n"));
assert!(!rendered.contains("TryExec="));
assert!(rendered.contains("[Desktop Action new-window]\n"));
assert!(rendered.contains("Exec=old --new-window\n"));
}
#[test]
fn preserves_localized_names_when_name_is_unchanged() {
let mut edit = request();
edit.replace_localized_name = false;
let rendered = render_editable_desktop_entry(
"[Desktop Entry]\nName=Example\nName[fr]=Exemple\nExec=old\n",
&edit,
);
assert!(rendered.contains("Name=Example\n"));
assert!(rendered.contains("Name[fr]=Exemple\n"));
}
}