feat: add editable dock launchers
This commit is contained in:
parent
9677159db6
commit
d58fe9d74c
5 changed files with 735 additions and 36 deletions
|
|
@ -5,4 +5,10 @@ quit-all = Quit All
|
||||||
new-window = New Window
|
new-window = New Window
|
||||||
run = Run
|
run = Run
|
||||||
run-on = Run on {$gpu}
|
run-on = Run on {$gpu}
|
||||||
run-on-default = (Default)
|
run-on-default = (Default)
|
||||||
|
edit-launcher = Edit launcher
|
||||||
|
launcher-name = Name
|
||||||
|
launcher-command = Command
|
||||||
|
launcher-icon = Icon
|
||||||
|
save = Save
|
||||||
|
cancel = Cancel
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,9 @@ new-window = Nouvelle fenêtre
|
||||||
run = Exécuter
|
run = Exécuter
|
||||||
run-on = Lancer avec { $gpu }
|
run-on = Lancer avec { $gpu }
|
||||||
run-on-default = (Défaut)
|
run-on-default = (Défaut)
|
||||||
|
edit-launcher = Modifier le lanceur
|
||||||
|
launcher-name = Nom
|
||||||
|
launcher-command = Commande
|
||||||
|
launcher-icon = Icône
|
||||||
|
save = Enregistrer
|
||||||
|
cancel = Annuler
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
fl,
|
fl,
|
||||||
|
launcher_edit::{self, LauncherEditRequest},
|
||||||
wayland_subscription::{
|
wayland_subscription::{
|
||||||
OutputUpdate, ToplevelRequest, ToplevelUpdate, WaylandImage, WaylandRequest, WaylandUpdate,
|
OutputUpdate, ToplevelRequest, ToplevelUpdate, WaylandImage, WaylandRequest, WaylandUpdate,
|
||||||
wayland_subscription,
|
wayland_subscription,
|
||||||
|
|
@ -47,7 +48,7 @@ use cosmic::{
|
||||||
icon::{self, from_name},
|
icon::{self, from_name},
|
||||||
image::Handle,
|
image::Handle,
|
||||||
rectangle_tracker::{RectangleTracker, RectangleUpdate, rectangle_tracker_subscription},
|
rectangle_tracker::{RectangleTracker, RectangleUpdate, rectangle_tracker_subscription},
|
||||||
svg, text,
|
svg, text, text_input,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use cosmic::{
|
use cosmic::{
|
||||||
|
|
@ -387,10 +388,25 @@ pub struct Popup {
|
||||||
popup_type: PopupType,
|
popup_type: PopupType,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct LauncherEditState {
|
||||||
|
original_app_id: String,
|
||||||
|
source_path: PathBuf,
|
||||||
|
original_name: String,
|
||||||
|
original_exec: String,
|
||||||
|
name: String,
|
||||||
|
exec: String,
|
||||||
|
icon: String,
|
||||||
|
terminal: bool,
|
||||||
|
saving: bool,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
struct CosmicAppList {
|
struct CosmicAppList {
|
||||||
core: cosmic::app::Core,
|
core: cosmic::app::Core,
|
||||||
popup: Option<Popup>,
|
popup: Option<Popup>,
|
||||||
|
launcher_edit: Option<LauncherEditState>,
|
||||||
subscription_ctr: u32,
|
subscription_ctr: u32,
|
||||||
item_ctr: u32,
|
item_ctr: u32,
|
||||||
desktop_entries: Vec<DesktopEntry>,
|
desktop_entries: Vec<DesktopEntry>,
|
||||||
|
|
@ -432,6 +448,7 @@ struct CosmicAppList {
|
||||||
pub enum PopupType {
|
pub enum PopupType {
|
||||||
RightClickMenu,
|
RightClickMenu,
|
||||||
ToplevelList,
|
ToplevelList,
|
||||||
|
LauncherEditor,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -440,6 +457,13 @@ enum Message {
|
||||||
Wayland(WaylandUpdate),
|
Wayland(WaylandUpdate),
|
||||||
PinApp(u32),
|
PinApp(u32),
|
||||||
UnpinApp(u32),
|
UnpinApp(u32),
|
||||||
|
EditLauncher(u32),
|
||||||
|
LauncherNameChanged(String),
|
||||||
|
LauncherExecChanged(String),
|
||||||
|
LauncherIconChanged(String),
|
||||||
|
SaveLauncherEdit,
|
||||||
|
CancelLauncherEdit,
|
||||||
|
LauncherEditSaved(Result<launcher_edit::LauncherEditResult, String>),
|
||||||
/// Yoda: pointer entered (Some) or left (None) a dock icon — drives
|
/// Yoda: pointer entered (Some) or left (None) a dock icon — drives
|
||||||
/// the macOS Tahoe-style hover magnification effect.
|
/// the macOS Tahoe-style hover magnification effect.
|
||||||
DockItemHover(Option<DockItemId>),
|
DockItemHover(Option<DockItemId>),
|
||||||
|
|
@ -822,10 +846,45 @@ impl CosmicAppList {
|
||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sync_pinned_list_from_config(&mut self) {
|
||||||
|
for item in self.pinned_list.drain(..) {
|
||||||
|
if !item.toplevels.is_empty() {
|
||||||
|
self.active_list.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.pinned_list = find_desktop_entries(&self.desktop_entries, &self.config.favorites)
|
||||||
|
.zip(&self.config.favorites)
|
||||||
|
.map(|(de, original_id)| {
|
||||||
|
if let Some(p) = self
|
||||||
|
.active_list
|
||||||
|
.iter()
|
||||||
|
.position(|dock_item| dock_item.desktop_info.id() == de.id())
|
||||||
|
{
|
||||||
|
let mut d = self.active_list.remove(p);
|
||||||
|
d.desktop_info = de.clone();
|
||||||
|
d.original_app_id.clone_from(original_id);
|
||||||
|
d
|
||||||
|
} else {
|
||||||
|
self.item_ctr += 1;
|
||||||
|
DockItem {
|
||||||
|
id: self.item_ctr,
|
||||||
|
toplevels: Vec::new(),
|
||||||
|
desktop_info: de.clone(),
|
||||||
|
original_app_id: original_id.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
/// Close any open popups.
|
/// Close any open popups.
|
||||||
fn close_popups(&mut self) -> Task<cosmic::Action<Message>> {
|
fn close_popups(&mut self) -> Task<cosmic::Action<Message>> {
|
||||||
let mut commands = Vec::new();
|
let mut commands = Vec::new();
|
||||||
if let Some(popup) = self.popup.take() {
|
if let Some(popup) = self.popup.take() {
|
||||||
|
if popup.popup_type == PopupType::LauncherEditor {
|
||||||
|
self.launcher_edit = None;
|
||||||
|
}
|
||||||
commands.push(destroy_popup(popup.id));
|
commands.push(destroy_popup(popup.id));
|
||||||
}
|
}
|
||||||
if let Some(popup) = self.overflow_active_popup.take() {
|
if let Some(popup) = self.overflow_active_popup.take() {
|
||||||
|
|
@ -1199,6 +1258,182 @@ impl cosmic::Application for CosmicAppList {
|
||||||
return destroy_popup(popup_id);
|
return destroy_popup(popup_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Message::EditLauncher(id) => {
|
||||||
|
let Some(dock_item) = self.pinned_list.iter().find(|t| t.id == id).cloned() else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
let Some(exec) = dock_item.desktop_info.exec() else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
let Some(existing_popup) = self.popup.take() else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
let Some(rectangle) = self.rectangles.get(&dock_item.id.into()) else {
|
||||||
|
self.popup = Some(existing_popup);
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
|
||||||
|
let original_name = dock_item
|
||||||
|
.desktop_info
|
||||||
|
.desktop_entry("Name")
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.or_else(|| {
|
||||||
|
dock_item
|
||||||
|
.desktop_info
|
||||||
|
.name(&self.locales)
|
||||||
|
.map(Cow::into_owned)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| dock_item.original_app_id.clone());
|
||||||
|
let original_exec = exec.to_string();
|
||||||
|
|
||||||
|
self.launcher_edit = Some(LauncherEditState {
|
||||||
|
original_app_id: dock_item.original_app_id.clone(),
|
||||||
|
source_path: dock_item.desktop_info.path.clone(),
|
||||||
|
original_name: original_name.clone(),
|
||||||
|
original_exec: original_exec.clone(),
|
||||||
|
name: original_name,
|
||||||
|
exec: original_exec,
|
||||||
|
icon: dock_item
|
||||||
|
.desktop_info
|
||||||
|
.icon()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
terminal: dock_item.desktop_info.terminal(),
|
||||||
|
saving: false,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let new_id = window::Id::unique();
|
||||||
|
let mut popup_settings = self.core.applet.get_popup_settings(
|
||||||
|
existing_popup.parent,
|
||||||
|
new_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let iced::Rectangle {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
} = *rectangle;
|
||||||
|
popup_settings.positioner.anchor_rect = iced::Rectangle::<i32> {
|
||||||
|
x: x as i32,
|
||||||
|
y: y as i32,
|
||||||
|
width: width as i32,
|
||||||
|
height: height as i32,
|
||||||
|
};
|
||||||
|
popup_settings.positioner.size_limits = Limits::NONE
|
||||||
|
.min_width(480.)
|
||||||
|
.min_height(1.)
|
||||||
|
.max_width(520.)
|
||||||
|
.max_height(1000.);
|
||||||
|
|
||||||
|
self.popup = Some(Popup {
|
||||||
|
parent: existing_popup.parent,
|
||||||
|
id: new_id,
|
||||||
|
dock_item,
|
||||||
|
popup_type: PopupType::LauncherEditor,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task::batch([destroy_popup(existing_popup.id), get_popup(popup_settings)]);
|
||||||
|
}
|
||||||
|
Message::LauncherNameChanged(name) => {
|
||||||
|
if let Some(edit) = self.launcher_edit.as_mut()
|
||||||
|
&& !edit.saving
|
||||||
|
{
|
||||||
|
edit.name = name;
|
||||||
|
edit.error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::LauncherExecChanged(exec) => {
|
||||||
|
if let Some(edit) = self.launcher_edit.as_mut()
|
||||||
|
&& !edit.saving
|
||||||
|
{
|
||||||
|
edit.exec = exec;
|
||||||
|
edit.error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::LauncherIconChanged(icon) => {
|
||||||
|
if let Some(edit) = self.launcher_edit.as_mut()
|
||||||
|
&& !edit.saving
|
||||||
|
{
|
||||||
|
edit.icon = icon;
|
||||||
|
edit.error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::SaveLauncherEdit => {
|
||||||
|
let Some(edit) = self.launcher_edit.as_mut() else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
if edit.saving {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
if let Err(error) =
|
||||||
|
launcher_edit::validate_launcher_fields(&edit.name, &edit.exec, &edit.icon)
|
||||||
|
{
|
||||||
|
edit.error = Some(error);
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = LauncherEditRequest {
|
||||||
|
current_app_id: edit.original_app_id.clone(),
|
||||||
|
source_path: edit.source_path.clone(),
|
||||||
|
name: edit.name.clone(),
|
||||||
|
exec: edit.exec.clone(),
|
||||||
|
icon: edit.icon.clone(),
|
||||||
|
terminal: edit.terminal,
|
||||||
|
replace_localized_name: edit.name.trim() != edit.original_name.trim(),
|
||||||
|
disable_dbus_activation: edit.exec.trim() != edit.original_exec.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
edit.saving = true;
|
||||||
|
edit.error = None;
|
||||||
|
|
||||||
|
return Task::perform(launcher_edit::save_launcher_edit(request), |result| {
|
||||||
|
cosmic::Action::App(Message::LauncherEditSaved(result))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Message::CancelLauncherEdit => {
|
||||||
|
return self.close_popups();
|
||||||
|
}
|
||||||
|
Message::LauncherEditSaved(result) => match result {
|
||||||
|
Ok(result) => {
|
||||||
|
tracing::info!(
|
||||||
|
app_id = result.new_app_id,
|
||||||
|
path = ?result.path,
|
||||||
|
"saved editable launcher"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut favorites = self.config.favorites.clone();
|
||||||
|
let mut favorites_changed = false;
|
||||||
|
for favorite in &mut favorites {
|
||||||
|
if *favorite == result.old_app_id && *favorite != result.new_app_id {
|
||||||
|
*favorite = result.new_app_id.clone();
|
||||||
|
favorites_changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if favorites_changed {
|
||||||
|
self.config.update_pinned(
|
||||||
|
favorites.clone(),
|
||||||
|
&Config::new(APP_ID, AppListConfig::VERSION).unwrap(),
|
||||||
|
);
|
||||||
|
self.config.favorites = favorites;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update_desktop_entries();
|
||||||
|
self.sync_pinned_list_from_config();
|
||||||
|
self.launcher_edit = None;
|
||||||
|
return self.close_popups();
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
if let Some(edit) = self.launcher_edit.as_mut() {
|
||||||
|
edit.saving = false;
|
||||||
|
edit.error = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
Message::Activate(handle) => {
|
Message::Activate(handle) => {
|
||||||
if let Some(tx) = self.wayland_sender.as_ref() {
|
if let Some(tx) = self.wayland_sender.as_ref() {
|
||||||
let _ = tx.send(WaylandRequest::Toplevel(ToplevelRequest::Activate(handle)));
|
let _ = tx.send(WaylandRequest::Toplevel(ToplevelRequest::Activate(handle)));
|
||||||
|
|
@ -1651,6 +1886,9 @@ impl cosmic::Application for CosmicAppList {
|
||||||
},
|
},
|
||||||
Message::ClosePopup => {
|
Message::ClosePopup => {
|
||||||
if let Some(p) = self.popup.take() {
|
if let Some(p) = self.popup.take() {
|
||||||
|
if p.popup_type == PopupType::LauncherEditor {
|
||||||
|
self.launcher_edit = None;
|
||||||
|
}
|
||||||
return destroy_popup(p.id);
|
return destroy_popup(p.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1665,42 +1903,16 @@ impl cosmic::Application for CosmicAppList {
|
||||||
}
|
}
|
||||||
Message::ConfigUpdated(config) => {
|
Message::ConfigUpdated(config) => {
|
||||||
self.config = config;
|
self.config = config;
|
||||||
// drain to active list
|
self.update_desktop_entries();
|
||||||
for item in self.pinned_list.drain(..) {
|
self.sync_pinned_list_from_config();
|
||||||
if !item.toplevels.is_empty() {
|
|
||||||
self.active_list.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pull back configured items into the favorites list
|
|
||||||
self.pinned_list =
|
|
||||||
find_desktop_entries(&self.desktop_entries, &self.config.favorites)
|
|
||||||
.zip(&self.config.favorites)
|
|
||||||
.map(|(de, original_id)| {
|
|
||||||
if let Some(p) = self
|
|
||||||
.active_list
|
|
||||||
.iter()
|
|
||||||
// match using heuristic id
|
|
||||||
.position(|dock_item| dock_item.desktop_info.id() == de.id())
|
|
||||||
{
|
|
||||||
let mut d = self.active_list.remove(p);
|
|
||||||
// but use the id from the config
|
|
||||||
d.original_app_id.clone_from(original_id);
|
|
||||||
d
|
|
||||||
} else {
|
|
||||||
self.item_ctr += 1;
|
|
||||||
DockItem {
|
|
||||||
id: self.item_ctr,
|
|
||||||
toplevels: Vec::new(),
|
|
||||||
desktop_info: de.clone(),
|
|
||||||
original_app_id: original_id.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
}
|
}
|
||||||
Message::CloseRequested(id) => {
|
Message::CloseRequested(id) => {
|
||||||
if Some(id) == self.popup.as_ref().map(|p| p.id) {
|
if let Some(popup) = &self.popup
|
||||||
|
&& popup.id == id
|
||||||
|
{
|
||||||
|
if popup.popup_type == PopupType::LauncherEditor {
|
||||||
|
self.launcher_edit = None;
|
||||||
|
}
|
||||||
self.popup = None;
|
self.popup = None;
|
||||||
}
|
}
|
||||||
if self.overflow_active_popup.is_some_and(|p| p == id) {
|
if self.overflow_active_popup.is_some_and(|p| p == id) {
|
||||||
|
|
@ -2372,6 +2584,19 @@ impl cosmic::Application for CosmicAppList {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if is_pinned && desktop_info.exec().is_some() {
|
||||||
|
content = content.push(
|
||||||
|
menu_button(
|
||||||
|
row![
|
||||||
|
icon::icon(from_name("edit-symbolic").into()).size(16),
|
||||||
|
text::body(fl!("edit-launcher"))
|
||||||
|
]
|
||||||
|
.spacing(8),
|
||||||
|
)
|
||||||
|
.on_press(Message::EditLauncher(*id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if !toplevels.is_empty() {
|
if !toplevels.is_empty() {
|
||||||
content = content.push(divider::horizontal::light());
|
content = content.push(divider::horizontal::light());
|
||||||
content = match toplevels.len() {
|
content = match toplevels.len() {
|
||||||
|
|
@ -2418,6 +2643,89 @@ impl cosmic::Application for CosmicAppList {
|
||||||
)
|
)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
PopupType::LauncherEditor => {
|
||||||
|
let Some(edit) = self.launcher_edit.as_ref() else {
|
||||||
|
return text::body("").into();
|
||||||
|
};
|
||||||
|
|
||||||
|
let spacing = theme::spacing();
|
||||||
|
let can_save = !edit.saving
|
||||||
|
&& launcher_edit::validate_launcher_fields(
|
||||||
|
&edit.name, &edit.exec, &edit.icon,
|
||||||
|
)
|
||||||
|
.is_ok();
|
||||||
|
|
||||||
|
let mut form = column![
|
||||||
|
text::title4(fl!("edit-launcher")),
|
||||||
|
text_input("", edit.name.as_str())
|
||||||
|
.label(fl!("launcher-name"))
|
||||||
|
.on_input(Message::LauncherNameChanged)
|
||||||
|
.on_submit(|_| Message::SaveLauncherEdit)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.size(14),
|
||||||
|
text_input("", edit.exec.as_str())
|
||||||
|
.label(fl!("launcher-command"))
|
||||||
|
.on_input(Message::LauncherExecChanged)
|
||||||
|
.on_submit(|_| Message::SaveLauncherEdit)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.size(14),
|
||||||
|
text_input("", edit.icon.as_str())
|
||||||
|
.label(fl!("launcher-icon"))
|
||||||
|
.on_input(Message::LauncherIconChanged)
|
||||||
|
.on_submit(|_| Message::SaveLauncherEdit)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.size(14),
|
||||||
|
]
|
||||||
|
.spacing(spacing.space_s)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
if let Some(error) = edit.error.as_ref() {
|
||||||
|
form = form.push(text::caption(error.as_str()).class(
|
||||||
|
cosmic::theme::Text::Color(theme.cosmic().destructive_color().into()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancel =
|
||||||
|
button::custom(text::body(fl!("cancel")).center().width(Length::Fill))
|
||||||
|
.on_press_maybe(if edit.saving {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(Message::CancelLauncherEdit)
|
||||||
|
})
|
||||||
|
.padding([spacing.space_xxs, spacing.space_s])
|
||||||
|
.width(142);
|
||||||
|
|
||||||
|
let save = button::custom(text::body(fl!("save")).center().width(Length::Fill))
|
||||||
|
.class(Button::Suggested)
|
||||||
|
.on_press_maybe(if can_save {
|
||||||
|
Some(Message::SaveLauncherEdit)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
})
|
||||||
|
.padding([spacing.space_xxs, spacing.space_s])
|
||||||
|
.width(142);
|
||||||
|
|
||||||
|
let actions = row![horizontal_space(), cancel, save]
|
||||||
|
.spacing(spacing.space_xxs)
|
||||||
|
.align_y(Alignment::Center);
|
||||||
|
|
||||||
|
let content = column![form, actions]
|
||||||
|
.spacing(spacing.space_m)
|
||||||
|
.padding(spacing.space_m)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
self.core
|
||||||
|
.applet
|
||||||
|
.popup_container(container(content).width(Length::Fill))
|
||||||
|
.limits(
|
||||||
|
Limits::NONE
|
||||||
|
.min_width(480.)
|
||||||
|
.min_height(1.)
|
||||||
|
.max_width(520.)
|
||||||
|
.max_height(1000.),
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
PopupType::ToplevelList => match self.core.applet.anchor {
|
PopupType::ToplevelList => match self.core.applet.anchor {
|
||||||
PanelAnchor::Left | PanelAnchor::Right => {
|
PanelAnchor::Left | PanelAnchor::Right => {
|
||||||
let mut content =
|
let mut content =
|
||||||
|
|
|
||||||
378
cosmic-app-list/src/launcher_edit.rs
Normal file
378
cosmic-app-list/src/launcher_edit.rs
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
// 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-only
|
// SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
|
mod launcher_edit;
|
||||||
mod localize;
|
mod localize;
|
||||||
mod wayland_handler;
|
mod wayland_handler;
|
||||||
mod wayland_subscription;
|
mod wayland_subscription;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue