feat(client/android): hosts get a stable identity, and own their clipboard

The host store was keyed by `"address:port"`, which had two consequences.
Editing a host's address had to re-key its record — delete the old key,
write the new one — and nothing could hold a durable reference to a host at
all, because the key moved whenever the host did. Profile bindings, pinned
cards and `punktfunk://` shortcuts all need exactly such a reference, so the
store is now keyed by a minted stable id: a lowercase UUID, the same shape
Apple's `StoredHost.id` and the Rust `KnownHost.id` already carry, so a host
reference is one grammar on every platform. The re-key-on-edit dance is
gone; an edit is a plain save.

`clipboardSync` moves from a global onto the record. It was always a
decision about a HOST — whether text on this device may cross to that
machine — and one global for the work box and the couch box was the wrong
shape; the desktop clients have had it per-host since it shipped. It is
edited from the host's Edit sheet, which is also where the profile binding
will live.

Both, plus the minted ids, ride ONE migration pass. The store is being
rewritten anyway and every extra pass is another chance to strand somebody's
hosts. It runs once against real user data, so its pure half is tested
against a verbatim pre-migration blob — every host survives with its pin,
paired flag and MACs, IPv6 addresses included; each lands on its own id;
whatever the global clipboard setting said lands on every host, on or off;
and a second pass over its own output changes nothing.

Re-trusting a host now goes through `KnownHostStore.trust`, which preserves
the existing record's identity and everything the user set on it. Three call
sites used to construct a fresh `KnownHost` — harmless while the key was the
address, but with an id it would have forked the record on every re-pair.

