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:
@@ -90,6 +90,21 @@
|
|||||||
<!-- TV launcher entry. -->
|
<!-- TV launcher entry. -->
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.dp
|
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.ActiveSession
|
||||||
import io.unom.punktfunk.models.Tab
|
import io.unom.punktfunk.models.Tab
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App(forceGamepadUi: Boolean = false) {
|
fun App(forceGamepadUi: Boolean = false) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val activity = context as? MainActivity
|
||||||
val settingsStore = remember { SettingsStore(context) }
|
val settingsStore = remember { SettingsStore(context) }
|
||||||
var settings by remember { mutableStateOf(settingsStore.load()) }
|
var settings by remember { mutableStateOf(settingsStore.load()) }
|
||||||
// The active session (null = not streaming). It carries the settings the connect resolved,
|
// 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 controllerConnected by rememberControllerConnected()
|
||||||
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
|
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(
|
AnimatedContent(
|
||||||
targetState = session,
|
targetState = session,
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
@@ -75,6 +103,8 @@ fun App(forceGamepadUi: Boolean = false) {
|
|||||||
settings = settings,
|
settings = settings,
|
||||||
onSettingsChange = { settings = it; settingsStore.save(it) },
|
onSettingsChange = { settings = it; settingsStore.save(it) },
|
||||||
onConnected = { session = it },
|
onConnected = { session = it },
|
||||||
|
deepLink = pendingLink,
|
||||||
|
onDeepLinkHandled = { activity?.pendingDeepLink = null },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
|
// 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"
|
label = "TabTransition"
|
||||||
) { targetTab ->
|
) { targetTab ->
|
||||||
when (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(
|
Tab.Settings -> SettingsScreen(
|
||||||
initial = settings,
|
initial = settings,
|
||||||
onChange = { settings = it; settingsStore.save(it) },
|
onChange = { settings = it; settingsStore.save(it) },
|
||||||
@@ -170,6 +205,8 @@ fun GamepadShell(
|
|||||||
settings: Settings,
|
settings: Settings,
|
||||||
onSettingsChange: (Settings) -> Unit,
|
onSettingsChange: (Settings) -> Unit,
|
||||||
onConnected: (ActiveSession) -> Unit,
|
onConnected: (ActiveSession) -> Unit,
|
||||||
|
deepLink: String? = null,
|
||||||
|
onDeepLinkHandled: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var screen by remember { mutableStateOf(GamepadScreen.Home) }
|
var screen by remember { mutableStateOf(GamepadScreen.Home) }
|
||||||
@@ -196,6 +233,8 @@ fun GamepadShell(
|
|||||||
GamepadScreen.Home -> ConnectScreen(
|
GamepadScreen.Home -> ConnectScreen(
|
||||||
settings = settings,
|
settings = settings,
|
||||||
onConnected = onConnected,
|
onConnected = onConnected,
|
||||||
|
deepLink = deepLink,
|
||||||
|
onDeepLinkHandled = onDeepLinkHandled,
|
||||||
gamepadUi = true,
|
gamepadUi = true,
|
||||||
onOpenSettings = { screen = GamepadScreen.Settings },
|
onOpenSettings = { screen = GamepadScreen.Settings },
|
||||||
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
|
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.NativeBridge
|
||||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||||
import io.unom.punktfunk.kit.discovery.HostDiscovery
|
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.ClientIdentity
|
||||||
import io.unom.punktfunk.kit.security.IdentityStore
|
import io.unom.punktfunk.kit.security.IdentityStore
|
||||||
import io.unom.punktfunk.kit.security.KnownHost
|
import io.unom.punktfunk.kit.security.KnownHost
|
||||||
@@ -111,6 +116,11 @@ fun ConnectScreen(
|
|||||||
onOpenSettings: () -> Unit = {},
|
onOpenSettings: () -> Unit = {},
|
||||||
onOpenLibrary: (KnownHost) -> Unit = {},
|
onOpenLibrary: (KnownHost) -> Unit = {},
|
||||||
navGate: Boolean = true, // false while the console home is cross-fading out
|
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 scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -281,9 +291,10 @@ fun ConnectScreen(
|
|||||||
pinHex: String,
|
pinHex: String,
|
||||||
timeoutMs: Int,
|
timeoutMs: Int,
|
||||||
profile: StreamProfile?,
|
profile: StreamProfile?,
|
||||||
|
launch: String?,
|
||||||
): Long = connectToHost(
|
): Long = connectToHost(
|
||||||
context, settings.effectiveFor(profile), id, targetHost, targetPort, pinHex,
|
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
|
// 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),
|
settings.effectiveFor(profile),
|
||||||
clipboardSync = record?.clipboardSync ?: true,
|
clipboardSync = record?.clipboardSync ?: true,
|
||||||
profileName = profile?.name,
|
profileName = profile?.name,
|
||||||
|
hostId = record?.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
|
||||||
@@ -307,6 +319,7 @@ fun ConnectScreen(
|
|||||||
name: String,
|
name: String,
|
||||||
pinHex: String?,
|
pinHex: String?,
|
||||||
profile: StreamProfile?,
|
profile: StreamProfile?,
|
||||||
|
launch: String? = null,
|
||||||
onFailure: (() -> Unit)? = null,
|
onFailure: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val id = identity ?: run {
|
val id = identity ?: run {
|
||||||
@@ -319,7 +332,8 @@ fun ConnectScreen(
|
|||||||
status = null
|
status = null
|
||||||
discovery.stop() // free the Wi-Fi radio before the stream session
|
discovery.stop() // free the Wi-Fi radio before the stream session
|
||||||
scope.launch {
|
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
|
// 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.
|
// cancelConnect — drop the just-opened session silently rather than navigating into it.
|
||||||
if (thisAttempt.cancelled.get()) {
|
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…"
|
// 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
|
// overlay), which redials once the host reappears. Otherwise (auto-wake off, no MAC, or already
|
||||||
// seen live) dial straight through.
|
// 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) {
|
if (identity == null) {
|
||||||
status = "Identity not ready yet — try again in a moment"
|
status = "Identity not ready yet — try again in a moment"
|
||||||
return
|
return
|
||||||
@@ -392,7 +413,7 @@ fun ConnectScreen(
|
|||||||
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
|
if (settings.autoWakeEnabled && macs.isNotEmpty() && liveAdvert() == null) {
|
||||||
// Fire-and-forget first packet (harmless if it's awake), then dial-first.
|
// Fire-and-forget first packet (harmless if it's awake), then dial-first.
|
||||||
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
|
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(
|
waker.start(
|
||||||
hostName = name,
|
hostName = name,
|
||||||
connectsAfter = true,
|
connectsAfter = true,
|
||||||
@@ -408,12 +429,15 @@ fun ConnectScreen(
|
|||||||
knownHostStore.save(kh.copy(address = live.host, port = live.port))
|
knownHostStore.save(kh.copy(address = live.host, port = live.port))
|
||||||
savedHosts = knownHostStore.all()
|
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 {
|
} else {
|
||||||
doConnectDirect(targetHost, targetPort, name, pinHex, profile)
|
doConnectDirect(targetHost, targetPort, name, pinHex, profile, launch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +464,10 @@ fun ConnectScreen(
|
|||||||
val pinHex = target.advertisedFp ?: ""
|
val pinHex = target.advertisedFp ?: ""
|
||||||
// A host being trusted for the first time can't have a binding yet, so this is always
|
// 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.
|
// 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
|
// Cancelled while we were parked: tear the (possibly just-approved) session down and
|
||||||
// don't touch UI a fresh action may now own.
|
// don't touch UI a fresh action may now own.
|
||||||
if (req.cancelled.get()) {
|
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
|
// `""` = 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.
|
// therefore survive as a value rather than collapsing into "unset". NEVER rebinds.
|
||||||
oneOffProfile: String? = null,
|
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
|
// 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.
|
// can only EPERM its way to a 10 s timeout, so ask instead of pretending to try.
|
||||||
@@ -500,18 +529,24 @@ fun ConnectScreen(
|
|||||||
when {
|
when {
|
||||||
// Known host whose advertised fp still matches the pin → silent pinned reconnect.
|
// Known host whose advertised fp still matches the pin → silent pinned reconnect.
|
||||||
known != null && (adv == null || adv == known.fpHex) ->
|
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 host whose fp changed → force re-pairing (no silent re-trust shortcut).
|
||||||
known != null -> pendingTrust =
|
known != null -> pendingTrust = PendingTrust(
|
||||||
PendingTrust(targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED)
|
targetHost, targetPort, known.name, adv, PendingTrust.Kind.FP_CHANGED,
|
||||||
|
oneOffProfile, launch,
|
||||||
|
)
|
||||||
// Host explicitly advertised pair=optional → trust-on-first-use is permitted (offer it,
|
// Host explicitly advertised pair=optional → trust-on-first-use is permitted (offer it,
|
||||||
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
|
// clearly labeled, alongside PIN pairing). Smart-cast: this branch ⇒ dh != null.
|
||||||
dh?.pairingRequired == false -> pendingTrust =
|
dh?.pairingRequired == false -> pendingTrust = PendingTrust(
|
||||||
PendingTrust(targetHost, targetPort, name, dh.fingerprint, PendingTrust.Kind.TRUST_NEW)
|
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
|
// 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.
|
// "request access" (approve in the console) or the SPAKE2 PIN ceremony.
|
||||||
else -> pendingTrust =
|
else -> pendingTrust = PendingTrust(
|
||||||
PendingTrust(targetHost, targetPort, name, adv, PendingTrust.Kind.REQUEST_ACCESS)
|
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) }
|
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) }
|
var showManualSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
if (gamepadUi) {
|
if (gamepadUi) {
|
||||||
@@ -880,12 +998,12 @@ fun ConnectScreen(
|
|||||||
knownHostStore.trust(pt.host, pt.port, pt.name, fp, paired = true)
|
knownHostStore.trust(pt.host, pt.port, pt.name, fp, paired = true)
|
||||||
savedHosts = knownHostStore.all()
|
savedHosts = knownHostStore.all()
|
||||||
pendingTrust = null
|
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) {
|
when (pt.kind) {
|
||||||
PendingTrust.Kind.TRUST_NEW ->
|
PendingTrust.Kind.TRUST_NEW ->
|
||||||
if (gamepadUi) GamepadTrustNewDialog(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, null) }, 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 ->
|
PendingTrust.Kind.FP_CHANGED ->
|
||||||
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||||
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
|
||||||
|
|||||||
@@ -96,6 +96,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
|
var lastPadStyle by mutableStateOf(Gamepad.PadStyle.GENERIC)
|
||||||
private set
|
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. */
|
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
|
||||||
private var highRefreshModeId = 0
|
private var highRefreshModeId = 0
|
||||||
|
|
||||||
@@ -125,6 +136,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
pendingDeepLink = deepLinkFrom(intent)
|
||||||
lastPadIsGamepad = !isTvDevice(this)
|
lastPadIsGamepad = !isTvDevice(this)
|
||||||
lastPadStyle = Gamepad.styleFor(Gamepad.firstPad())
|
lastPadStyle = Gamepad.styleFor(Gamepad.firstPad())
|
||||||
resolveHighRefreshMode()
|
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() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
startSc2MenuNav()
|
startSc2MenuNav()
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ data class PendingTrust(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val advertisedFp: String?,
|
val advertisedFp: String?,
|
||||||
val kind: Kind,
|
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 }
|
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.
|
* so "which profile am I on?" is answerable from inside the stream, as on the other clients.
|
||||||
*/
|
*/
|
||||||
val profileName: String? = null,
|
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. */
|
/** 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user