//! "Create shortcut…" — a desktop entry that boots straight into one host (optionally with a //! profile or a game), design/client-deep-links.md §5. //! //! The shortcut is a **container for a URL**, not a second launch mechanism: it invokes the //! client with a positional `punktfunk://…`, which is the same door xdg-open and a browser //! prompt use. That is deliberate — it keeps working if scheme registration is lost or the //! host store changes, because the URL itself carries the stable id, the address and the pin. //! //! Under flatpak the sandbox cannot write `~/.local/share/applications`, so this offers the //! URL to copy instead. The `org.freedesktop.portal.DynamicLauncher` route (which exists for //! exactly this, with its own confirmation) is the intended upgrade there. use std::path::PathBuf; /// Are we inside the flatpak sandbox? `/.flatpak-info` is present in every flatpak run and /// nowhere else — the standard check, and the one the portal docs use. pub fn sandboxed() -> bool { std::path::Path::new("/.flatpak-info").exists() } /// Write `~/.local/share/applications/punktfunk-.desktop` for this URL and return the /// path. Best-effort `update-desktop-database` afterwards: an entry nothing indexes still /// works from a file manager, it just won't show up in search straight away. pub fn write_desktop_entry(label: &str, url: &str) -> Result { let home = std::env::var("HOME").map_err(|_| "HOME isn't set".to_string())?; let dir = PathBuf::from(home).join(".local/share/applications"); std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; let path = dir.join(format!("punktfunk-{}.desktop", file_slug(label))); // Desktop-entry values are line-oriented: a newline in a host name would end the Name // key and turn the rest into an unparsable line (or, worse, another key). let name = one_line(label); let entry = format!( "[Desktop Entry]\n\ Type=Application\n\ Name={name}\n\ Comment=Stream from this Punktfunk host\n\ Exec=punktfunk-client \"{url}\"\n\ Icon=io.unom.Punktfunk\n\ Terminal=false\n\ Categories=Game;Network;\n\ StartupNotify=true\n" ); std::fs::write(&path, entry).map_err(|e| format!("{}: {e}", path.display()))?; // Some desktops only offer a `.desktop` as a launchable icon when it is executable. #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)); } let _ = std::process::Command::new("update-desktop-database") .arg(&dir) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status(); Ok(path) } /// A filename-safe slug: ASCII alphanumerics and `-`, everything else collapsed to one `-`, /// capped so a long host+profile pair can't produce a name the filesystem rejects. fn file_slug(label: &str) -> String { let mut out = String::new(); for c in label.chars() { if c.is_ascii_alphanumeric() { out.push(c.to_ascii_lowercase()); } else if !out.ends_with('-') { out.push('-'); } } let trimmed = out.trim_matches('-'); let capped: String = trimmed.chars().take(48).collect(); if capped.is_empty() { "host".to_string() } else { capped } } /// Flatten to one line — see [`write_desktop_entry`] on why a newline here is not cosmetic. fn one_line(s: &str) -> String { s.replace(['\n', '\r'], " ").trim().to_string() } #[cfg(test)] mod tests { use super::*; /// Names become safe filenames, and a name that survives to `Name=` can't break the file. #[test] fn labels_are_sanitised_both_ways() { assert_eq!(file_slug("Living Room PC"), "living-room-pc"); assert_eq!(file_slug("Büro · Mac"), "b-ro-mac"); assert_eq!(file_slug("Desk · Work"), "desk-work"); assert_eq!(file_slug("////"), "host"); assert_eq!(file_slug(""), "host"); assert!(file_slug(&"x".repeat(200)).len() <= 48); // The classic injection: a newline would end the Name key and start a new one. assert_eq!( one_line("Desk\nExec=rm -rf ~"), "Desk Exec=rm -rf ~".to_string() ); } }