1use crate::{EntryKind, Manifest, is_safe_component};
6
7pub fn exec_arg(s: &str) -> String {
15 let reserved = |c: char| c.is_whitespace() || "\"'\\<>~|&;$*?#()`".contains(c);
16 let quote = s.is_empty() || s.chars().any(reserved);
17 let mut out = String::with_capacity(s.len() + 2);
18 if quote {
19 out.push('"');
20 }
21 for c in s.chars() {
22 if quote && matches!(c, '"' | '`' | '$' | '\\') {
23 out.push('\\');
24 }
25 if c == '%' {
26 out.push('%');
27 }
28 out.push(c);
29 }
30 if quote {
31 out.push('"');
32 }
33 string_value(&out)
34}
35
36pub fn string_value(s: &str) -> String {
41 let mut out = String::with_capacity(s.len());
42 for (i, c) in s.chars().enumerate() {
43 match c {
44 '\\' => out.push_str("\\\\"),
45 '\n' => out.push_str("\\n"),
46 '\t' => out.push_str("\\t"),
47 '\r' => out.push_str("\\r"),
48 ' ' if i == 0 => out.push_str("\\s"),
49 c => out.push(c),
50 }
51 }
52 out
53}
54
55pub fn exec_arg_tail(value: &str) -> &str {
60 let bytes = value.as_bytes();
61 let mut i = 0;
62 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
63 i += 1;
64 }
65 if i < bytes.len() && bytes[i] == b'"' {
66 i += 1;
70 while i < bytes.len() {
71 match bytes[i] {
72 b'\\' if i + 1 < bytes.len() => {
73 let backslash = bytes[i + 1] == b'\\';
74 i += 2;
75 if backslash && i < bytes.len() {
76 i += if bytes[i] == b'\\' && i + 1 < bytes.len() {
77 2
78 } else {
79 1
80 };
81 }
82 }
83 b'"' => {
84 i += 1;
85 break;
86 }
87 _ => i += 1,
88 }
89 }
90 } else {
91 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
92 i += 1;
93 }
94 }
95 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
96 i += 1;
97 }
98 &value[i..]
99}
100
101pub fn exec_command(path: &str, entrypoint: Option<&str>) -> String {
105 match entrypoint {
106 Some(name) => format!(
107 "env {} {}",
108 exec_arg(&format!("{ENTRYPOINT_VAR}={name}")),
109 exec_arg(path)
110 ),
111 None => exec_arg(path),
112 }
113}
114
115const ENTRYPOINT_VAR: &str = "ONELF_ENTRYPOINT";
117
118fn without_entrypoint_prefix(value: &str) -> &str {
121 let Some(rest) = value.strip_prefix("env ") else {
122 return value;
123 };
124 let rest = rest.trim_start();
125 let token = rest.split_whitespace().next().unwrap_or_default();
126 if token.trim_start_matches('"').starts_with(ENTRYPOINT_VAR) {
127 rest[token.len()..].trim_start()
128 } else {
129 value
130 }
131}
132
133pub fn patch_desktop_file(
151 content: &str,
152 exec_path: &str,
153 entrypoint: Option<&str>,
154 icon_name: Option<&str>,
155) -> String {
156 let quoted = exec_command(exec_path, entrypoint);
157 let try_exec = string_value(exec_path);
158 let mut lines: Vec<String> = content.lines().map(String::from).collect();
159 let mut has_exec = false;
160 let mut has_tryexec = false;
161 let mut in_entry = false;
162 let mut in_action = false;
163
164 for line in &mut lines {
165 let trimmed = line.trim_start();
166 if trimmed.starts_with('[') && trimmed.ends_with(']') {
167 in_entry = trimmed == "[Desktop Entry]";
168 in_action = trimmed.starts_with("[Desktop Action ");
169 continue;
170 }
171 if !in_entry && !in_action {
172 continue;
173 }
174 if line.starts_with("Exec=") {
175 let tail = exec_arg_tail(without_entrypoint_prefix(&line[5..]));
178 if tail.is_empty() {
179 *line = format!("Exec={quoted}");
180 } else {
181 *line = format!("Exec={quoted} {tail}");
182 }
183 has_exec |= in_entry;
184 } else if in_action {
185 continue;
186 } else if line.starts_with("TryExec=") {
187 *line = format!("TryExec={try_exec}");
188 has_tryexec = true;
189 } else if line.starts_with("Icon=")
190 && let Some(name) = icon_name
191 {
192 *line = format!("Icon={name}");
193 }
194 }
195
196 let mut insert_at = lines
197 .iter()
198 .position(|l| l.trim() == "[Desktop Entry]")
199 .map(|h| h + 1)
200 .unwrap_or(lines.len());
201 if !has_exec {
202 lines.insert(insert_at, format!("Exec={quoted}"));
203 insert_at += 1;
204 }
205 if !has_tryexec {
206 lines.insert(insert_at, format!("TryExec={try_exec}"));
207 }
208
209 let mut result = lines.join("\n");
210 if !result.ends_with('\n') {
211 result.push('\n');
212 }
213 result
214}
215
216pub fn entry_icon(content: &str) -> Option<&str> {
219 let mut in_entry = false;
220 for line in content.lines() {
221 let trimmed = line.trim();
222 if trimmed.starts_with('[') && trimmed.ends_with(']') {
223 in_entry = trimmed == "[Desktop Entry]";
224 } else if in_entry && let Some(value) = line.strip_prefix("Icon=") {
225 let value = value.trim();
226 return (!value.is_empty() && !value.contains('/')).then_some(value);
227 }
228 }
229 None
230}
231
232pub struct ThemeIcon {
234 pub size: String,
236 pub ext: &'static str,
238 pub data: Vec<u8>,
239}
240
241pub fn icon_set(
251 manifest: &Manifest,
252 names: &[&str],
253 bundled: Option<usize>,
254 mut read: impl FnMut(usize) -> std::io::Result<Vec<u8>>,
255) -> std::io::Result<Vec<ThemeIcon>> {
256 let paths: Vec<(usize, String)> = (0..manifest.entries.len())
257 .filter(|&i| manifest.entries[i].kind == EntryKind::File)
258 .map(|i| (i, manifest.entry_path(i)))
259 .collect();
260 let mut found: Vec<(String, &'static str, usize)> = Vec::new();
261 for name in names {
262 for (i, path) in &paths {
263 if let Some((size, ext)) = theme_slot(path, name)
264 && !found.iter().any(|(s, ..)| *s == size)
265 {
266 found.push((size.to_string(), ext, *i));
267 }
268 }
269 if !found.is_empty() {
270 break;
271 }
272 }
273 let mut icons = Vec::new();
274 for (size, ext, i) in found {
275 let data = read(i)?;
276 if ext == "svg" || png_dimensions(&data).is_some() {
277 icons.push(ThemeIcon { size, ext, data });
278 }
279 }
280 if let Some(i) = bundled {
281 let data = read(i)?;
282 let path = manifest.entry_path(i);
283 let slot = if path.ends_with(".svg") {
284 Some(("scalable".to_string(), "svg"))
285 } else {
286 png_dimensions(&data).map(|(w, h)| (format!("{w}x{h}"), "png"))
287 };
288 match slot {
289 Some((size, ext)) => {
290 icons.retain(|icon| icon.size != size);
291 icons.push(ThemeIcon { size, ext, data });
292 }
293 None => eprintln!(" Icon {path}: neither SVG nor a decodable PNG; not installed"),
294 }
295 }
296 Ok(icons)
297}
298
299fn theme_slot<'p>(path: &'p str, name: &str) -> Option<(&'p str, &'static str)> {
302 const THEME: &str = "icons/hicolor/";
303 let at = if path.starts_with(THEME) {
304 0
305 } else {
306 path.find("/icons/hicolor/")? + 1
307 };
308 let (size, file) = path[at + THEME.len()..].split_once("/apps/")?;
309 let ext = match file.strip_prefix(name)? {
310 ".png" => "png",
311 ".svg" => "svg",
312 _ => return None,
313 };
314 is_safe_component(size).then_some((size, ext))
315}
316
317fn png_dimensions(data: &[u8]) -> Option<(u32, u32)> {
319 if data.len() < 24 || data[0..8] != *b"\x89PNG\r\n\x1a\n" {
320 return None;
321 }
322 let w = u32::from_be_bytes(data[16..20].try_into().ok()?);
323 let h = u32::from_be_bytes(data[20..24].try_into().ok()?);
324 Some((w, h))
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn exec_arg_tail_handles_quoting() {
333 assert_eq!(exec_arg_tail("/usr/bin/foo %U"), "%U");
334 assert_eq!(exec_arg_tail("\"/opt/my app/bin\" %F extra"), "%F extra");
335 assert_eq!(exec_arg_tail("/usr/bin/foo"), "");
336 assert_eq!(exec_arg_tail("\"/opt/my app/bin\""), "");
337 assert_eq!(exec_arg_tail(" /usr/bin/foo %U"), "%U");
338 }
339
340 #[test]
341 fn exec_arg_quotes_only_what_needs_it() {
342 assert_eq!(exec_arg("/opt/app/bin"), "/opt/app/bin");
343 assert_eq!(exec_arg("/opt/my app/bin"), "\"/opt/my app/bin\"");
344 assert_eq!(exec_arg(""), "\"\"");
345 }
346
347 #[test]
350 fn exec_arg_escapes_for_both_levels_and_field_codes() {
351 assert_eq!(exec_arg(r#"/a"b$c"#), r#""/a\\"b\\$c""#);
352 assert_eq!(exec_arg(r"/a\b"), r#""/a\\\\b""#);
353 assert_eq!(exec_arg("/opt/100%/app"), "/opt/100%%/app");
354 assert_eq!(exec_arg("/opt/50% off/app"), r#""/opt/50%% off/app""#);
355 assert_eq!(exec_arg("/a\nb"), r#""/a\nb""#);
356 assert_eq!(string_value(r"/a\b"), r"/a\\b");
357 assert_eq!(string_value(" a"), r"\sa");
358 }
359
360 #[test]
363 fn exec_arg_tail_skips_what_exec_arg_writes() {
364 for path in [r#"/a"b c"#, r"/a\ b", r"/a b\", r#"/x y\"z"#] {
365 let line = format!("{} %U", exec_arg(path));
366 assert_eq!(exec_arg_tail(&line), "%U", "{line}");
367 }
368 }
369
370 #[test]
371 fn tryexec_is_string_escaped() {
372 let out = patch_desktop_file("[Desktop Entry]\n", r"/opt/a\b/app", None, None);
373 assert!(out.contains(r"TryExec=/opt/a\\b/app"), "{out}");
374 assert!(out.contains(r#"Exec="/opt/a\\\\b/app""#), "{out}");
375 }
376
377 #[test]
378 fn patch_replaces_exec_keeps_tail_and_icon() {
379 let desktop = "[Desktop Entry]\nType=Application\nExec=\"/opt/my app/bin\" %F\nIcon=old\n";
380 let out = patch_desktop_file(desktop, "/new/path/app", None, Some("newicon"));
381 assert!(out.contains("Exec=/new/path/app %F"), "got:\n{out}");
382 assert!(!out.contains("app/bin"), "stale path fragment left:\n{out}");
383 assert!(out.contains("Icon=newicon"));
384 }
385
386 #[test]
387 fn a_spaced_path_is_quoted_in_exec_and_literal_in_tryexec() {
388 let desktop = "[Desktop Entry]\nExec=/usr/bin/old\nTryExec=/usr/bin/old\n";
389 let out = patch_desktop_file(desktop, "/opt/has space/app", None, None);
390 assert!(out.contains("Exec=\"/opt/has space/app\"\n"), "got:\n{out}");
391 assert!(
392 out.contains("TryExec=/opt/has space/app\n"),
393 "TryExec is a literal path, not a quoted field: {out}"
394 );
395 }
396
397 #[test]
398 fn an_exec_in_an_action_group_does_not_speak_for_the_entry() {
399 let desktop = "[Desktop Entry]\nType=Application\nName=App\n\
402 [Desktop Action new-window]\nExec=/opt/old/app --new\n";
403 let out = patch_desktop_file(desktop, "/opt/new/app", None, None);
404 let entry_end = out.find("[Desktop Action").unwrap();
405 let entry = &out[..entry_end];
406 let action = &out[entry_end..];
407 assert!(
408 entry.contains("Exec=/opt/new/app\n"),
409 "the entry got its Exec: {out}"
410 );
411 assert!(
412 entry.contains("TryExec=/opt/new/app\n"),
413 "the entry got its TryExec: {out}"
414 );
415 assert!(
416 action.contains("Exec=/opt/new/app --new\n"),
417 "the action launches the package with its own arguments: {out}"
418 );
419 assert!(!action.contains("TryExec"), "{out}");
420 }
421
422 #[test]
425 fn actions_launch_the_package_through_the_entrypoint() {
426 let desktop = "[Desktop Entry]\nExec=myapp %U\nActions=new;private;\n\
427 [Desktop Action new]\nName=New\nExec=myapp --new-window %U\nIcon=keep\n\
428 [Desktop Action private]\nExec=\"/opt/my app/app\" --private\n\
429 [X-Other]\nExec=leave me\n";
430 let out = patch_desktop_file(desktop, "/opt/app.onelf", Some("daemon"), Some("icon"));
431 let launch = "env ONELF_ENTRYPOINT=daemon /opt/app.onelf";
432 assert!(out.contains(&format!("Exec={launch} %U\n")), "{out}");
433 assert!(
434 out.contains(&format!("Exec={launch} --new-window %U\n")),
435 "{out}"
436 );
437 assert!(out.contains(&format!("Exec={launch} --private\n")), "{out}");
438 assert!(
439 out.contains("Icon=keep\n"),
440 "an action's icon is its own: {out}"
441 );
442 assert!(
443 out.contains("Exec=leave me\n"),
444 "other groups are left: {out}"
445 );
446 assert_eq!(out.matches("TryExec=").count(), 1, "{out}");
447
448 let again = patch_desktop_file(&out, "/new/app.onelf", None, None);
449 assert!(
450 again.contains("Exec=/new/app.onelf --new-window %U\n"),
451 "{again}"
452 );
453 assert!(again.contains("Exec=/new/app.onelf --private\n"), "{again}");
454 }
455
456 #[test]
457 fn theme_icons_are_found_at_every_size() {
458 assert_eq!(
459 theme_slot("share/icons/hicolor/48x48/apps/app.png", "app"),
460 Some(("48x48", "png"))
461 );
462 assert_eq!(
463 theme_slot("icons/hicolor/scalable/apps/app.svg", "app"),
464 Some(("scalable", "svg"))
465 );
466 assert_eq!(
467 theme_slot("usr/share/icons/hicolor/32x32/apps/app.xpm", "app"),
468 None
469 );
470 assert_eq!(
471 theme_slot("share/icons/hicolor/48x48/apps/app2.png", "app"),
472 None
473 );
474 assert_eq!(
475 theme_slot("share/icons/hicolor/../apps/app.png", "app"),
476 None
477 );
478 assert_eq!(
479 entry_icon("[Desktop Entry]\nIcon=app\n[Desktop Action a]\nIcon=b\n"),
480 Some("app")
481 );
482 assert_eq!(entry_icon("[Desktop Entry]\nIcon=/opt/app.png\n"), None);
483 }
484
485 #[test]
486 fn a_chosen_entrypoint_is_named_in_the_launch() {
487 let desktop = "[Desktop Entry]\nName=App\nExec=old %U\n";
488 let out = patch_desktop_file(desktop, "/opt/app.onelf", Some("daemon"), None);
489 assert!(
490 out.contains("Exec=env ONELF_ENTRYPOINT=daemon /opt/app.onelf %U"),
491 "{out}"
492 );
493 assert!(out.contains("TryExec=/opt/app.onelf"), "{out}");
494
495 let again = patch_desktop_file(&out, "/new/app.onelf", Some("daemon"), None);
498 assert!(
499 again.contains("Exec=env ONELF_ENTRYPOINT=daemon /new/app.onelf %U"),
500 "{again}"
501 );
502 let plain = patch_desktop_file(&out, "/new/app.onelf", None, None);
503 assert!(plain.contains("Exec=/new/app.onelf %U"), "{plain}");
504 }
505}