diff --git a/clients/linux/src/main.rs b/clients/linux/src/main.rs index 7925386d..5c8d37b0 100644 --- a/clients/linux/src/main.rs +++ b/clients/linux/src/main.rs @@ -14,6 +14,9 @@ pub use pf_client_core::{discovery, gamepad, library, trust, video, wol}; mod app; #[cfg(target_os = "linux")] mod cli; +// "Create shortcut…" — the desktop-entry writer (design/client-deep-links.md §5). +#[cfg(target_os = "linux")] +mod shortcuts; #[cfg(target_os = "linux")] mod spawn; #[cfg(target_os = "linux")] diff --git a/clients/linux/src/shortcuts.rs b/clients/linux/src/shortcuts.rs new file mode 100644 index 00000000..ab856827 --- /dev/null +++ b/clients/linux/src/shortcuts.rs @@ -0,0 +1,102 @@ +//! "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() + ); + } +} diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index 6f3d2628..4a425f51 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -109,6 +109,11 @@ pub enum CardOutput { }, /// Put this card's `punktfunk://` URL on the clipboard. CopyLink(String), + /// Write a desktop entry that launches this card's URL. + CreateShortcut { + label: String, + url: String, + }, /// Add or remove a pinned host+profile card (design §5.2a). Presentation only — it never /// changes the host's default profile, and unpinning never touches the profile itself. TogglePin { @@ -407,6 +412,27 @@ impl relm4::factory::FactoryComponent for HostCard { }); actions.add_action(&a); } + // "Create shortcut…": the same URL as Copy link, wrapped in a desktop + // entry so it is double-clickable from the app grid. + { + let (host, profile) = (k.clone(), pinned.clone()); + let a = gio::SimpleAction::new("shortcut", None); + let sender = sender.clone(); + a.connect_activate(move |_, _| { + let url = pf_client_core::deeplink::DeepLink::for_host( + &host, + None, + profile.as_ref().map(|(id, _)| id.as_str()), + ) + .to_url(); + let label = match &profile { + Some((_, name)) => format!("{} \u{00b7} {name}", host.name), + None => host.name.clone(), + }; + let _ = sender.output(CardOutput::CreateShortcut { label, url }); + }); + actions.add_action(&a); + } // The same action pins from a primary card and unpins from a pinned one — // which of the two this card is decides the direction. let (fp, addr, port) = (k.fp_hex.clone(), k.addr.clone(), k.port); @@ -453,6 +479,7 @@ impl relm4::factory::FactoryComponent for HostCard { ); menu.append_item(&unpin); menu.append(Some("Copy link"), Some("card.copy-link")); + menu.append(Some("Create shortcut\u{2026}"), Some("card.shortcut")); } else { if !profiles.is_empty() { let with = gio::Menu::new(); @@ -503,6 +530,7 @@ impl relm4::factory::FactoryComponent for HostCard { menu.append(Some("Browse library\u{2026}"), Some("card.library")); } menu.append(Some("Copy link"), Some("card.copy-link")); + menu.append(Some("Create shortcut\u{2026}"), Some("card.shortcut")); menu.append(Some("Edit\u{2026}"), Some("card.rename")); menu.append(Some("Forget"), Some("card.forget")); } @@ -915,6 +943,9 @@ impl SimpleComponent for HostsPage { } let _ = sender.output(HostsOutput::Toast("Link copied".into())); } + CardOutput::CreateShortcut { label, url } => { + self.shortcut_result(&sender, &label, &url); + } CardOutput::TogglePin { fp_hex, addr, @@ -1073,6 +1104,45 @@ impl HostsPage { } /// Rename a saved host — an entry in an alert, then upsert + refresh. + /// Write the shortcut, or — inside the flatpak sandbox, which cannot reach + /// `~/.local/share/applications` — hand the user the URL to place themselves. The + /// DynamicLauncher portal is the intended upgrade for that case (design §5); until then + /// the fallback is the one the design already sanctions, not a dead end. + fn shortcut_result(&self, sender: &ComponentSender, label: &str, url: &str) { + if crate::shortcuts::sandboxed() { + let dialog = adw::AlertDialog::new( + Some("Create Shortcut"), + Some( + "Punktfunk is sandboxed here, so it can't add the shortcut itself. Copy \ + this link and make a launcher for it \u{2014} it opens the same stream.", + ), + ); + let entry = gtk::Entry::builder().text(url).editable(false).build(); + dialog.set_extra_child(Some(&entry)); + dialog.add_responses(&[("close", "Close"), ("copy", "Copy link")]); + dialog.set_response_appearance("copy", adw::ResponseAppearance::Suggested); + dialog.set_close_response("close"); + { + let url = url.to_string(); + dialog.connect_response(Some("copy"), move |_, _| { + if let Some(display) = gtk::gdk::Display::default() { + display.clipboard().set_text(&url); + } + }); + } + dialog.present(Some(&self.widgets.stack)); + return; + } + let msg = match crate::shortcuts::write_desktop_entry(label, url) { + Ok(_) => format!("Shortcut for \u{201c}{label}\u{201d} added to your applications"), + Err(e) => { + tracing::warn!(error = %e, "writing the shortcut"); + format!("Couldn't create the shortcut \u{2014} {e}") + } + }; + let _ = sender.output(HostsOutput::Toast(msg)); + } + /// The host edit sheet — the per-host settings that are properties of the HOST, not of /// the stream: its name, whether this machine shares its clipboard with it, and which /// settings profile it defaults to.