From 6cf5d4ab87b70ac737956e212c6c47ad67380e5f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 28 Jul 2026 21:36:36 +0200 Subject: [PATCH] feat(client/linux): punktfunk:// links open the GTK client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D1's activation half (design/client-deep-links.md §4.1). The client already had a `--connect` CLI, but nothing claimed the scheme, so nothing outside a terminal could start a stream: no browser prompt, no xdg-open, no double-clickable shortcut. The `.desktop` entries gain `MimeType=x-scheme-handler/punktfunk;` and `Exec=… %u`, and the rpm scriptlet joins deb and arch in running `update-desktop-database` — an entry nothing indexes claims nothing. (nix substitutes the Exec line by prefix, so `%u` survives; flatpak's exported entry is renamed to the app id by the standard mechanism.) In the app, `HANDLES_OPEN` plus a positional-URL argv is the whole mechanism, and the second half is the interesting one: argv stays withheld from GApplication as before EXCEPT when it carries a URL, and then GIO's own single-instance forwarding delivers it to the running window over D-Bus. Clicking a link with Punktfunk already open reuses that window instead of racing a second instance, and we wrote no IPC to get it. A URL that lands during a cold start (before the model exists) is parked rather than dropped — silently losing a link is the one outcome that would make the feature untrustworthy. Routing is four lines of translation, because the decisions are the brain's: parse, resolve the host, resolve the profile, refuse. A link that resolves becomes exactly the message a card click raises — the same wake, trust gate, and error surfaces — so there is no second connect path to keep in sync. A link to a host we don't know, or know but never pinned, opens the ordinary PIN ceremony seeded with what the link claimed, which is how "a URL may never pair or trust on its own" stays true while still being useful. Never preempting a live session is checked here, since only this layer knows one is running. Verified on .21 under Xvfb against hand-authored stores: `connect/Bound%20Box?launch=` resolved the host by name, carried the launch id through the trust gate, and spawned the session; `punktfunk://pair/1234` and an unknown `profile=` both refused before any dial. Co-Authored-By: Claude Opus 5 (1M context) --- clients/linux/src/app.rs | 145 +++++++++++++++++++- clients/linux/src/cli.rs | 12 ++ packaging/flatpak/io.unom.Punktfunk.desktop | 3 +- packaging/linux/io.unom.Punktfunk.desktop | 3 +- packaging/rpm/punktfunk.spec | 4 + 5 files changed, 162 insertions(+), 5 deletions(-) diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index f555d62f..1d7ee558 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -76,6 +76,9 @@ pub struct AppModel { #[derive(Debug)] pub enum AppMsg { + /// A `punktfunk://` URL arrived (scheme handler, a shortcut, or a second invocation + /// forwarded to this instance by GApplication) — design/client-deep-links.md §4.1. + DeepLink(String), /// The trust gate in front of every connect (rules 1–3, see `update`). Connect(ConnectRequest), /// Connect to a saved host that isn't advertising but has a known MAC: fire a wake @@ -269,6 +272,13 @@ impl SimpleComponent for AppModel { } window.present(); + // The deep-link seam is live from here: anything GApplication delivered during a cold + // start has been parked, and everything from now on arrives as a message. + LINK_TX.with_borrow_mut(|tx| *tx = Some(sender.input_sender().clone())); + for url in PENDING_LINKS.with_borrow_mut(std::mem::take) { + sender.input(AppMsg::DeepLink(url)); + } + ComponentParts { model, widgets: AppWidgets {}, @@ -277,6 +287,7 @@ impl SimpleComponent for AppModel { fn update(&mut self, msg: AppMsg, sender: ComponentSender) { match msg { + AppMsg::DeepLink(url) => self.open_deep_link(&url, &sender), // The trust gate (the host is the policy authority — it advertises // `pair=optional` only when it accepts unpaired clients): // 1. PINNED RECONNECT — a stored fingerprint connects silently. @@ -504,6 +515,82 @@ impl AppModel { self.toasts.add_toast(adw::Toast::new(msg)); } + /// Route a `punktfunk://` URL (design/client-deep-links.md §4.1). Parsing, host/profile + /// resolution and every refusal rule live in the shared brain (`plan_from_link`); this is + /// only the GTK end of it — turn the outcome into the same messages a card click raises, + /// so a link gets the identical wake, trust and error surfaces and NOT a second connect + /// path of its own. + fn open_deep_link(&mut self, url: &str, sender: &ComponentSender) { + use pf_client_core::deeplink; + use pf_client_core::orchestrate::{plan_from_link, PlanOutcome}; + use pf_client_core::profiles::ProfilesFile; + + tracing::debug!(%url, "deep link"); + let link = match deeplink::parse(url) { + Ok(l) => l, + Err(e) => return self.toast(&e.message()), + }; + let known = trust::KnownHosts::load(); + let outcome = plan_from_link( + &link, + &known, + &ProfilesFile::load(), + &self.settings.borrow(), + ); + match outcome { + Ok(PlanOutcome::Connect(plan)) => { + // Rule 2 of §3: never preempt a live session. Only this layer knows one is + // running, which is why the brain leaves the check here. + if self.busy { + return self.toast("A session is already running — end it first."); + } + let req = ConnectRequest { + name: plan.host.name.clone(), + addr: plan.host.addr.clone(), + port: plan.host.port, + fp_hex: plan.host.fp_hex.clone(), + pair_optional: false, + launch: plan.launch.clone().map(|id| (id.clone(), id)), + mac: plan.host.mac.clone(), + }; + // A link is a launch like any other: with a MAC it takes the dial-first wake + // path, so a sleeping host wakes instead of erroring. + sender.input(if plan.wake { + AppMsg::WakeConnect(req) + } else { + AppMsg::Connect(req) + }); + } + Ok(PlanOutcome::ConfirmUnknown(unknown)) => { + // Known-but-unpinned, or not known at all: the link may not pair and may not + // trust on its own, so it opens the ordinary ceremony under the user's eyes — + // the PIN dialog, seeded with what the link claimed. + if self.busy { + return self.toast("A session is already running — end it first."); + } + let req = ConnectRequest { + name: unknown.name.clone().unwrap_or_else(|| unknown.addr.clone()), + addr: unknown.addr.clone(), + port: unknown.port, + fp_hex: unknown.fp.clone(), + pair_optional: false, + launch: unknown.launch.clone().map(|id| (id.clone(), id)), + mac: Vec::new(), + }; + self.toast(&format!( + "{} isn't paired with this device yet — pair it to continue.", + req.name + )); + crate::ui_trust::pin_dialog(&self.window, sender, self.identity.clone(), req); + } + Ok(PlanOutcome::Unsupported(route)) => self.toast(&format!( + "Punktfunk can't open “{}” links yet.", + route.as_str() + )), + Err(e) => self.toast(&e.message()), + } + } + fn close_waiting(&mut self) { if let Some(w) = self.waiting.borrow_mut().take() { w.close(); @@ -607,6 +694,31 @@ impl AppModel { } } +thread_local! { + /// Where a delivered URL goes once the window exists. Both ends of this live on the GTK + /// main thread: `connect_open` fires there, and so does the model's `init`. + static LINK_TX: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; + /// URLs that arrived before the model existed — the cold-start case, where GApplication + /// runs `open` before `activate` builds the window. A dropped URL is the one outcome a + /// link must never have, so they wait here instead. + static PENDING_LINKS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +/// Hand a URL to the running app, or park it until there is one. +fn deliver_deep_link(url: String) { + let queued = LINK_TX.with_borrow(|tx| match tx { + Some(tx) => { + let _ = tx.send(AppMsg::DeepLink(url.clone())); + false + } + None => true, + }); + if queued { + PENDING_LINKS.with_borrow_mut(|q| q.push(url)); + } +} + pub fn run() -> glib::ExitCode { tracing_subscriber::fmt() .with_env_filter( @@ -649,16 +761,43 @@ pub fn run() -> glib::ExitCode { return crate::cli::exec_session(); } - let mut builder = adw::Application::builder().application_id(APP_ID); + // HANDLES_OPEN is what makes `Exec=punktfunk-client %u` work: GApplication turns the URI + // into an `open` call, and — this is the part that matters — a SECOND invocation forwards + // its URI to the already-running instance over D-Bus and exits, so clicking a link with + // Punktfunk open reuses the window instead of racing a new one. + let mut builder = adw::Application::builder() + .application_id(APP_ID) + .flags(gio::ApplicationFlags::HANDLES_OPEN); // Screenshot mode launches the app once per scene back-to-back; NON_UNIQUE keeps // each launch its own primary instance. if crate::cli::shot_scene().is_some() { - builder = builder.flags(gio::ApplicationFlags::NON_UNIQUE); + builder = + builder.flags(gio::ApplicationFlags::NON_UNIQUE | gio::ApplicationFlags::HANDLES_OPEN); } let adw_app = builder.build(); + adw_app.connect_open(|app, files, _hint| { + for f in files { + deliver_deep_link(f.uri().to_string()); + } + // `open` does not raise a window on its own; the model's activate handler does. + app.activate(); + }); // One SDL context for the whole process, started while single-threaded. let gamepad = crate::gamepad::GamepadService::start(); - let app = relm4::RelmApp::from_app(adw_app).with_args(Vec::new()); + // argv stays withheld from GApplication — except for a positional URL, which is exactly + // what GIO's single-instance forwarding is for. Passing it through means the FIRST + // instance's `open` fires locally and a later one's is delivered to the primary, with no + // IPC of our own. + let args: Vec = match crate::cli::deep_link_arg() { + Some(url) => vec![ + std::env::args() + .next() + .unwrap_or_else(|| "punktfunk-client".into()), + url, + ], + None => Vec::new(), + }; + let app = relm4::RelmApp::from_app(adw_app).with_args(args); app.run::(AppInit { gamepad }); glib::ExitCode::SUCCESS } diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index 3156a3a1..96d94ba1 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -38,6 +38,18 @@ pub fn arg_flag(flag: &str) -> bool { std::env::args().any(|a| a == flag) } +/// A positional `punktfunk://` (or the `pf://` input alias) anywhere in argv — the deep-link +/// door (design/client-deep-links.md §4.1). It is positional, not a flag, because that is what +/// `Exec=punktfunk-client %u` hands us, what a `.desktop` shortcut embeds, and what a browser's +/// "Open Punktfunk?" prompt ends up invoking. Validation happens later, in the shared parser — +/// this only decides whether argv contains something addressed to us. +pub fn deep_link_arg() -> Option { + std::env::args().skip(1).find(|a| { + let lower = a.to_ascii_lowercase(); + lower.starts_with("punktfunk://") || lower.starts_with("pf://") + }) +} + /// Fullscreen the shell — the Gaming-Mode fallback for a bare launch (streams and the /// console library exec the session binary, which handles its own fullscreen). pub fn fullscreen_mode() -> bool { diff --git a/packaging/flatpak/io.unom.Punktfunk.desktop b/packaging/flatpak/io.unom.Punktfunk.desktop index cf40580d..6ef2073b 100644 --- a/packaging/flatpak/io.unom.Punktfunk.desktop +++ b/packaging/flatpak/io.unom.Punktfunk.desktop @@ -2,9 +2,10 @@ Type=Application Name=Punktfunk Comment=Stream a remote punktfunk host -Exec=punktfunk-client +Exec=punktfunk-client %u Icon=io.unom.Punktfunk Terminal=false Categories=Network;Game; Keywords=streaming;remote;game;moonlight; StartupNotify=true +MimeType=x-scheme-handler/punktfunk; diff --git a/packaging/linux/io.unom.Punktfunk.desktop b/packaging/linux/io.unom.Punktfunk.desktop index ac4eeca3..eac86d1a 100644 --- a/packaging/linux/io.unom.Punktfunk.desktop +++ b/packaging/linux/io.unom.Punktfunk.desktop @@ -2,9 +2,10 @@ Type=Application Name=Punktfunk Comment=Stream a remote punktfunk host -Exec=punktfunk-client +Exec=punktfunk-client %u Icon=video-display Terminal=false Categories=Network;Game; Keywords=streaming;remote;game;moonlight; StartupNotify=true +MimeType=x-scheme-handler/punktfunk; diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index 8d3a0eb9..23c946c3 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -475,6 +475,10 @@ udevadm trigger --subsystem-match=hidraw 2>/dev/null || : # Apply the UDP recv-buffer tuning now (also auto-applied at boot by systemd-sysctl; on # rpm-ostree it takes effect on the next boot into the layered deployment). sysctl -p %{_prefix}/lib/sysctl.d/99-punktfunk-client-net.conf >/dev/null 2>&1 || : +# Register the punktfunk:// scheme handler the .desktop entry declares (deb and arch do the +# same in their own scriptlets) — without this, xdg-open and browser prompts have no idea the +# client claims those links. +update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || : %if %{with host} %post