Files
punktfunk/clients/windows/src/deeplink.rs
T
enricobuehlerandClaude Fable 5 35c61fee64 chore(client): the windows-rs pin moves a month forward, onto the SDK-metadata bindings
The July 2026 windows-rs brings a reconciler keyed-child-order fix (#4728), widget
validation (#4727), a DPI collision fix (#4751), icon elements (#4736), multi-window
support (#4730) and scroll virtualization (#4710) — the re-render fixes the Windows
client has been working around at the architecture level. All three pinned deps
(windows-reactor, windows, windows-reactor-setup) move together so windows-core
stays unified across the swap-chain hand-off, and pf-client-core moves with them.

The bulk of the diff is #4689: windows/windows-sys now generate straight from the
Windows SDK, so the `Win32_*` namespace features became one feature per SDK header
(winuser, dxgi, d3d11, …), the PascalCase namespace modules became header-named
modules, struct-returning COM methods take explicit out-params and return HRESULT,
Win32 functions return their raw BOOL/HANDLE instead of Result, and flag constants
are plain integers. Both crates' Win32 code is rewritten to that shape; behaviour
is unchanged on every path.

Riding along, all already stale before the bump: the README and the three Windows
workflows stop claiming windows-reactor's build.rs needs CARGO_WORKSPACE_DIR (that
build.rs no longer exists — staging moved to windows-reactor-setup via OUT_DIR);
the README layout section stops describing modules that moved into the session
binary long ago and gains the manual smoke checklist; the notices generator learns
the SPDX for crates that ship license files without a `license` field, which turns
windows-reactor-setup's UNKNOWN into MIT OR Apache-2.0; and the crate records its
real rust-version (1.96) instead of inheriting the workspace's 1.82.

Verified: cargo check/clippy/fmt clean on punktfunk-client-windows, pf-client-core
and punktfunk-client-session; both bins build; --discover finds the LAN hosts; the
GUI shell comes up (WinAppSDK bootstrap intact under the new reactor-setup).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:47 +02:00

304 lines
14 KiB
Rust

//! `punktfunk://` activation for the Windows shell (design/client-deep-links.md §4.2).
//!
//! Protocol activation of a full-trust packaged app delivers the URI as the command line, so
//! a browser prompt, `start punktfunk://…` and a written `.lnk` all arrive the same way: as a
//! positional argument. What Windows does NOT give us is single-instancing — unlike
//! GApplication on Linux, a second activation is simply a second process. So this module adds
//! it: the first instance claims a named mutex, and any later one hands its URL to the winner
//! over `WM_COPYDATA` and exits.
//!
//! A URL must never be silently dropped, which is why the hand-off retries while the primary's
//! window is still coming up, and why a hand-off that ultimately fails falls back to running
//! this instance normally rather than exiting quietly.
use std::sync::atomic::{AtomicIsize, Ordering};
use std::sync::Mutex;
use windows::Win32::commctrl::{DefSubclassProc, SetWindowSubclass};
use windows::Win32::minwindef::{LPARAM, LRESULT, WPARAM};
use windows::Win32::synchapi::{CreateMutexW, ReleaseMutex};
use windows::Win32::windef::HWND;
use windows::Win32::winuser::{FindWindowW, SendMessageW, COPYDATASTRUCT, WM_COPYDATA};
/// The single-instance mutex. Named per the design; deliberately not `Global\` — one shell per
/// user session is the rule, and a second desktop user gets their own.
const MUTEX_NAME: windows::core::PCWSTR = windows::core::w!("unom.punktfunk.client");
/// Tags our `WM_COPYDATA` so a stray message from anything else is ignored rather than parsed.
const COPYDATA_URL: usize = 0x7066_0001; // 'pf' + 1
/// Subclass id for the receiver hook.
const SUBCLASS_ID: usize = 0x7066_0002;
/// URLs delivered by another instance, waiting for the app's poll to pick them up. A queue
/// rather than a single slot: two shortcuts double-clicked in quick succession are two links,
/// and dropping either would be exactly the silent loss this design forbids.
static INBOX: Mutex<Vec<String>> = Mutex::new(Vec::new());
/// The claimed mutex handle, held for the process lifetime. Stored so it is released on exit
/// (Windows would release it anyway when the process dies; being explicit costs nothing and
/// documents the intent).
static MUTEX: AtomicIsize = AtomicIsize::new(0);
/// A positional `punktfunk://` (or the `pf://` input alias) anywhere in argv — how protocol
/// activation, `start`, and a `.lnk` shortcut all deliver a link. Validation happens later in
/// the shared parser; this only decides whether argv carries something addressed to us.
pub(crate) fn positional_url(args: &[String]) -> Option<String> {
args.iter()
.skip(1)
.find(|a| {
let lower = a.to_ascii_lowercase();
lower.starts_with("punktfunk://") || lower.starts_with("pf://")
})
.cloned()
}
/// Try to become the one shell for this user. `true` = we are it; `false` = another instance
/// holds the mutex and this process should hand off and exit.
pub(crate) fn claim_primary() -> bool {
// SAFETY: `CreateMutexW` takes a static wide name literal and no pointer we own; the handle it
// returns is stored in `MUTEX` and released once in `release_primary`.
unsafe {
let handle = CreateMutexW(None, true, MUTEX_NAME);
// Without the mutex we cannot tell primary from secondary; behaving as primary is
// the safe answer — a second window is a nuisance, a dropped launch is a bug.
if handle.0.is_null() {
let e = windows::Win32::errhandlingapi::GetLastError();
tracing::warn!(error = e, "single instance mutex; continuing as primary");
return true;
}
// ERROR_ALREADY_EXISTS means someone else created it first — `CreateMutexW` still
// hands back a valid handle, so ask the OS what actually happened.
let already = windows::Win32::errhandlingapi::GetLastError()
== windows::Win32::winerror::ERROR_ALREADY_EXISTS as u32;
if already {
return false;
}
MUTEX.store(handle.0 as isize, Ordering::Relaxed);
true
}
}
/// Release the single-instance mutex (process exit).
pub(crate) fn release_primary() {
let raw = MUTEX.swap(0, Ordering::Relaxed);
if raw != 0 {
// SAFETY: `raw` is the handle `claim_primary` stored, taken out of the atomic by `swap` so
// this runs at most once even if two threads race here.
unsafe {
let _ = ReleaseMutex(windows::Win32::winnt::HANDLE(raw as *mut _));
}
}
}
/// Hand `url` to the running shell. Retries briefly: the primary may still be creating its
/// window when a second launch lands (a double-clicked shortcut while the app is starting is
/// the ordinary case), and giving up in that window would drop the link.
///
/// `false` = the primary never answered, and the caller should just run normally.
pub(crate) fn forward_to_primary(url: &str) -> bool {
let wide: Vec<u16> = url.encode_utf16().collect();
for attempt in 0..20 {
// SAFETY: `FindWindowW` takes static literals. The `COPYDATASTRUCT` points at `wide`, a
// local that outlives the call because `SendMessage` is synchronous — the receiver has
// finished with the buffer before it returns, which is precisely why this is not `Post`.
unsafe {
let hwnd = FindWindowW(None, windows::core::w!("Punktfunk"));
if !hwnd.0.is_null() {
let data = COPYDATASTRUCT {
dwData: COPYDATA_URL,
cbData: (wide.len() * 2) as u32,
lpData: wide.as_ptr() as *mut _,
};
// SendMessage, not Post: the buffer must stay alive until the receiver has
// copied it, which only a synchronous send guarantees.
SendMessageW(
hwnd,
WM_COPYDATA as u32,
WPARAM(0),
LPARAM(&data as *const _ as isize),
);
tracing::info!(attempt, "handed the link to the running shell");
return true;
}
}
std::thread::sleep(std::time::Duration::from_millis(150));
}
tracing::warn!("no running shell answered; opening this link here instead");
false
}
/// Start listening for links from later instances. Idempotent, and safe to call before the
/// window exists — it retries on its own thread until the shell window can be found.
pub(crate) fn install_receiver() {
std::thread::Builder::new()
.name("pf-deeplink-receiver".into())
.spawn(|| {
for _ in 0..200 {
// SAFETY: `FindWindowW` takes static literals, and `SetWindowSubclass` is given
// our own `wnd_proc` plus a plain id; the window handle is one the OS just returned.
unsafe {
let hwnd = FindWindowW(None, windows::core::w!("Punktfunk"));
if !hwnd.0.is_null() {
// Subclassing (rather than replacing the window proc) is what lets the
// WinUI window keep behaving as itself; the same mechanism the stream
// input hooks already use.
let _ = SetWindowSubclass(hwnd, Some(wnd_proc), SUBCLASS_ID, 0);
return;
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
tracing::warn!("shell window never appeared; links from other instances won't arrive");
})
.ok();
}
/// The subclass hook: copy our tagged payload out and queue it, pass everything else through.
unsafe extern "system" fn wnd_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
_id: usize,
_data: usize,
) -> LRESULT {
if msg == WM_COPYDATA as u32 {
// SAFETY: for `WM_COPYDATA` the OS marshals the sender's `COPYDATASTRUCT` and its buffer
// into THIS process and keeps both valid for the duration of the handler — that is the
// guarantee this relies on, not the sender's honesty, which is why a hostile sender can at
// worst supply a wrong `dwData`/contents rather than a bad pointer.
let cds = unsafe { &*(lparam.0 as *const COPYDATASTRUCT) };
if cds.dwData == COPYDATA_URL && !cds.lpData.is_null() {
let len = cds.cbData as usize / 2;
// SAFETY: as above, `lpData` is the OS-marshalled copy, valid for `cbData` bytes and
// suitably aligned because the OS allocated it; `len` is `cbData / 2`, so the slice
// cannot read past the buffer even if `cbData` is odd (the division rounds down).
let slice = unsafe { std::slice::from_raw_parts(cds.lpData as *const u16, len) };
let url = String::from_utf16_lossy(slice);
tracing::debug!(%url, "link from another instance");
INBOX.lock().unwrap().push(url);
return LRESULT(1);
}
}
// SAFETY: the default handler is called with exactly the parameters the OS passed this window
// procedure, unmodified — forwarding them on is what a subclass proc is required to do for any
// message it does not consume.
unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) }
}
/// Take everything delivered since the last call — the app polls this and routes each one.
pub(crate) fn drain() -> Vec<String> {
std::mem::take(&mut *INBOX.lock().unwrap())
}
/// Queue a link this process was launched with, so the cold start and the hand-off path feed
/// the router through one door.
pub(crate) fn queue(url: String) {
INBOX.lock().unwrap().push(url);
}
/// Write a `.lnk` on the Desktop that launches this URL, and return its path.
///
/// The shortcut targets the app execution alias with the URL as an ARGUMENT, rather than being
/// a `.url` internet shortcut. Both would work while the scheme is registered; only this one
/// still works if it isn't, because it invokes the client directly — which is the whole point
/// of a shortcut being a container for a URL rather than a second launch mechanism
/// (design/client-deep-links.md §5). Targeting the alias (not the package path) is what keeps
/// it valid across updates, since the install path changes and the alias doesn't.
pub(crate) fn write_shortcut(label: &str, url: &str) -> Result<std::path::PathBuf, String> {
use windows::core::{Interface, HSTRING};
use windows::Win32::combaseapi::{CoCreateInstance, CoInitializeEx};
use windows::Win32::objbase::COINIT_APARTMENTTHREADED;
use windows::Win32::objidl::IPersistFile;
use windows::Win32::shobjidl_core::{IShellLinkW, ShellLink};
use windows::Win32::wtypesbase::CLSCTX_INPROC_SERVER;
let desktop = std::env::var("USERPROFILE")
.map(|p| std::path::PathBuf::from(p).join("Desktop"))
.map_err(|_| "USERPROFILE isn't set".to_string())?;
let path = desktop.join(format!("{}.lnk", file_name(label)));
// SAFETY: COM calls on this thread's apartment. `CoCreateInstance` returns an owned interface
// checked by `?`, and every setter below takes a borrowed `HSTRING`/`PCWSTR` that outlives its
// synchronous call; nothing here dereferences a pointer the caller supplied.
unsafe {
// The UI thread is already apartment-threaded; this is belt and braces for the case
// where a caller ever moves this off it. An already-initialised apartment returns
// S_FALSE, which is not an error.
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED as u32);
let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)
.map_err(|e| format!("shell link: {e}"))?;
link.SetPath(&HSTRING::from("punktfunk-client.exe"))
.ok()
.map_err(|e| format!("shortcut target: {e}"))?;
link.SetArguments(&HSTRING::from(url))
.ok()
.map_err(|e| format!("shortcut argument: {e}"))?;
link.SetDescription(&HSTRING::from(format!("Stream from {label}")))
.ok()
.map_err(|e| format!("shortcut description: {e}"))?;
let persist: IPersistFile = link.cast().map_err(|e| format!("shortcut save: {e}"))?;
// `to_string_lossy` rather than the OsStr: HSTRING is UTF-16 and the path came from an
// env var plus our own sanitised name, so there is nothing lossy left to lose.
persist
.Save(&HSTRING::from(path.to_string_lossy().as_ref()), true)
.ok()
.map_err(|e| format!("{}: {e}", path.display()))?;
}
Ok(path)
}
/// A filename Windows will accept: its reserved characters replaced, length capped, and never
/// empty. Host and profile names are user text and reach this directly.
fn file_name(label: &str) -> String {
let cleaned: String = label
.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '-',
c if (c as u32) < 0x20 => '-',
c => c,
})
.take(64)
.collect();
let trimmed = cleaned.trim().trim_end_matches('.').to_string();
if trimmed.is_empty() {
"Punktfunk".to_string()
} else {
trimmed
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Links are recognised wherever they sit in argv, and nothing else is.
#[test]
fn positional_url_finds_links_only() {
let args = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(
positional_url(&args(&["punktfunk-client.exe", "punktfunk://connect/Desk"])),
Some("punktfunk://connect/Desk".into())
);
// The alias form still parses (it is never emitted, only accepted).
assert_eq!(
positional_url(&args(&["exe", "--windowed", "PF://connect/Desk"])),
Some("PF://connect/Desk".into())
);
assert_eq!(positional_url(&args(&["exe", "--console"])), None);
// argv[0] is never a link, even if someone renames the binary.
assert_eq!(positional_url(&args(&["punktfunk://connect/Desk"])), None);
}
/// Shortcut names survive user text: reserved characters, control characters, a trailing
/// dot (which Windows silently strips, breaking the path) and an empty result.
#[test]
fn shortcut_file_names_are_safe() {
assert_eq!(file_name("Living Room PC"), "Living Room PC");
assert_eq!(file_name("Desk: Work/Play"), "Desk- Work-Play");
assert_eq!(file_name("Desk\u{1}"), "Desk-");
assert_eq!(file_name("Trailing."), "Trailing");
assert_eq!(file_name(" "), "Punktfunk");
assert!(file_name(&"x".repeat(300)).len() <= 64);
}
}