forked from unom/punktfunk
feat(client/linux): "Create shortcut…" — a double-clickable entry for a host or a pinned profile
D1's last Linux piece (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://…`, the same door xdg-open and a browser prompt use. That is what makes it survive things it has no control over — scheme registration being lost, the host moving to a new address, the store being wiped — because the URL itself carries the stable id, the address and the pin. A pinned card writes its profile into the shortcut, so "Desktop · Work" on the app grid is one double-click to that exact session. Two sanitisation points that are not cosmetic: the filename is slugged to ASCII (a host called "Büro · Mac" must still produce a writable name), and the `Name=` value is flattened to one line — desktop entries are line-oriented, so a newline in a host name would end the key and turn the rest into another one. Under flatpak the sandbox cannot write ~/.local/share/applications, so the user gets the URL to place themselves — the fallback the design already sanctions. The `org.freedesktop.portal.DynamicLauncher` route, which exists for exactly this and shows its own confirmation, is the intended upgrade there and is not built yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")]
|
||||
|
||||
@@ -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-<slug>.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<PathBuf, String> {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Self>, 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.
|
||||
|
||||
Reference in New Issue
Block a user