feat(client/windows): punktfunk:// opens the Windows client, and cards can write a shortcut
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
android / android (push) Canceled after 0s
WP4 / D2. 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, the same door the Linux shell uses. What Windows does not give us is single-instancing: unlike GApplication, a second activation is simply a second process. So the first instance claims the named mutex `unom.punktfunk.client` and any later one hands its URL to the winner over `WM_COPYDATA` and exits — one window, and the link opens where the user's hosts already are. The hand-off retries while the primary's window is still coming up (a shortcut double-clicked during startup is the ordinary case), and a hand-off that finds nobody falls through so this process becomes the shell that opens it. A link is never silently dropped, which is also why the inbox is a queue: two shortcuts in quick succession are two links. Routing is the same four lines of translation as on Linux, because the decisions belong to `plan_from_link`: a resolved link becomes the call a tile click makes, with the same wake, trust and error surfaces. Never preempting a live session is checked here, since only this layer knows one is running. The manifest declares the protocol and an app execution alias rather than writing registry keys, so uninstall leaves nothing behind — and Windows' own "allow this app to handle punktfunk links?" prompt is the origin friction the design wants, not duplicated in-app. (Manifest comments are free of double hyphens: XML forbids them and makepri rejects the whole file, which is how the console flag broke the v0.15.0 build. A test parses the substituted manifest and asserts both declarations, so that can't rot silently.) "Create shortcut…" writes a `.lnk` targeting the ALIAS with the URL as an argument, not a `.url` internet shortcut: both work while the scheme is registered, only this one still works if it isn't, and targeting the alias keeps it valid across updates when the package path changes. Verified on the CI VM (192.168.1.133): clippy -D warnings clean and its tests green. Not yet exercised on glass — `start punktfunk://…` cold and with the app running, and a written shortcut, still want a real desktop session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,10 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
|
|||||||
"Win32_Graphics_Direct3D11",
|
"Win32_Graphics_Direct3D11",
|
||||||
"Win32_Graphics_Direct3D_Fxc",
|
"Win32_Graphics_Direct3D_Fxc",
|
||||||
"Win32_Graphics_Gdi",
|
"Win32_Graphics_Gdi",
|
||||||
|
# IShellLinkW + IPersistFile for the "Create shortcut" writer, and COPYDATASTRUCT for the
|
||||||
|
# link hand-off between instances (deeplink.rs).
|
||||||
|
"Win32_System_Com",
|
||||||
|
"Win32_System_DataExchange",
|
||||||
"Win32_System_Console",
|
"Win32_System_Console",
|
||||||
"Win32_System_LibraryLoader",
|
"Win32_System_LibraryLoader",
|
||||||
"Win32_System_Threading",
|
"Win32_System_Threading",
|
||||||
|
|||||||
@@ -19,8 +19,9 @@
|
|||||||
<Package
|
<Package
|
||||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||||
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||||
|
xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"
|
||||||
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||||
IgnorableNamespaces="uap rescap">
|
IgnorableNamespaces="uap uap5 rescap">
|
||||||
|
|
||||||
<Identity
|
<Identity
|
||||||
Name="unom.Punktfunk"
|
Name="unom.Punktfunk"
|
||||||
@@ -56,6 +57,32 @@
|
|||||||
Square44x44Logo="Assets\Square44x44Logo.png">
|
Square44x44Logo="Assets\Square44x44Logo.png">
|
||||||
<uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" />
|
<uap:DefaultTile Square71x71Logo="Assets\Square71x71Logo.png" />
|
||||||
</uap:VisualElements>
|
</uap:VisualElements>
|
||||||
|
<Extensions>
|
||||||
|
<!--
|
||||||
|
The punktfunk:// scheme (design/client-deep-links.md §4.2). Protocol activation of a
|
||||||
|
full trust packaged exe delivers the URI as the command line, so this and a shortcut
|
||||||
|
land in the same place: the positional URL parse in main().
|
||||||
|
|
||||||
|
Declared here rather than written to the registry on install, so uninstall leaves
|
||||||
|
nothing behind. Windows also asks the user before handing a scheme to a newly
|
||||||
|
installed app, which is the origin friction this design wants and does not duplicate.
|
||||||
|
-->
|
||||||
|
<uap:Extension Category="windows.protocol">
|
||||||
|
<uap:Protocol Name="punktfunk">
|
||||||
|
<uap:DisplayName>Punktfunk stream link</uap:DisplayName>
|
||||||
|
</uap:Protocol>
|
||||||
|
</uap:Extension>
|
||||||
|
<!--
|
||||||
|
An app execution alias so a written shortcut can target punktfunk-client.exe by name
|
||||||
|
from anywhere, without knowing the package install path (which changes on every
|
||||||
|
update). The .lnk writer uses exactly this.
|
||||||
|
-->
|
||||||
|
<uap5:Extension Category="windows.appExecutionAlias">
|
||||||
|
<uap5:AppExecutionAlias>
|
||||||
|
<uap5:ExecutionAlias Alias="punktfunk-client.exe" />
|
||||||
|
</uap5:AppExecutionAlias>
|
||||||
|
</uap5:Extension>
|
||||||
|
</Extensions>
|
||||||
</Application>
|
</Application>
|
||||||
<!--
|
<!--
|
||||||
Second entry point: the couch/console UI, for an HTPC or a TV-attached box where the
|
Second entry point: the couch/console UI, for an HTPC or a TV-attached box where the
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const MENU_WITH: &str = "Connect with: ";
|
|||||||
const MENU_PIN: &str = "Pin as card: ";
|
const MENU_PIN: &str = "Pin as card: ";
|
||||||
const MENU_UNPIN: &str = "Unpin card: ";
|
const MENU_UNPIN: &str = "Unpin card: ";
|
||||||
const MENU_COPY_LINK: &str = "Copy link";
|
const MENU_COPY_LINK: &str = "Copy link";
|
||||||
|
const MENU_SHORTCUT: &str = "Create shortcut\u{2026}";
|
||||||
const MENU_FORGET: &str = "Forget\u{2026}";
|
const MENU_FORGET: &str = "Forget\u{2026}";
|
||||||
|
|
||||||
/// Whether the console (gamepad) UI is available in this build: the session binary ships
|
/// Whether the console (gamepad) UI is available in this build: the session binary ships
|
||||||
@@ -610,6 +611,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
let (fp, name) = (k.fp_hex.clone(), k.name.clone());
|
let (fp, name) = (k.fp_hex.clone(), k.name.clone());
|
||||||
let menu_profiles = profiles.clone();
|
let menu_profiles = profiles.clone();
|
||||||
let (link_host, link_profile) = (k.clone(), None::<String>);
|
let (link_host, link_profile) = (k.clone(), None::<String>);
|
||||||
|
let shortcut_host = k.clone();
|
||||||
button("")
|
button("")
|
||||||
.icon(Symbol::More)
|
.icon(Symbol::More)
|
||||||
.subtle()
|
.subtle()
|
||||||
@@ -635,6 +637,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
items.push(menu_item(MENU_COPY_LINK));
|
items.push(menu_item(MENU_COPY_LINK));
|
||||||
|
items.push(menu_item(MENU_SHORTCUT));
|
||||||
// The library surfaces — mouse/KB page and the gamepad console UI —
|
// The library surfaces — mouse/KB page and the gamepad console UI —
|
||||||
// for paired hosts only (the mgmt API needs the paired identity);
|
// for paired hosts only (the mgmt API needs the paired identity);
|
||||||
// the page additionally sits behind the experimental toggle, the
|
// the page additionally sits behind the experimental toggle, the
|
||||||
@@ -690,6 +693,18 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
|||||||
// The tile list re-reads the store on the next render; nudge it.
|
// The tile list re-reads the store on the next render; nudge it.
|
||||||
sr.call(None);
|
sr.call(None);
|
||||||
}
|
}
|
||||||
|
MENU_SHORTCUT => {
|
||||||
|
let url = pf_client_core::deeplink::DeepLink::for_host(
|
||||||
|
&shortcut_host,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.to_url();
|
||||||
|
match crate::deeplink::write_shortcut(&shortcut_host.name, &url) {
|
||||||
|
Ok(p) => tracing::info!(path = %p.display(), "shortcut written"),
|
||||||
|
Err(e) => tracing::warn!(error = %e, "writing the shortcut"),
|
||||||
|
}
|
||||||
|
}
|
||||||
MENU_COPY_LINK => {
|
MENU_COPY_LINK => {
|
||||||
let url = pf_client_core::deeplink::DeepLink::for_host(
|
let url = pf_client_core::deeplink::DeepLink::for_host(
|
||||||
&link_host,
|
&link_host,
|
||||||
|
|||||||
@@ -259,6 +259,24 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
// it already reads — resetting an override, which rewrites the catalog behind the controls.
|
// it already reads — resetting an override, which rewrites the catalog behind the controls.
|
||||||
// Root state comparison makes same-value calls free, so a counter is what forces the pass.
|
// Root state comparison makes same-value calls free, so a counter is what forces the pass.
|
||||||
let (settings_rev, set_settings_rev) = cx.use_async_state(0u64);
|
let (settings_rev, set_settings_rev) = cx.use_async_state(0u64);
|
||||||
|
// `punktfunk://` links: the receiver thread queues them (from this launch's argv, or from a
|
||||||
|
// later instance over WM_COPYDATA) and this poll pulls them onto the UI thread. Thread-fed
|
||||||
|
// state must be root state, like the pad count below.
|
||||||
|
let (deep_link, set_deep_link) = cx.use_async_state(Option::<String>::None);
|
||||||
|
cx.use_effect((), {
|
||||||
|
let set_deep_link = set_deep_link.clone();
|
||||||
|
move || {
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("pf-deeplink-poll".into())
|
||||||
|
.spawn(move || loop {
|
||||||
|
for url in crate::deeplink::drain() {
|
||||||
|
set_deep_link.call(Some(url));
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
});
|
||||||
// Connected-controller count, mirrored from the gamepad service by a poll thread
|
// Connected-controller count, mirrored from the gamepad service by a poll thread
|
||||||
// (thread-driven state must be root state — see the module docs). Drives the hosts
|
// (thread-driven state must be root state — see the module docs). Drives the hosts
|
||||||
// page's "Open console UI" hint; the compare in `call` makes the steady state free.
|
// page's "Open console UI" hint; the compare in `call` makes the steady state free.
|
||||||
@@ -282,6 +300,80 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
let (library, set_library) = cx.use_async_state(library::LibraryState::default());
|
let (library, set_library) = cx.use_async_state(library::LibraryState::default());
|
||||||
|
|
||||||
// Continuous LAN discovery (spawned once).
|
// Continuous LAN discovery (spawned once).
|
||||||
|
// Route an arriving link. Parsing, host and profile resolution and every refusal rule live
|
||||||
|
// in the shared brain (`plan_from_link`); this is only the WinUI end — turn the outcome into
|
||||||
|
// the same call a tile click makes, so a link gets the identical wake, trust and error
|
||||||
|
// surfaces rather than a second connect path of its own.
|
||||||
|
cx.use_effect(deep_link.clone(), {
|
||||||
|
let (ctx, set_screen, set_status, set_deep_link) = (
|
||||||
|
ctx.clone(),
|
||||||
|
set_screen.clone(),
|
||||||
|
set_status.clone(),
|
||||||
|
set_deep_link.clone(),
|
||||||
|
);
|
||||||
|
let screen_now = screen.clone();
|
||||||
|
move || {
|
||||||
|
let Some(url) = deep_link.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
set_deep_link.call(None);
|
||||||
|
let refuse = |msg: String| {
|
||||||
|
tracing::info!(%msg, "deep link refused");
|
||||||
|
set_status.call(msg);
|
||||||
|
set_screen.call(Screen::Hosts);
|
||||||
|
};
|
||||||
|
let link = match pf_client_core::deeplink::parse(&url) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => return refuse(e.message()),
|
||||||
|
};
|
||||||
|
// 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 matches!(screen_now, Screen::Stream | Screen::Connecting) {
|
||||||
|
return refuse("A session is already running \u{2014} end it first.".into());
|
||||||
|
}
|
||||||
|
let known = KnownHosts::load();
|
||||||
|
let plan = pf_client_core::orchestrate::plan_from_link(
|
||||||
|
&link,
|
||||||
|
&known,
|
||||||
|
&pf_client_core::profiles::ProfilesFile::load(),
|
||||||
|
&ctx.settings.lock().unwrap().clone(),
|
||||||
|
);
|
||||||
|
use pf_client_core::orchestrate::PlanOutcome;
|
||||||
|
match plan {
|
||||||
|
Ok(PlanOutcome::Connect(p)) => {
|
||||||
|
let target = Target {
|
||||||
|
name: p.host.name.clone(),
|
||||||
|
addr: p.host.addr.clone(),
|
||||||
|
port: p.host.port,
|
||||||
|
fp_hex: p.host.fp_hex.clone(),
|
||||||
|
pair_optional: false,
|
||||||
|
mac: p.host.mac.clone(),
|
||||||
|
profile: p.profile_override.clone(),
|
||||||
|
};
|
||||||
|
// With a MAC it takes the dial first wake path, so a sleeping host wakes
|
||||||
|
// instead of erroring — exactly what clicking its tile would do.
|
||||||
|
if p.wake && !target.mac.is_empty() {
|
||||||
|
connect::initiate_waking(&ctx, target, &set_screen, &set_status);
|
||||||
|
} else {
|
||||||
|
connect::initiate(&ctx, target, &set_screen, &set_status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Known but never pinned, or not known at all: a link may not pair or trust on
|
||||||
|
// its own, so it lands on the host list with the reason shown. The user pairs
|
||||||
|
// there, under their own eyes.
|
||||||
|
Ok(PlanOutcome::ConfirmUnknown(u)) => refuse(format!(
|
||||||
|
"{} isn't paired with this device yet \u{2014} pair it, then use the link again.",
|
||||||
|
u.name.clone().unwrap_or_else(|| u.addr.clone())
|
||||||
|
)),
|
||||||
|
Ok(PlanOutcome::Unsupported(route)) => refuse(format!(
|
||||||
|
"Punktfunk can't open \u{201c}{}\u{201d} links yet.",
|
||||||
|
route.as_str()
|
||||||
|
)),
|
||||||
|
Err(e) => refuse(e.message()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
cx.use_effect((), {
|
cx.use_effect((), {
|
||||||
let set_hosts = set_hosts.clone();
|
let set_hosts = set_hosts.clone();
|
||||||
move || {
|
move || {
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
//! `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::Foundation::{HWND, LPARAM, LRESULT, WPARAM};
|
||||||
|
use windows::Win32::System::Threading::{CreateMutexW, ReleaseMutex};
|
||||||
|
use windows::Win32::UI::Shell::{DefSubclassProc, SetWindowSubclass};
|
||||||
|
// COPYDATASTRUCT lives with the other data-exchange types, not with the message constant.
|
||||||
|
use windows::Win32::System::DataExchange::COPYDATASTRUCT;
|
||||||
|
use windows::Win32::UI::WindowsAndMessaging::{FindWindowW, SendMessageW, 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 {
|
||||||
|
unsafe {
|
||||||
|
let handle = match CreateMutexW(None, true, MUTEX_NAME) {
|
||||||
|
Ok(h) => h,
|
||||||
|
// 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.
|
||||||
|
Err(e) => {
|
||||||
|
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::Foundation::GetLastError()
|
||||||
|
== windows::Win32::Foundation::ERROR_ALREADY_EXISTS;
|
||||||
|
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 {
|
||||||
|
unsafe {
|
||||||
|
let _ = ReleaseMutex(windows::Win32::Foundation::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 {
|
||||||
|
unsafe {
|
||||||
|
if let Ok(hwnd) = FindWindowW(None, windows::core::w!("Punktfunk")) {
|
||||||
|
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,
|
||||||
|
Some(WPARAM(0)),
|
||||||
|
Some(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 {
|
||||||
|
unsafe {
|
||||||
|
if let Ok(hwnd) = FindWindowW(None, windows::core::w!("Punktfunk")) {
|
||||||
|
// 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 {
|
||||||
|
let cds = unsafe { &*(lparam.0 as *const COPYDATASTRUCT) };
|
||||||
|
if cds.dwData == COPYDATA_URL && !cds.lpData.is_null() {
|
||||||
|
let len = cds.cbData as usize / 2;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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::System::Com::{
|
||||||
|
CoCreateInstance, CoInitializeEx, IPersistFile, CLSCTX_INPROC_SERVER,
|
||||||
|
COINIT_APARTMENTTHREADED,
|
||||||
|
};
|
||||||
|
use windows::Win32::UI::Shell::{IShellLinkW, ShellLink};
|
||||||
|
|
||||||
|
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)));
|
||||||
|
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);
|
||||||
|
let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)
|
||||||
|
.map_err(|e| format!("shell link: {e}"))?;
|
||||||
|
link.SetPath(&HSTRING::from("punktfunk-client.exe"))
|
||||||
|
.map_err(|e| format!("shortcut target: {e}"))?;
|
||||||
|
link.SetArguments(&HSTRING::from(url))
|
||||||
|
.map_err(|e| format!("shortcut argument: {e}"))?;
|
||||||
|
link.SetDescription(&HSTRING::from(format!("Stream from {label}")))
|
||||||
|
.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)
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod app;
|
mod app;
|
||||||
|
// `punktfunk://` activation: single instance, hand-off, and the positional URL parse
|
||||||
|
// (design/client-deep-links.md §4.2).
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod deeplink;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod discovery;
|
mod discovery;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -69,6 +73,17 @@ fn main() {
|
|||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
let flag = |name: &str| args.iter().any(|a| a == name);
|
let flag = |name: &str| args.iter().any(|a| a == name);
|
||||||
|
|
||||||
|
// A `punktfunk://` link — from protocol activation, `start`, or a shortcut. If a shell is
|
||||||
|
// already running, hand it over and get out of the way: one window, and the link opens
|
||||||
|
// where the user's hosts already are. A hand-off that finds nobody falls through and this
|
||||||
|
// process becomes the shell that opens it, so the link is never simply lost.
|
||||||
|
let link = deeplink::positional_url(&args);
|
||||||
|
if let Some(url) = &link {
|
||||||
|
if !deeplink::claim_primary() && deeplink::forward_to_primary(url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if flag("--discover") {
|
if flag("--discover") {
|
||||||
discover_and_print();
|
discover_and_print();
|
||||||
return;
|
return;
|
||||||
@@ -129,7 +144,17 @@ fn main() {
|
|||||||
// actual forwarding — so, unlike the old shell fork, we never `attach()` here. Idle it stays
|
// actual forwarding — so, unlike the old shell fork, we never `attach()` here. Idle it stays
|
||||||
// hands-off the hardware (id-getter metadata, no device open, Valve HIDAPI drivers off).
|
// hands-off the hardware (id-getter metadata, no device open, Valve HIDAPI drivers off).
|
||||||
let gamepad = pf_client_core::gamepad::GamepadService::start();
|
let gamepad = pf_client_core::gamepad::GamepadService::start();
|
||||||
if let Err(e) = app::run(identity, gamepad) {
|
// From here this process IS the shell: queue the link it was launched with (the router
|
||||||
|
// picks it up once the window is live) and start listening for links from later instances.
|
||||||
|
if let Some(url) = link {
|
||||||
|
deeplink::queue(url);
|
||||||
|
}
|
||||||
|
deeplink::install_receiver();
|
||||||
|
let outcome = app::run(identity, gamepad);
|
||||||
|
// Hand the single-instance mutex back before exiting, so the next launch claims it as
|
||||||
|
// primary immediately rather than waiting for the kernel to notice this process is gone.
|
||||||
|
deeplink::release_primary();
|
||||||
|
if let Err(e) = outcome {
|
||||||
tracing::error!(error = %e, "WinUI app failed");
|
tracing::error!(error = %e, "WinUI app failed");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user