feat(client/android): punktfunk:// links open a stream

Android was the last client with no URL door at all: no VIEW intent filter,
no `onNewIntent`, no parser. Now a Playnite entry, an OS shortcut, a Stream
Deck macro or a wiki link can open a stream on a host this device already
trusts.

The parser is a PORT, not a new design. `clients/shared/deeplink-vectors.json`
is the cross-language contract, and the Kotlin suite runs the same 44 cases
the Rust one does — including every refusal code — so three parsers cannot
drift into three different security postures. It is resolved from the shared
path rather than copied, because a copy would be a fourth contract free to go
stale. Strict percent-decoding with a REPORTING UTF-8 decoder, so `%FF` is a
refusal rather than a U+FFFD that survives into a log line or a filename.

The routing lives in `ConnectScreen` because that is what owns the connect
path — trust decisions, the local-network grant, wake-and-retry — and a link
must go THROUGH all of it, never around it. The rules are absolute and each
one is a branch you can point at: a known, pinned host does exactly what
tapping its card does; an unknown or never-pinned one gets the confirmation
sheet, from which the normal pairing flow proceeds under the user's eyes; an
`fp=` that contradicts the stored pin is a hard refusal; an ambiguous host
name or a profile this device doesn't have refuses with a notice naming what
failed, because a "Work" shortcut streaming with the wrong settings is worse
than an error; a link arriving mid-stream never preempts it (pointing at the
host already being streamed is a no-op — the intent has already brought the
app forward, which is what focusing it means). `wake` and `browse` parse, and
are refused with a notice rather than silently connecting.

`launch=` and `profile=` ride the whole path, including through a trust
decision: a link to a host that still needs pairing keeps its game and its
profile across the confirmation instead of quietly landing on a plain desktop
session.

