Three things found by actually driving the client. **The marker didn't appear until you reopened the dialog.** It was rendered once, at build time, from the stored overlay — so changing a setting inside a profile looked like it did nothing. The design says touching a control creates the override and the marker appears immediately, and it has to: a user who changes a row and sees no acknowledgement has no reason to believe it took. Every profileable row now builds its dot and reset hidden, and the same handler that records the touch reveals them. Resetting a row touched in the same sitting also un-touches it, so the commit can't re-write what the reset just removed. **Profiles had no colour.** `accent` has been in the schema since P0 and nothing could set it, which left every chip the same grey — and telling profiles apart at a glance across a grid is the entire reason chips exist. Creating a profile now picks a colour in the same breath as its name (hunting for it afterwards is what leaves them all grey), an existing profile has a Colour row, and host-card chips are tinted with it. A palette of eight rather than a free picker: legibility across light and dark is the job, and the schema still accepts any `#RRGGBB` a hand-edit or a future picker writes. Anything that isn't `#RRGGBB` is refused rather than interpolated into CSS, and each distinct colour registers one display-wide rule (per-widget providers are gone since GTK 4.10). **4:4:4 had no switch anywhere but Apple.** `VIDEO_CAP_444` has been on the wire for a while with only `punktfunk-probe`'s env var to set it. It is now a setting — and a profileable one, which is the point: full chroma is what makes small text and thin UI lines crisp, so a "Work" profile wants it where "Game" usually doesn't. The host still gates it on its own policy, HEVC, and a GPU that can actually encode it; advertising only says "I can decode this and I want it". 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()
|
|
);
|
|
}
|
|
}
|