forked from unom/punktfunk
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>
103 lines
4.3 KiB
Rust
103 lines
4.3 KiB
Rust
//! "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()
|
|
);
|
|
}
|
|
}
|