While the wiring was open: `StreamScreen` takes an `ActiveSession` — the
settings the connect actually resolved, plus that host's clipboard answer —
instead of re-reading `SettingsStore` behind its own connect's back. That is
also the seam profiles need.
This commit is contained in:
2026-07-29 12:17:18 +02:00
parent e7c0024543
commit 35ce0401a8
11 changed files with 392 additions and 86 deletions
@@ -32,7 +32,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -42,6 +41,7 @@ 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 io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.Tab
@Composable
@@ -49,7 +49,9 @@ fun App(forceGamepadUi: Boolean = false) {
val context = LocalContext.current
val settingsStore = remember { SettingsStore(context) }
var settings by remember { mutableStateOf(settingsStore.load()) }
var streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming
// The active session (null = not streaming). It carries the settings the connect resolved,
// so the stream screen never re-reads the store behind its own connect's back.
var session by remember { mutableStateOf<ActiveSession?>(null) }
var tab by remember { mutableStateOf(Tab.Connect) }
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
@@ -59,20 +61,20 @@ fun App(forceGamepadUi: Boolean = false) {
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
AnimatedContent(
targetState = streamHandle != 0L,
targetState = session,
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
label = "StreamTransition"
) { isStreaming ->
if (isStreaming) {
) { active ->
if (active != null) {
// Immersive: the stream takes the whole screen, no bottom bar.
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
StreamScreen(active, onDisconnect = { session = null })
} else if (gamepadUi) {
GamepadShell(
settings = settings,
onSettingsChange = { settings = it; settingsStore.save(it) },
onConnected = { streamHandle = it },
onConnected = { session = it },
)
} else {
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
@@ -103,7 +105,7 @@ fun App(forceGamepadUi: Boolean = false) {
label = "TabTransition"
) { targetTab ->
when (targetTab) {
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it })
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { session = it })
Tab.Settings -> SettingsScreen(
initial = settings,
onChange = { settings = it; settingsStore.save(it) },
@@ -167,7 +169,7 @@ private enum class GamepadScreen { Home, Settings, Library }
fun GamepadShell(
settings: Settings,
onSettingsChange: (Settings) -> Unit,
onConnected: (Long) -> Unit,
onConnected: (ActiveSession) -> Unit,
) {
val context = LocalContext.current
var screen by remember { mutableStateOf(GamepadScreen.Home) }
@@ -19,6 +19,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
@@ -353,11 +354,12 @@ internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
}
/**
* Edit a saved host: name, address, port, and the Wake-on-LAN MAC. The MAC is auto-learned from the
* host's mDNS advert while it's online, but this is where you can enter or correct it (e.g. to wake a
* host you've only ever reached by address). [suggestedMacs] prefills the field from the live advert
* when nothing's been learned yet. Keyed by the host so reopening resets the fields. Mirrors the
* Apple client's edit form.
* Edit a saved host: name, address, port, the Wake-on-LAN MAC, and the per-host settings the record
* owns — shared clipboard (a trust decision about THIS machine, so it was never really a global).
* The MAC is auto-learned from the host's mDNS advert while it's online, but this is where you can
* enter or correct it (e.g. to wake a host you've only ever reached by address). [suggestedMacs]
* prefills the field from the live advert when nothing's been learned yet. Keyed by the host so
* reopening resets the fields. Mirrors the Apple client's edit form.
*/
@Composable
internal fun EditHostDialog(
@@ -372,6 +374,7 @@ internal fun EditHostDialog(
var mac by remember(target) {
mutableStateOf(target.mac.ifEmpty { suggestedMacs }.joinToString(", "))
}
var clipboard by remember(target) { mutableStateOf(target.clipboardSync) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Edit host") },
@@ -407,6 +410,20 @@ internal fun EditHostDialog(
placeholder = { Text("auto-filled when the host is seen") },
singleLine = true,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text("Shared clipboard", style = MaterialTheme.typography.bodyLarge)
Text(
"Text copied here pastes on this host and vice versa",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = clipboard, onCheckedChange = { clipboard = it })
}
}
},
confirmButton = {
@@ -419,6 +436,7 @@ internal fun EditHostDialog(
address = address.trim(),
port = port.toIntOrNull() ?: target.port,
mac = KnownHostStore.parseMacs(mac),
clipboardSync = clipboard,
),
)
},
@@ -63,6 +63,7 @@ import io.unom.punktfunk.kit.security.IdentityStore
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.kit.security.obtainIdentity
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.HostStatus
import io.unom.punktfunk.models.PendingTrust
import java.util.concurrent.atomic.AtomicBoolean
@@ -101,7 +102,7 @@ private class ConnectAttempt(val hostName: String) {
@Composable
fun ConnectScreen(
settings: Settings,
onConnected: (Long) -> Unit,
onConnected: (ActiveSession) -> Unit,
// Console (gamepad) mode: render the host carousel instead of the touch grid, sharing all of this
// screen's connect/trust/discovery logic. [onOpenSettings]/[onOpenLibrary] are the X/Y actions the
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
@@ -270,6 +271,12 @@ fun ConnectScreen(
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long =
connectToHost(context, settings, id, targetHost, targetPort, pinHex, launch = null, timeoutMs = timeoutMs)
// What the stream screen is handed: the settings this connect actually used, plus the HOST's
// clipboard decision (a property of the record, not a global). A host we never saved — a
// connect that failed to pin — falls back to the on default the setting always had.
fun session(handle: Long, record: KnownHost?) =
ActiveSession(handle, settings, clipboardSync = record?.clipboardSync ?: true)
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
// the host presented (as an unpaired known host) so the next connect goes straight through and it
// appears in the saved-hosts list. [onFailure], when set, takes over a failed dial (the wake-wait
@@ -296,13 +303,14 @@ fun ConnectScreen(
attempt = null
connecting = false
if (handle != 0L) {
var record = knownHostStore.get(targetHost, targetPort)
if (pinHex == null) { // TOFU: pin what we observed (unpaired)
val fp = NativeBridge.nativeHostFingerprint(handle)
if (fp.isNotEmpty()) {
knownHostStore.save(KnownHost(targetHost, targetPort, name, fp, paired = false))
record = knownHostStore.trust(targetHost, targetPort, name, fp, paired = false)
}
}
onConnected(handle)
onConnected(session(handle, record))
} else {
discovery.start()
val token = NativeBridge.nativeTakeLastError()
@@ -368,7 +376,7 @@ fun ConnectScreen(
// connects) point at the live one, then dial there (no fallback on this
// redial — a second failure surfaces as the plain error).
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
knownHostStore.save(kh.copy(address = live.host, port = live.port))
savedHosts = knownHostStore.all()
}
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
@@ -414,11 +422,12 @@ fun ConnectScreen(
// Approved — save the host as PAIRED, pinning the fingerprint it presented, so
// future connects are silent (exactly like after a PIN ceremony).
val fp = NativeBridge.nativeHostFingerprint(handle)
var record = knownHostStore.get(target.host, target.port)
if (fp.isNotEmpty()) {
knownHostStore.save(KnownHost(target.host, target.port, target.name, fp, paired = true))
record = knownHostStore.trust(target.host, target.port, target.name, fp, paired = true)
savedHosts = knownHostStore.all()
}
onConnected(handle)
onConnected(session(handle, record))
} else {
// Cause-specific: an operator denial, an approval timeout, and a request that
// never reached the host are different problems with different fixes.
@@ -621,7 +630,7 @@ fun ConnectScreen(
enabled = !connecting,
onConnect = { connect(kh.address, kh.port) },
onForget = {
knownHostStore.remove(kh.address, kh.port)
knownHostStore.remove(kh)
savedHosts = knownHostStore.all()
},
onEdit = { editTarget = kh },
@@ -741,7 +750,7 @@ fun ConnectScreen(
// Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }
val onSavePaired = { fp: String ->
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
knownHostStore.trust(pt.host, pt.port, pt.name, fp, paired = true)
savedHosts = knownHostStore.all()
pendingTrust = null
doConnect(pt.host, pt.port, pt.name, fp)
@@ -801,7 +810,7 @@ fun ConnectScreen(
},
onEdit = { optionsTarget = null; editTarget = kh },
onForget = {
knownHostStore.remove(kh.address, kh.port)
knownHostStore.remove(kh)
savedHosts = knownHostStore.all()
optionsTarget = null
},
@@ -814,7 +823,7 @@ fun ConnectScreen(
// `discovery.hosts.first { host.matches($0) }?.macAddresses`.
val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList()
val onSaveHost: (KnownHost) -> Unit = { updated ->
knownHostStore.update(kh.address, kh.port, updated)
knownHostStore.save(updated)
savedHosts = knownHostStore.all()
editTarget = null
}
@@ -63,6 +63,7 @@ import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.IdentityStore
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.obtainIdentity
import io.unom.punktfunk.models.ActiveSession
import kotlin.math.PI
import kotlin.math.absoluteValue
import kotlin.math.cos
@@ -85,7 +86,7 @@ private sealed class LibState {
fun LibraryScreen(
host: KnownHost,
settings: Settings,
onLaunched: (Long) -> Unit,
onLaunched: (ActiveSession) -> Unit,
onBack: () -> Unit,
navActive: Boolean = true,
) {
@@ -142,7 +143,11 @@ fun LibraryScreen(
host.address, host.port, host.fpHex, launch = game.id,
)
launching = false
if (handle != 0L) onLaunched(handle)
if (handle != 0L) {
onLaunched(
ActiveSession(handle, settings, host.clipboardSync),
)
}
else Toast.makeText(
context,
"Launch failed — check the host and try again.",
@@ -123,13 +123,10 @@ data class Settings(
* the Apple/GTK clients' "Invert scroll direction".
*/
val invertScroll: Boolean = false,
/**
* Sync text copied on this device to the host and vice versa while streaming (the desktop
* clients' shared clipboard, text-only here). Only effective when the host advertises the
* clipboard capability; the protocol is opt-in per session either way.
*/
val clipboardSync: Boolean = true,
// NOTE: clipboard sync is NOT here. It is a decision about a HOST, not about this device or
// this stream (design/client-settings-profiles.md §3, tier H), so it lives on the host record
// — see `KnownHost.clipboardSync`. It used to be a global here; `KnownHostStore.migrate`
// copied that value onto every saved host and retired the key.
)
/** [Settings.touchMode] values; persisted by name. */
@@ -214,7 +211,6 @@ class SettingsStore(context: Context) {
// lands where it already was.
?: if (prefs.getBoolean(K_POINTER_CAPTURE, false)) MouseMode.CAPTURE else MouseMode.DESKTOP,
invertScroll = prefs.getBoolean(K_INVERT_SCROLL, false),
clipboardSync = prefs.getBoolean(K_CLIPBOARD_SYNC, true),
)
fun save(s: Settings) {
@@ -240,7 +236,6 @@ class SettingsStore(context: Context) {
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
.putBoolean(K_CLIPBOARD_SYNC, s.clipboardSync)
.apply()
}
@@ -284,7 +279,6 @@ class SettingsStore(context: Context) {
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
const val K_POINTER_CAPTURE = "pointer_capture"
const val K_INVERT_SCROLL = "invert_scroll"
const val K_CLIPBOARD_SYNC = "clipboard_sync"
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
const val K_TRACKPAD = "trackpad_mode"
@@ -500,13 +500,8 @@ private fun InputSettings(s: Settings, update: (Settings) -> Unit) {
checked = s.invertScroll,
onCheckedChange = { on -> update(s.copy(invertScroll = on)) },
)
ToggleRow(
title = "Shared clipboard",
subtitle = "Text copied here pastes on the host and vice versa (hosts with " +
"clipboard sharing enabled)",
checked = s.clipboardSync,
onCheckedChange = { on -> update(s.copy(clipboardSync = on)) },
)
// "Shared clipboard" is NOT here any more: it is a trust decision about one host, so it
// lives on the host record and is edited from that host's Edit sheet.
}
}
@@ -59,11 +59,21 @@ import io.unom.punktfunk.kit.deviceBodyVibrator
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.Sc2Capture
import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.models.ActiveSession
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.delay
/**
* The immersive stream. Everything it reads about the session comes from [session] — the settings
* the connect actually resolved (globals, or a profile's overrides on top of them) and the HOST's
* clipboard decision — rather than from a fresh `SettingsStore` load, which could disagree with
* the connect that produced this handle.
*/
@Composable
fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
val handle = session.handle
val initialSettings = session.settings
val micEnabled = initialSettings.micEnabled
val context = LocalContext.current
val activity = context as? MainActivity
val window = activity?.window
@@ -85,7 +95,6 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// Settings. The tier only changes how many lines `StatsOverlay` draws — switching between the
// visible tiers keeps sampling running (the effect keys on `statsOn`, not the tier) so it never
// blanks the numbers for a poll interval.
val initialSettings = remember { SettingsStore(context).load() }
var stats by remember { mutableStateOf<DoubleArray?>(null) }
var decoderLabel by remember { mutableStateOf("") }
var codecLabel by remember { mutableStateOf("") }
@@ -276,7 +285,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
activity?.remotePointer = remote
// Shared clipboard (text v1): only when the user setting is on AND the host has a
// working clipboard service. Protocol-level opt-in + the poll thread live in the sync.
val clip = if (initialSettings.clipboardSync && NativeBridge.nativeClipSupported(handle)) {
val clip = if (session.clipboardSync && NativeBridge.nativeClipSupported(handle)) {
ClipboardSync(context, handle).also { it.start() }
} else {
null
@@ -29,6 +29,21 @@ data class PendingTrust(
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR, REQUEST_ACCESS }
}
/**
* A stream session that just opened, and the state the stream screen needs about it.
*
* [settings] is the settings the connect ACTUALLY used, resolved once at connect time — not
* "whatever the settings store says now". Every post-connect read (the stats tier, the touch and
* mouse models, the low-latency pipeline, rumble, SC2 capture) takes it, so the stream can never
* disagree with the connect that produced it. [clipboardSync] comes from the host record, because
* clipboard sync is a decision about that host rather than about this device.
*/
data class ActiveSession(
val handle: Long,
val settings: io.unom.punktfunk.Settings,
val clipboardSync: Boolean,
)
/** Trust state of a host, shown as a colored pill on its card. */
enum class HostStatus(val label: String) {
PAIRED("Paired"),