`launchMode` stays `standard` and the `configChanges` set is untouched — its
`keyboard` entry is what keeps an SC2 claim from killing a running stream.
The VIEW intent is read in both `onCreate` and `onNewIntent`, which is what
that launch mode requires.
This commit is contained in:
2026-07-29 12:17:18 +02:00
parent 46461eb0b6
commit b63f9f4ac0
7 changed files with 841 additions and 18 deletions
@@ -90,6 +90,21 @@
<!-- TV launcher entry. -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!-- punktfunk:// deep links (design/client-deep-links.md §2): an external tool, an OS
shortcut or a wiki page opens a stream on a host this device already trusts. The
URL carries only REFERENCES to things that exist here (a host record, a settings
profile, a library id) — never resolution/bitrate/codec values, and never a
pairing route; MainActivity's router enforces the rest. BROWSABLE is what lets a
browser hand it over (behind its own "Open Punktfunk?" prompt).
NOTE: launchMode deliberately stays `standard` and the configChanges set above is
untouched — its `keyboard` entry is what keeps an SC2 claim from killing a running
stream, and neither has anything to gain from this filter. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="punktfunk" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -31,6 +31,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -41,12 +42,18 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import android.widget.Toast
import io.unom.punktfunk.kit.link.DeepLinkResult
import io.unom.punktfunk.kit.link.DeepLinks
import io.unom.punktfunk.kit.link.HostResolution
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.Tab
@Composable
fun App(forceGamepadUi: Boolean = false) {
val context = LocalContext.current
val activity = context as? MainActivity
val settingsStore = remember { SettingsStore(context) }
var settings by remember { mutableStateOf(settingsStore.load()) }
// The active session (null = not streaming). It carries the settings the connect resolved,
@@ -60,6 +67,27 @@ fun App(forceGamepadUi: Boolean = false) {
val controllerConnected by rememberControllerConnected()
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
// A `punktfunk://` URL that arrived while a session is live is REFUSED, never honoured: a URL
// may not preempt a stream (design/client-deep-links.md §3.2). Pointing at the host already
// being streamed is the one exception, and its right answer is to do nothing — the intent has
// already brought the app forward, which is exactly what "focus it" means here.
val pendingLink = activity?.pendingDeepLink
LaunchedEffect(pendingLink, session) {
val url = pendingLink ?: return@LaunchedEffect
val live = session ?: return@LaunchedEffect // not streaming: ConnectScreen routes it
activity.pendingDeepLink = null
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return@LaunchedEffect
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(context).all())
val sameHost = target is HostResolution.Known && target.host.id == live.hostId
if (!sameHost) {
Toast.makeText(
context,
"Already streaming — end this session first.",
Toast.LENGTH_LONG,
).show()
}
}
AnimatedContent(
targetState = session,
transitionSpec = {
@@ -75,6 +103,8 @@ fun App(forceGamepadUi: Boolean = false) {
settings = settings,
onSettingsChange = { settings = it; settingsStore.save(it) },
onConnected = { session = it },
deepLink = pendingLink,
onDeepLinkHandled = { activity?.pendingDeepLink = null },
)
} else {
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
@@ -105,7 +135,12 @@ fun App(forceGamepadUi: Boolean = false) {
label = "TabTransition"
) { targetTab ->
when (targetTab) {
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { session = it })
Tab.Connect -> ConnectScreen(
settings = settings,
onConnected = { session = it },
deepLink = pendingLink,
onDeepLinkHandled = { activity?.pendingDeepLink = null },
)
Tab.Settings -> SettingsScreen(
initial = settings,
onChange = { settings = it; settingsStore.save(it) },
@@ -170,6 +205,8 @@ fun GamepadShell(
settings: Settings,
onSettingsChange: (Settings) -> Unit,
onConnected: (ActiveSession) -> Unit,
deepLink: String? = null,
onDeepLinkHandled: () -> Unit = {},
) {
val context = LocalContext.current
var screen by remember { mutableStateOf(GamepadScreen.Home) }
@@ -196,6 +233,8 @@ fun GamepadShell(
GamepadScreen.Home -> ConnectScreen(
settings = settings,
onConnected = onConnected,
deepLink = deepLink,
onDeepLinkHandled = onDeepLinkHandled,
gamepadUi = true,
onOpenSettings = { screen = GamepadScreen.Settings },
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
@@ -59,6 +59,11 @@ import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.discovery.HostDiscovery
import io.unom.punktfunk.kit.link.DeepLinkResult
import io.unom.punktfunk.kit.link.DeepLinks
import io.unom.punktfunk.kit.link.HostResolution
import io.unom.punktfunk.kit.link.LinkError
import io.unom.punktfunk.kit.link.LinkRoute
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.IdentityStore
import io.unom.punktfunk.kit.security.KnownHost
@@ -111,6 +116,11 @@ fun ConnectScreen(
onOpenSettings: () -> Unit = {},
onOpenLibrary: (KnownHost) -> Unit = {},
navGate: Boolean = true, // false while the console home is cross-fading out
// A `punktfunk://` URL to route (design/client-deep-links.md §3). This screen owns it because
// it owns the connect path — trust decisions, the local-network grant, wake-and-retry — and a
// link must go through all of them, not around them.
deepLink: String? = null,
onDeepLinkHandled: () -> Unit = {},
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
@@ -281,9 +291,10 @@ fun ConnectScreen(
pinHex: String,
timeoutMs: Int,
profile: StreamProfile?,
launch: String?,
): Long = connectToHost(
context, settings.effectiveFor(profile), id, targetHost, targetPort, pinHex,
launch = null, timeoutMs = timeoutMs,
launch = launch, timeoutMs = timeoutMs,
)
// What the stream screen is handed: the settings this connect actually used, plus the HOST's
@@ -294,6 +305,7 @@ fun ConnectScreen(
settings.effectiveFor(profile),
clipboardSync = record?.clipboardSync ?: true,
profileName = profile?.name,
hostId = record?.id,
)
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
@@ -307,6 +319,7 @@ fun ConnectScreen(
name: String,
pinHex: String?,
profile: StreamProfile?,
launch: String? = null,
onFailure: (() -> Unit)? = null,
) {
val id = identity ?: run {
@@ -319,7 +332,8 @@ fun ConnectScreen(
status = null
discovery.stop() // free the Wi-Fi radio before the stream session
scope.launch {
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS, profile)
val handle =
connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS, profile, launch)
// Cancelled mid-dial: the UI's already been returned (and discovery restarted) by
// cancelConnect — drop the just-opened session silently rather than navigating into it.
if (thisAttempt.cancelled.get()) {
@@ -373,7 +387,14 @@ fun ConnectScreen(
// only a FAILED dial falls into the wake-and-WAIT-for-mDNS flow (WakeController's "Waking…"
// overlay), which redials once the host reappears. Otherwise (auto-wake off, no MAC, or already
// seen live) dial straight through.
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?, oneOffProfile: String?) {
fun doConnect(
targetHost: String,
targetPort: Int,
name: String,
pinHex: String?,
oneOffProfile: String?,
launch: String? = null,
) {
if (identity == null) {
status = "Identity not ready yet — try again in a moment"
return
@@ -392,7 +413,7 @@ fun ConnectScreen(
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
// Fire-and-forget first packet (harmless if it's awake), then dial-first.
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
doConnectDirect(targetHost, targetPort, name, pinHex, profile, onFailure = {
doConnectDirect(targetHost, targetPort, name, pinHex, profile, launch, onFailure = {
waker.start(
hostName = name,
connectsAfter = true,
@@ -408,12 +429,15 @@ fun ConnectScreen(
knownHostStore.save(kh.copy(address = live.host, port = live.port))
savedHosts = knownHostStore.all()
}
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex, profile)
doConnectDirect(
live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex,
profile, launch,
)
},
)
})
} else {
doConnectDirect(targetHost, targetPort, name, pinHex, profile)
doConnectDirect(targetHost, targetPort, name, pinHex, profile, launch)
}
}
@@ -440,7 +464,10 @@ fun ConnectScreen(
val pinHex = target.advertisedFp ?: ""
// A host being trusted for the first time can't have a binding yet, so this is always
// the plain defaults — a profile only ever enters via a later, deliberate choice.
val handle = connectNative(id, target.host, target.port, pinHex, REQUEST_ACCESS_TIMEOUT_MS, null)
val handle = connectNative(
id, target.host, target.port, pinHex, REQUEST_ACCESS_TIMEOUT_MS,
profile = null, launch = target.launch,
)
// Cancelled while we were parked: tear the (possibly just-approved) session down and
// don't touch UI a fresh action may now own.
if (req.cancelled.get()) {
@@ -485,6 +512,8 @@ fun ConnectScreen(
// `""` = force the global defaults, which is a real choice on a bound host and must
// therefore survive as a value rather than collapsing into "unset". NEVER rebinds.
oneOffProfile: String? = null,
// A library id the host should boot straight into (`launch=` on a link).
launch: String? = null,
) {
// Every dial/pair path funnels through here — with local network access denied the connect
// can only EPERM its way to a 10 s timeout, so ask instead of pretending to try.
@@ -500,18 +529,24 @@ fun ConnectScreen(
when {
// Known host whose advertised fp still matches the pin → silent pinned reconnect.
known != null && (adv == null || adv == known.fpHex) ->
doConnect(targetHost, targetPort, known.name, known.fpHex, oneOffProfile)
doConnect(targetHost, targetPort, known.name, known.fpHex, oneOffProfile, launch)
// Known host whose fp changed → force re-pairing (no silent re-trust shortcut).
known != null -> pendingTrust =
PendingTrust(targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED)
known != null -> pendingTrust = PendingTrust(
targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED,
oneOffProfile, launch,
)
// Host explicitly advertised pair=optional → trust-on-first-use is permitted (offer it,
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
dh?.pairingRequired == false -> pendingTrust =
PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW)
dh?.pairingRequired == false -> pendingTrust = PendingTrust(
targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW,
oneOffProfile, launch,
)
// pair=required, or a manual/unknown-policy host → offer the two ways in: a no-PIN
// "request access" (approve in the console) or the SPAKE2 PIN ceremony.
else -> pendingTrust =
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.REQUEST_ACCESS)
else -> pendingTrust = PendingTrust(
targetHost, targetPort, name, adv, PendingTrust.Kind.REQUEST_ACCESS,
oneOffProfile, launch,
)
}
}
@@ -565,6 +600,89 @@ fun ConnectScreen(
listOf(HostCardEntry(kh, null)) + profileStore.pinsFor(kh).map { HostCardEntry(kh, it) }
}
// ---- punktfunk:// routing (design/client-deep-links.md §3) --------------------------------
//
// The invariant: a URL may only ever do what a click on an existing card could do, MINUS trust
// decisions. So it never pairs, never trusts on its own, and carries references rather than
// values. Everything below is either "do exactly what the card does" or "refuse and say why" —
// a shortcut that can't honour its reference must say so, because streaming with the wrong
// settings is worse than an explanatory notice.
LaunchedEffect(deepLink, identity, savedHosts) {
val url = deepLink ?: return@LaunchedEffect
// Wait for the identity rather than refusing: it arrives a beat after first composition and
// the effect re-runs when it does.
if (identity == null) return@LaunchedEffect
onDeepLinkHandled()
val parsed = DeepLinks.parse(url)
if (parsed is DeepLinkResult.Refused) {
// A link for someone else's scheme is not our business to complain about.
if (parsed.error != LinkError.NOT_OUR_SCHEME) status = parsed.message()
return@LaunchedEffect
}
val link = (parsed as DeepLinkResult.Parsed).link
if (link.route != LinkRoute.CONNECT) {
// `wake` and `browse` are reserved in the grammar and parse today; a front-end that
// hasn't implemented them refuses with a notice rather than silently connecting.
status = "Punktfunk on Android can't do “${link.route.word}” links yet."
return@LaunchedEffect
}
// A profile reference that can't be honoured refuses: a "Work" shortcut streaming with the
// wrong settings is worse than an error naming what failed.
val profileRef = link.profile
if (profileRef != null) {
val (_, resolution) = profileStore.resolve(profileRef)
if (resolution != ProfileResolution.FOUND) {
status = if (resolution == ProfileResolution.AMBIGUOUS) {
"More than one profile is called “$profileRef” — rename one and try again."
} else {
"That link asks for a profile called “$profileRef”, which isn't on this device."
}
return@LaunchedEffect
}
}
when (val resolved = DeepLinks.resolveHost(link, savedHosts)) {
// Known AND pinned is the one-click contract: do exactly what tapping its card does.
is HostResolution.Known -> {
// A pin that contradicts the stored one is the link being stale or lying. Hard
// refusal: this is the one case where doing what the card does would be wrong.
if (link.pinConflict(resolved.host)) {
status = "That link's fingerprint doesn't match the one pinned for " +
"${resolved.host.name} — it's out of date, or it isn't that host."
return@LaunchedEffect
}
if (resolved.host.fpHex.isEmpty()) {
// Saved but never pinned (nothing writes such a record today, but the rule is
// absolute): a link may not establish trust, so this is a confirmation.
pendingTrust = PendingTrust(
resolved.host.address, resolved.host.port, resolved.host.name,
link.fp, PendingTrust.Kind.REQUEST_ACCESS, profileRef, link.launch,
)
return@LaunchedEffect
}
connect(
resolved.host.address, resolved.host.port,
oneOffProfile = profileRef, launch = link.launch,
)
}
// Unknown, or known only by address: the confirmation sheet, from which the normal
// pairing flow proceeds under the user's eyes. Never a silent trust.
is HostResolution.Unknown -> pendingTrust = PendingTrust(
resolved.address,
resolved.port,
link.name ?: resolved.address,
resolved.fp,
PendingTrust.Kind.REQUEST_ACCESS,
profileRef,
link.launch,
)
HostResolution.Ambiguous ->
status = "More than one saved host is called “${link.hostRef}” — " +
"rename one, or use its address."
HostResolution.Unresolvable ->
status = "That link points at a host this device doesn't know."
}
}
var showManualSheet by remember { mutableStateOf(false) }
if (gamepadUi) {
@@ -880,12 +998,12 @@ fun ConnectScreen(
knownHostStore.trust(pt.host, pt.port, pt.name, fp, paired = true)
savedHosts = knownHostStore.all()
pendingTrust = null
doConnect(pt.host, pt.port, pt.name, fp, null)
doConnect(pt.host, pt.port, pt.name, fp, pt.profile, pt.launch)
}
when (pt.kind) {
PendingTrust.Kind.TRUST_NEW ->
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, null) }, onPair, { pendingTrust = null })
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, null) }, onPair, { pendingTrust = null })
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null })
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null, pt.profile, pt.launch) }, onPair, { pendingTrust = null })
PendingTrust.Kind.FP_CHANGED ->
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
@@ -96,6 +96,17 @@ class MainActivity : ComponentActivity() {
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
private set
/**
* A `punktfunk://` URL waiting to be routed — set from the VIEW intent that started (or
* re-entered) this activity, cleared by whoever handles it. Compose observes it.
*
* Read in BOTH [onCreate] and [onNewIntent] on purpose: `launchMode` is `standard`, so a second
* link usually arrives as a fresh activity instance (onCreate) and only sometimes as a new
* intent on this one (a caller that set `FLAG_ACTIVITY_SINGLE_TOP`). A link arriving while an
* earlier one is still unhandled replaces it — the user's latest intent is the live one.
*/
var pendingDeepLink by mutableStateOf<String?>(null)
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
private var highRefreshModeId = 0
@@ -125,6 +136,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pendingDeepLink = deepLinkFrom(intent)
lastPadIsGamepad = !isTvDevice(this)
lastPadStyle = Gamepad.styleFor(Gamepad.firstPad())
resolveHighRefreshMode()
@@ -185,6 +197,20 @@ class MainActivity : ComponentActivity() {
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Keep `getIntent()` truthful for anything that reads it later (the gamepad-UI dev flag).
setIntent(intent)
deepLinkFrom(intent)?.let { pendingDeepLink = it }
}
/**
* The `punktfunk://` URL of a VIEW intent, or null. Only VIEW: the launcher's MAIN intent
* carries no data, and nothing else may inject a URL into the router.
*/
private fun deepLinkFrom(intent: Intent?): String? =
intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data?.toString()
override fun onResume() {
super.onResume()
startSc2MenuNav()
@@ -25,6 +25,14 @@ data class PendingTrust(
val name: String,
val advertisedFp: String?,
val kind: Kind,
/**
* What the connect on the far side of this decision should carry — a `punktfunk://` link's
* one-off profile and library id. A link to an unknown host goes through the confirmation
* first, and the user's stated intent must survive that detour rather than being silently
* dropped on the way to a plain desktop session.
*/
val profile: String? = null,
val launch: String? = null,
) {
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR, REQUEST_ACCESS }
}
@@ -47,6 +55,12 @@ data class ActiveSession(
* so "which profile am I on?" is answerable from inside the stream, as on the other clients.
*/
val profileName: String? = null,
/**
* The stable id of the host being streamed, when it is a saved one — so a `punktfunk://` link
* that arrives mid-stream can tell "this same host" (a no-op; the intent already focused us)
* from "a different host" (a notice; a URL may never preempt a live session).
*/
val hostId: String? = null,
)
/** Trust state of a host, shown as a colored pill on its card. */
@@ -0,0 +1,451 @@
package io.unom.punktfunk.kit.link
import io.unom.punktfunk.kit.security.KnownHost
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
import java.nio.charset.StandardCharsets
/**
* The `punktfunk://` URL grammar (design/client-deep-links.md §2). A **port**, not a new design:
* the Rust `crates/pf-client-core/src/deeplink.rs` is the reference, Swift keeps a third copy, and
* all three are held together by `clients/shared/deeplink-vectors.json`, which each language's test
* suite runs verbatim — so the three parsers cannot drift into three different security postures.
*
* ```text
* punktfunk://connect/<host-ref>[?fp=<64-hex>][&host=<addr[:port]>][&launch=<id>]
* [&profile=<ref>][&name=<label>]
* ```
*
* The invariant the grammar exists to keep: **a URL may only ever do what a click on an existing
* card could do, minus trust decisions.** So it carries *references* to things that already exist
* on this device — a host record, a settings profile, a library id — and never values: no
* resolution, no bitrate, no codec. A web page must not be able to shape a session beyond picking
* among the user's own configurations. `pair` is deliberately not a route and never will be;
* pairing stays an interactive ceremony.
*
* `pf://` parses as an alias so a hand-typed or legacy link still works, but nothing ever *emits*
* or registers it.
*/
object DeepLinks {
/** Hostile-input caps. Generous for a real link, small enough that a pasted megabyte stops here. */
const val MAX_URL_LEN = 2048
const val MAX_HOST_REF_LEN = 128
const val MAX_LAUNCH_LEN = 128
const val MAX_PROFILE_LEN = 64
const val MAX_NAME_LEN = 64
/** The default native port, as everywhere else in the clients. */
const val DEFAULT_PORT = 9777
/**
* Parse a `punktfunk://` (or `pf://`) URL. Everything hostile is rejected here, once: over-long
* input, malformed escapes, control characters, out-of-charset launch ids and fingerprints that
* aren't fingerprints. What the caller still has to do is *resolve* — the references may name
* things that don't exist on this device (see [resolveHost]).
*/
fun parse(url: String): DeepLinkResult {
if (url.length > MAX_URL_LEN) return DeepLinkResult.Refused(LinkError.TOO_LONG)
val sep = url.indexOf("://")
if (sep < 0) return DeepLinkResult.Refused(LinkError.NOT_OUR_SCHEME)
val scheme = url.substring(0, sep)
if (!scheme.equals("punktfunk", ignoreCase = true) && !scheme.equals("pf", ignoreCase = true)) {
return DeepLinkResult.Refused(LinkError.NOT_OUR_SCHEME)
}
// A fragment is never part of this grammar; drop it rather than folding it into the last
// parameter, where it would smuggle unvalidated text past the caps.
val rest = url.substring(sep + 3).substringBefore('#')
val path = rest.substringBefore('?').trimEnd('/')
val query = if (rest.contains('?')) rest.substringAfter('?') else ""
val slash = path.indexOf('/')
val routeWord: String
val hostRefRaw: String
when {
slash >= 0 -> {
routeWord = path.substring(0, slash)
hostRefRaw = path.substring(slash + 1)
}
// A single segment: Apple's shipped links are always `connect/<uuid>`, but a bare
// reference is unambiguous as long as it isn't one of the route words — those stay
// routes (with a missing reference), so `punktfunk://pair` refuses instead of hunting
// for a host called "pair".
isRouteWord(path) -> {
routeWord = path
hostRefRaw = ""
}
else -> {
routeWord = "connect"
hostRefRaw = path
}
}
val route = when (routeWord.lowercase()) {
"connect" -> LinkRoute.CONNECT
"wake" -> LinkRoute.WAKE
"browse" -> LinkRoute.BROWSE
"pair" -> return DeepLinkResult.Refused(LinkError.PAIR_REFUSED)
else -> return DeepLinkResult.Refused(LinkError.UNKNOWN_ROUTE, routeWord)
}
val hostRef = when (val d = decode(hostRefRaw)) {
is Decoded.Err -> return DeepLinkResult.Refused(d.error)
is Decoded.Ok -> d.text
}
if (hostRef.isEmpty()) return DeepLinkResult.Refused(LinkError.MISSING_HOST_REF)
if (scalarCount(hostRef) > MAX_HOST_REF_LEN) {
return DeepLinkResult.Refused(LinkError.PARAM_TOO_LONG, "host-ref")
}
var fp: String? = null
var host: Pair<String, Int>? = null
var launch: String? = null
var profile: String? = null
var name: String? = null
for (pair in query.split('&')) {
if (pair.isEmpty()) continue
val eq = pair.indexOf('=')
val rawKey = if (eq >= 0) pair.substring(0, eq) else pair
val rawValue = if (eq >= 0) pair.substring(eq + 1) else ""
val key = when (val d = decode(rawKey)) {
is Decoded.Err -> return DeepLinkResult.Refused(d.error)
is Decoded.Ok -> d.text.lowercase()
}
val value = when (val d = decode(rawValue)) {
is Decoded.Err -> return DeepLinkResult.Refused(d.error)
is Decoded.Ok -> d.text
}
// `?launch=` with nothing after it is "not given", not an error.
if (value.isEmpty()) continue
// First occurrence wins, and unknown keys are ignored: a newer emitter's parameter must
// not turn an otherwise valid link into a refusal, and appending a second `fp=` must
// not be able to override the first.
when {
key == "fp" && fp == null -> {
val hex = value.lowercase()
if (hex.length != 64 || !hex.all { it.isDigit() || it in 'a'..'f' }) {
return DeepLinkResult.Refused(LinkError.BAD_FINGERPRINT)
}
fp = hex
}
key == "host" && host == null ->
host = parseAddrPort(value) ?: return DeepLinkResult.Refused(LinkError.BAD_HOST_PARAM)
key == "launch" && launch == null -> {
if (value.toByteArray(StandardCharsets.UTF_8).size > MAX_LAUNCH_LEN) {
return DeepLinkResult.Refused(LinkError.PARAM_TOO_LONG, "launch")
}
if (!isSafeLaunchId(value)) return DeepLinkResult.Refused(LinkError.BAD_LAUNCH_ID)
launch = value
}
key == "profile" && profile == null -> {
if (scalarCount(value) > MAX_PROFILE_LEN) {
return DeepLinkResult.Refused(LinkError.PARAM_TOO_LONG, "profile")
}
profile = value
}
key == "name" && name == null -> {
if (scalarCount(value) > MAX_NAME_LEN) {
return DeepLinkResult.Refused(LinkError.PARAM_TOO_LONG, "name")
}
name = value
}
}
}
return DeepLinkResult.Parsed(DeepLink(route, hostRef, fp, host, launch, profile, name))
}
/**
* Resolve a link's host reference against the local store, in the documented order: stable
* record id → unique case-insensitive name → `addr[:port]` literal. The `host=` parameter is
* the recovery path — a self-emitted shortcut that outlived the record it was written from
* still lands on the right box (degraded to the confirmation sheet).
*/
fun resolveHost(link: DeepLink, hosts: List<KnownHost>): HostResolution {
hosts.firstOrNull { it.id == link.hostRef }?.let { return HostResolution.Known(it) }
val byName = hosts.filter { it.name.equals(link.hostRef, ignoreCase = true) }
when (byName.size) {
1 -> return HostResolution.Known(byName[0])
0 -> Unit
else -> return HostResolution.Ambiguous
}
// `addr[:port]` literal, then the `host=` recovery parameter — both matched the way every
// other per-host lookup matches. The literal is only considered when the reference COULD be
// an address: a stale record id must fall through to `host=` (or to a refusal), never be
// offered as a box to dial.
val literal = if (looksLikeAddress(link.hostRef)) parseAddrPort(link.hostRef) else null
for ((addr, port) in listOfNotNull(literal, link.host)) {
hosts.firstOrNull { it.address == addr && it.port == port }
?.let { return HostResolution.Known(it) }
}
val fallback = literal ?: link.host ?: return HostResolution.Unresolvable
return HostResolution.Unknown(fallback.first, fallback.second, link.name, link.fp)
}
/**
* The self-emitted form for a saved host: id first (address-independent), with the address and
* pin alongside so the link degrades to a confirmation sheet instead of a dead click when the
* record is gone.
*/
fun forHost(host: KnownHost, launch: String? = null, profile: String? = null) = DeepLink(
route = LinkRoute.CONNECT,
hostRef = host.id,
fp = host.fpHex.ifEmpty { null },
host = host.address to host.port,
launch = launch,
profile = profile,
)
/** The reserved first path segments — plus `pair`, reserved precisely so it can be refused. */
private fun isRouteWord(s: String) = s.lowercase() in setOf("connect", "wake", "browse", "pair")
/**
* Could this reference be a network address (an IP literal or a host name) rather than a record
* id or a display name? Only then may an unmatched reference become "an unknown host at this
* address". A stale record id is NOT an address: offering to dial a UUID as a hostname would
* turn a wiped store into a confusing dead end instead of the `host=`-driven recovery.
*/
private fun looksLikeAddress(s: String): Boolean {
val uuidShaped = s.length == 36 && s.withIndex().all { (i, c) ->
if (i in setOf(8, 13, 18, 23)) c == '-' else isHex(c)
}
return !uuidShaped && s.isNotEmpty() &&
s.all { it.isLetterOrDigit() && it.code < 128 || it in ".-_:[]" }
}
/**
* `addr`, `addr:port`, `[v6]`, `[v6]:port` — null when the port isn't a number. A bare IPv6
* literal (`::1`) keeps its colons and takes the default port; anything else splits at the last
* colon, like every other host-parsing site in the clients.
*/
private fun parseAddrPort(s: String): Pair<String, Int>? {
if (s.isEmpty()) return null
if (s.startsWith("[")) {
val close = s.indexOf(']', 1)
if (close < 0) return null
val addr = s.substring(1, close)
if (addr.isEmpty()) return null
val tail = s.substring(close + 1)
if (tail.isEmpty()) return addr to DEFAULT_PORT
if (!tail.startsWith(":")) return null
return addr to (tail.substring(1).toIntOrNull()?.takeIf { it in 1..65535 } ?: return null)
}
val lastColon = s.lastIndexOf(':')
if (lastColon < 0) return s to DEFAULT_PORT
val head = s.substring(0, lastColon)
// `::1` and friends: the head still has a colon, so this isn't a port separator.
if (head.contains(':')) return s to DEFAULT_PORT
if (head.isEmpty()) return null
val port = s.substring(lastColon + 1).toIntOrNull()?.takeIf { it in 1..65535 } ?: return null
return head to port
}
/**
* The launch-id charset the whole product already agrees on: printable, non-space ASCII with no
* shell metacharacters (Decky rides ids through Steam launch options as an env token, so a
* quote or a backtick genuinely breaks something downstream). Validation only — the id is
* opaque and the host matches it verbatim against its own library.
*/
private fun isSafeLaunchId(id: String): Boolean =
id.isNotEmpty() && id.toByteArray(StandardCharsets.UTF_8)
.all { it in 0x21..0x7e && it.toInt().toChar() !in "\"'\\$`" }
/**
* Strict percent-decoding: `%` must be followed by exactly two hex digits, the result must be
* UTF-8, and no control character may survive. Lenient decoders are how `%00`, a stray newline
* or a half-escape end up inside a filename or a log line.
*/
private fun decode(s: String): Decoded {
val bytes = s.toByteArray(StandardCharsets.UTF_8)
val out = java.io.ByteArrayOutputStream(bytes.size)
var i = 0
while (i < bytes.size) {
val b = bytes[i]
if (b == '%'.code.toByte()) {
if (i + 2 >= bytes.size) return Decoded.Err(LinkError.BAD_ESCAPE)
val hi = hexValue(bytes[i + 1].toInt().toChar())
val lo = hexValue(bytes[i + 2].toInt().toChar())
if (hi < 0 || lo < 0) return Decoded.Err(LinkError.BAD_ESCAPE)
out.write(hi * 16 + lo)
i += 3
} else {
out.write(b.toInt())
i += 1
}
}
// REPORT, not the default REPLACE: `%FF` must be a refusal, never a U+FFFD that survives.
val decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
val text = runCatching { decoder.decode(ByteBuffer.wrap(out.toByteArray())).toString() }
.getOrNull() ?: return Decoded.Err(LinkError.BAD_ESCAPE)
// `Char.isISOControl` is exactly Unicode's Cc category (C0 + DEL + C1), which is what the
// Rust side rejects.
if (text.any { it.isISOControl() }) return Decoded.Err(LinkError.CONTROL_CHAR)
return Decoded.Ok(text)
}
/** [decode]'s outcome — the decoded text, or which refusal it is. */
private sealed interface Decoded {
data class Ok(val text: String) : Decoded
data class Err(val error: LinkError) : Decoded
}
private fun hexValue(c: Char): Int = when (c) {
in '0'..'9' -> c - '0'
in 'a'..'f' -> c - 'a' + 10
in 'A'..'F' -> c - 'A' + 10
else -> -1
}
private fun isHex(c: Char) = hexValue(c) >= 0
/** Unicode scalars, matching the Rust caps (which count `chars()`, not UTF-16 units). */
private fun scalarCount(s: String) = s.codePointCount(0, s.length)
/**
* Percent-encode for emission: unreserved characters plus `:` (legal in a query value and left
* alone by Apple's `URLComponents`, so the three emitters agree on `steam:570`).
*/
internal fun encode(s: String): String {
val out = StringBuilder(s.length)
for (b in s.toByteArray(StandardCharsets.UTF_8)) {
val c = b.toInt().toChar()
if (c in 'A'..'Z' || c in 'a'..'z' || c in '0'..'9' || c in "-._~:") {
out.append(c)
} else {
out.append('%').append("%02X".format(b.toInt() and 0xFF))
}
}
return out.toString()
}
}
/** What the URL asks for. `WAKE`/`BROWSE` are reserved in the grammar and parse today. */
enum class LinkRoute(val word: String) {
CONNECT("connect"),
WAKE("wake"),
BROWSE("browse"),
}
/**
* Why a URL was rejected. The [code] strings are the cross-language contract — the vector file names
* them, and Swift and Rust report the same code for the same input.
*/
enum class LinkError(val code: String) {
/** Not a `punktfunk://` (or `pf://`) URL at all — ignore it, don't warn. */
NOT_OUR_SCHEME("not-our-scheme"),
TOO_LONG("too-long"),
UNKNOWN_ROUTE("unknown-route"),
/** `punktfunk://pair/…` — pairing is an interactive ceremony, never a link. */
PAIR_REFUSED("pair-refused"),
MISSING_HOST_REF("missing-host-ref"),
/** A `%` escape that isn't two hex digits, or a decode that isn't UTF-8. */
BAD_ESCAPE("bad-escape"),
/** A control character survived decoding — no legitimate field contains one. */
CONTROL_CHAR("control-char"),
PARAM_TOO_LONG("param-too-long"),
BAD_FINGERPRINT("bad-fingerprint"),
BAD_HOST_PARAM("bad-host-param"),
BAD_LAUNCH_ID("bad-launch-id"),
}
/**
* A parsed, validated link. Every field is already length- and charset-checked, so a consumer never
* has to re-validate hostile input.
*/
data class DeepLink(
val route: LinkRoute = LinkRoute.CONNECT,
/** The host reference as written: a stable record id, a host name, or `addr[:port]`. */
val hostRef: String,
/** Expected host certificate fingerprint, lowercase hex (64 chars). */
val fp: String? = null,
/** Recovery address for a stable id that no longer resolves (store wiped, reinstall). */
val host: Pair<String, Int>? = null,
/** A store-qualified library id (`steam:570`) for the host to launch on arrival. */
val launch: String? = null,
/** A settings-profile reference (id, or a unique name) — one-off, never rebinding. */
val profile: String? = null,
/** Display label for the unknown-host confirmation sheet (external emitters). */
val name: String? = null,
) {
/** The canonical URL for this link — always `punktfunk://`, never the `pf://` alias. */
fun toUrl(): String {
val sb = StringBuilder("punktfunk://${route.word}/${DeepLinks.encode(hostRef)}")
var sep = '?'
fun push(key: String, value: String) {
sb.append(sep).append(key).append('=').append(DeepLinks.encode(value))
sep = '&'
}
fp?.let { push("fp", it) }
host?.let { (addr, port) ->
push(
"host",
when {
port == DeepLinks.DEFAULT_PORT -> addr
addr.contains(':') -> "[$addr]:$port" // literal IPv6 needs its brackets back
else -> "$addr:$port"
},
)
}
launch?.let { push("launch", it) }
profile?.let { push("profile", it) }
name?.let { push("name", it) }
return sb.toString()
}
/**
* True when this link's `fp` contradicts what we have pinned for that host — the link is stale
* or lying, and the only safe answer is a hard refusal.
*/
fun pinConflict(host: KnownHost): Boolean =
fp != null && host.fpHex.isNotEmpty() && !fp.equals(host.fpHex, ignoreCase = true)
}
/** A parse outcome: a link, or a refusal carrying the shared code. */
sealed interface DeepLinkResult {
data class Parsed(val link: DeepLink) : DeepLinkResult
data class Refused(val error: LinkError, val detail: String? = null) : DeepLinkResult {
/**
* A sentence for the notice a refusing front-end shows. Deliberately names what failed: a
* shortcut that can't honour its reference says so instead of streaming with the wrong one.
*/
fun message(): String = when (error) {
LinkError.NOT_OUR_SCHEME -> "That isn't a Punktfunk link."
LinkError.TOO_LONG -> "That link is too long to be genuine."
LinkError.UNKNOWN_ROUTE -> "Punktfunk links can't do “$detail”."
LinkError.PAIR_REFUSED ->
"Pairing can't be done from a link — pair the host in Punktfunk first."
LinkError.MISSING_HOST_REF -> "That link doesn't say which host to use."
LinkError.BAD_ESCAPE, LinkError.CONTROL_CHAR -> "That link is malformed and was ignored."
LinkError.PARAM_TOO_LONG -> "That link's “$detail” value is too long."
LinkError.BAD_FINGERPRINT -> "That link's host fingerprint isn't a valid one."
LinkError.BAD_HOST_PARAM -> "That link's host address isn't valid."
LinkError.BAD_LAUNCH_ID -> "That link's game id isn't a valid one."
}
}
}
/** What the local host store made of a link's references. */
sealed interface HostResolution {
/** A record we already trust (subject to [DeepLink.pinConflict]). */
data class Known(val host: KnownHost) : HostResolution
/**
* No record, but the link says where to dial: the confirmation sheet's input, from which the
* normal pairing/TOFU flow proceeds under the user's eyes. Never an auto-connect.
*/
data class Unknown(
val address: String,
val port: Int,
val name: String?,
val fp: String?,
) : HostResolution
/** The name matched more than one saved host — refuse with a notice, never guess. */
data object Ambiguous : HostResolution
/** A reference that resolves to nothing and carries no address to fall back on. */
data object Unresolvable : HostResolution
}
@@ -0,0 +1,160 @@
package io.unom.punktfunk.kit.link
import io.unom.punktfunk.kit.security.KnownHost
import java.io.File
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* **The cross-language contract.** `clients/shared/deeplink-vectors.json` is consumed verbatim by
* the Rust, Swift and Kotlin suites, so the three parsers cannot drift into three different
* security postures — a URL that Rust refuses as a control-character smuggle must not quietly
* parse here. Any new case belongs in that file, not in this one.
*/
class DeepLinkVectorTest {
private val vectors: JSONObject by lazy {
// Gradle runs a unit test with the module directory as its working directory, so the shared
// file is two levels up (clients/android/kit → clients/shared). Resolved rather than
// copied: a copy would be a fourth contract, free to go stale.
val file = File("../../shared/deeplink-vectors.json")
assertTrue(
"the shared vector file must be reachable at ${file.absolutePath}",
file.isFile,
)
JSONObject(file.readText())
}
@Test
fun everySharedVectorAgrees() {
val cases = vectors.getJSONArray("cases")
assertTrue("the vector file is the contract; keep it rich", cases.length() > 20)
for (i in 0 until cases.length()) {
val case = cases.getJSONObject(i)
val name = case.getString("name")
val result = DeepLinks.parse(case.getString("url"))
if (case.has("error")) {
val refusal = result as? DeepLinkResult.Refused
?: throw AssertionError("$name: expected ${case.getString("error")}, parsed ok")
assertEquals(name, case.getString("error"), refusal.error.code)
assertTrue("$name: a refusal must be explainable", refusal.message().isNotEmpty())
continue
}
val link = (result as? DeepLinkResult.Parsed)?.link
?: throw AssertionError("$name: refused, expected a parse — $result")
val want = case.getJSONObject("expect")
assertEquals(name, want.getString("route"), link.route.word)
assertEquals(name, want.getString("host_ref"), link.hostRef)
assertEquals("$name fp", want.optStringOrNull("fp"), link.fp)
assertEquals("$name launch", want.optStringOrNull("launch"), link.launch)
assertEquals("$name profile", want.optStringOrNull("profile"), link.profile)
assertEquals("$name name", want.optStringOrNull("name"), link.name)
assertEquals("$name host_addr", want.optStringOrNull("host_addr"), link.host?.first)
assertEquals(
"$name host_port",
if (want.has("host_port")) want.getInt("host_port") else null,
link.host?.second,
)
if (case.has("emit")) assertEquals("$name emit", case.getString("emit"), link.toUrl())
}
}
private fun JSONObject.optStringOrNull(key: String): String? =
if (has(key)) getString(key) else null
}
/**
* Resolution and emission — the half the vector file can't cover, because it depends on what is in
* THIS device's host store. The rules are the one-click contract in resolution form: an id beats a
* name beats an address, an ambiguous name refuses rather than guesses, and a link whose record is
* gone still lands on the confirmation sheet via `host=`+`fp=` instead of dying.
*/
class DeepLinkResolutionTest {
private val fp = "a".repeat(64)
private val desk = host("Desk", "192.168.1.50", "11111111-2222-4333-8444-555555555555", fp)
private val hosts = listOf(
desk,
host("Couch", "192.168.1.60", "66666666-7777-4888-8999-aaaaaaaaaaaa", ""),
host("Couch", "192.168.1.61", "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", ""),
)
private fun resolve(url: String) =
DeepLinks.resolveHost((DeepLinks.parse(url) as DeepLinkResult.Parsed).link, hosts)
@Test
fun idBeatsNameBeatsAddress() {
assertEquals(desk, (resolve("punktfunk://connect/${desk.id}") as HostResolution.Known).host)
assertEquals(desk, (resolve("punktfunk://connect/desk") as HostResolution.Known).host)
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50") as HostResolution.Known).host)
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50:9777") as HostResolution.Known).host)
// Two hosts answer to "Couch" — refuse with a notice, never pick one.
assertEquals(HostResolution.Ambiguous, resolve("punktfunk://connect/couch"))
}
@Test
fun aStaleIdRecoversThroughTheHostParameter() {
val stale = "00000000-0000-4000-8000-000000000000"
assertEquals(
desk,
(resolve("punktfunk://connect/$stale?host=192.168.1.50") as HostResolution.Known).host,
)
// …but a stale id is NOT a hostname: dialing "00000000-…" would be a confusing dead end
// rather than the recovery the grammar specifies.
assertEquals(HostResolution.Unresolvable, resolve("punktfunk://connect/$stale"))
// Neither is a display name that can't be an address.
assertEquals(HostResolution.Unresolvable, resolve("punktfunk://connect/Basement%20PC"))
}
@Test
fun anUnknownHostBecomesTheConfirmationSheetsInput() {
val r = resolve("punktfunk://connect/10.0.0.9:7000?name=Studio&fp=$fp")
assertEquals(HostResolution.Unknown("10.0.0.9", 7000, "Studio", fp), r)
// An mDNS/DNS name we've never saved is offered the same way — the sheet, never a connect.
assertEquals(
HostResolution.Unknown("nas.local", DeepLinks.DEFAULT_PORT, null, null),
resolve("punktfunk://connect/nas.local"),
)
}
@Test
fun aPinThatContradictsTheStoredOneIsTheLinkLying() {
fun link(url: String) = (DeepLinks.parse(url) as DeepLinkResult.Parsed).link
assertTrue(link("punktfunk://connect/desk?fp=${"b".repeat(64)}").pinConflict(desk))
assertFalse(link("punktfunk://connect/desk?fp=$fp").pinConflict(desk))
// No pin stored (an address-only record) → nothing to contradict; the trust flow runs.
assertFalse(link("punktfunk://connect/desk?fp=${"b".repeat(64)}").pinConflict(hosts[1]))
}
@Test
fun selfEmittedLinksRoundTripAndSurviveAWipedStore() {
val h = desk.copy(port = 7777)
val link = DeepLinks.forHost(h, launch = "steam:570", profile = "aaaaaaaaaaaa")
val url = link.toUrl()
assertEquals(
"punktfunk://connect/${h.id}?fp=$fp&host=192.168.1.50:7777" +
"&launch=steam:570&profile=aaaaaaaaaaaa",
url,
)
assertEquals(link, (DeepLinks.parse(url) as DeepLinkResult.Parsed).link)
// Names with spaces and non-ASCII survive the round trip.
val labelled = DeepLink(hostRef = "Wohnzimmer PC", name = "Büro · Mac")
assertTrue(labelled.toUrl().startsWith("punktfunk://connect/Wohnzimmer%20PC?"))
assertEquals(labelled, (DeepLinks.parse(labelled.toUrl()) as DeepLinkResult.Parsed).link)
// An emitted IPv6 host parameter comes back bracketed, so it parses again.
val v6 = DeepLink(hostRef = "x", host = "::1" to 1234)
assertEquals(v6.host, (DeepLinks.parse(v6.toUrl()) as DeepLinkResult.Parsed).link.host)
}
@Test
fun aHostWithNoPinEmitsNoFingerprint() {
assertNull(DeepLinks.forHost(hosts[1]).fp)
}
private fun host(name: String, addr: String, id: String, fp: String) =
KnownHost(addr, DeepLinks.DEFAULT_PORT, name, fp, paired = true, id = id)
}