diff --git a/Cargo.lock b/Cargo.lock index 04b226c4..1d7202fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -389,6 +389,7 @@ dependencies = [ "env_logger", "futures-util", "hex", + "httparse", "log", "nix", "nostr", diff --git a/README.md b/README.md index 035c0df4..b283ea5d 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ Pairing is a one-time QR scan; it survives restarts on both ends. - NIP-42 `AUTH` relays, and Tor on both ends (see below) - A direct link on your LAN or VPN: the phone talks to the bridge over its own WebSocket, with the relays as the fallback +- A web page on the same port: the bridge's dashboard in a browser, signed in + from a paired phone instead of a password + ([`docs/BRIDGE.md`](docs/BRIDGE.md#web-page)) ## About this fork diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/Shell.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/Shell.kt index 78c00a27..3c4cf00c 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/Shell.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/Shell.kt @@ -25,6 +25,7 @@ import com.codedeck.plus.ui.screens.SessionsScreen import com.codedeck.plus.platform.Login import com.codedeck.plus.ui.screens.LogsScreen import com.codedeck.plus.ui.screens.MachineSettingsScreen +import com.codedeck.plus.ui.screens.WebSignInScreen import com.codedeck.plus.ui.screens.PairingScreen import com.codedeck.plus.ui.screens.McpScreen import com.codedeck.plus.ui.screens.AgentSettingsScreen @@ -51,6 +52,8 @@ private sealed interface Screen { data object Pairing : Screen /** A machine's settings page, opened from its name on the sessions list. */ data class Machine(val machine: String) : Screen + /** Signing a browser in to a machine's web page, opened from the machine's page. */ + data class WebSignIn(val machine: String) : Screen /** One agent's page on a machine, opened from the machine's page. */ data class Agent(val machine: String, val agent: String) : Screen /** One agent's plugins on a machine, opened from the agent's page. */ @@ -75,6 +78,7 @@ private fun Screen.parts(): List = when (this) { Screen.Logs -> listOf("logs") Screen.Pairing -> listOf("pairing") is Screen.Machine -> listOf("machine", machine) + is Screen.WebSignIn -> listOf("web-sign-in", machine) is Screen.Agent -> listOf("agent", machine, agent) is Screen.Plugins -> listOf("plugins", machine, agent) is Screen.Mcp -> listOf("mcp", machine, agent) @@ -89,7 +93,7 @@ private fun Screen.stateKey(): String = parts().joinToString("/") * shallower one. */ private fun Screen.depth(): Int = when (this) { Screen.Sessions -> 0 - Screen.Logs, is Screen.Agent, is Screen.Terminal -> 2 + Screen.Logs, is Screen.Agent, is Screen.Terminal, is Screen.WebSignIn -> 2 is Screen.Plugins, is Screen.Mcp, is Screen.PermissionRules, is Screen.Providers -> 3 else -> 1 } @@ -106,6 +110,7 @@ private val ScreenSaver = listSaver( "logs" -> Screen.Logs "pairing" -> Screen.Pairing "machine" -> Screen.Machine(saved[1]) + "web-sign-in" -> Screen.WebSignIn(saved[1]) "agent" -> Screen.Agent(saved[1], saved[2]) "plugins" -> Screen.Plugins(saved[1], saved[2]) "mcp" -> Screen.Mcp(saved[1], saved[2]) @@ -272,6 +277,7 @@ fun Shell( when (val current = screen) { Screen.Logs -> Screen.Settings is Screen.Agent -> Screen.Machine(current.machine) + is Screen.WebSignIn -> Screen.Machine(current.machine) is Screen.Plugins -> Screen.Agent(current.machine, current.agent) is Screen.Mcp -> Screen.Agent(current.machine, current.agent) is Screen.PermissionRules -> Screen.Agent(current.machine, current.agent) @@ -298,6 +304,7 @@ fun Shell( onBack = { go(Screen.Sessions) }, onOpenAgent = { agent -> go(Screen.Agent(current.machine, agent)) }, onOpenTerminal = { term -> go(Screen.Terminal(current.machine, term, session = null)) }, + onOpenWebSignIn = { go(Screen.WebSignIn(current.machine)) }, ) is Screen.Terminal -> TerminalScreen( core, @@ -306,6 +313,7 @@ fun Shell( current.session, onBack = { leaveTerminal(current) }, ) + is Screen.WebSignIn -> WebSignInScreen(core, current.machine, onBack = { go(Screen.Machine(current.machine)) }) is Screen.Agent -> AgentSettingsScreen( core, current.machine, diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineSettings.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineSettings.kt index 4f64e7d9..43bda6c2 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineSettings.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineSettings.kt @@ -119,7 +119,8 @@ internal fun shortKey(key: String): String = if (key.length <= 20) key else "${k * plugins, MCP servers, saved permissions) or installs one it lacks — its * terminals ([onOpenTerminal]: a new one, or one of [terminals]), then * what is the machine's own: the relays it is reached over, its direct link, - * the bridge's credentials, and forgetting it. Pure — the caller supplies + * its web page and signing a browser in to it ([onOpenWebSignIn]), the + * bridge's credentials, and forgetting it. Pure — the caller supplies * the machine and a dispatcher — so it renders the same in a snapshot. */ @Composable @@ -133,6 +134,7 @@ fun MachineSettingsContent( onOpenAgent: (agent: String) -> Unit, terminals: List, onOpenTerminal: (term: String?) -> Unit, + onOpenWebSignIn: () -> Unit, ) { var confirmRemove by remember(machine.pubkeyHex) { mutableStateOf(false) } @@ -148,6 +150,7 @@ fun MachineSettingsContent( ) { GroupBody { MachineDirectLink(machine, dispatch) } } + MachineWebPageGroup(machine, onOpenWebSignIn) if (machine.credentials.isNotEmpty()) { Group(title = "Credentials") { GroupBody { MachineCredentials(machine, credentialsStatus, dispatch, scope = CredentialScope.Bridge) } @@ -329,6 +332,7 @@ fun MachineSettingsScreen( onBack: () -> Unit, onOpenAgent: (agent: String) -> Unit, onOpenTerminal: (term: String?) -> Unit, + onOpenWebSignIn: () -> Unit, ) { val connection by core.connection.collectAsState() val ui by core.ui.collectAsState() @@ -344,5 +348,6 @@ fun MachineSettingsScreen( onOpenAgent = onOpenAgent, terminals = ui?.terminals?.byMachine?.get(pubkey).orEmpty(), onOpenTerminal = onOpenTerminal, + onOpenWebSignIn = onOpenWebSignIn, ) } diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineWebPage.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineWebPage.kt new file mode 100644 index 00000000..29fc51d8 --- /dev/null +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/MachineWebPage.kt @@ -0,0 +1,379 @@ +package com.codedeck.plus.ui.screens + +import android.Manifest +import android.content.ClipData +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Logout +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.QrCodeScanner +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextOverflow +import androidx.core.content.ContextCompat +import com.codedeck.plus.core.CoreHost +import com.codedeck.plus.ui.components.ConfirmDialog +import com.codedeck.plus.ui.components.EmptyState +import com.codedeck.plus.ui.components.ErrorNote +import com.codedeck.plus.ui.components.Field +import com.codedeck.plus.ui.components.Group +import com.codedeck.plus.ui.components.GroupBody +import com.codedeck.plus.ui.components.IconAction +import com.codedeck.plus.ui.components.NavRow +import com.codedeck.plus.ui.components.Page +import com.codedeck.plus.ui.components.PrimaryButton +import com.codedeck.plus.ui.components.RowSpinner +import com.codedeck.plus.ui.components.SecondaryButton +import com.codedeck.plus.ui.components.ValueRow +import com.codedeck.plus.ui.theme.Tokens +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import uniffi.client_ffi.UniffiIntent +import uniffi.client_ffi.UniffiMachineSummary +import uniffi.client_ffi.UniffiWebBrowser +import uniffi.client_ffi.UniffiWebSignIn +import uniffi.client_ffi.UniffiWebSignInState + +/** How long a sent code waits for the bridge's answer before the page says none came. */ +private const val ANSWER_WAIT_MS = 15_000L + +/** + * The address of the bridge's web page for each of its `wss://` direct + * endpoints: the page is served on the direct link's own port, at its root. + * An onion endpoint serves no page, and a plain `ws://` one has no TLS to + * serve it over. + */ +internal fun webPageUrls(machine: UniffiMachineSummary): List = + (machine.directAdvertised + machine.directEndpoints) + .filter { it.startsWith("wss://", ignoreCase = true) } + .map { it.substring("wss://".length).substringBefore('/').substringBefore('?') } + .filter { it.isNotEmpty() && !it.substringBeforeLast(':').endsWith(".onion", ignoreCase = true) } + .map { "https://$it/" } + .distinct() + +/** What a scanned QR asks of this page. */ +internal sealed interface ScannedSignIn { + data class Code(val code: String) : ScannedSignIn + + /** A web page's sign-in QR, but for another machine's page. */ + data object OtherMachine : ScannedSignIn + + data object NotSignIn : ScannedSignIn +} + +/** + * Reads `codedeck://web-sign-in?machine=&code=`, the QR a web + * page shows. A code is only sent to the machine the QR names: a browser on + * another machine's page would otherwise be signed in to the wrong one, or a + * code shown by one bridge be handed to another. + */ +internal fun readSignInQr(text: String, machineNpub: String): ScannedSignIn { + val query = text.trim().removePrefix("codedeck://web-sign-in?").takeIf { it != text.trim() } ?: return ScannedSignIn.NotSignIn + val params = query.split('&').mapNotNull { part -> part.split('=', limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1] } }.toMap() + val code = params["code"]?.takeIf { it.isNotBlank() } ?: return ScannedSignIn.NotSignIn + return if (params["machine"] == machineNpub) ScannedSignIn.Code(code) else ScannedSignIn.OtherMachine +} + +/** + * The "Web page" group on a machine's page: the addresses its web page is + * served on (each copies, in full with its https://), and the row that opens + * [WebSignInContent]: signing a browser in, and the ones signed in. + */ +@Composable +internal fun MachineWebPageGroup(machine: UniffiMachineSummary, onOpenWebSignIn: () -> Unit) { + val urls = webPageUrls(machine) + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + Group( + title = "Web page", + footer = if (urls.isEmpty()) { + "The bridge serves a dashboard on its direct link's wss:// address. Open it in a browser, then sign it in from here." + } else { + "The bridge's dashboard, for a browser that reaches the direct link. Sign it in from here." + }, + ) { + urls.forEach { url -> + Row( + Modifier.fillMaxWidth().padding(start = Tokens.Space4, end = Tokens.Space1, top = Tokens.Space1, bottom = Tokens.Space1), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Tokens.Space3), + ) { + Text( + url.removePrefix("https://").removeSuffix("/"), + color = Tokens.Text, + fontSize = Tokens.TextSm, + fontFamily = Tokens.FontMono, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + IconAction( + Icons.Outlined.ContentCopy, + "Copy $url", + onClick = { scope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("address", url))) } }, + tint = Tokens.TextMuted, + ) + } + Divider() + } + NavRow("Browsers", onClick = onOpenWebSignIn, subtitle = "Sign one in, or out") + } +} + +/** + * Signing a browser in to the machine's web page: scan the QR the page shows, + * or type its code. The phone sends the code to the bridge over its usual + * signed path, and the bridge signs in the browser showing it. Pure — the + * caller holds the draft and the round trip — so it renders in a snapshot. + * + * [signIn] is the code this phone sent and the bridge's answer; [noAnswer] + * that the answer did not come in time; [notice] what was wrong with a scan. + * Under that, the browsers signed in now ([browsers], null until the bridge + * answers), each signed out with [onSignOut] after a confirmation; + * [ownNpub] is this phone's identity, which a row does not repeat. + */ +@Composable +fun WebSignInContent( + signIn: UniffiWebSignIn?, + noAnswer: Boolean, + notice: String?, + code: String, + onCodeChange: (String) -> Unit, + onSend: (String) -> Unit, + onScan: () -> Unit, + browsers: List?, + ownNpub: String, + now: Long, + onSignOut: (UniffiWebBrowser) -> Unit, + onBack: () -> Unit, +) { + var confirming by remember { mutableStateOf(null) } + Page(title = "Browsers", onBack = onBack) { + if (signIn?.state == UniffiWebSignInState.SIGNED_IN) { + EmptyState( + icon = Icons.Outlined.Check, + title = "Browser signed in", + body = "It opens the dashboard now, and stays signed in for 30 days or until it is signed out.", + action = "Done", + onAction = onBack, + modifier = Modifier.fillMaxWidth().padding(top = Tokens.Space7), + ) + return@Page + } + val sending = signIn?.state == UniffiWebSignInState.SENDING && !noAnswer + val error = when { + notice != null -> notice + noAnswer -> "No answer from the bridge. Check that it is online, then try again." + signIn?.state == UniffiWebSignInState.NOT_MATCHED -> "That code did not match. It works once and for 5 minutes: reload the page for a new one." + else -> null + } + error?.let { ErrorNote(it) } + + Group(footer = "Open the machine's web page in a browser. It shows a QR and a code.") { + GroupBody { + PrimaryButton("Scan the browser's QR", onClick = onScan, icon = Icons.Outlined.QrCodeScanner, enabled = !sending, modifier = Modifier.fillMaxWidth()) + } + } + Group(title = "Or type the code") { + GroupBody { + Field( + value = code, + onValueChange = onCodeChange, + placeholder = "ABCD-EFGH", + mono = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters, autoCorrectEnabled = false, imeAction = ImeAction.Done), + ) + if (sending) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Tokens.Space1)) { + RowSpinner() + Text("Signing in…", color = Tokens.TextMuted, fontSize = Tokens.TextMd) + } + } else { + SecondaryButton("Sign in", onClick = { onSend(code) }, enabled = code.isNotBlank()) + } + } + } + SignedInBrowsers(browsers, ownNpub, now, onSignOut = { confirming = it }) + } + confirming?.let { browser -> + ConfirmDialog( + title = "Sign out ${browserName(browser.agent)}?", + body = "The browser at ${browser.peer} sees only the sign-in page again, until a phone signs it in.", + confirm = "Sign out", + onConfirm = { + onSignOut(browser) + confirming = null + }, + onDismiss = { confirming = null }, + ) + } +} + +/** "Firefox on Windows" from a User-Agent, as the web page names it, or the string itself. */ +internal fun browserName(agent: String): String { + if (agent.isBlank()) return "A browser" + val name = when { + "Edg/" in agent -> "Edge" + "OPR/" in agent -> "Opera" + "Firefox/" in agent -> "Firefox" + "Chrome/" in agent -> "Chrome" + "Safari/" in agent -> "Safari" + else -> return agent + } + val os = when { + "Android" in agent -> "Android" + "iPhone" in agent || "iPad" in agent -> "iOS" + "Windows" in agent -> "Windows" + "Mac OS X" in agent -> "macOS" + "Linux" in agent -> "Linux" + else -> return name + } + return "$name on $os" +} + +private fun ago(ms: Long, now: Long): String { + val minutes = (now - ms).coerceAtLeast(0) / 60_000 + return when { + minutes < 1 -> "just now" + minutes < 60 -> "$minutes min ago" + minutes < 48 * 60 -> "${minutes / 60} h ago" + else -> "${minutes / (24 * 60)} days ago" + } +} + +/** The browsers signed in to the page now, each with where it is, when it was last seen, and Sign out. */ +@Composable +private fun SignedInBrowsers(browsers: List?, ownNpub: String, now: Long, onSignOut: (UniffiWebBrowser) -> Unit) { + Group(title = "Signed in", footer = "A browser stays signed in for 30 days after its sign-in.") { + when { + browsers == null -> ValueRow("Asking the bridge") { RowSpinner() } + browsers.isEmpty() -> ValueRow("No browser is signed in") {} + else -> browsers.forEachIndexed { i, b -> + if (i > 0) Divider() + val by = b.by.takeIf { it != ownNpub }?.let { " · by ${shortKey(it)}" }.orEmpty() + ValueRow(browserName(b.agent), subtitle = "${b.peer} · seen ${ago(b.lastSeenMs.toLong(), now)}$by") { + IconAction(Icons.AutoMirrored.Outlined.Logout, "Sign out ${browserName(b.agent)}", onClick = { onSignOut(b) }, tint = Tokens.TextMuted) + } + } + } + } +} + +/** + * [WebSignInContent] for the machine [pubkey], from the core. Closing it + * drops this machine's sign-in from the core, so an answer that arrives later + * is not kept for a screen that is gone. + */ +@Composable +fun WebSignInScreen(core: CoreHost, pubkey: String, onBack: () -> Unit) { + val ui by core.ui.collectAsState() + val scope = rememberCoroutineScope() + val context = LocalContext.current + val machine = machineOrLeave(core, pubkey, onGone = onBack) ?: return + val signIn = ui?.webSignIn?.get(pubkey) + val browsers = ui?.webBrowsers?.get(pubkey) + val ownNpub = remember { runCatching { core.identityNpub() }.getOrDefault("") } + // Asked once when the page opens, and again once a browser signs in from + // here: the list changes only then, so it is never polled. + LaunchedEffect(pubkey, signIn?.state == UniffiWebSignInState.SIGNED_IN) { + core.dispatch(UniffiIntent.RequestWebBrowsers(pubkey)) + } + var code by rememberSaveable { mutableStateOf("") } + var notice by remember { mutableStateOf(null) } + var noAnswer by remember { mutableStateOf(false) } + var scanOpen by remember { mutableStateOf(false) } + // Each send waits for its own answer, a resend of the same code too. + var attempt by remember { mutableIntStateOf(0) } + + fun send(sent: String) { + notice = null + noAnswer = false + attempt++ + scope.launch { core.dispatch(UniffiIntent.WebSignIn(pubkey, sent)) } + } + + DisposableEffect(pubkey) { + onDispose { core.dispatchDetached(UniffiIntent.ClearWebSignIn(pubkey)) } + } + // An answer, however late, replaces "no answer": the bridge's own verdict + // (did not match, signed in) is what the user needs to read. + LaunchedEffect(attempt, signIn?.code, signIn?.state) { + noAnswer = false + if (signIn?.state == UniffiWebSignInState.SENDING) { + delay(ANSWER_WAIT_MS) + noAnswer = true + } + } + + val cameraPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + // Denied leaves the typed path; the next tap asks again. + if (granted) scanOpen = true + } + + Box(Modifier.fillMaxSize()) { + WebSignInContent( + signIn = signIn, + noAnswer = noAnswer, + notice = notice, + code = code, + onCodeChange = { code = it }, + onSend = ::send, + onScan = { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + scanOpen = true + } else { + cameraPermission.launch(Manifest.permission.CAMERA) + } + }, + browsers = browsers, + ownNpub = ownNpub, + now = System.currentTimeMillis(), + onSignOut = { b -> scope.launch { core.dispatch(UniffiIntent.SignOutWebBrowser(pubkey, b.id)) } }, + onBack = onBack, + ) + if (scanOpen) { + PairingScanView( + prompt = "Point the camera at the QR on the web page", + onDecoded = { text -> + scanOpen = false + when (val scanned = readSignInQr(text, machine.npub)) { + is ScannedSignIn.Code -> { + code = scanned.code + send(scanned.code) + } + ScannedSignIn.OtherMachine -> notice = "That QR is another machine's web page, not this one's." + ScannedSignIn.NotSignIn -> notice = "That is not a web page's sign-in QR." + } + }, + onDismiss = { scanOpen = false }, + ) + } + } +} diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/PairingScanView.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/PairingScanView.kt index e21174b8..d544f533 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/PairingScanView.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/PairingScanView.kt @@ -53,7 +53,8 @@ import kotlin.coroutines.resume import kotlinx.coroutines.suspendCancellableCoroutine /** - * Full-screen in-app QR camera scan for the pairing screen — port of + * Full-screen in-app QR camera scan, with [prompt] saying what to point it + * at (the bridge's pairing QR, a web page's sign-in QR) — port of * `apps/mobile/src/platform/qrScan.ts`'s contract: back camera, QR format * only, and "no scan" on every exit path. Cancel (close button or system * back), a denied CAMERA prompt, missing camera hardware, or a failed @@ -82,6 +83,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine */ @Composable fun PairingScanView( + prompt: String, onDecoded: (String) -> Unit, onDismiss: () -> Unit, ) { @@ -171,7 +173,7 @@ fun PairingScanView( .border(2.dp, Tokens.Text, RoundedCornerShape(Tokens.RadiusMd)), ) Text( - "Point the camera at the bridge's pairing QR", + prompt, color = Tokens.TextMuted, fontSize = Tokens.TextSm, modifier = Modifier.padding(top = Tokens.Space3), diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/PairingScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/PairingScreen.kt index 8257746b..8ecfd38a 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/PairingScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/PairingScreen.kt @@ -167,6 +167,7 @@ internal fun PairingBody( // runs exactly once per granted+open window. if (scanOpen) { PairingScanView( + prompt = "Point the camera at the bridge's pairing QR", onDecoded = { decoded -> // One path, same as a paste: the raw string fills the // visible field AND crosses into BeginPairing diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt index b94c23de..99508217 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt @@ -87,6 +87,8 @@ private const val UI_SCALE_DEFAULT = 1f internal sealed interface SettingsPage { data object Hub : SettingsPage data class Machine(val pubkey: String) : SettingsPage + /** Signing a browser in to a machine's web page, opened from the machine's page. */ + data class WebSignIn(val pubkey: String) : SettingsPage /** One agent's page on a machine, opened from the machine's page. */ data class Agent(val pubkey: String, val agent: String) : SettingsPage /** One agent's plugins on a machine, opened from the agent's page. */ @@ -110,6 +112,7 @@ internal sealed interface SettingsPage { fun save(): String = when (this) { Hub -> "hub" is Machine -> "machine:$pubkey" + is WebSignIn -> "web-sign-in:$pubkey" is Agent -> "agent:$pubkey:$agent" is Plugins -> "plugins:$pubkey:$agent" is Mcp -> "mcp:$pubkey:$agent" @@ -129,7 +132,7 @@ internal sealed interface SettingsPage { val depth: Int get() = when (this) { Hub -> 0 - is Agent, is Terminal -> 2 + is Agent, is Terminal, is WebSignIn -> 2 is Plugins, is Mcp, is PermissionRules, is Providers -> 3 else -> 1 } @@ -137,6 +140,7 @@ internal sealed interface SettingsPage { companion object { fun restore(saved: String): SettingsPage = when { saved.startsWith("machine:") -> Machine(saved.removePrefix("machine:")) + saved.startsWith("web-sign-in:") -> WebSignIn(saved.removePrefix("web-sign-in:")) saved.startsWith("agent:") -> saved.split(':').let { Agent(it[1], it.drop(2).joinToString(":")) } saved.startsWith("plugins:") -> saved.split(':').let { Plugins(it[1], it.drop(2).joinToString(":")) } saved.startsWith("mcp:") -> saved.split(':').let { Mcp(it[1], it.drop(2).joinToString(":")) } @@ -200,12 +204,13 @@ fun SettingsScreen( pageKey = next.save() } val toHub = { open(SettingsPage.Hub) } - // An agent's page goes back to its machine's, and a page of the agent's + // An agent's page and a web sign-in go back to their machine's, and a page of the agent's // (its providers, plugins, MCP servers, permissions) to the agent's, // where each was left; every other page to the hub. val parent: SettingsPage? = when (page) { is SettingsPage.Agent -> SettingsPage.Machine(page.pubkey) is SettingsPage.Terminal -> SettingsPage.Machine(page.pubkey) + is SettingsPage.WebSignIn -> SettingsPage.Machine(page.pubkey) is SettingsPage.Plugins -> SettingsPage.Agent(page.pubkey, page.agent) is SettingsPage.Mcp -> SettingsPage.Agent(page.pubkey, page.agent) is SettingsPage.PermissionRules -> SettingsPage.Agent(page.pubkey, page.agent) @@ -257,10 +262,12 @@ fun SettingsScreen( onOpenAgent = { agent -> open(SettingsPage.Agent(machine.pubkeyHex, agent)) }, terminals = ui?.terminals?.byMachine?.get(machine.pubkeyHex).orEmpty(), onOpenTerminal = { term -> open(SettingsPage.Terminal(machine.pubkeyHex, term)) }, + onOpenWebSignIn = { open(SettingsPage.WebSignIn(machine.pubkeyHex)) }, ) } } is SettingsPage.Terminal -> TerminalScreen(core, page.pubkey, page.term, session = null, onBack = back) + is SettingsPage.WebSignIn -> WebSignInScreen(core, page.pubkey, onBack = back) is SettingsPage.Agent -> AgentSettingsScreen( core, page.pubkey, diff --git a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt index 55bd8691..682f619b 100644 --- a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt +++ b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt @@ -8747,6 +8747,21 @@ data class UniffiUiView ( */ var `providerProfileStatus`: Map , + /** + * Keyed by machine pubkey: the code this phone sent to sign a browser in + * to that machine's web page, and where the round trip stands. Absent + * until a sign-in screen asks, and again once it closes. + */ + var `webSignIn`: Map + , + /** + * Keyed by machine pubkey: the browsers signed in to that machine's web + * page, as the last `web-browsers` listed them. Absent for a machine + * nobody has opened the screen for — the phone asks then, never on a + * timer. + */ + var `webBrowsers`: Map> + , /** * Present while a delete's undo window is open. */ @@ -8780,6 +8795,8 @@ public object FfiConverterTypeUniffiUiView: FfiConverterRustBuffer FfiConverterMapStringString.read(buf), FfiConverterMapStringTypeUniffiCredentialsAck.read(buf), FfiConverterMapStringTypeUniffiProviderProfileAck.read(buf), + FfiConverterMapStringTypeUniffiWebSignIn.read(buf), + FfiConverterMapStringSequenceTypeUniffiWebBrowser.read(buf), FfiConverterOptionalTypeUniffiUndoToast.read(buf), FfiConverterTypeUniffiTerminalsView.read(buf), ) @@ -8793,6 +8810,8 @@ public object FfiConverterTypeUniffiUiView: FfiConverterRustBuffer FfiConverterMapStringString.allocationSize(value.`planApprovalChoices`) + FfiConverterMapStringTypeUniffiCredentialsAck.allocationSize(value.`credentialsStatus`) + FfiConverterMapStringTypeUniffiProviderProfileAck.allocationSize(value.`providerProfileStatus`) + + FfiConverterMapStringTypeUniffiWebSignIn.allocationSize(value.`webSignIn`) + + FfiConverterMapStringSequenceTypeUniffiWebBrowser.allocationSize(value.`webBrowsers`) + FfiConverterOptionalTypeUniffiUndoToast.allocationSize(value.`undoToast`) + FfiConverterTypeUniffiTerminalsView.allocationSize(value.`terminals`) ) @@ -8805,6 +8824,8 @@ public object FfiConverterTypeUniffiUiView: FfiConverterRustBuffer FfiConverterMapStringString.write(value.`planApprovalChoices`, buf) FfiConverterMapStringTypeUniffiCredentialsAck.write(value.`credentialsStatus`, buf) FfiConverterMapStringTypeUniffiProviderProfileAck.write(value.`providerProfileStatus`, buf) + FfiConverterMapStringTypeUniffiWebSignIn.write(value.`webSignIn`, buf) + FfiConverterMapStringSequenceTypeUniffiWebBrowser.write(value.`webBrowsers`, buf) FfiConverterOptionalTypeUniffiUndoToast.write(value.`undoToast`, buf) FfiConverterTypeUniffiTerminalsView.write(value.`terminals`, buf) } @@ -8982,6 +9003,119 @@ public object FfiConverterTypeUniffiUsageWindow: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): UniffiWebBrowser { + return UniffiWebBrowser( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterULong.read(buf), + FfiConverterULong.read(buf), + FfiConverterULong.read(buf), + FfiConverterString.read(buf), + FfiConverterString.read(buf), + ) + } + + override fun allocationSize(value: UniffiWebBrowser) = ( + FfiConverterString.allocationSize(value.`id`) + + FfiConverterString.allocationSize(value.`by`) + + FfiConverterULong.allocationSize(value.`sinceMs`) + + FfiConverterULong.allocationSize(value.`lastSeenMs`) + + FfiConverterULong.allocationSize(value.`expiresMs`) + + FfiConverterString.allocationSize(value.`peer`) + + FfiConverterString.allocationSize(value.`agent`) + ) + + override fun write(value: UniffiWebBrowser, buf: ByteBuffer) { + FfiConverterString.write(value.`id`, buf) + FfiConverterString.write(value.`by`, buf) + FfiConverterULong.write(value.`sinceMs`, buf) + FfiConverterULong.write(value.`lastSeenMs`, buf) + FfiConverterULong.write(value.`expiresMs`, buf) + FfiConverterString.write(value.`peer`, buf) + FfiConverterString.write(value.`agent`, buf) + } +} + + + +/** + * The code a phone sent to sign a browser in to a machine's web page, and + * where that round trip stands — mirrors `client_core::stores::ui::WebSignIn` + * field for field. `code` is the one the phone sent, which is what the + * bridge's answer has to name for this entry to move. + */ +data class UniffiWebSignIn ( + var `code`: kotlin.String + , + var `state`: UniffiWebSignInState + +){ + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeUniffiWebSignIn: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): UniffiWebSignIn { + return UniffiWebSignIn( + FfiConverterString.read(buf), + FfiConverterTypeUniffiWebSignInState.read(buf), + ) + } + + override fun allocationSize(value: UniffiWebSignIn) = ( + FfiConverterString.allocationSize(value.`code`) + + FfiConverterTypeUniffiWebSignInState.allocationSize(value.`state`) + ) + + override fun write(value: UniffiWebSignIn, buf: ByteBuffer) { + FfiConverterString.write(value.`code`, buf) + FfiConverterTypeUniffiWebSignInState.write(value.`state`, buf) + } +} + + + /** @@ -9875,6 +10009,61 @@ sealed class UniffiIntent { companion object } + /** + * Sign in the browser showing `code` on `machine`'s web page. The code + * is trimmed; surrounding whitespace never reaches the bridge. + */ + data class WebSignIn( + val `machine`: kotlin.String, + val `code`: kotlin.String) : UniffiIntent() + + { + + + companion object + } + + /** + * The sign-in screen closed: drop this machine's code and its answer, so + * a late one is not reported to a screen that is gone. + */ + data class ClearWebSignIn( + val `machine`: kotlin.String) : UniffiIntent() + + { + + + companion object + } + + /** + * Ask `machine`'s web page which browsers are signed in to it. Sent when + * the screen that lists them opens; the answer replaces this machine's + * list in `UniffiUiView.web_browsers`. + */ + data class RequestWebBrowsers( + val `machine`: kotlin.String) : UniffiIntent() + + { + + + companion object + } + + /** + * End one signed-in browser by the page id the list showed. The row goes + * at once, and the bridge's answer replaces the list with the truth. + */ + data class SignOutWebBrowser( + val `machine`: kotlin.String, + val `id`: kotlin.String) : UniffiIntent() + + { + + + companion object + } + /** * Answer a permission request with one of its options' ids. */ @@ -10543,13 +10732,27 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer UniffiIntent.RespondPermission( + 27 -> UniffiIntent.WebSignIn( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + ) + 28 -> UniffiIntent.ClearWebSignIn( + FfiConverterString.read(buf), + ) + 29 -> UniffiIntent.RequestWebBrowsers( + FfiConverterString.read(buf), + ) + 30 -> UniffiIntent.SignOutWebBrowser( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + ) + 31 -> UniffiIntent.RespondPermission( FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterString.read(buf), ) - 28 -> UniffiIntent.AnswerQuestion( + 32 -> UniffiIntent.AnswerQuestion( FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterString.read(buf), @@ -10557,90 +10760,90 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer UniffiIntent.RespondPlan( + 33 -> UniffiIntent.RespondPlan( FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterOptionalString.read(buf), ) - 30 -> UniffiIntent.SetOption( + 34 -> UniffiIntent.SetOption( FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterString.read(buf), ) - 31 -> UniffiIntent.SelectSession( + 35 -> UniffiIntent.SelectSession( FfiConverterString.read(buf), FfiConverterOptionalString.read(buf), ) - 32 -> UniffiIntent.SetPlanApprovalChoice( + 36 -> UniffiIntent.SetPlanApprovalChoice( FfiConverterString.read(buf), FfiConverterString.read(buf), ) - 33 -> UniffiIntent.RetryOutboxItem( + 37 -> UniffiIntent.RetryOutboxItem( FfiConverterString.read(buf), FfiConverterString.read(buf), ) - 34 -> UniffiIntent.DeleteSession( + 38 -> UniffiIntent.DeleteSession( FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterOptionalString.read(buf), ) - 35 -> UniffiIntent.UndoDelete - 36 -> UniffiIntent.SetTorEnabled( + 39 -> UniffiIntent.UndoDelete + 40 -> UniffiIntent.SetTorEnabled( FfiConverterBoolean.read(buf), ) - 37 -> UniffiIntent.SetStayConnected( + 41 -> UniffiIntent.SetStayConnected( FfiConverterBoolean.read(buf), ) - 38 -> UniffiIntent.SetBlossomServer( + 42 -> UniffiIntent.SetBlossomServer( FfiConverterString.read(buf), ) - 39 -> UniffiIntent.SetNotificationsEnabled( + 43 -> UniffiIntent.SetNotificationsEnabled( FfiConverterBoolean.read(buf), ) - 40 -> UniffiIntent.SetUiScale( + 44 -> UniffiIntent.SetUiScale( FfiConverterDouble.read(buf), ) - 41 -> UniffiIntent.SetShowUsageBadge( + 45 -> UniffiIntent.SetShowUsageBadge( FfiConverterBoolean.read(buf), ) - 42 -> UniffiIntent.SetShowCommitBadge( + 46 -> UniffiIntent.SetShowCommitBadge( FfiConverterBoolean.read(buf), ) - 43 -> UniffiIntent.AddQuickPrompt( + 47 -> UniffiIntent.AddQuickPrompt( FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterString.read(buf), ) - 44 -> UniffiIntent.UpdateQuickPrompt( + 48 -> UniffiIntent.UpdateQuickPrompt( FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterString.read(buf), ) - 45 -> UniffiIntent.RemoveQuickPrompt( + 49 -> UniffiIntent.RemoveQuickPrompt( FfiConverterString.read(buf), ) - 46 -> UniffiIntent.DismissPendingSession( + 50 -> UniffiIntent.DismissPendingSession( FfiConverterString.read(buf), ) - 47 -> UniffiIntent.RemoveMachine( + 51 -> UniffiIntent.RemoveMachine( FfiConverterString.read(buf), ) - 48 -> UniffiIntent.SetDirectEndpoints( + 52 -> UniffiIntent.SetDirectEndpoints( FfiConverterString.read(buf), FfiConverterSequenceString.read(buf), ) - 49 -> UniffiIntent.SetMachineRelays( + 53 -> UniffiIntent.SetMachineRelays( FfiConverterString.read(buf), FfiConverterSequenceString.read(buf), ) - 50 -> UniffiIntent.SetDefaultAgent( + 54 -> UniffiIntent.SetDefaultAgent( FfiConverterString.read(buf), FfiConverterOptionalString.read(buf), ) - 51 -> UniffiIntent.SetAgentDefaults( + 55 -> UniffiIntent.SetAgentDefaults( FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterString.read(buf), @@ -10648,66 +10851,66 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer UniffiIntent.BeginPairing( + 56 -> UniffiIntent.BeginPairing( FfiConverterString.read(buf), FfiConverterString.read(buf), ) - 53 -> UniffiIntent.BeginManualPairing( + 57 -> UniffiIntent.BeginManualPairing( FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterString.read(buf), ) - 54 -> UniffiIntent.StagePairing( + 58 -> UniffiIntent.StagePairing( FfiConverterString.read(buf), ) - 55 -> UniffiIntent.ConfirmStagedPairing( + 59 -> UniffiIntent.ConfirmStagedPairing( FfiConverterString.read(buf), ) - 56 -> UniffiIntent.DismissStagedPairing - 57 -> UniffiIntent.ResetPairing - 58 -> UniffiIntent.SetBackupRelay( + 60 -> UniffiIntent.DismissStagedPairing + 61 -> UniffiIntent.ResetPairing + 62 -> UniffiIntent.SetBackupRelay( FfiConverterString.read(buf), ) - 59 -> UniffiIntent.BackupNow - 60 -> UniffiIntent.DisableBackup( + 63 -> UniffiIntent.BackupNow + 64 -> UniffiIntent.DisableBackup( FfiConverterBoolean.read(buf), ) - 61 -> UniffiIntent.OpenTerminal( + 65 -> UniffiIntent.OpenTerminal( FfiConverterString.read(buf), FfiConverterOptionalString.read(buf), FfiConverterUShort.read(buf), FfiConverterUShort.read(buf), ) - 62 -> UniffiIntent.AttachTerminal( + 66 -> UniffiIntent.AttachTerminal( FfiConverterString.read(buf), FfiConverterString.read(buf), ) - 63 -> UniffiIntent.DetachTerminal( + 67 -> UniffiIntent.DetachTerminal( FfiConverterString.read(buf), ) - 64 -> UniffiIntent.TerminalInput( + 68 -> UniffiIntent.TerminalInput( FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterByteArray.read(buf), FfiConverterBoolean.read(buf), ) - 65 -> UniffiIntent.TerminalPaste( + 69 -> UniffiIntent.TerminalPaste( FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterByteArray.read(buf), ) - 66 -> UniffiIntent.ResizeTerminal( + 70 -> UniffiIntent.ResizeTerminal( FfiConverterString.read(buf), FfiConverterString.read(buf), FfiConverterUShort.read(buf), FfiConverterUShort.read(buf), ) - 67 -> UniffiIntent.TakeOverTerminal( + 71 -> UniffiIntent.TakeOverTerminal( FfiConverterString.read(buf), FfiConverterString.read(buf), ) - 68 -> UniffiIntent.CloseTerminal( + 72 -> UniffiIntent.CloseTerminal( FfiConverterString.read(buf), FfiConverterString.read(buf), ) @@ -10952,6 +11155,36 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`machine`) + + FfiConverterString.allocationSize(value.`code`) + ) + } + is UniffiIntent.ClearWebSignIn -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`machine`) + ) + } + is UniffiIntent.RequestWebBrowsers -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`machine`) + ) + } + is UniffiIntent.SignOutWebBrowser -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterString.allocationSize(value.`machine`) + + FfiConverterString.allocationSize(value.`id`) + ) + } is UniffiIntent.RespondPermission -> { // Add the size for the Int that specifies the variant plus the size needed for all fields ( @@ -11480,16 +11713,38 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer { + is UniffiIntent.WebSignIn -> { buf.putInt(27) FfiConverterString.write(value.`machine`, buf) + FfiConverterString.write(value.`code`, buf) + Unit + } + is UniffiIntent.ClearWebSignIn -> { + buf.putInt(28) + FfiConverterString.write(value.`machine`, buf) + Unit + } + is UniffiIntent.RequestWebBrowsers -> { + buf.putInt(29) + FfiConverterString.write(value.`machine`, buf) + Unit + } + is UniffiIntent.SignOutWebBrowser -> { + buf.putInt(30) + FfiConverterString.write(value.`machine`, buf) + FfiConverterString.write(value.`id`, buf) + Unit + } + is UniffiIntent.RespondPermission -> { + buf.putInt(31) + FfiConverterString.write(value.`machine`, buf) FfiConverterString.write(value.`sessionId`, buf) FfiConverterString.write(value.`requestId`, buf) FfiConverterString.write(value.`optionId`, buf) Unit } is UniffiIntent.AnswerQuestion -> { - buf.putInt(28) + buf.putInt(32) FfiConverterString.write(value.`machine`, buf) FfiConverterString.write(value.`sessionId`, buf) FfiConverterString.write(value.`requestId`, buf) @@ -11499,7 +11754,7 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer { - buf.putInt(29) + buf.putInt(33) FfiConverterString.write(value.`machine`, buf) FfiConverterString.write(value.`sessionId`, buf) FfiConverterString.write(value.`requestId`, buf) @@ -11508,7 +11763,7 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer { - buf.putInt(30) + buf.putInt(34) FfiConverterString.write(value.`machine`, buf) FfiConverterString.write(value.`sessionId`, buf) FfiConverterString.write(value.`option`, buf) @@ -11516,118 +11771,118 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer { - buf.putInt(31) + buf.putInt(35) FfiConverterString.write(value.`machine`, buf) FfiConverterOptionalString.write(value.`sessionId`, buf) Unit } is UniffiIntent.SetPlanApprovalChoice -> { - buf.putInt(32) + buf.putInt(36) FfiConverterString.write(value.`cardId`, buf) FfiConverterString.write(value.`key`, buf) Unit } is UniffiIntent.RetryOutboxItem -> { - buf.putInt(33) + buf.putInt(37) FfiConverterString.write(value.`machine`, buf) FfiConverterString.write(value.`id`, buf) Unit } is UniffiIntent.DeleteSession -> { - buf.putInt(34) + buf.putInt(38) FfiConverterString.write(value.`machine`, buf) FfiConverterString.write(value.`sessionId`, buf) FfiConverterOptionalString.write(value.`label`, buf) Unit } is UniffiIntent.UndoDelete -> { - buf.putInt(35) + buf.putInt(39) Unit } is UniffiIntent.SetTorEnabled -> { - buf.putInt(36) + buf.putInt(40) FfiConverterBoolean.write(value.`enabled`, buf) Unit } is UniffiIntent.SetStayConnected -> { - buf.putInt(37) + buf.putInt(41) FfiConverterBoolean.write(value.`enabled`, buf) Unit } is UniffiIntent.SetBlossomServer -> { - buf.putInt(38) + buf.putInt(42) FfiConverterString.write(value.`url`, buf) Unit } is UniffiIntent.SetNotificationsEnabled -> { - buf.putInt(39) + buf.putInt(43) FfiConverterBoolean.write(value.`enabled`, buf) Unit } is UniffiIntent.SetUiScale -> { - buf.putInt(40) + buf.putInt(44) FfiConverterDouble.write(value.`scale`, buf) Unit } is UniffiIntent.SetShowUsageBadge -> { - buf.putInt(41) + buf.putInt(45) FfiConverterBoolean.write(value.`enabled`, buf) Unit } is UniffiIntent.SetShowCommitBadge -> { - buf.putInt(42) + buf.putInt(46) FfiConverterBoolean.write(value.`enabled`, buf) Unit } is UniffiIntent.AddQuickPrompt -> { - buf.putInt(43) + buf.putInt(47) FfiConverterString.write(value.`id`, buf) FfiConverterString.write(value.`label`, buf) FfiConverterString.write(value.`text`, buf) Unit } is UniffiIntent.UpdateQuickPrompt -> { - buf.putInt(44) + buf.putInt(48) FfiConverterString.write(value.`id`, buf) FfiConverterString.write(value.`label`, buf) FfiConverterString.write(value.`text`, buf) Unit } is UniffiIntent.RemoveQuickPrompt -> { - buf.putInt(45) + buf.putInt(49) FfiConverterString.write(value.`id`, buf) Unit } is UniffiIntent.DismissPendingSession -> { - buf.putInt(46) + buf.putInt(50) FfiConverterString.write(value.`pendingId`, buf) Unit } is UniffiIntent.RemoveMachine -> { - buf.putInt(47) + buf.putInt(51) FfiConverterString.write(value.`pubkeyHex`, buf) Unit } is UniffiIntent.SetDirectEndpoints -> { - buf.putInt(48) + buf.putInt(52) FfiConverterString.write(value.`machine`, buf) FfiConverterSequenceString.write(value.`endpoints`, buf) Unit } is UniffiIntent.SetMachineRelays -> { - buf.putInt(49) + buf.putInt(53) FfiConverterString.write(value.`machine`, buf) FfiConverterSequenceString.write(value.`relays`, buf) Unit } is UniffiIntent.SetDefaultAgent -> { - buf.putInt(50) + buf.putInt(54) FfiConverterString.write(value.`machine`, buf) FfiConverterOptionalString.write(value.`agent`, buf) Unit } is UniffiIntent.SetAgentDefaults -> { - buf.putInt(51) + buf.putInt(55) FfiConverterString.write(value.`machine`, buf) FfiConverterString.write(value.`agent`, buf) FfiConverterString.write(value.`mode`, buf) @@ -11637,13 +11892,13 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer { - buf.putInt(52) + buf.putInt(56) FfiConverterString.write(value.`url`, buf) FfiConverterString.write(value.`label`, buf) Unit } is UniffiIntent.BeginManualPairing -> { - buf.putInt(53) + buf.putInt(57) FfiConverterString.write(value.`npub`, buf) FfiConverterString.write(value.`token`, buf) FfiConverterString.write(value.`relays`, buf) @@ -11651,39 +11906,39 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer { - buf.putInt(54) + buf.putInt(58) FfiConverterString.write(value.`url`, buf) Unit } is UniffiIntent.ConfirmStagedPairing -> { - buf.putInt(55) + buf.putInt(59) FfiConverterString.write(value.`label`, buf) Unit } is UniffiIntent.DismissStagedPairing -> { - buf.putInt(56) + buf.putInt(60) Unit } is UniffiIntent.ResetPairing -> { - buf.putInt(57) + buf.putInt(61) Unit } is UniffiIntent.SetBackupRelay -> { - buf.putInt(58) + buf.putInt(62) FfiConverterString.write(value.`url`, buf) Unit } is UniffiIntent.BackupNow -> { - buf.putInt(59) + buf.putInt(63) Unit } is UniffiIntent.DisableBackup -> { - buf.putInt(60) + buf.putInt(64) FfiConverterBoolean.write(value.`delete`, buf) Unit } is UniffiIntent.OpenTerminal -> { - buf.putInt(61) + buf.putInt(65) FfiConverterString.write(value.`machine`, buf) FfiConverterOptionalString.write(value.`session`, buf) FfiConverterUShort.write(value.`cols`, buf) @@ -11691,18 +11946,18 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer { - buf.putInt(62) + buf.putInt(66) FfiConverterString.write(value.`machine`, buf) FfiConverterString.write(value.`term`, buf) Unit } is UniffiIntent.DetachTerminal -> { - buf.putInt(63) + buf.putInt(67) FfiConverterString.write(value.`machine`, buf) Unit } is UniffiIntent.TerminalInput -> { - buf.putInt(64) + buf.putInt(68) FfiConverterString.write(value.`machine`, buf) FfiConverterString.write(value.`term`, buf) FfiConverterByteArray.write(value.`bytes`, buf) @@ -11710,14 +11965,14 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer { - buf.putInt(65) + buf.putInt(69) FfiConverterString.write(value.`machine`, buf) FfiConverterString.write(value.`term`, buf) FfiConverterByteArray.write(value.`bytes`, buf) Unit } is UniffiIntent.ResizeTerminal -> { - buf.putInt(66) + buf.putInt(70) FfiConverterString.write(value.`machine`, buf) FfiConverterString.write(value.`term`, buf) FfiConverterUShort.write(value.`cols`, buf) @@ -11725,13 +11980,13 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer { - buf.putInt(67) + buf.putInt(71) FfiConverterString.write(value.`machine`, buf) FfiConverterString.write(value.`term`, buf) Unit } is UniffiIntent.CloseTerminal -> { - buf.putInt(68) + buf.putInt(72) FfiConverterString.write(value.`machine`, buf) FfiConverterString.write(value.`term`, buf) Unit @@ -11756,6 +12011,19 @@ sealed class UniffiIntentException: kotlin.Exception() { get() = "detail=${ `detail` }" } + /** + * The intent needs this value and got nothing (whitespace counts as + * nothing): there is no command to send without it, so it is refused + * here rather than published as an empty field. + */ + class EmptyValue( + + val `field`: kotlin.String + ) : UniffiIntentException() { + override val message + get() = "field=${ `field` }" + } + @@ -11778,6 +12046,9 @@ public object FfiConverterTypeUniffiIntentError : FfiConverterRustBuffer UniffiIntentException.BadEnumValue( FfiConverterString.read(buf), ) + 2 -> UniffiIntentException.EmptyValue( + FfiConverterString.read(buf), + ) else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } } @@ -11789,6 +12060,11 @@ public object FfiConverterTypeUniffiIntentError : FfiConverterRustBuffer ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.`field`) + ) } } @@ -11799,6 +12075,11 @@ public object FfiConverterTypeUniffiIntentError : FfiConverterRustBuffer { + buf.putInt(2) + FfiConverterString.write(value.`field`, buf) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } @@ -12066,6 +12347,47 @@ public object FfiConverterTypeUniffiUserMessageShape : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer) = try { + UniffiWebSignInState.values()[buf.getInt() - 1] + } catch (e: IndexOutOfBoundsException) { + throw RuntimeException("invalid enum value, something is very wrong!!", e) + } + + override fun allocationSize(value: UniffiWebSignInState) = 4UL + + override fun write(value: UniffiWebSignInState, buf: ByteBuffer) { + buf.putInt(value.ordinal + 1) + } +} + + + + + /** * @suppress @@ -14259,6 +14581,34 @@ public object FfiConverterSequenceTypeUniffiUsageWindow: FfiConverterRustBuffer< +/** + * @suppress + */ +public object FfiConverterSequenceTypeUniffiWebBrowser: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterTypeUniffiWebBrowser.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.map { FfiConverterTypeUniffiWebBrowser.allocationSize(it) }.sum() + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterTypeUniffiWebBrowser.write(it, buf) + } + } +} + + + + /** * @suppress */ @@ -14415,6 +14765,45 @@ public object FfiConverterMapStringTypeUniffiProviderProfileAck: FfiConverterRus +/** + * @suppress + */ +public object FfiConverterMapStringTypeUniffiWebSignIn: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): Map { + val len = buf.getInt() + return buildMap(len) { + repeat(len) { + val k = FfiConverterString.read(buf) + val v = FfiConverterTypeUniffiWebSignIn.read(buf) + this[k] = v + } + } + } + + override fun allocationSize(value: Map): ULong { + val spaceForMapSize = 4UL + val spaceForChildren = value.map { (k, v) -> + FfiConverterString.allocationSize(k) + + FfiConverterTypeUniffiWebSignIn.allocationSize(v) + }.sum() + return spaceForMapSize + spaceForChildren + } + + override fun write(value: Map, buf: ByteBuffer) { + buf.putInt(value.size) + // The parens on `(k, v)` here ensure we're calling the right method, + // which is important for compatibility with older android devices. + // Ref https://blog.danlew.net/2017/03/16/kotlin-puzzler-whose-line-is-it-anyways/ + value.forEach { (k, v) -> + FfiConverterString.write(k, buf) + FfiConverterTypeUniffiWebSignIn.write(v, buf) + } + } +} + + + + /** * @suppress */ @@ -14493,6 +14882,45 @@ public object FfiConverterMapStringSequenceTypeUniffiTerminalInfo: FfiConverterR +/** + * @suppress + */ +public object FfiConverterMapStringSequenceTypeUniffiWebBrowser: FfiConverterRustBuffer>> { + override fun read(buf: ByteBuffer): Map> { + val len = buf.getInt() + return buildMap>(len) { + repeat(len) { + val k = FfiConverterString.read(buf) + val v = FfiConverterSequenceTypeUniffiWebBrowser.read(buf) + this[k] = v + } + } + } + + override fun allocationSize(value: Map>): ULong { + val spaceForMapSize = 4UL + val spaceForChildren = value.map { (k, v) -> + FfiConverterString.allocationSize(k) + + FfiConverterSequenceTypeUniffiWebBrowser.allocationSize(v) + }.sum() + return spaceForMapSize + spaceForChildren + } + + override fun write(value: Map>, buf: ByteBuffer) { + buf.putInt(value.size) + // The parens on `(k, v)` here ensure we're calling the right method, + // which is important for compatibility with older android devices. + // Ref https://blog.danlew.net/2017/03/16/kotlin-puzzler-whose-line-is-it-anyways/ + value.forEach { (k, v) -> + FfiConverterString.write(k, buf) + FfiConverterSequenceTypeUniffiWebBrowser.write(v, buf) + } + } +} + + + + diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt index a6848f02..d63b6543 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt @@ -26,6 +26,7 @@ import com.codedeck.plus.ui.screens.ExtraKeysRow import com.codedeck.plus.ui.screens.TerminalLockedContent import com.codedeck.plus.ui.components.Page import androidx.compose.material3.Text +import com.codedeck.plus.ui.screens.WebSignInContent import com.codedeck.plus.ui.screens.McpContent import com.codedeck.plus.ui.screens.PermissionRulesContent import com.codedeck.plus.ui.screens.ConnectionPage @@ -50,6 +51,9 @@ import uniffi.client_ffi.UniffiAttachedTerminal import uniffi.client_ffi.UniffiTerminalEnded import uniffi.client_ffi.UniffiTerminalInfo import uniffi.client_ffi.UniffiTerminalRefusal +import uniffi.client_ffi.UniffiWebBrowser +import uniffi.client_ffi.UniffiWebSignIn +import uniffi.client_ffi.UniffiWebSignInState import uniffi.client_ffi.UniffiUsageData import uniffi.client_ffi.UniffiBackupStatus import uniffi.client_ffi.UniffiBackupView @@ -295,14 +299,14 @@ private val pages: Map Unit> = linkedMapOf( MachineSettingsContent( machine = workstation.copy(capabilities = listOf(TERMINAL_CAPABILITY)), connectedRelays = setOf("wss://relay.example.org"), credentialsStatus = null, now = NOW, dispatch = {}, onBack = {}, onOpenAgent = {}, - terminals = listOf(bashTerminal), onOpenTerminal = {}, + terminals = listOf(bashTerminal), onOpenTerminal = {}, onOpenWebSignIn = {}, ) }, "settings_machine_update_needed" to { MachineSettingsContent( machine = workstation.copy(updateNeeded = "bridge"), connectedRelays = setOf("wss://relay.example.org"), credentialsStatus = null, now = NOW, dispatch = {}, onBack = {}, onOpenAgent = {}, - terminals = emptyList(), onOpenTerminal = {}, + terminals = emptyList(), onOpenTerminal = {}, onOpenWebSignIn = {}, ) }, "terminal_opening" to { Terminal(attached(term = null)) }, @@ -329,6 +333,12 @@ private val pages: Map Unit> = linkedMapOf( MachineTerminalsGroup(workstation, emptyList(), NOW, onOpen = {}, onAttach = {}) } }, + "web_sign_in" to { WebSignIn(signIn = null, code = "") }, + "web_sign_in_asking" to { WebSignIn(signIn = null, code = "", browsers = null) }, + "web_sign_in_none" to { WebSignIn(signIn = null, code = "", browsers = emptyList()) }, + "web_sign_in_sending" to { WebSignIn(UniffiWebSignIn("K7QM-4XTR", UniffiWebSignInState.SENDING), code = "K7QM-4XTR") }, + "web_sign_in_not_matched" to { WebSignIn(UniffiWebSignIn("K7QM-4XTR", UniffiWebSignInState.NOT_MATCHED), code = "K7QM-4XTR") }, + "web_sign_in_signed_in" to { WebSignIn(UniffiWebSignIn("K7QM-4XTR", UniffiWebSignInState.SIGNED_IN), code = "K7QM-4XTR") }, "settings_agent_opencode" to { AgentPage("opencode") }, "settings_agent_claude" to { AgentPage("claude-code") }, "settings_agent_not_installed" to { AgentPage("deepseek-harness") }, @@ -470,6 +480,25 @@ private fun AgentPage(agent: String) = AgentSettingsContent( onOpenProviders = {}, onOpenPlugins = {}, onOpenMcp = {}, onOpenPermissionRules = {}, ) +/** Signing a browser in to the workstation's web page. */ +@Composable +private fun WebSignIn(signIn: UniffiWebSignIn?, code: String, browsers: List? = signedInBrowsers) = WebSignInContent( + signIn = signIn, noAnswer = false, notice = null, code = code, onCodeChange = {}, onSend = {}, onScan = {}, + browsers = browsers, ownNpub = workstation.npub, now = NOW, onSignOut = {}, onBack = {}, +) + +/** Two browsers on the workstation's page: one this identity signed in, one another identity did. */ +private val signedInBrowsers = listOf( + UniffiWebBrowser( + "0123456789abcdef", workstation.npub, (NOW - 3 * 86_400_000).toULong(), (NOW - 4 * 60_000).toULong(), (NOW + 27 * 86_400_000).toULong(), + "192.168.1.20", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0", + ), + UniffiWebBrowser( + "fedcba9876543210", "npub1q8zy7gyw0l9fh2qkj6x4wlcw5h6xyq9d0k3e8w2yv3m5l6n7p8r9s0tuvx", (NOW - 86_400_000).toULong(), (NOW - 2 * 3_600_000).toULong(), + (NOW + 29 * 86_400_000).toULong(), "10.8.0.6", "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + ), +) + @Composable private fun Styled(look: AgentLook, content: @Composable () -> Unit) = CompositionLocalProvider(LocalAgentLook provides look, content = content) @@ -540,6 +569,12 @@ class DesignSnapshotTest { @Test fun terminal_locked_unavailable() = paparazzi.page("terminal_locked_unavailable") @Test fun session_options_terminal() = paparazzi.page("session_options_terminal") @Test fun machine_terminals() = paparazzi.page("machine_terminals") + @Test fun web_sign_in() = paparazzi.page("web_sign_in") + @Test fun web_sign_in_asking() = paparazzi.page("web_sign_in_asking") + @Test fun web_sign_in_none() = paparazzi.page("web_sign_in_none") + @Test fun web_sign_in_sending() = paparazzi.page("web_sign_in_sending") + @Test fun web_sign_in_not_matched() = paparazzi.page("web_sign_in_not_matched") + @Test fun web_sign_in_signed_in() = paparazzi.page("web_sign_in_signed_in") @Test fun settings_agent_opencode() = paparazzi.page("settings_agent_opencode") @Test fun settings_agent_claude() = paparazzi.page("settings_agent_claude") @Test fun settings_agent_not_installed() = paparazzi.page("settings_agent_not_installed") @@ -580,7 +615,7 @@ class DesignSnapshotTest { class DesignFullPageSnapshotTest { @get:Rule val paparazzi = Paparazzi( - deviceConfig = DeviceConfig.PIXEL_6.copy(softButtons = false, screenWidth = 1080, screenHeight = 4200, density = Density.XXHIGH), + deviceConfig = DeviceConfig.PIXEL_6.copy(softButtons = false, screenWidth = 1080, screenHeight = 5400, density = Density.XXHIGH), showSystemUi = false, ) @@ -590,4 +625,5 @@ class DesignFullPageSnapshotTest { @Test fun settings_machine() = paparazzi.page("settings_machine") @Test fun plugins() = paparazzi.page("plugins") @Test fun settings_messages() = paparazzi.page("settings_messages") + @Test fun web_sign_in() = paparazzi.page("web_sign_in") } diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/screens/WebSignInTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/screens/WebSignInTest.kt new file mode 100644 index 00000000..5a37ace7 --- /dev/null +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/screens/WebSignInTest.kt @@ -0,0 +1,29 @@ +package com.codedeck.plus.ui.screens + +import com.codedeck.plus.ui.DesignFixtures +import org.junit.Assert.assertEquals +import org.junit.Test + +class WebSignInTest { + private val machine = DesignFixtures.workstation + + @Test + fun the_page_is_served_at_the_root_of_each_wss_endpoint() { + assertEquals(listOf("https://192.168.1.20:7447/", "https://workstation.tail1234.ts.net:7447/"), webPageUrls(machine)) + val more = machine.copy( + directAdvertised = listOf("wss://192.168.1.20:7447/", "ws://abcdef.onion:7447", "wss://abcdef.onion:7447"), + directEndpoints = listOf("wss://192.168.1.20:7447"), + ) + assertEquals(listOf("https://192.168.1.20:7447/"), webPageUrls(more)) + } + + @Test + fun a_scanned_code_is_sent_only_to_the_machine_its_qr_names() { + val npub = machine.npub + assertEquals(ScannedSignIn.Code("K7QM-4XTR"), readSignInQr(" codedeck://web-sign-in?machine=$npub&code=K7QM-4XTR\n", npub)) + assertEquals(ScannedSignIn.OtherMachine, readSignInQr("codedeck://web-sign-in?machine=npub1other&code=K7QM-4XTR", npub)) + assertEquals(ScannedSignIn.OtherMachine, readSignInQr("codedeck://web-sign-in?code=K7QM-4XTR", npub)) + assertEquals(ScannedSignIn.NotSignIn, readSignInQr("codedeck://pair?npub=$npub&token=x", npub)) + assertEquals(ScannedSignIn.NotSignIn, readSignInQr("codedeck://web-sign-in?machine=$npub&code=", npub)) + } +} diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_new_session.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_new_session.png index f642d9b6..a5f7db3d 100644 Binary files a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_new_session.png and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_new_session.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_pairing.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_pairing.png index 6ca95989..806d6773 100644 Binary files a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_pairing.png and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_pairing.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_plugins.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_plugins.png index 8e3a1c35..8233026c 100644 Binary files a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_plugins.png and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_plugins.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings.png index 36eb9b84..64dc0367 100644 Binary files a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings.png and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings_machine.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings_machine.png index 00f1f04d..bfa3b7ee 100644 Binary files a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings_machine.png and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings_machine.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings_messages.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings_messages.png index 8209742f..4f4b4848 100644 Binary files a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings_messages.png and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_settings_messages.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_web_sign_in.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_web_sign_in.png new file mode 100644 index 00000000..68978664 Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignFullPageSnapshotTest_web_sign_in.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in.png new file mode 100644 index 00000000..30b4fc82 Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in_asking.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in_asking.png new file mode 100644 index 00000000..f1122d3f Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in_asking.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in_none.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in_none.png new file mode 100644 index 00000000..9f5501b3 Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in_none.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in_not_matched.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in_not_matched.png new file mode 100644 index 00000000..254bbf4d Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in_not_matched.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in_sending.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in_sending.png new file mode 100644 index 00000000..a44778be Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in_sending.png differ diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in_signed_in.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in_signed_in.png new file mode 100644 index 00000000..0316fa81 Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_web_sign_in_signed_in.png differ diff --git a/crates/bridge-core/src/engine/mod.rs b/crates/bridge-core/src/engine/mod.rs index 4258765f..4a2388be 100644 --- a/crates/bridge-core/src/engine/mod.rs +++ b/crates/bridge-core/src/engine/mod.rs @@ -27,9 +27,11 @@ mod permission_rules; mod phone; mod session_keys; pub use session_keys::MAX_CLIENTS; +pub use web::MAX_WEB_SIGN_IN_CODES; mod settings; mod terminal; pub use terminal::{TerminalAllowlist, TerminalAuditRecord, TerminalAuditWhat, TerminalConfig, PendingDevice}; +mod web; use std::collections::BTreeMap; @@ -173,6 +175,8 @@ pub struct Engine { terminals: Terminals, pairing: Option, pairing_epoch: u64, + /// The sign-in codes the web page is showing. + web_sign_ins: web::WebSignIns, nacks: NackBudget, started: bool, stopped: bool, @@ -230,6 +234,7 @@ impl Engine { sync, terminals, pairing: None, + web_sign_ins: web::WebSignIns::default(), pairing_epoch: 0, nacks: NackBudget::default(), started: false, @@ -276,6 +281,8 @@ impl Engine { Input::TerminalAllowlist(allowlist) => self.on_terminal_allowlist(allowlist), Input::Unpaired { identity } => self.unpair(&identity), Input::Shutdown => self.shutdown(), + Input::WebSignInPending { code, expires_ms } => self.on_web_sign_in_pending(code, expires_ms), + Input::WebSignInWithdrawn { code } => self.on_web_sign_in_withdrawn(&code), } self.requester = None; self.flush(); diff --git a/crates/bridge-core/src/engine/phone.rs b/crates/bridge-core/src/engine/phone.rs index 1a750498..d67e4e1a 100644 --- a/crates/bridge-core/src/engine/phone.rs +++ b/crates/bridge-core/src/engine/phone.rs @@ -64,6 +64,9 @@ fn type_name(msg: &PhoneToBridge) -> &'static str { PhoneToBridge::TerminalReplay(_) => "terminal-replay", PhoneToBridge::TerminalTakeover(_) => "terminal-takeover", PhoneToBridge::TerminalClose(_) => "terminal-close", + PhoneToBridge::WebSignIn(_) => "web-sign-in", + PhoneToBridge::WebBrowsersRequest(_) => "web-browsers-request", + PhoneToBridge::WebBrowserSignOut(_) => "web-browser-sign-out", } } @@ -194,6 +197,9 @@ impl Engine { PhoneToBridge::TerminalReplay(m) => self.on_terminal_replay(m, super::terminal::via_of(via)), PhoneToBridge::TerminalTakeover(m) => self.on_terminal_takeover(m, super::terminal::via_of(via)), PhoneToBridge::TerminalClose(m) => self.on_terminal_close(m, super::terminal::via_of(via)), + PhoneToBridge::WebSignIn(m) => self.on_web_sign_in(&m.code, phone), + PhoneToBridge::WebBrowsersRequest(_) => self.on_web_browsers_request(), + PhoneToBridge::WebBrowserSignOut(m) => self.on_web_browser_sign_out(m.id), } } diff --git a/crates/bridge-core/src/engine/web.rs b/crates/bridge-core/src/engine/web.rs new file mode 100644 index 00000000..684b3e3f --- /dev/null +++ b/crates/bridge-core/src/engine/web.rs @@ -0,0 +1,105 @@ +//! Browser sign-in for the bridge's web page. +//! +//! The page shows a short code to a browser that asks to sign in, and tells +//! the engine ([`Input::WebSignInPending`]); a paired phone sends that code +//! (`web-sign-in`), and the engine — which already knows the command came +//! from a paired identity — matches it and lets that browser in +//! ([`Effect::WebSignedIn`]). A code works once, and only until it lapses: +//! anyone who reads it off the screen still needs a paired phone to use it. + +use std::collections::HashMap; + +use protocol::events::{BridgeToPhone, WebSignInResultMsg}; + +use super::Engine; +use crate::io::Effect; + +/// Codes the page is showing at once; the soonest to lapse goes first past +/// it. The page caps its own pending sign-ins at this too, and withdraws a +/// code it stops showing ([`crate::Input::WebSignInWithdrawn`]), so the two sets +/// stay the same. +pub const MAX_WEB_SIGN_IN_CODES: usize = 64; + +/// The codes the web page is showing, with when each lapses (ms). +#[derive(Debug, Default)] +pub(super) struct WebSignIns { + codes: HashMap, +} + +/// A code as a phone may type it: any case, the dash optional, spaces +/// around it ignored. +fn normalize(code: &str) -> String { + let compact: String = code.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_uppercase()).collect(); + if compact.len() == 8 { + format!("{}-{}", &compact[..4], &compact[4..]) + } else { + compact + } +} + +impl Engine { + /// The web page is showing `code` until `expires_ms`. + pub(super) fn on_web_sign_in_pending(&mut self, code: String, expires_ms: u64) { + let now = self.now(); + let codes = &mut self.web_sign_ins.codes; + codes.retain(|_, expires| *expires > now); + while codes.len() >= MAX_WEB_SIGN_IN_CODES { + let Some(soonest) = codes.iter().min_by_key(|(_, e)| **e).map(|(c, _)| c.clone()) else { break }; + codes.remove(&soonest); + } + codes.insert(normalize(&code), expires_ms); + } + + /// The page stopped showing `code`: no phone may use it any more. + pub(super) fn on_web_sign_in_withdrawn(&mut self, code: &str) { + self.web_sign_ins.codes.remove(&normalize(code)); + } + + /// A paired phone sent `code`: sign in the browser showing it, once. The + /// answer echoes the code exactly as the phone sent it, so the phone can + /// match it to the attempt it is showing however the user typed it. + pub(super) fn on_web_sign_in(&mut self, sent: &str, phone: &str) { + let code = normalize(sent); + let now = self.now(); + let live = self.web_sign_ins.codes.remove(&code).is_some_and(|expires| expires > now); + if live { + let by = self.paired.iter().find(|p| p.pubkey_hex == phone).map(|p| p.npub.clone()).unwrap_or_else(|| phone.to_string()); + log::info!("[Engine] A browser was signed in to the web page"); + self.out.push(Effect::WebSignedIn { code: code.clone(), by }); + } else { + log::info!("[Engine] A web sign-in code did not match"); + } + self.publish_to(phone, BridgeToPhone::WebSignInResult(WebSignInResultMsg { code: sent.to_string(), signed_in: live })); + } + + /// A paired phone asked which browsers are signed in to the page. The + /// engine holds no browser list — the page does — so this is an effect the + /// runtime answers from its own `Web`, to the client that asked alone. + pub(super) fn on_web_browsers_request(&mut self) { + if let Some(client) = self.requester.clone() { + self.out.push(Effect::WebBrowsers { client }); + } + } + + /// A paired phone ended one signed-in browser by the page id it saw. The + /// engine does not know the browser either, so the runtime revokes it and + /// answers the same client with the fresh list. + pub(super) fn on_web_browser_sign_out(&mut self, id: String) { + if let Some(client) = self.requester.clone() { + self.out.push(Effect::WebBrowserSignOut { id, client }); + } + } +} + +#[cfg(test)] +mod tests { + use super::normalize; + + #[test] + fn a_typed_code_is_read_in_any_case_with_or_without_its_dash() { + assert_eq!(normalize("k7qm-4xpa"), "K7QM-4XPA"); + assert_eq!(normalize(" K7QM4XPA "), "K7QM-4XPA"); + assert_eq!(normalize("K7QM 4XPA"), "K7QM-4XPA"); + assert_eq!(normalize("ABC"), "ABC", "a wrong length is kept as typed, and matches nothing"); + } +} diff --git a/crates/bridge-core/src/io.rs b/crates/bridge-core/src/io.rs index 33df0041..e2ee3422 100644 --- a/crates/bridge-core/src/io.rs +++ b/crates/bridge-core/src/io.rs @@ -234,6 +234,13 @@ pub enum Input { /// Stop: end every session, publish the offline heartbeat, then /// [`Effect::Stopped`]. Shutdown, + /// The web page is showing sign-in `code` to a browser until + /// `expires_ms`; a paired phone sending it signs that browser in. + WebSignInPending { code: String, expires_ms: u64 }, + /// The page no longer shows `code`: its browser signed out, or was + /// dropped to make room, before a phone sent it. A phone sending it now + /// would be told a browser was let in that is no longer there. + WebSignInWithdrawn { code: String }, } #[derive(Debug)] @@ -301,6 +308,19 @@ pub enum Effect { /// Shutdown is complete: every publish before this one must still go /// out, then the runtime may close the host and the relays. Stopped, + /// A paired phone sent the web page's sign-in `code`: the browser + /// showing it is signed in, by identity `by` (its npub). + WebSignedIn { code: String, by: String }, + /// A paired phone asked which browsers are signed in to the web page + /// (`web-browsers-request`). The engine holds no browser list — the page + /// does — so the runtime answers `client` from its own `Web` with + /// `web-browsers`, an empty list when the bridge runs no page. + WebBrowsers { client: Addressee }, + /// A paired phone ended one signed-in browser (`web-browser-sign-out`) by + /// the page id `id`. The runtime revokes it and answers the same `client` + /// with the fresh list, so an id that matches nothing is still answered + /// with the truth rather than silence. + WebBrowserSignOut { id: String, client: Addressee }, } /// Well-known [`Store`](crate::ports::Store) keys. diff --git a/crates/bridge-core/src/lib.rs b/crates/bridge-core/src/lib.rs index 89212ed7..8a777df0 100644 --- a/crates/bridge-core/src/lib.rs +++ b/crates/bridge-core/src/lib.rs @@ -34,7 +34,7 @@ pub mod time; pub use engine::{ Config, Engine, PendingDevice, TerminalAllowlist, TerminalAuditRecord, TerminalAuditWhat, TerminalConfig, - DEFAULT_HEARTBEAT_INTERVAL_MS, MAX_CLIENTS, + DEFAULT_HEARTBEAT_INTERVAL_MS, MAX_CLIENTS, MAX_WEB_SIGN_IN_CODES, }; pub use sync::SyncConfig; pub use io::*; diff --git a/crates/bridge-core/tests/web_browsers.rs b/crates/bridge-core/tests/web_browsers.rs new file mode 100644 index 00000000..9fe94a0c --- /dev/null +++ b/crates/bridge-core/tests/web_browsers.rs @@ -0,0 +1,56 @@ +//! Listing and ending the browsers signed in to the bridge's web page, through +//! the engine: the phone asks, and the runtime's page — which alone holds the +//! list — answers. The engine only turns each command into the effect that +//! names the asking client. + +mod support; + +use bridge_core::{Effect, Via}; +use protocol::crypto::generate_keypair; +use serde_json::json; +use support::*; + +/// The client a `web-browsers-request` asked the runtime to answer, if any. +fn requested(rig: &mut Rig) -> Option> { + rig.send(json!({"v":12,"type":"web-browsers-request"})); + rig.take().into_iter().find_map(|e| match e { + Effect::WebBrowsers { client } => Some(vec![client.phone]), + _ => None, + }) +} + +#[test] +fn a_request_asks_the_runtime_for_the_list_for_the_asking_client() { + let mut rig = Rig::new(); + let phone = rig.phone.pubkey_hex.clone(); + assert_eq!(requested(&mut rig), Some(vec![phone])); +} + +#[test] +fn a_sign_out_names_the_browser_for_the_runtime_to_end() { + let mut rig = Rig::new(); + let phone = rig.phone.pubkey_hex.clone(); + rig.send(json!({"v":12,"type":"web-browser-sign-out","id":"0123456789abcdef"})); + let (id, to) = rig + .take() + .into_iter() + .find_map(|e| match e { + Effect::WebBrowserSignOut { id, client } => Some((id, vec![client.phone])), + _ => None, + }) + .expect("a sign-out effect"); + assert_eq!(id, "0123456789abcdef"); + assert_eq!(to, vec![phone]); +} + +#[test] +fn an_unpaired_identity_gets_no_list_and_ends_no_browser() { + let mut rig = Rig::new(); + let stranger = generate_keypair(); + rig.phone_event(&stranger, json!({"v":12,"type":"web-browsers-request"}), Via::Relay); + rig.phone_event(&stranger, json!({"v":12,"type":"web-browser-sign-out","id":"0123456789abcdef"}), Via::Relay); + assert!( + rig.take().iter().all(|e| !matches!(e, Effect::WebBrowsers { .. } | Effect::WebBrowserSignOut { .. })), + "a stranger's commands reach no page" + ); +} diff --git a/crates/bridge-core/tests/web_sign_in.rs b/crates/bridge-core/tests/web_sign_in.rs new file mode 100644 index 00000000..a94e5863 --- /dev/null +++ b/crates/bridge-core/tests/web_sign_in.rs @@ -0,0 +1,68 @@ +//! Signing a browser in to the bridge's web page, through the engine: the +//! page's code is let in once, only by a paired phone, only before it lapses. + +mod support; + +use bridge_core::{Effect, Input}; +use protocol::events::BridgeToPhone; +use serde_json::json; +use support::*; + +/// The page shows `code` for five minutes. +fn show(rig: &mut Rig, code: &str) { + let expires_ms = rig.system_now() + 5 * 60 * 1000; + rig.input(Input::WebSignInPending { code: code.into(), expires_ms }); + rig.take(); +} + +/// What the engine did with one `web-sign-in`: the browser it let in (by +/// its code), and the answer the phone got (code as echoed, signed in). +fn sign_in(rig: &mut Rig, sent: &str) -> (Option, Option<(String, bool)>) { + rig.send(json!({"v":12,"type":"web-sign-in","code":sent})); + let effects = rig.take(); + let let_in = effects.iter().find_map(|e| match e { + Effect::WebSignedIn { code, .. } => Some(code.clone()), + _ => None, + }); + let answer = effects.iter().find_map(|e| match e { + Effect::Publish { message: BridgeToPhone::WebSignInResult(r), .. } => Some((r.code.clone(), r.signed_in)), + _ => None, + }); + (let_in, answer) +} + +#[test] +fn a_code_the_page_shows_signs_its_browser_in_once() { + let mut rig = Rig::new(); + show(&mut rig, "K7QM-4XPA"); + assert_eq!(sign_in(&mut rig, "K7QM-4XPA"), (Some("K7QM-4XPA".into()), Some(("K7QM-4XPA".into(), true)))); + assert_eq!(sign_in(&mut rig, "K7QM-4XPA"), (None, Some(("K7QM-4XPA".into(), false))), "used up"); +} + +#[test] +fn a_typed_code_matches_in_any_case_and_its_answer_echoes_what_was_typed() { + let mut rig = Rig::new(); + show(&mut rig, "K7QM-4XPA"); + // The phone matches the answer to the code it sent, so the echo is the + // phone's own spelling, not the page's. + assert_eq!(sign_in(&mut rig, "k7qm4xpa"), (Some("K7QM-4XPA".into()), Some(("k7qm4xpa".into(), true)))); +} + +#[test] +fn a_lapsed_or_unknown_code_signs_nothing_in() { + let mut rig = Rig::new(); + show(&mut rig, "K7QM-4XPA"); + assert_eq!(sign_in(&mut rig, "AAAA-BBBB"), (None, Some(("AAAA-BBBB".into(), false)))); + rig.advance(5 * 60 * 1000 + 1); + rig.take(); + assert_eq!(sign_in(&mut rig, "K7QM-4XPA"), (None, Some(("K7QM-4XPA".into(), false))), "lapsed"); +} + +#[test] +fn a_withdrawn_code_signs_nothing_in() { + let mut rig = Rig::new(); + show(&mut rig, "K7QM-4XPA"); + rig.input(Input::WebSignInWithdrawn { code: "K7QM-4XPA".into() }); + rig.take(); + assert_eq!(sign_in(&mut rig, "K7QM-4XPA"), (None, Some(("K7QM-4XPA".into(), false))), "its browser left"); +} diff --git a/crates/bridge-runtime/Cargo.toml b/crates/bridge-runtime/Cargo.toml index 1dc298b6..9f77053f 100644 --- a/crates/bridge-runtime/Cargo.toml +++ b/crates/bridge-runtime/Cargo.toml @@ -48,6 +48,10 @@ tokio-rustls = { version = "0.26", default-features = false, features = ["ring", rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } rcgen = { version = "0.13", default-features = false, features = ["crypto", "ring"] } futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } +# The web page's request parsing (`src/web.rs`), already in the tree through +# the WebSocket handshake. +httparse = "1" + # Terminals (src/terminal.rs): a real pty per terminal the phone opens — a # unix pty, ConPTY on Windows — so interactive programs behave as they do # over ssh. The runtime owns it; the engine only decides. diff --git a/crates/bridge-runtime/src/config.rs b/crates/bridge-runtime/src/config.rs index 301c8ddc..9779af54 100644 --- a/crates/bridge-runtime/src/config.rs +++ b/crates/bridge-runtime/src/config.rs @@ -2,7 +2,7 @@ //! `$CODEDECK_HOME/config.json` > defaults. Same keys as the TypeScript //! bridge's config, so an existing `config.json` keeps working. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::net::SocketAddr; use std::path::{Path, PathBuf}; @@ -10,6 +10,7 @@ use std::path::{Path, PathBuf}; use protocol::capabilities::BridgeHostKind; use protocol::relays::DEFAULT_RELAYS; use serde::Deserialize; +use serde_json::Value; /// Flags that feed configuration (the rest of the CLI lives in `main.rs`). #[derive(Debug, Default, Clone)] @@ -135,9 +136,12 @@ impl DirectConfig { } } -/// Whether `url` is a direct endpoint a phone may dial: `wss://` anywhere, -/// `ws://` only to an onion service. -pub fn is_direct_endpoint(url: &str) -> bool { +/// Whether `url` is a WebSocket URL that may carry the bridge's traffic: +/// `wss://` anywhere, `ws://` only to an onion service. Cleartext never +/// leaves the machine except inside Tor, so a `ws://` host that is not a +/// `.onion` is refused wherever such a URL is accepted — relays and direct +/// endpoints alike. +fn is_wss_or_onion(url: &str) -> bool { let host = |rest: &str| rest.split(['/', ':']).next().unwrap_or("").to_ascii_lowercase(); if let Some(rest) = url.strip_prefix("wss://") { !host(rest).is_empty() @@ -148,6 +152,16 @@ pub fn is_direct_endpoint(url: &str) -> bool { } } +/// Whether `url` is a direct endpoint a phone may dial. +pub fn is_direct_endpoint(url: &str) -> bool { + is_wss_or_onion(url) +} + +/// Whether `url` is a relay a phone or the bridge may connect to. +pub fn is_relay_url(url: &str) -> bool { + is_wss_or_onion(url) +} + fn direct_config(flags: &Flags, file: Option) -> Result { let file = file.unwrap_or_default(); let addr = |what: &str, value: Option| -> Result, String> { @@ -290,6 +304,11 @@ pub struct Config { pub workspace_roots: Vec, pub relay_register: Option, pub blossom_register: Option, + /// The admin endpoints as configured, with or without their token: the + /// page shows an endpoint and offers its token even before the token is + /// set (a register endpoint works only with both). + pub relay_register_endpoint: Option, + pub blossom_register_endpoint: Option, /// SOCKS5 proxy for all Nostr traffic (relays, Blossom, pubkey /// registration), as `host:port`. pub tor_proxy: Option, @@ -306,6 +325,10 @@ pub struct Config { pub terminal: TerminalSettings, /// How the terminal audit is kept. pub audit: AuditSettings, + /// The config-file keys a command-line flag or a `CODEDECK_*` variable + /// overrode. The page's config API reports these as `overridden`, since + /// an edit of the file would not take effect while the override stands. + pub overridden: BTreeSet, } /// Read an env var; empty counts as unset (Compose's `${VAR:-}` defines every @@ -466,14 +489,10 @@ pub fn load(flags: &Flags) -> Result { (Some(url), Some(token)) => Some(RegisterEndpoint { url, token }), _ => None, }; - let relay_register = register( - env("CODEDECK_RELAY_REGISTER_ENDPOINT").or(file.relay_register_endpoint), - env("CODEDECK_RELAY_REGISTER_TOKEN").or(file.relay_register_token), - ); - let blossom_register = register( - env("CODEDECK_BLOSSOM_REGISTER_ENDPOINT").or(file.blossom_register_endpoint), - env("CODEDECK_BLOSSOM_REGISTER_TOKEN").or(file.blossom_register_token), - ); + let relay_register_endpoint = env("CODEDECK_RELAY_REGISTER_ENDPOINT").or(file.relay_register_endpoint); + let blossom_register_endpoint = env("CODEDECK_BLOSSOM_REGISTER_ENDPOINT").or(file.blossom_register_endpoint); + let relay_register = register(relay_register_endpoint.clone(), env("CODEDECK_RELAY_REGISTER_TOKEN").or(file.relay_register_token)); + let blossom_register = register(blossom_register_endpoint.clone(), env("CODEDECK_BLOSSOM_REGISTER_TOKEN").or(file.blossom_register_token)); let tor_proxy = flags .tor_proxy .clone() @@ -530,6 +549,8 @@ pub fn load(flags: &Flags) -> Result { workspace_roots, relay_register, blossom_register, + relay_register_endpoint, + blossom_register_endpoint, tor_proxy, transcript_keep_last, node_path: env("CODEDECK_NODE_PATH") @@ -542,10 +563,195 @@ pub fn load(flags: &Flags) -> Result { direct, terminal, audit, + overridden: overridden_keys(flags), home, }) } +/// The config-file keys a flag or a `CODEDECK_*` variable supplied, named as +/// they are in the file (`direct.listen` for the nested key). Read from the +/// same sources `load` reads, so the two cannot drift. +fn overridden_keys(flags: &Flags) -> BTreeSet { + let mut keys = BTreeSet::new(); + let mut mark = |set: bool, key: &str| { + if set { + keys.insert(key.to_string()); + } + }; + mark(flags.machine_name.is_some() || env("CODEDECK_MACHINE_NAME").is_some(), "machineName"); + // An environment value `load` would not take (empty, unparsable) leaves + // the file in effect, so it is no override. + mark(!flags.relays.is_empty() || split_list(env("CODEDECK_RELAYS")).is_some(), "relays"); + mark(!flags.workspaces.is_empty() || split_list(env("CODEDECK_WORKSPACE_ROOTS")).is_some(), "workspaceRoots"); + mark(env("CODEDECK_RELAY_REGISTER_ENDPOINT").is_some(), "relayRegisterEndpoint"); + mark(env("CODEDECK_RELAY_REGISTER_TOKEN").is_some(), "relayRegisterToken"); + mark(env("CODEDECK_BLOSSOM_REGISTER_ENDPOINT").is_some(), "blossomRegisterEndpoint"); + mark(env("CODEDECK_BLOSSOM_REGISTER_TOKEN").is_some(), "blossomRegisterToken"); + mark(env("CODEDECK_TRANSCRIPT_KEEP_LAST").and_then(|v| v.parse::().ok()).is_some(), "transcriptKeepLast"); + mark(flags.tor_proxy.is_some() || env("CODEDECK_TOR_PROXY_URL").is_some(), "torProxyUrl"); + mark(env("CODEDECK_NODE_PATH").is_some(), "nodePath"); + mark(flags.agent_host.is_some() || env("CODEDECK_AGENT_HOST").is_some(), "agentHostPath"); + mark(flags.test_mode || env_bool("CODEDECK_TEST_MODE").is_some(), "testMode"); + mark(flags.direct_listen.is_some() || env("CODEDECK_DIRECT_LISTEN").is_some(), "direct.listen"); + mark(flags.direct_onion_listen.is_some() || env("CODEDECK_DIRECT_ONION_LISTEN").is_some(), "direct.onionListen"); + mark(!flags.direct_endpoints.is_empty() || split_list(env("CODEDECK_DIRECT_ENDPOINTS")).is_some(), "direct.endpoints"); + keys +} + +/// The config-file keys a signed-in browser may change through the page's +/// config API — the "safe set". Everything else stays read-only: the other +/// keys name paths, listeners or process settings whose change could break a +/// running bridge, so the page has no business writing them. +pub const WEB_EDITABLE: [&str; 4] = ["machineName", "relays", "direct.endpoints", "transcriptKeepLast"]; + +/// The secrets the page may set or clear. Write-only: the page never reads +/// one back, so a token can be replaced but never leaked. +pub const WEB_EDITABLE_SECRETS: [&str; 2] = ["relayRegisterToken", "blossomRegisterToken"]; + +/// The listeners the bridge runs with, which a direct endpoint needs: `load` +/// refuses to start with a `wss://` endpoint and no direct listener, or a +/// `ws://` one and no onion listener, so the page must not write either. +#[derive(Debug, Clone, Copy, Default)] +pub struct WebListeners { + pub direct: bool, + pub onion: bool, +} + +/// Check one value the page wants to write for a safe-set key. The rules +/// mirror what `load` accepts, so a written file is one the bridge would +/// read back — a page edit must never leave a bridge that will not start. +pub fn validate_web_value(key: &str, value: &Value, listeners: WebListeners) -> Result<(), String> { + match key { + "machineName" => { + let name = value.as_str().ok_or("machineName: expected a string")?; + let chars = name.chars().count(); + if !(1..=64).contains(&chars) { + return Err(format!("machineName: 1 to 64 characters, got {chars}")); + } + if name.chars().any(char::is_control) { + return Err("machineName: no control characters".into()); + } + Ok(()) + } + "relays" => { + let urls = value.as_array().ok_or("relays: expected an array of URLs")?; + if urls.is_empty() { + return Err("relays: name at least one relay".into()); + } + for url in urls { + let url = url.as_str().ok_or("relays: every entry must be a string")?; + if !is_relay_url(url) { + return Err(format!("relays: {url:?} must be wss://, or ws:// only to a .onion")); + } + } + Ok(()) + } + "direct.endpoints" => { + let urls = value.as_array().ok_or("direct.endpoints: expected an array of URLs")?; + for url in urls { + let url = url.as_str().ok_or("direct.endpoints: every entry must be a string")?; + if !is_direct_endpoint(url) { + return Err(format!("direct.endpoints: {url:?} must be wss://, or ws:// only to a .onion")); + } + if url.starts_with("wss://") && !listeners.direct { + return Err(format!("direct.endpoints: {url:?} needs the direct listener, which this bridge does not run")); + } + if url.starts_with("ws://") && !listeners.onion { + return Err(format!("direct.endpoints: {url:?} needs the onion listener, which this bridge does not run")); + } + } + Ok(()) + } + "transcriptKeepLast" => match value { + Value::Null => Ok(()), + Value::Number(n) if n.as_u64().is_some() => Ok(()), + _ => Err("transcriptKeepLast: a whole number of transcripts, or null".into()), + }, + _ => Err(format!("{key}: not a setting the page may change")), + } +} + +/// Check one value the page wants to write for a secret key: a non-empty +/// token, or `null` to clear it. +pub fn validate_web_secret(key: &str, value: &Value) -> Result<(), String> { + match value { + Value::Null => Ok(()), + Value::String(s) if !s.is_empty() => Ok(()), + _ => Err(format!("{key}: a non-empty token, or null to clear")), + } +} + +/// Change the given config-file keys and write the file back atomically, +/// keeping every other key — including ones this bridge does not know — +/// exactly as it was. `None` removes a key (clearing a token, or the +/// retention limit). The file may hold admin tokens, so it stays 0600. +pub fn write_web_settings(path: &Path, changes: &[(String, Option)]) -> Result<(), String> { + let mut root: Value = match fs::read_to_string(path) { + Ok(raw) => serde_json::from_str(&raw).map_err(|e| format!("invalid config in {}: {e}", path.display()))?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}), + Err(e) => return Err(format!("cannot read {}: {e}", path.display())), + }; + let obj = root.as_object_mut().ok_or_else(|| format!("{} does not hold a JSON object", path.display()))?; + for (key, value) in changes { + match key.as_str() { + "direct.endpoints" => { + let direct = obj.entry("direct").or_insert_with(|| serde_json::json!({})); + let direct = direct.as_object_mut().ok_or("direct is not a JSON object")?; + set_or_remove(direct, "endpoints", value.clone()); + } + plain => set_or_remove(obj, plain, value.clone()), + } + } + // The last guard against a file the bridge could not start from: what is + // written must still read as its config. + serde_json::from_value::(root.clone()).map_err(|e| format!("the changed config would not load: {e}"))?; + let json = serde_json::to_vec_pretty(&root).map_err(|e| e.to_string())?; + crate::state::write_private(path, &json).map_err(|e| format!("cannot write {}: {e}", path.display())) +} + +fn set_or_remove(obj: &mut serde_json::Map, key: &str, value: Option) { + match value { + Some(value) => { + obj.insert(key.to_string(), value); + } + None => { + obj.remove(key); + } + } +} + +/// The page's view of the config: every field as it is in effect, with +/// whether the page may change it, whether it is a secret (shown only as +/// set/not set), and whether a flag or environment variable overrides the +/// file — an edit of an overridden field would not take effect. +pub fn web_config_fields(config: &Config) -> Vec { + let overridden = |key: &str| config.overridden.contains(key); + let field = |key: &str, value: Value, editable: bool| { + serde_json::json!({ "key": key, "value": value, "editable": editable, "secret": false, "overridden": overridden(key) }) + }; + let secret = |key: &str, set: bool, editable: bool| { + serde_json::json!({ "key": key, "set": set, "editable": editable, "secret": true, "overridden": overridden(key) }) + }; + let path = |p: &Path| p.to_string_lossy().into_owned(); + vec![ + field("machineName", Value::from(config.machine.clone()), true), + field("relays", serde_json::json!(config.relays), true), + field("direct.endpoints", serde_json::json!(config.direct.endpoints), true), + field("transcriptKeepLast", config.transcript_keep_last.map(|n| Value::from(n as u64)).unwrap_or(Value::Null), true), + field("workspaceRoots", serde_json::json!(config.workspace_roots.iter().map(|p| path(p)).collect::>()), false), + field("torProxyUrl", config.tor_proxy.clone().map(Value::from).unwrap_or(Value::Null), false), + field("nodePath", Value::from(config.node_path.clone()), false), + field("agentHostPath", Value::from(path(&config.agent_host_path)), false), + field("testMode", Value::from(config.test_mode), false), + field("direct.listen", config.direct.listen.map(|a| Value::from(a.to_string())).unwrap_or(Value::Null), false), + field("direct.onionListen", config.direct.onion_listen.map(|a| Value::from(a.to_string())).unwrap_or(Value::Null), false), + field("relayRegisterEndpoint", config.relay_register_endpoint.clone().map(Value::from).unwrap_or(Value::Null), false), + field("blossomRegisterEndpoint", config.blossom_register_endpoint.clone().map(Value::from).unwrap_or(Value::Null), false), + secret("relayRegisterToken", config.relay_register.is_some(), true), + secret("blossomRegisterToken", config.blossom_register.is_some(), true), + ] +} + #[cfg(test)] mod tests { use super::*; @@ -768,4 +974,115 @@ mod tests { let err = load(&Flags { home: Some(dir.path().into()), ..Default::default() }).unwrap_err(); assert!(err.contains("invalid config")); } + + const ALL: WebListeners = WebListeners { direct: true, onion: true }; + + #[test] + fn a_direct_endpoint_needs_the_listener_load_requires() { + let none = WebListeners::default(); + assert!(validate_web_value("direct.endpoints", &serde_json::json!(["wss://192.168.1.9:7447"]), none).is_err()); + assert!(validate_web_value("direct.endpoints", &serde_json::json!(["ws://abc.onion:7448"]), WebListeners { direct: true, onion: false }).is_err()); + assert!(validate_web_value("direct.endpoints", &serde_json::json!(["wss://192.168.1.9:7447"]), WebListeners { direct: true, onion: false }).is_ok()); + } + + #[test] + fn the_safe_set_validates_names_relays_and_endpoints() { + // A machine name: 1 to 64 characters, no control characters. + assert!(validate_web_value("machineName", &Value::from("laptop"), ALL).is_ok()); + assert!(validate_web_value("machineName", &Value::from(""), ALL).is_err()); + assert!(validate_web_value("machineName", &Value::from("a".repeat(65)), ALL).is_err()); + assert!(validate_web_value("machineName", &Value::from("two\nlines"), ALL).is_err()); + // Relays: wss:// anywhere, ws:// only to a .onion, at least one. + assert!(validate_web_value("relays", &serde_json::json!(["wss://relay.example", "ws://abc.onion:80"]), ALL).is_ok()); + assert!(validate_web_value("relays", &serde_json::json!(["ws://192.168.1.9:7447"]), ALL).is_err()); + assert!(validate_web_value("relays", &serde_json::json!([]), ALL).is_err()); + // Direct endpoints: the same rule, but an empty list is allowed. + assert!(validate_web_value("direct.endpoints", &serde_json::json!([]), ALL).is_ok()); + assert!(validate_web_value("direct.endpoints", &serde_json::json!(["wss://192.168.1.9:7447"]), ALL).is_ok()); + assert!(validate_web_value("direct.endpoints", &serde_json::json!(["https://x"]), ALL).is_err()); + // Retention: a whole number, or null to clear. + assert!(validate_web_value("transcriptKeepLast", &Value::from(0), ALL).is_ok()); + assert!(validate_web_value("transcriptKeepLast", &Value::Null, ALL).is_ok()); + assert!(validate_web_value("transcriptKeepLast", &Value::from(-1), ALL).is_err()); + // Anything else is not the page's to change. + assert!(validate_web_value("nodePath", &Value::from("/usr/bin/node"), ALL).is_err()); + assert!(validate_web_secret("relayRegisterToken", &Value::from("tok")).is_ok()); + assert!(validate_web_secret("relayRegisterToken", &Value::Null).is_ok()); + assert!(validate_web_secret("relayRegisterToken", &Value::from("")).is_err()); + } + + #[test] + fn writing_settings_keeps_unknown_keys_and_clears_on_none() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + fs::write(&path, r#"{"machineName":"desktop","direct":{"listen":"0.0.0.0:7447"},"keepMe":{"x":1}}"#).unwrap(); + let changes = vec![ + ("machineName".to_string(), Some(Value::from("laptop"))), + ("direct.endpoints".to_string(), Some(serde_json::json!(["wss://192.168.1.9:7447"]))), + ("relayRegisterToken".to_string(), Some(Value::from("tok"))), + ]; + write_web_settings(&path, &changes).unwrap(); + let written: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(written["machineName"], "laptop"); + assert_eq!(written["direct"]["endpoints"][0], "wss://192.168.1.9:7447"); + assert_eq!(written["direct"]["listen"], "0.0.0.0:7447", "a key beside the changed one is kept"); + assert_eq!(written["keepMe"]["x"], 1, "an unknown key is kept"); + assert_eq!(written["relayRegisterToken"], "tok"); + + // None clears: the key comes out, everything else stays. + write_web_settings(&path, &[("relayRegisterToken".to_string(), None)]).unwrap(); + let written: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert!(written.get("relayRegisterToken").is_none()); + assert_eq!(written["machineName"], "laptop"); + + // A missing file starts from an empty object. + let fresh = dir.path().join("fresh.json"); + write_web_settings(&fresh, &[("machineName".to_string(), Some(Value::from("new")))]).unwrap(); + assert_eq!(serde_json::from_str::(&fs::read_to_string(&fresh).unwrap()).unwrap()["machineName"], "new"); + } + + #[test] + fn the_config_view_shows_secrets_as_set_or_not_and_marks_overrides() { + let config = Config { + home: PathBuf::from("/home/me/.codedeck"), + config_file: PathBuf::from("/home/me/.codedeck/config.json"), + config_file_exists: true, + machine: "desktop".into(), + host_kind: BridgeHostKind::Cli, + relays: vec!["wss://relay.example".into()], + workspace_roots: vec![PathBuf::from("/srv/w")], + relay_register: Some(RegisterEndpoint { url: "https://relay.example/admin".into(), token: "s3cret".into() }), + blossom_register: None, + relay_register_endpoint: Some("https://relay.example/admin".into()), + // An endpoint whose token is not set yet: shown, its token offered. + blossom_register_endpoint: Some("https://blossom.example/admin".into()), + tor_proxy: Some("127.0.0.1:9050".into()), + transcript_keep_last: Some(5), + node_path: "node".into(), + agent_host_path: PathBuf::from("/opt/agent-host/dist/main.js"), + test_mode: false, + host_env: BTreeMap::new(), + direct: DirectConfig { listen: Some("0.0.0.0:7447".parse().unwrap()), onion_listen: None, endpoints: vec![] }, + terminal: TerminalSettings::default(), + audit: AuditSettings::default(), + overridden: BTreeSet::from(["machineName".to_string()]), + }; + let fields = web_config_fields(&config); + let of = |key: &str| fields.iter().find(|f| f["key"] == key).unwrap().clone(); + assert_eq!(of("machineName")["value"], "desktop"); + assert_eq!(of("machineName")["editable"], true); + assert_eq!(of("machineName")["overridden"], true, "a flag or env overrides the file"); + assert_eq!(of("nodePath")["editable"], false, "a path stays read-only"); + assert_eq!(of("nodePath")["overridden"], false); + assert_eq!(of("direct.listen")["value"], "0.0.0.0:7447"); + // A secret is shown as set/not set, and never carries its value. + let token = of("relayRegisterToken"); + assert_eq!(token["secret"], true); + assert_eq!(token["editable"], true); + assert_eq!(token["set"], true); + assert!(token.get("value").is_none()); + assert_eq!(of("blossomRegisterToken")["set"], false); + assert_eq!(of("blossomRegisterEndpoint")["value"], "https://blossom.example/admin", "an endpoint shows before its token is set"); + assert!(!serde_json::to_string(&fields).unwrap().contains("s3cret"), "no secret value is in the view"); + } } diff --git a/crates/bridge-runtime/src/direct.rs b/crates/bridge-runtime/src/direct.rs index 2f61e96f..2775b113 100644 --- a/crates/bridge-runtime/src/direct.rs +++ b/crates/bridge-runtime/src/direct.rs @@ -7,6 +7,10 @@ //! - `ws://` on [`DirectConfig::onion_listen`], loopback only, for an onion //! service to forward to. //! +//! The `wss://` listener also serves the bridge's web page (`crate::web`): a +//! request that is not a WebSocket upgrade is a page request. The onion +//! listener serves no page. +//! //! A connection must finish its TLS and WebSocket handshakes within //! `HANDSHAKE_TIMEOUT`, then answer the challenge with a paired identity's //! HELLO within `HELLO_TIMEOUT`. Then it gets every event the bridge publishes @@ -31,7 +35,7 @@ use protocol::direct::{ use protocol::kinds::{COMMAND_KIND, LIVE_KIND}; use protocol::nostr_event::SignedEvent; use sha2::{Digest, Sha256}; -use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use tokio::net::TcpListener; use tokio::sync::mpsc; use tokio::task::JoinHandle; @@ -49,6 +53,11 @@ const HELLO_TIMEOUT: Duration = Duration::from_secs(60); const IDLE_TIMEOUT: Duration = Duration::from_secs(400); /// Connections served at once, authenticated or not. const MAX_CONNECTIONS: usize = 64; +/// Connections one host holds at once (an IPv6 host by its /64). A phone +/// holds one and a browser a few; the cap keeps a single host — a flood of +/// page requests or of handshakes left hanging — from taking the slots the +/// phones need. +const MAX_CONNECTIONS_PER_HOST: usize = 8; /// Events kept for resuming, at most (besides [`OUTBOX_SECS`]). const OUTBOX_CAP: usize = 5000; /// Largest frame accepted from a phone. Commands are small; an image goes @@ -72,6 +81,8 @@ fn short(key: &str) -> &str { /// What the runtime needs of a running direct link. pub struct Direct { pub hub: Rc, + /// The web page served on the `wss://` listener. + pub web: Rc, /// What the heartbeat advertises. pub info: DirectInfo, tasks: Vec>, @@ -97,6 +108,8 @@ pub struct Hub { /// Published events, oldest first, with the identity each is for. outbox: RefCell>, open: Cell, + /// Connections open now by host (`web::rate_key`). + open_by_host: RefCell>, } struct Conn { @@ -171,6 +184,7 @@ impl Hub { next_id: Cell::new(0), outbox: RefCell::default(), open: Cell::new(0), + open_by_host: RefCell::default(), }) } @@ -263,16 +277,20 @@ impl Hub { } /// Start the listeners `config` asks for; `None` when it asks for none. +/// `web_config` is what the page's config API reads and writes. pub async fn start( config: &DirectConfig, home: &Path, bridge_pubkey: String, inputs: mpsc::UnboundedSender, + web_config: crate::web::ConfigView, ) -> Result, String> { if !config.enabled() { log::info!("[Direct] Off (no direct listener configured)"); return Ok(None); } + let npub = protocol::crypto::npub_from_hex(&bridge_pubkey).unwrap_or_else(|_| bridge_pubkey.clone()); + let web = crate::web::Web::new(npub, &config.endpoints, inputs.clone(), home, web_config); let hub = Hub::new(bridge_pubkey, inputs); let mut tasks = Vec::new(); let mut bound_wss = None; @@ -287,10 +305,24 @@ pub async fn start( bound_wss = Some(bound); log::info!("[Direct] Listening on wss://{bound} (certificate sha256 {})", cert.sha256_hex()); let hub = Rc::clone(&hub); + let web = Rc::clone(&web); tasks.push(tokio::task::spawn_local(async move { - accept_loop(listener, hub, move |stream| { + let pages = Rc::clone(&web); + accept_loop(listener, hub, Some(web), move |stream| { let acceptor = acceptor.clone(); - async move { acceptor.accept(stream).await.ok() } + let pages = Rc::clone(&pages); + async move { + // A TLS handshake opens with a handshake record (0x16); + // anything else is a browser that typed `http://`, which + // is sent to the `https://` page rather than handed TLS + // bytes it cannot read. + let mut first = [0u8; 1]; + if matches!(stream.peek(&mut first).await, Ok(1) if first[0] != 0x16) { + crate::web::redirect_to_https(stream, &pages).await; + return None; + } + acceptor.accept(stream).await.ok() + } }) .await })); @@ -300,20 +332,25 @@ pub async fn start( log::info!("[Direct] Listening on ws://{addr} for an onion service"); let hub = Rc::clone(&hub); tasks.push(tokio::task::spawn_local(async move { - accept_loop(listener, hub, |stream| async move { Some(stream) }).await + accept_loop(listener, hub, None, |stream| async move { Some(stream) }).await })); } let endpoints = advertised_endpoints(config, bound_wss, in_container()); for endpoint in &endpoints { log::info!("[Direct] Advertising {endpoint}"); } + // The page is on the same `wss://` listener, so it is wherever phones + // are told to dial it (`localhost` too, on the machine itself). + for page in endpoints.iter().filter_map(|e| e.strip_prefix("wss://")) { + log::info!("[Web] The bridge's web page: https://{}/", page.trim_end_matches('/')); + } if endpoints.is_empty() { log::info!( "[Direct] Advertising no address: in a container the bridge sees only its own. \ Add the host's address to direct.endpoints, or on the phone (the machine's page)." ); } - Ok(Some(Direct { hub, info: DirectInfo { endpoints, cert_sha256 }, tasks })) + Ok(Some(Direct { hub, web, info: DirectInfo { endpoints, cert_sha256 }, tasks })) } /// The endpoints the heartbeat advertises: the configured ones, led by the @@ -372,7 +409,7 @@ fn lan_ip() -> Option { socket.local_addr().ok().map(|a| a.ip()).filter(|ip| !ip.is_unspecified() && !ip.is_loopback()) } -async fn accept_loop(listener: TcpListener, hub: Rc, secure: F) +async fn accept_loop(listener: TcpListener, hub: Rc, web: Option>, secure: F) where S: AsyncRead + AsyncWrite + Unpin + 'static, F: Fn(tokio::net::TcpStream) -> Fut + 'static, @@ -391,18 +428,85 @@ where log::warn!("[Direct] Refusing {peer}: {MAX_CONNECTIONS} connections open"); continue; } + let host = crate::web::rate_key(peer.ip()); + { + let mut by_host = hub.open_by_host.borrow_mut(); + let held = by_host.entry(host).or_insert(0); + if *held >= MAX_CONNECTIONS_PER_HOST { + log::info!("[Direct] Refusing {peer}: {MAX_CONNECTIONS_PER_HOST} connections open from that host"); + continue; + } + *held += 1; + } hub.open.set(hub.open.get() + 1); let hub = Rc::clone(&hub); + let web = web.clone(); let secured = secure(stream); tokio::task::spawn_local(async move { if let Ok(Some(stream)) = tokio::time::timeout(HANDSHAKE_TIMEOUT, secured).await { - serve(stream, &hub).await; + match web { + Some(web) => route(stream, peer.ip(), &hub, &web).await, + None => serve(stream, &hub).await, + } } hub.open.set(hub.open.get() - 1); + let mut by_host = hub.open_by_host.borrow_mut(); + if let Some(held) = by_host.get_mut(&host) { + *held -= 1; + if *held == 0 { + by_host.remove(&host); + } + } }); } } +/// A connection on the listener that also serves the web page: read the +/// request head, then hand a WebSocket upgrade to the direct link (with the +/// bytes already read put back in front) and anything else to the page. +async fn route(mut stream: S, peer: IpAddr, hub: &Hub, web: &crate::web::Web) { + let Ok(Some(head)) = tokio::time::timeout(HANDSHAKE_TIMEOUT, crate::web::read_head(&mut stream)).await else { return }; + if crate::web::is_websocket(&head) { + serve(Replay { prefix: head, at: 0, inner: stream }, hub).await; + } else { + crate::web::serve(&head, stream, peer, web).await; + } +} + +/// A stream that yields `prefix` before reading on from `inner`: the request +/// head [`route`] read to tell a WebSocket from a page, given back to the +/// WebSocket handshake that needs it. +struct Replay { + prefix: Vec, + at: usize, + inner: S, +} + +impl AsyncRead for Replay { + fn poll_read(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &mut ReadBuf<'_>) -> std::task::Poll> { + if self.at < self.prefix.len() { + let n = (self.prefix.len() - self.at).min(buf.remaining()); + let at = self.at; + buf.put_slice(&self.prefix[at..at + n]); + self.at += n; + return std::task::Poll::Ready(Ok(())); + } + std::pin::Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for Replay { + fn poll_write(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &[u8]) -> std::task::Poll> { + std::pin::Pin::new(&mut self.inner).poll_write(cx, buf) + } + fn poll_flush(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll> { + std::pin::Pin::new(&mut self.inner).poll_flush(cx) + } + fn poll_shutdown(mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll> { + std::pin::Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + /// One connection: the handshake, then events both ways. async fn serve(stream: S, hub: &Hub) { let config = WebSocketConfig { max_message_size: Some(MAX_FRAME_BYTES), max_frame_size: Some(MAX_FRAME_BYTES), ..Default::default() }; diff --git a/crates/bridge-runtime/src/lib.rs b/crates/bridge-runtime/src/lib.rs index dc273ac4..7f158b2c 100644 --- a/crates/bridge-runtime/src/lib.rs +++ b/crates/bridge-runtime/src/lib.rs @@ -11,6 +11,7 @@ pub mod config; pub mod direct; pub mod host; pub mod uploads; +pub mod web; pub mod qr; pub mod relay; pub mod runtime; diff --git a/crates/bridge-runtime/src/qr.rs b/crates/bridge-runtime/src/qr.rs index 2234a816..9b1e91fa 100644 --- a/crates/bridge-runtime/src/qr.rs +++ b/crates/bridge-runtime/src/qr.rs @@ -34,8 +34,36 @@ pub fn render(data: &str) -> String { out } +/// The same code as an SVG for the web page: one path of unit squares on a +/// white field with a four-module quiet zone, which phone cameras need to +/// find the code. `None` when `data` does not fit in a QR code. +pub fn svg(data: &str) -> Option { + const SVG_QUIET: usize = 4; + let code = QrCode::with_error_correction_level(data, EcLevel::M).ok()?; + let width = code.width(); + let size = width + 2 * SVG_QUIET; + let mut path = String::new(); + for (i, color) in code.to_colors().iter().enumerate() { + if *color == Color::Dark { + let (x, y) = (i % width + SVG_QUIET, i / width + SVG_QUIET); + path.push_str(&format!("M{x} {y}h1v1h-1z")); + } + } + Some(format!( + "\ + " + )) +} + #[cfg(test)] mod tests { + #[test] + fn draws_an_svg_with_its_quiet_zone() { + let svg = super::svg("codedeck://web-sign-in?code=ABCD").unwrap(); + assert!(svg.starts_with("")); + assert!(svg.contains("fill=\"#fff\"") && svg.contains("M4 4h1v1h-1z"), "the finder pattern starts past the quiet zone"); + } + #[test] fn renders_a_square_block_with_a_light_border() { let qr = super::render("codedeck://pair?npub=npub1xyz&token=abc"); diff --git a/crates/bridge-runtime/src/runtime.rs b/crates/bridge-runtime/src/runtime.rs index ada72916..2986f57e 100644 --- a/crates/bridge-runtime/src/runtime.rs +++ b/crates/bridge-runtime/src/runtime.rs @@ -21,7 +21,7 @@ use bridge_core::{ }; use protocol::commands::UploadFileMsg; use protocol::crypto::Keypair; -use protocol::events::{BridgeToPhone, TerminalOutputMsg}; +use protocol::events::{BridgeToPhone, TerminalOutputMsg, WebBrowserInfo, WebBrowsersMsg}; use tokio::sync::mpsc; use tokio::task::JoinHandle; @@ -166,7 +166,8 @@ pub async fn run(config: Config, state: StateFile, keys: Keypair, options: Optio }; let (inputs, mut rx) = mpsc::unbounded_channel(); - let direct = crate::direct::start(&config.direct, &config.home, keys.pubkey_hex.clone(), inputs.clone()).await?; + let web_config = crate::web::ConfigView { path: config.config_file.clone(), fields: crate::config::web_config_fields(&config) }; + let direct = crate::direct::start(&config.direct, &config.home, keys.pubkey_hex.clone(), inputs.clone(), web_config).await?; engine_config.direct = direct.as_ref().map(|d| d.info.clone()); let relays = Relays::start( config.relays.clone(), @@ -339,13 +340,51 @@ impl Runtime { false } + /// The browsers signed in to the web page, as the page itself lists them + /// (`Web::browsers`). Empty when this bridge runs no page — no direct + /// link — so a request is still answered, with the truth. + fn web_browsers(&self) -> Vec { + self.direct.as_ref().map(|direct| direct.web.browsers()).unwrap_or_default() + } + /// Carry out one effect; true for [`Effect::Stopped`]. Async because one /// effect does blocking file work that must not run here: opening a /// terminal's recording, which is handed to a blocking task before its /// shell is started. async fn execute(&mut self, effect: Effect) -> bool { match effect { - Effect::Publish { to, message } => self.relays.publish(to, message), + Effect::Publish { to, message } => { + if let (protocol::events::BridgeToPhone::Sessions(list), Some(direct)) = (&message, &self.direct) { + direct.web.set_heartbeat(list); + // The devices API follows the heartbeat: the paired + // phones are cheap to hand over, and this is where they + // change. + direct.web.set_devices(self.engine.paired_phones()); + } + self.relays.publish(to, message) + } + Effect::WebSignedIn { code, by } => { + if let Some(direct) = &self.direct { + direct.web.signed_in(&code, &by); + } + } + // The page alone holds the list of signed-in browsers, so the + // runtime answers both from it: a request with what it has, a + // sign-out by ending that browser and then answering with the + // fresh list. Both go to the asking client alone. + Effect::WebBrowsers { client } => { + let message = BridgeToPhone::WebBrowsers(WebBrowsersMsg { browsers: self.web_browsers() }); + self.relays.publish(vec![client], message); + } + Effect::WebBrowserSignOut { id, client } => { + // `revoke` persists through the web page's own writer thread, + // so ending a browser never blocks the bridge's one thread. + if let Some(direct) = &self.direct { + direct.web.revoke(&id); + } + let message = BridgeToPhone::WebBrowsers(WebBrowsersMsg { browsers: self.web_browsers() }); + self.relays.publish(vec![client], message); + } Effect::Host(frame) => self.host.send(&frame), Effect::SetTimer { id, after_ms } => { let inputs = self.inputs.clone(); @@ -364,6 +403,9 @@ impl Runtime { let filter = self.engine.commands_filter(); if let Some(direct) = &self.direct { direct.hub.set_paired(&filter.authors); + // Pairing changed (this effect rebuilds the command + // subscription): keep the devices API in step at once. + direct.web.set_devices(self.engine.paired_phones()); } self.relays.subscribe_commands(filter.authors, filter.since, self.terminals_on_relays); } diff --git a/crates/bridge-runtime/src/web.rs b/crates/bridge-runtime/src/web.rs new file mode 100644 index 00000000..dc8774e9 --- /dev/null +++ b/crates/bridge-runtime/src/web.rs @@ -0,0 +1,1544 @@ +//! The bridge's web page: what the bridge is doing, for a browser on the +//! machine, the LAN or a VPN. +//! +//! It is served on the direct link's `wss://` listener — the same port and +//! the same self-signed certificate (whose SHA-256 the heartbeat already +//! carries, so a paired phone can show what the browser should see). A +//! request that asks for a WebSocket goes on to the direct link as before; +//! any other is a page request, handled here. Nothing is served in +//! cleartext: the onion listener (plain `ws://` on loopback, for Tor) serves +//! no page. +//! +//! A browser signs in with the owner's phone, never with a password: the page +//! shows a short code (and a QR of it), and a paired phone sends that code as +//! a signed command, which the engine matches (`engine::web`). Until then the +//! browser sees only the sign-in page. The session lives in a cookie the +//! server alone can read, and only HTTPS carries it (`__Host-`, `Secure`, +//! `HttpOnly`, `SameSite=Strict`); it lapses after 30 days. Signed-in +//! browsers survive a restart — they are kept in `/web-sessions.json`, +//! keyed by the SHA-256 of the cookie's value so the file holds no token a +//! copy could be replayed from — and signing out or revoking ends one. +//! +//! Signed in, the page can also read and change a little: `GET /api/browsers` +//! lists the signed-in browsers and `POST /api/browsers/sign-out` ends one; +//! `GET /api/devices` shows the paired identities and their clients; +//! `GET /api/config` shows the config as it is in effect and +//! `POST /api/config` changes the safe set of keys (never a path or a +//! listener, and a secret only as set/not set). +//! +//! Hardening, since a LAN or VPN is not a trusted place: +//! - the `Host` must be an IP address, `localhost`, or a host the direct +//! link's endpoints name — a DNS name an attacker points at this machine +//! is refused (and its certificate would not match anyway); +//! - every `POST` must carry an `Origin` equal to the page's own; +//! - every response forbids framing, sniffing, referrers, caching and any +//! script, style or connection not from the page itself (CSP); +//! - sign-in starts are rate-limited per address, pending codes and +//! sessions are capped, and requests are bounded in size and time. +//! +//! The page never shows a secret: what it reads is the heartbeat, which by +//! protocol carries credential status and never a credential. + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::time::Duration; + +use base64::Engine as _; +use bridge_core::{Input, PairedPhone}; +use protocol::events::{SessionListMsg, WebBrowserInfo}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::sync::mpsc; + +/// Largest request head accepted (request line and headers). +pub const MAX_HEAD_BYTES: usize = 16 * 1024; +/// Largest request body accepted; the page only ever posts a few fields. +const MAX_BODY_BYTES: usize = 16 * 1024; +/// How long a request has to arrive whole once its head has. +const BODY_TIMEOUT: Duration = Duration::from_secs(10); +/// How long a sign-in code shown on the page can be approved. +pub const PENDING_TTL_MS: u64 = 5 * 60 * 1000; +/// How long a signed-in browser stays signed in. +const SESSION_TTL_MS: u64 = 30 * 24 * 3600 * 1000; +/// How often a signed-in browser's `lastSeenMs` is written to disk at most; +/// a request touches it in memory every time, but the file no more often. +const LAST_SEEN_WRITE_MS: u64 = 60 * 1000; +/// Pending sign-ins kept at once; the oldest goes first past it. The +/// engine keeps as many codes, and is told of every one dropped here, so a +/// phone is never told it let in a browser this page no longer knows. +const MAX_PENDING: usize = bridge_core::MAX_WEB_SIGN_IN_CODES; +/// Signed-in browsers kept at once; the oldest goes first past it. +const MAX_SESSIONS: usize = 64; +/// Sign-in starts allowed per address per minute. +const STARTS_PER_MINUTE: u32 = 10; +/// Addresses whose sign-in starts are counted at once; the oldest windows go +/// first past it, so the count costs bounded memory whatever the peers. +const MAX_START_WINDOWS: usize = 4096; +/// The session cookie. `__Host-` makes the browser keep it only when it was +/// set over HTTPS, for the whole site, by this exact host. +const COOKIE: &str = "__Host-codedeck"; +/// The characters a sign-in code is drawn from: no 0/O, 1/I/L, so it can be +/// read off a screen and typed on a phone without doubt. +const CODE_ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZ"; +/// How many hex characters of a browser's key are its public id. +const ID_LEN: usize = 16; +/// Longest `User-Agent` kept for a browser; the header is the client's, so it +/// is bounded before it is stored or shown. +const AGENT_MAX_CHARS: usize = 120; + +const INDEX_HTML: &str = include_str!("../web/index.html"); +const APP_JS: &str = include_str!("../web/app.js"); +const APP_CSS: &str = include_str!("../web/app.css"); + +fn now_ms() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) +} + +/// Where one browser stands. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Access { + /// Showing `code`, waiting for a phone to send it. + Pending { code: String, expires_ms: u64 }, + /// Signed in by `by` (the identity that approved it). + SignedIn { by: String, since_ms: u64, expires_ms: u64 }, +} + +#[derive(Debug, Clone)] +struct Browser { + access: Access, + peer: IpAddr, + /// When it got where it stands, as a count: what the caps drop the + /// lowest of (a clock can give two browsers the same millisecond). + order: u64, + /// The `User-Agent` of the request that started the sign-in, cleaned + /// (control characters stripped, at most [`AGENT_MAX_CHARS`]). Shown on + /// the page so the owner can tell one browser from another. + agent: String, + /// When it last made an authenticated request. Kept in memory on every + /// one, written at most every [`LAST_SEEN_WRITE_MS`]. + last_seen_ms: u64, +} + +/// What the page's config API needs: where `config.json` lives, and the view +/// of the config as it is in effect (flags and environment already applied). +#[derive(Clone, Default)] +pub struct ConfigView { + pub path: PathBuf, + pub fields: Vec, +} + +/// One signed-in browser as it is kept on disk. Never a token: the `key` is +/// already the token's SHA-256, so a copy of this file cannot be replayed as +/// a cookie. +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredSession { + key: String, + by: String, + since_ms: u64, + expires_ms: u64, + last_seen_ms: u64, + peer: String, + agent: String, +} + +/// A paired phone as the devices API shows it: the identity, its label and +/// each client's key expiry. Never a secret — a session key's public half is +/// not shown either, since only its lapse matters to the page. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceView { + pub npub: String, + pub label: String, + pub paired_at: String, + pub clients: Vec, +} + +/// One client of a paired identity. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientView { + /// When the client's session key lapses (seconds since the Unix epoch). + pub expires_at: u64, + /// Until when (ms) the client counts as present. + pub present_until_ms: u64, +} + +/// The page's state: the latest heartbeat and the browsers it knows. +pub struct Web { + machine_npub: String, + /// Host names (lowercase, no port) a request may name besides an IP + /// address and `localhost`. + hosts: Vec, + heartbeat: RefCell>, + /// By the SHA-256 of the cookie's value, so the table holds no token a + /// memory dump could replay. + browsers: RefCell>, + /// Sign-in starts in the current minute, by [`rate_key`]. + starts: RefCell>, + /// The next [`Browser::order`]. + order: Cell, + inputs: mpsc::UnboundedSender, + /// Where the signed-in browsers are kept between restarts. + sessions_path: PathBuf, + /// The config file the config API writes. + config_file: PathBuf, + /// The config as it is in effect, for the config API to show. + config_fields: RefCell>, + /// The paired identities, refreshed whenever the heartbeat is. + devices: RefCell>, + /// When `lastSeenMs` was last written to disk. + last_persist_ms: Cell, + /// Where the browsers file is written, off the bridge's thread. + writer: Writer, + /// Page requests being served now ([`MAX_PAGE_REQUESTS`]). + serving: Cell, +} + +impl Web { + pub fn new( + machine_npub: String, + endpoints: &[String], + inputs: mpsc::UnboundedSender, + home: &Path, + config: ConfigView, + ) -> Rc { + let hosts = endpoints.iter().filter_map(|url| endpoint_host(url)).collect(); + let sessions_path = home.join("web-sessions.json"); + let browsers = load_sessions(&sessions_path); + let order = browsers.values().map(|b| b.order).max().unwrap_or(0) + 1; + if !browsers.is_empty() { + log::info!("[Web] {} signed-in browser(s) restored from {}", browsers.len(), sessions_path.display()); + } + Rc::new(Self { + machine_npub, + hosts, + heartbeat: RefCell::default(), + browsers: RefCell::new(browsers), + starts: RefCell::default(), + order: Cell::new(order), + inputs, + sessions_path, + config_file: config.path, + config_fields: RefCell::new(config.fields), + devices: RefCell::default(), + last_persist_ms: Cell::new(0), + writer: Writer::spawn(), + serving: Cell::new(0), + }) + } + + /// The heartbeat the bridge just published, which the status page shows. + pub fn set_heartbeat(&self, list: &SessionListMsg) { + *self.heartbeat.borrow_mut() = Some(list.clone()); + } + + /// The paired identities, refreshed alongside the heartbeat: what the + /// devices API shows. Only what is not a secret goes in. + pub fn set_devices(&self, phones: &[PairedPhone]) { + let devices = phones + .iter() + .map(|phone| DeviceView { + npub: phone.npub.clone(), + label: phone.label.clone(), + paired_at: phone.paired_at.clone(), + clients: phone + .clients + .iter() + .map(|client| ClientView { expires_at: client.key.expires_at, present_until_ms: client.present_until_ms }) + .collect(), + }) + .collect(); + *self.devices.borrow_mut() = devices; + } + + /// A phone of `by` sent `code`, and the engine matched it to a code this + /// page showed: that browser is signed in. + pub fn signed_in(&self, code: &str, by: &str) { + let now = now_ms(); + { + let mut browsers = self.browsers.borrow_mut(); + for browser in browsers.values_mut() { + if matches!(&browser.access, Access::Pending { code: c, .. } if c == code) { + log::info!("[Web] A browser at {} signed in", browser.peer); + browser.access = Access::SignedIn { by: by.to_string(), since_ms: now, expires_ms: now + SESSION_TTL_MS }; + browser.last_seen_ms = now; + // The session's age is from its sign-in, so the cap below + // drops the longest signed in, never the one just let in. + browser.order = self.next_order(); + } + } + evict_oldest(&mut browsers, MAX_SESSIONS, |b| matches!(b.access, Access::SignedIn { .. })); + } + self.persist(); + } + + fn browser(&self, token: Option<&str>) -> Option<(String, Browser)> { + let key = token_key(token?); + let now = now_ms(); + let mut expired = false; + let mut touched = false; + { + let mut browsers = self.browsers.borrow_mut(); + let browser = browsers.get_mut(&key)?; + let expires_ms = match browser.access { + Access::Pending { expires_ms, .. } | Access::SignedIn { expires_ms, .. } => expires_ms, + }; + if expires_ms <= now { + expired = true; + } else if matches!(browser.access, Access::SignedIn { .. }) { + // An authenticated request: its `lastSeenMs` moves, and the + // file follows at most once a minute. + browser.last_seen_ms = now; + touched = true; + } + } + if expired { + self.browsers.borrow_mut().remove(&key); + self.persist(); + return None; + } + if touched && now.saturating_sub(self.last_persist_ms.get()) >= LAST_SEEN_WRITE_MS { + self.last_persist_ms.set(now); + self.persist(); + } + self.browsers.borrow().get(&key).map(|b| (key.clone(), b.clone())) + } + + /// Start a sign-in for a browser at `peer` (its `User-Agent` is `agent`): + /// a new code, and the cookie that names it. `None` when the address + /// started too many lately. + fn start_sign_in(&self, peer: IpAddr, agent: &str) -> Option<(String, String)> { + let now = now_ms(); + { + let mut starts = self.starts.borrow_mut(); + starts.retain(|_, (window, _)| now - *window < 60_000); + let key = rate_key(peer); + while !starts.contains_key(&key) && starts.len() >= MAX_START_WINDOWS { + let Some(oldest) = starts.iter().min_by_key(|(_, (window, _))| *window).map(|(k, _)| *k) else { break }; + starts.remove(&oldest); + } + let entry = starts.entry(key).or_insert((now, 0)); + if entry.1 >= STARTS_PER_MINUTE { + return None; + } + entry.1 += 1; + } + let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()); + let code = new_code(); + let expires_ms = now + PENDING_TTL_MS; + let mut browsers = self.browsers.borrow_mut(); + browsers.retain(|_, b| match b.access { + Access::Pending { expires_ms, .. } | Access::SignedIn { expires_ms, .. } => expires_ms > now, + }); + let browser = Browser { + access: Access::Pending { code: code.clone(), expires_ms }, + peer, + order: self.next_order(), + agent: agent.to_string(), + last_seen_ms: now, + }; + browsers.insert(token_key(&token), browser); + let _ = self.inputs.send(Input::WebSignInPending { code: code.clone(), expires_ms }); + // Pending browsers are not written to disk, so a dropped one needs no + // persist — but the file may have lost an expired signed-in one above. + for dropped in evict_oldest(&mut browsers, MAX_PENDING, |b| matches!(b.access, Access::Pending { .. })) { + self.withdraw(&dropped); + } + Some((token, code)) + } + + fn sign_out(&self, key: &str) { + let removed = self.browsers.borrow_mut().remove(key); + if let Some(browser) = removed { + self.withdraw(&browser); + self.persist(); + } + } + + /// Sign out the browser whose id (the first [`ID_LEN`] hex characters of + /// its key) is `id`; false when no signed-in browser has it. Any + /// signed-in browser may sign out any other: they are all the owner's. + /// The engine asks for this on `web-browser-sign-out`, and the page's own + /// API on `POST /api/browsers/sign-out` — one path, so the two cannot + /// end different browsers. + pub fn revoke(&self, id: &str) -> bool { + if id.len() != ID_LEN { + return false; + } + let key = self + .browsers + .borrow() + .iter() + .find(|(key, browser)| key.starts_with(id) && matches!(browser.access, Access::SignedIn { .. })) + .map(|(key, _)| key.clone()); + match key { + Some(key) => { + self.sign_out(&key); + true + } + None => false, + } + } + + fn next_order(&self) -> u64 { + let order = self.order.get(); + self.order.set(order + 1); + order + } + + /// Write the signed-in browsers to disk, owner-only and atomically. A + /// pending one is not written (its code is short-lived and useless after + /// a restart), and no token ever is — only the key's SHA-256. + fn persist(&self) { + let mut sessions: Vec = self + .browsers + .borrow() + .iter() + .filter_map(|(key, browser)| match &browser.access { + Access::SignedIn { by, since_ms, expires_ms } => Some(StoredSession { + key: key.clone(), + by: by.clone(), + since_ms: *since_ms, + expires_ms: *expires_ms, + last_seen_ms: browser.last_seen_ms, + peer: browser.peer.to_string(), + agent: browser.agent.clone(), + }), + Access::Pending { .. } => None, + }) + .collect(); + sessions.sort_by(|a, b| a.since_ms.cmp(&b.since_ms).then_with(|| a.key.cmp(&b.key))); + let Ok(json) = serde_json::to_vec_pretty(&sessions) else { return }; + self.writer.write(self.sessions_path.clone(), json); + } + + /// The listeners in effect, from the config the page shows. + fn listeners(&self) -> crate::config::WebListeners { + let fields = self.config_fields.borrow(); + let running = |key: &str| fields.iter().any(|f| f["key"] == key && !f["value"].is_null()); + crate::config::WebListeners { direct: running("direct.listen"), onion: running("direct.onionListen") } + } + + /// The page stopped showing a pending browser's code: the engine must not + /// let a phone use it. + fn withdraw(&self, browser: &Browser) { + if let Access::Pending { code, .. } = &browser.access { + let _ = self.inputs.send(Input::WebSignInWithdrawn { code: code.clone() }); + } + } + + fn status_json(&self, by: &str, since_ms: u64) -> String { + let heartbeat = self.heartbeat.borrow(); + serde_json::json!({ + "machine": self.machine_npub, + "signedInBy": by, + "signedInSince": since_ms, + "heartbeat": heartbeat.as_ref().map(|h| serde_json::to_value(h).unwrap_or(serde_json::Value::Null)), + }) + .to_string() + } + + /// The signed-in browsers, oldest signed in first, as `(key, info)`. The + /// page's own list: both `browsers_json` and `browsers` read it, so what + /// `GET /api/browsers` serves and what a phone's `web-browsers-request` + /// is answered with can never disagree. + /// A browser whose sign-in has lapsed is left out: it is only reaped when + /// it next asks for something, and until then it is not signed in. + fn browser_rows(&self) -> Vec<(String, WebBrowserInfo)> { + let now = now_ms(); + let mut rows: Vec<(u64, String, WebBrowserInfo)> = self + .browsers + .borrow() + .iter() + .filter_map(|(key, browser)| match &browser.access { + Access::SignedIn { by, since_ms, expires_ms } if *expires_ms > now => Some(( + browser.order, + key.clone(), + WebBrowserInfo { + id: key[..ID_LEN].to_string(), + by: by.clone(), + since_ms: *since_ms, + last_seen_ms: browser.last_seen_ms, + expires_ms: *expires_ms, + peer: browser.peer.to_string(), + agent: browser.agent.clone(), + }, + )), + _ => None, + }) + .collect(); + rows.sort_by_key(|(order, _, _)| *order); + rows.into_iter().map(|(_, key, info)| (key, info)).collect() + } + + /// The signed-in browsers, oldest signed in first: what a paired phone's + /// `web-browsers-request` is answered with, from the same rows + /// `GET /api/browsers` serves. + pub fn browsers(&self) -> Vec { + self.browser_rows().into_iter().map(|(_, info)| info).collect() + } + + /// The signed-in browsers, oldest first, for `GET /api/browsers`. Each + /// row carries `current` — whether it is the browser asking — which only + /// the page's own request can know. + fn browsers_json(&self, current: Option<&str>) -> String { + let rows: Vec = self + .browser_rows() + .into_iter() + .map(|(key, info)| { + let mut row = serde_json::to_value(&info).unwrap_or(Value::Null); + if let Value::Object(map) = &mut row { + map.insert("current".to_string(), Value::Bool(current == Some(key.as_str()))); + } + row + }) + .collect(); + serde_json::json!({ "browsers": rows }).to_string() + } + + /// The paired identities, for `GET /api/devices`. + fn devices_json(&self) -> String { + serde_json::json!({ "devices": self.devices.borrow().clone() }).to_string() + } + + /// The config as it is in effect, for `GET /api/config`. + fn config_json(&self) -> String { + serde_json::json!({ "fields": self.config_fields.borrow().clone() }).to_string() + } + + /// Apply a `POST /api/config` body: only safe-set keys, each validated, + /// then written to the config file keeping every other key. `by` is the + /// approver's npub, for the log line (key names only, never a value). + fn apply_config(&self, body: &[u8], by: &str) -> Response { + let Ok(doc) = serde_json::from_slice::(body) else { return Response::error(400) }; + let Some(obj) = doc.as_object() else { return Response::error(400) }; + if obj.keys().any(|key| key != "set" && key != "secrets") { + return Response::error(400); + } + let empty = serde_json::Map::new(); + let set = match obj.get("set") { + None => &empty, + Some(Value::Object(map)) => map, + Some(_) => return Response::error(400), + }; + let secrets = match obj.get("secrets") { + None => &empty, + Some(Value::Object(map)) => map, + Some(_) => return Response::error(400), + }; + let mut changes: Vec<(String, Option)> = Vec::new(); + for (key, value) in set { + if !crate::config::WEB_EDITABLE.contains(&key.as_str()) { + return bad_request(key, "not a setting the page may change"); + } + if let Err(message) = crate::config::validate_web_value(key, value, self.listeners()) { + return bad_request(key, &message); + } + changes.push((key.clone(), Some(value.clone()))); + } + for (key, value) in secrets { + if !crate::config::WEB_EDITABLE_SECRETS.contains(&key.as_str()) { + return bad_request(key, "not a secret the page may change"); + } + if let Err(message) = crate::config::validate_web_secret(key, value) { + return bad_request(key, &message); + } + // `null` clears the token: the key comes out of the file. + changes.push((key.clone(), (!value.is_null()).then(|| value.clone()))); + } + if changes.is_empty() { + return bad_request("", "nothing to change"); + } + let mut response = Response::json(200, serde_json::json!({ "ok": true, "restartNeeded": true }).to_string()); + response.config_write = Some(ConfigWrite { path: self.config_file.clone(), changes, by: by[..8.min(by.len())].to_string() }); + response + } +} + +/// The signed-in browsers a previous run left in `path`, expired ones +/// dropped. A missing or unreadable file is an empty set, with a warning: a +/// page that will not start is worse than one whose browsers sign in again. +fn load_sessions(path: &Path) -> HashMap { + let now = now_ms(); + let raw = match std::fs::read_to_string(path) { + Ok(raw) => raw, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return HashMap::new(), + Err(e) => { + log::warn!("[Web] cannot read {}: {e}; starting with no signed-in browsers", path.display()); + return HashMap::new(); + } + }; + let stored: Vec = match serde_json::from_str(&raw) { + Ok(stored) => stored, + Err(e) => { + log::warn!("[Web] ignoring {}: {e}; starting with no signed-in browsers", path.display()); + return HashMap::new(); + } + }; + let mut browsers = HashMap::new(); + let mut order = 1; + for session in stored { + // A key is always 64 hex characters; anything else is not one this + // page wrote, so it is dropped rather than trusted. + let Ok(peer) = session.peer.parse::() else { continue }; + if session.expires_ms <= now || session.key.len() != 64 { + continue; + } + let access = Access::SignedIn { by: session.by, since_ms: session.since_ms, expires_ms: session.expires_ms }; + browsers.insert( + session.key, + Browser { access, peer, order, agent: session.agent, last_seen_ms: session.last_seen_ms }, + ); + order += 1; + } + browsers +} + +/// Drop the oldest browsers matching `which` until at most `cap` of them are +/// left, and return them. +fn evict_oldest(browsers: &mut HashMap, cap: usize, which: impl Fn(&Browser) -> bool) -> Vec { + let mut dropped = Vec::new(); + loop { + let matching: Vec<(&String, &Browser)> = browsers.iter().filter(|(_, b)| which(b)).collect(); + if matching.len() <= cap { + return dropped; + } + let Some(oldest) = matching.iter().min_by_key(|(_, b)| b.order).map(|(k, _)| (*k).clone()) else { return dropped }; + dropped.extend(browsers.remove(&oldest)); + } +} + +/// Page requests served at once; past it a request is answered 503. +const MAX_PAGE_REQUESTS: usize = 16; + +/// Writes files in order on a thread of its own. The bridge runs on one +/// thread, and a write ends in an fsync that a slow disk (or a Docker +/// Desktop volume) can hold for tens of milliseconds — time every relay and +/// phone link would otherwise wait. Writes keep their order, so the file on +/// disk is always the latest one handed over. +struct Writer { + tx: std::sync::mpsc::Sender, +} + +enum WriterJob { + Write(PathBuf, Vec), + /// Answered once every earlier write is done (tests). + #[cfg(test)] + Flush(std::sync::mpsc::Sender<()>), +} + +impl Writer { + fn spawn() -> Self { + let (tx, rx) = std::sync::mpsc::channel::(); + let spawned = std::thread::Builder::new().name("codedeck-web-writer".into()).spawn(move || { + for job in rx { + match job { + WriterJob::Write(path, bytes) => { + if let Err(e) = crate::state::write_private(&path, &bytes) { + log::warn!("[Web] cannot write {}: {e}", path.display()); + } + } + #[cfg(test)] + WriterJob::Flush(done) => { + let _ = done.send(()); + } + } + } + }); + if let Err(e) = spawned { + log::warn!("[Web] no writer thread ({e}); signed-in browsers will not survive a restart"); + } + Self { tx } + } + + fn write(&self, path: PathBuf, bytes: Vec) { + let _ = self.tx.send(WriterJob::Write(path, bytes)); + } + + #[cfg(test)] + fn flush(&self) { + let (done, wait) = std::sync::mpsc::channel(); + if self.tx.send(WriterJob::Flush(done)).is_ok() { + let _ = wait.recv(); + } + } +} + +/// What sign-in starts are counted by: an IPv4 address, or an IPv6 +/// address's /64. One host holds a whole /64 and can pick any address in it, +/// so counting single IPv6 addresses would let it start without limit. +pub(crate) fn rate_key(peer: IpAddr) -> IpAddr { + match peer { + IpAddr::V4(v4) => IpAddr::V4(v4), + IpAddr::V6(v6) => match v6.to_ipv4_mapped() { + Some(v4) => IpAddr::V4(v4), + None => { + let s = v6.segments(); + IpAddr::V6(std::net::Ipv6Addr::new(s[0], s[1], s[2], s[3], 0, 0, 0, 0)) + } + }, + } +} + +fn token_key(token: &str) -> String { + hex::encode(Sha256::digest(token.as_bytes())) +} + +/// Eight characters in two groups (`K7QM-4XPA`): about 39 bits, guessed only +/// by sending a signed command from a paired phone within its five minutes. +fn new_code() -> String { + let bytes = rand::random::<[u8; 8]>(); + let chars: String = bytes.iter().map(|b| CODE_ALPHABET[*b as usize % CODE_ALPHABET.len()] as char).collect(); + format!("{}-{}", &chars[..4], &chars[4..]) +} + +/// The host part of a direct endpoint URL, lowercase. +fn endpoint_host(url: &str) -> Option { + let rest = url.split_once("://")?.1; + let authority = rest.split('/').next()?; + let host = if let Some(v6) = authority.strip_prefix('[') { v6.split(']').next()? } else { authority.split(':').next()? }; + (!host.is_empty()).then(|| host.to_ascii_lowercase()) +} + +/// Where the head of the request in `buf` ends, once it has. +pub fn head_len(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n").map(|i| i + 4) +} + +/// Whether the request head in `buf` asks for a WebSocket. +pub fn is_websocket(buf: &[u8]) -> bool { + let mut headers = [httparse::EMPTY_HEADER; 64]; + let mut request = httparse::Request::new(&mut headers); + if request.parse(buf).is_err() { + return false; + } + request.headers.iter().any(|h| { + h.name.eq_ignore_ascii_case("upgrade") && std::str::from_utf8(h.value).is_ok_and(|v| v.trim().eq_ignore_ascii_case("websocket")) + }) +} + +/// One parsed request. +struct Request { + method: String, + path: String, + host: Option, + origin: Option, + cookie: Option, + user_agent: Option, + /// The body, read off whole and bounded (empty for a request without one). + body: Vec, +} + +/// Parse the request whose head is in `buf` (with whatever of its body came +/// with it), reading the rest of the body from `stream`. +async fn read_request(buf: &[u8], stream: &mut S) -> Result { + let mut headers = [httparse::EMPTY_HEADER; 64]; + let mut parsed = httparse::Request::new(&mut headers); + let head = match parsed.parse(buf) { + Ok(httparse::Status::Complete(n)) => n, + _ => return Err(400), + }; + let header = |name: &str| { + parsed.headers.iter().find(|h| h.name.eq_ignore_ascii_case(name)).and_then(|h| std::str::from_utf8(h.value).ok()).map(str::to_string) + }; + let length: usize = match header("content-length") { + Some(v) => v.trim().parse().map_err(|_| 400u16)?, + None => 0, + }; + if length > MAX_BODY_BYTES || header("transfer-encoding").is_some() { + return Err(413); + } + // The body is read off whole and bounded, so the request ends cleanly + // even for a route that does not look at it. + let arrived = (buf.len() - head).min(length); + let mut body = buf[head..head + arrived].to_vec(); + if arrived < length { + let mut rest = vec![0u8; length - arrived]; + tokio::time::timeout(BODY_TIMEOUT, stream.read_exact(&mut rest)).await.map_err(|_| 408u16)?.map_err(|_| 400u16)?; + body.extend_from_slice(&rest); + } + let cookie = header("cookie").and_then(|all| { + all.split(';').find_map(|pair| pair.trim().strip_prefix(&format!("{COOKIE}=")).map(str::to_string)) + }); + Ok(Request { + method: parsed.method.unwrap_or("").to_string(), + path: parsed.path.unwrap_or("").split('?').next().unwrap_or("").to_string(), + host: header("host"), + origin: header("origin"), + cookie, + user_agent: header("user-agent"), + body, + }) +} + +/// A request's `User-Agent`, safe to store and show: control characters (a +/// newline above all) removed, at most [`AGENT_MAX_CHARS`] characters. +fn clean_agent(raw: Option<&str>) -> String { + raw.unwrap_or("").chars().filter(|c| !c.is_control()).take(AGENT_MAX_CHARS).collect() +} + +/// Whether `host` (a `Host` header: name and optional port) may be served. +fn host_allowed(host: &str, hosts: &[String]) -> bool { + let name = if let Some(v6) = host.strip_prefix('[') { + v6.split(']').next().unwrap_or("") + } else { + host.rsplit_once(':').map_or(host, |(name, port)| if port.chars().all(|c| c.is_ascii_digit()) { name } else { host }) + }; + let name = name.to_ascii_lowercase(); + !name.is_empty() && (name.parse::().is_ok() || name == "localhost" || hosts.contains(&name)) +} + +/// A response, written whole and the connection closed. +struct Response { + status: u16, + content_type: &'static str, + body: Vec, + set_cookie: Option, + /// A config write this answer waits on, done off the bridge's thread + /// ([`serve`]): the answer stands only if it succeeds. + config_write: Option, +} + +/// Config-file changes the page validated, still to be written. +struct ConfigWrite { + path: PathBuf, + changes: Vec<(String, Option)>, + /// Who asked, for the log line (an npub prefix). + by: String, +} + +impl Response { + fn new(status: u16, content_type: &'static str, body: impl Into>) -> Self { + Self { status, content_type, body: body.into(), set_cookie: None, config_write: None } + } + + fn json(status: u16, body: String) -> Self { + Self::new(status, "application/json", body) + } + + fn error(status: u16) -> Self { + Self::json(status, serde_json::json!({ "error": reason(status) }).to_string()) + } +} + +fn reason(status: u16) -> &'static str { + match status { + 200 => "OK", + 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not Found", + 405 => "Method Not Allowed", + 408 => "Request Timeout", + 413 => "Payload Too Large", + 429 => "Too Many Requests", + 500 => "Internal Server Error", + _ => "Error", + } +} + +async fn write_response(stream: &mut S, response: Response) { + let mut head = format!( + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\ + Cache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nX-Frame-Options: DENY\r\n\ + Referrer-Policy: no-referrer\r\nCross-Origin-Opener-Policy: same-origin\r\n\ + Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; \ + connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'\r\n", + response.status, + reason(response.status), + response.content_type, + response.body.len(), + ); + if let Some(cookie) = response.set_cookie { + head.push_str(&format!("Set-Cookie: {cookie}\r\n")); + } + head.push_str("\r\n"); + let _ = stream.write_all(head.as_bytes()).await; + let _ = stream.write_all(&response.body).await; + let _ = stream.shutdown().await; +} + +fn session_cookie(token: &str, max_age_secs: u64) -> String { + format!("{COOKIE}={token}; Path=/; Max-Age={max_age_secs}; Secure; HttpOnly; SameSite=Strict") +} + +/// Answer a plain-HTTP request that reached the TLS port with a redirect to +/// the same page over HTTPS. Nothing else is served in cleartext, and a +/// `Host` the page would refuse gets no redirect, so this is no open +/// redirector. +pub async fn redirect_to_https(mut stream: S, web: &Web) { + let Ok(Some(head)) = tokio::time::timeout(BODY_TIMEOUT, read_head(&mut stream)).await else { return }; + let mut headers = [httparse::EMPTY_HEADER; 64]; + let mut request = httparse::Request::new(&mut headers); + let host = match request.parse(&head) { + Ok(httparse::Status::Complete(_)) => { + request.headers.iter().find(|h| h.name.eq_ignore_ascii_case("host")).and_then(|h| std::str::from_utf8(h.value).ok()).map(str::to_string) + } + _ => None, + }; + let path = request.path.filter(|p| p.starts_with('/') && !p.starts_with("//")).unwrap_or("/"); + let reply = match host.filter(|h| host_allowed(h, &web.hosts)) { + Some(host) => format!("HTTP/1.1 301 Moved Permanently +Location: https://{host}{path} +Content-Length: 0 +Connection: close + +"), + None => "HTTP/1.1 400 Bad Request +Content-Length: 0 +Connection: close + +".to_string(), + }; + let _ = stream.write_all(reply.as_bytes()).await; + let _ = stream.shutdown().await; +} + +/// Read until the request head is complete (with whatever followed it in the +/// same reads), or `None` past [`MAX_HEAD_BYTES`] or at the end. +pub async fn read_head(stream: &mut S) -> Option> { + let mut buf = Vec::with_capacity(1024); + let mut chunk = [0u8; 2048]; + while head_len(&buf).is_none() { + if buf.len() >= MAX_HEAD_BYTES { + return None; + } + let read = stream.read(&mut chunk).await.ok()?; + if read == 0 { + return None; + } + buf.extend_from_slice(&chunk[..read]); + } + Some(buf) +} + +/// Serve one page request whose head is in `buf`, from a browser at `peer`. +pub async fn serve(buf: &[u8], mut stream: S, peer: IpAddr, web: &Web) { + // The page shares the bridge's one thread with the relays, the engine + // and the phones' links: past this many requests at once, a page request + // is turned away rather than let it queue work in front of theirs. + if web.serving.get() >= MAX_PAGE_REQUESTS { + write_response(&mut stream, Response::error(503)).await; + return; + } + web.serving.set(web.serving.get() + 1); + let mut response = match read_request(buf, &mut stream).await { + Ok(request) => route(&request, peer, web), + Err(status) => Response::error(status), + }; + if let Some(write) = response.config_write.take() { + response = write_config(write).await; + } + web.serving.set(web.serving.get() - 1); + write_response(&mut stream, response).await; +} + +/// Write validated config changes on the blocking pool: a write ends in an +/// fsync, which on a slow disk would stall every phone's traffic for as long +/// if it ran on the bridge's thread. +async fn write_config(write: ConfigWrite) -> Response { + let ConfigWrite { path, changes, by } = write; + let keys: Vec = changes.iter().map(|(key, _)| key.clone()).collect(); + match tokio::task::spawn_blocking(move || crate::config::write_web_settings(&path, &changes)).await { + Ok(Ok(())) => { + log::info!("[Web] config changed by {by}: keys {}", keys.join(", ")); + Response::json(200, serde_json::json!({ "ok": true, "restartNeeded": true }).to_string()) + } + Ok(Err(err)) => { + log::warn!("[Web] config write failed: {err}"); + Response::error(500) + } + Err(err) => { + log::warn!("[Web] config write did not run: {err}"); + Response::error(500) + } + } +} + +fn route(request: &Request, peer: IpAddr, web: &Web) -> Response { + let Some(host) = request.host.as_deref().filter(|h| host_allowed(h, &web.hosts)) else { + return Response::error(403); + }; + if request.method == "POST" && request.origin.as_deref() != Some(&format!("https://{host}")) { + return Response::error(403); + } + let browser = web.browser(request.cookie.as_deref()); + match (request.method.as_str(), request.path.as_str()) { + ("GET", "/") => Response::new(200, "text/html; charset=utf-8", INDEX_HTML), + ("GET", "/app.js") => Response::new(200, "text/javascript; charset=utf-8", APP_JS), + ("GET", "/app.css") => Response::new(200, "text/css; charset=utf-8", APP_CSS), + ("GET", "/api/me") => Response::json(200, me_json(browser.as_ref().map(|(_, b)| b))), + ("POST", "/api/sign-in") => match web.start_sign_in(peer, &clean_agent(request.user_agent.as_deref())) { + Some((token, code)) => { + let mut response = Response::json(200, serde_json::json!({ "state": "pending", "code": code, "expiresInMs": PENDING_TTL_MS }).to_string()); + response.set_cookie = Some(session_cookie(&token, SESSION_TTL_MS / 1000)); + response + } + None => Response::error(429), + }, + ("GET", "/api/sign-in/qr.svg") => match browser { + Some((_, Browser { access: Access::Pending { code, .. }, .. })) => { + let url = format!("codedeck://web-sign-in?machine={}&code={code}", web.machine_npub); + match crate::qr::svg(&url) { + Some(svg) => Response::new(200, "image/svg+xml", svg), + None => Response::error(404), + } + } + _ => Response::error(404), + }, + ("POST", "/api/sign-out") => { + if let Some((key, _)) = browser { + web.sign_out(&key); + } + let mut response = Response::json(200, me_json(None)); + response.set_cookie = Some(session_cookie("", 0)); + response + } + ("GET", "/api/status") => match &browser { + Some((_, Browser { access: Access::SignedIn { by, since_ms, .. }, .. })) => Response::json(200, web.status_json(by, *since_ms)), + _ => Response::error(401), + }, + ("GET", "/api/browsers") => match signed_in(&browser) { + Some((key, _)) => Response::json(200, web.browsers_json(Some(key))), + None => Response::error(401), + }, + ("POST", "/api/browsers/sign-out") => match signed_in(&browser) { + Some(_) => match sign_out_id(&request.body) { + Some(id) if web.revoke(&id) => Response::json(200, serde_json::json!({ "ok": true }).to_string()), + Some(_) => Response::error(404), + None => Response::error(400), + }, + None => Response::error(401), + }, + ("GET", "/api/devices") => match signed_in(&browser) { + Some(_) => Response::json(200, web.devices_json()), + None => Response::error(401), + }, + ("GET", "/api/config") => match signed_in(&browser) { + Some(_) => Response::json(200, web.config_json()), + None => Response::error(401), + }, + ("POST", "/api/config") => match signed_in(&browser) { + Some((_, by)) => web.apply_config(&request.body, by), + None => Response::error(401), + }, + ( + _, + "/" | "/app.js" | "/app.css" | "/api/me" | "/api/sign-in" | "/api/sign-in/qr.svg" | "/api/sign-out" | "/api/status" + | "/api/browsers" | "/api/browsers/sign-out" | "/api/devices" | "/api/config", + ) => Response::error(405), + _ => Response::error(404), + } +} + +/// The signed-in browser's key and approver, if it is signed in. +fn signed_in(browser: &Option<(String, Browser)>) -> Option<(&str, &str)> { + match browser { + Some((key, Browser { access: Access::SignedIn { by, .. }, .. })) => Some((key.as_str(), by.as_str())), + _ => None, + } +} + +/// The browser id a sign-out POST names, or `None` when the body is not a +/// JSON object with a string `id`. +fn sign_out_id(body: &[u8]) -> Option { + let doc: Value = serde_json::from_slice(body).ok()?; + doc.get("id")?.as_str().map(str::to_string) +} + +/// A 400 naming the setting at fault, so the page can point at it. +fn bad_request(key: &str, message: &str) -> Response { + Response::json(400, serde_json::json!({ "error": message, "key": key }).to_string()) +} + +fn me_json(browser: Option<&Browser>) -> String { + let now = now_ms(); + match browser.map(|b| &b.access) { + Some(Access::Pending { code, expires_ms }) => { + serde_json::json!({ "state": "pending", "code": code, "expiresInMs": expires_ms.saturating_sub(now) }) + } + Some(Access::SignedIn { by, since_ms, .. }) => { + serde_json::json!({ "state": "signed-in", "by": by, "since": since_ms }) + } + None => serde_json::json!({ "state": "signed-out" }), + } + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A page over `home`, with no config view. + fn web_in(home: &Path) -> (Rc, mpsc::UnboundedReceiver) { + web_with_config(home, Vec::new()) + } + + /// A page over `home`, its config API showing `fields`. + fn web_with_config(home: &Path, fields: Vec) -> (Rc, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + let config = ConfigView { path: home.join("config.json"), fields }; + (Web::new("npub1machine".into(), &["wss://box.tail1234.ts.net:7447".into()], tx, home, config), rx) + } + + /// A page over a fresh temp dir, which the caller must keep alive. + fn web() -> (Rc, mpsc::UnboundedReceiver, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let (web, rx) = web_in(dir.path()); + (web, rx, dir) + } + + /// Start a sign-in at [`PEER`] and have the owner approve it, returning + /// the browser's cookie token. + fn sign_in(web: &Web, inputs: &mut mpsc::UnboundedReceiver) -> String { + let (token, _) = web.start_sign_in(PEER, "Mozilla/5.0").expect("a start"); + let Some(Input::WebSignInPending { code, .. }) = inputs.try_recv().ok() else { panic!("the engine is told the code") }; + web.signed_in(&code, "npub1owner"); + token + } + + fn request(method: &str, path: &str, host: &str) -> Request { + Request { + method: method.into(), + path: path.into(), + host: Some(host.into()), + origin: (method == "POST").then(|| format!("https://{host}")), + cookie: None, + user_agent: None, + body: Vec::new(), + } + } + + const PEER: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(192, 168, 1, 20)); + const HOST: &str = "192.168.1.5:7447"; + + #[test] + fn only_addresses_localhost_and_the_endpoints_hosts_are_served() { + let hosts = vec!["box.tail1234.ts.net".to_string()]; + for ok in ["192.168.1.5:7447", "localhost:7447", "[::1]:7447", "BOX.tail1234.ts.net:7447", "10.0.0.2"] { + assert!(host_allowed(ok, &hosts), "{ok}"); + } + for refused in ["evil.example:7447", "", "box.tail1234.ts.net.evil.example"] { + assert!(!host_allowed(refused, &hosts), "{refused}"); + } + } + + #[test] + fn a_post_from_another_origin_is_refused() { + let (web, _, _dir) = web(); + let mut post = request("POST", "/api/sign-in", "192.168.1.5:7447"); + post.origin = Some("https://evil.example".into()); + assert_eq!(route(&post, PEER, &web).status, 403); + post.origin = None; + assert_eq!(route(&post, PEER, &web).status, 403); + } + + #[test] + fn the_status_needs_a_signed_in_browser() { + let (web, mut inputs, _dir) = web(); + let host = "192.168.1.5:7447"; + assert_eq!(route(&request("GET", "/api/status", host), PEER, &web).status, 401); + + let started = route(&request("POST", "/api/sign-in", host), PEER, &web); + let cookie = started.set_cookie.clone().unwrap(); + assert!(cookie.contains("Secure") && cookie.contains("HttpOnly") && cookie.contains("SameSite=Strict")); + let token = cookie.split(';').next().unwrap().strip_prefix(&format!("{COOKIE}=")).unwrap().to_string(); + let Some(Input::WebSignInPending { code, .. }) = inputs.try_recv().ok() else { panic!("the engine is told the code") }; + + let mut status = request("GET", "/api/status", host); + status.cookie = Some(token.clone()); + assert_eq!(route(&status, PEER, &web).status, 401, "a pending browser sees no status"); + + web.signed_in(&code, "npub1owner"); + assert_eq!(route(&status, PEER, &web).status, 200); + + let mut out = request("POST", "/api/sign-out", host); + out.cookie = Some(token); + route(&out, PEER, &web); + assert_eq!(route(&status, PEER, &web).status, 401, "signed out"); + } + + #[test] + fn an_ipv6_host_is_rate_limited_by_its_slash_64() { + let (web, _, _dir) = web(); + for i in 0..STARTS_PER_MINUTE { + let peer = IpAddr::V6(std::net::Ipv6Addr::new(0x2001, 0xdb8, 1, 2, 0, 0, 0, i as u16 + 1)); + assert!(web.start_sign_in(peer, "").is_some()); + } + let same_64 = IpAddr::V6(std::net::Ipv6Addr::new(0x2001, 0xdb8, 1, 2, 9, 9, 9, 9)); + assert!(web.start_sign_in(same_64, "").is_none(), "another address in the same /64 counts as the same host"); + let other_64 = IpAddr::V6(std::net::Ipv6Addr::new(0x2001, 0xdb8, 1, 3, 0, 0, 0, 1)); + assert!(web.start_sign_in(other_64, "").is_some()); + } + + #[test] + fn a_pending_browser_dropped_or_signed_out_withdraws_its_code() { + let (web, mut inputs, _dir) = web(); + let mut first = None; + for i in 0..=MAX_PENDING { + // A peer of its own each, so the rate limit does not interfere. + let peer = IpAddr::V4(std::net::Ipv4Addr::new(10, 0, (i / 250) as u8, (i % 250) as u8 + 1)); + let (token, code) = web.start_sign_in(peer, "").expect("a start"); + first.get_or_insert((token, code)); + } + let (first_token, first_code) = first.unwrap(); + let withdrawn: Vec = std::iter::from_fn(|| inputs.try_recv().ok()) + .filter_map(|input| match input { + Input::WebSignInWithdrawn { code } => Some(code), + _ => None, + }) + .collect(); + assert_eq!(withdrawn, vec![first_code], "the oldest pending browser made room, and its code went with it"); + assert!(web.browser(Some(&first_token)).is_none()); + + let (token, code) = web.start_sign_in(IpAddr::V4(std::net::Ipv4Addr::new(10, 9, 9, 9)), "").unwrap(); + while inputs.try_recv().is_ok() {} + web.sign_out(&token_key(&token)); + assert!(matches!(inputs.try_recv(), Ok(Input::WebSignInWithdrawn { code: c }) if c == code)); + } + + #[test] + fn signed_in_browsers_are_capped_by_the_longest_signed_in() { + let (web, _, _dir) = web(); + let mut tokens = Vec::new(); + for i in 0..=MAX_SESSIONS { + let peer = IpAddr::V4(std::net::Ipv4Addr::new(10, 1, (i / 250) as u8, (i % 250) as u8 + 1)); + let (token, code) = web.start_sign_in(peer, "").unwrap(); + web.signed_in(&code, "npub1owner"); + tokens.push(token); + } + let signed_in = web.browsers.borrow().values().filter(|b| matches!(b.access, Access::SignedIn { .. })).count(); + assert_eq!(signed_in, MAX_SESSIONS); + assert!(web.browser(Some(&tokens[0])).is_none(), "the first one signed in went first"); + assert!(web.browser(tokens.last().map(String::as_str)).is_some(), "never the one just let in"); + } + + #[test] + fn sign_in_starts_are_rate_limited_per_address() { + let (web, _, _dir) = web(); + for _ in 0..STARTS_PER_MINUTE { + assert!(web.start_sign_in(PEER, "").is_some()); + } + assert!(web.start_sign_in(PEER, "").is_none()); + assert!(web.start_sign_in(IpAddr::V4(std::net::Ipv4Addr::new(192, 168, 1, 21)), "").is_some()); + } + + #[test] + fn pending_sign_ins_are_capped() { + let (web, _, _dir) = web(); + for i in 0..(MAX_PENDING as u8 + 10) { + web.start_sign_in(IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, i)), "").unwrap(); + } + assert!(web.browsers.borrow().len() <= MAX_PENDING); + } + + #[test] + fn a_code_is_two_unambiguous_groups() { + let code = new_code(); + assert_eq!(code.len(), 9); + assert!(code.chars().enumerate().all(|(i, c)| if i == 4 { c == '-' } else { CODE_ALPHABET.contains(&(c as u8)) })); + } + + #[test] + fn a_websocket_upgrade_is_told_apart_from_a_page_request() { + assert!(is_websocket(b"GET / HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n")); + assert!(!is_websocket(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n")); + assert_eq!(head_len(b"GET / HTTP/1.1\r\n\r\nbody"), Some(18)); + } + + #[test] + fn signed_in_browsers_survive_a_restart() { + let dir = tempfile::tempdir().unwrap(); + let (web, mut inputs) = web_in(dir.path()); + let token = sign_in(&web, &mut inputs); + web.writer.flush(); + + // A new page over the same home, as after a restart. + let (again, _rx) = web_in(dir.path()); + let mut status = request("GET", "/api/status", HOST); + status.cookie = Some(token.clone()); + assert_eq!(route(&status, PEER, &again).status, 200, "the cookie still works"); + + // The file holds the key's SHA-256, never the token itself. + let raw = std::fs::read_to_string(dir.path().join("web-sessions.json")).unwrap(); + assert!(!raw.contains(&token), "no token is written"); + assert!(raw.contains(&token_key(&token)), "the key's hash is"); + assert!(raw.contains("Mozilla/5.0"), "and the User-Agent came along"); + } + + #[test] + fn an_expired_or_corrupt_sessions_file_starts_empty() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("web-sessions.json"); + let expired = serde_json::json!([{ + "key": "a".repeat(64), "by": "npub1x", "sinceMs": 1, "expiresMs": 2, "lastSeenMs": 1, + "peer": "192.168.1.5", "agent": "x", + }]); + std::fs::write(&path, expired.to_string()).unwrap(); + assert!(web_in(dir.path()).0.browsers.borrow().is_empty(), "an expired one is dropped"); + + std::fs::write(&path, "{not json").unwrap(); + assert!(web_in(dir.path()).0.browsers.borrow().is_empty(), "a corrupt file is empty, not fatal"); + } + + #[test] + fn the_browsers_api_lists_browsers_and_marks_the_current_one() { + let (web, mut inputs, _dir) = web(); + let first = sign_in(&web, &mut inputs); + let (second, _) = web.start_sign_in(PEER, "curl/8").unwrap(); + let Some(Input::WebSignInPending { code, .. }) = inputs.try_recv().ok() else { panic!("the engine is told the code") }; + web.signed_in(&code, "npub1owner"); + + let mut get = request("GET", "/api/browsers", HOST); + get.cookie = Some(first.clone()); + let response = route(&get, PEER, &web); + assert_eq!(response.status, 200); + let body: Value = serde_json::from_slice(&response.body).unwrap(); + let rows = body["browsers"].as_array().unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0]["id"], &token_key(&first)[..ID_LEN]); + assert_eq!(rows[0]["current"], true); + assert_eq!(rows[1]["current"], false); + assert_eq!(rows[1]["agent"], "curl/8"); + assert_eq!(rows[0]["peer"], "192.168.1.20"); + for field in ["sinceMs", "lastSeenMs", "expiresMs"] { + assert!(rows[0][field].is_number(), "{field}"); + } + assert_eq!(rows[0]["by"], "npub1owner"); + assert!(serde_json::from_slice::(&response.body).unwrap().get("token").is_none()); + assert!(second.len() > ID_LEN, "the full token is not what the page sees"); + } + + #[test] + fn the_phone_list_is_the_page_list_without_the_current_mark() { + let (web, mut inputs, _dir) = web(); + let first = sign_in(&web, &mut inputs); + let (_second, _) = web.start_sign_in(PEER, "curl/8").unwrap(); + let Some(Input::WebSignInPending { code, .. }) = inputs.try_recv().ok() else { panic!("the engine is told the code") }; + web.signed_in(&code, "npub1owner"); + + let mut get = request("GET", "/api/browsers", HOST); + get.cookie = Some(first.clone()); + let body: Value = serde_json::from_slice(&route(&get, PEER, &web).body).unwrap(); + let json_ids: Vec = body["browsers"] + .as_array() + .unwrap() + .iter() + .map(|row| row["id"].as_str().unwrap().to_string()) + .collect(); + + // The phone's list and the page's are one list: same rows, same order. + let phone = web.browsers(); + assert_eq!(phone.iter().map(|b| b.id.clone()).collect::>(), json_ids); + assert_eq!(phone[0].id, &token_key(&first)[..ID_LEN], "the id is the key's short public form, never the token"); + assert_eq!(phone[0].by, "npub1owner"); + assert_eq!(phone[0].peer, "192.168.1.20"); + assert_eq!(phone[1].agent, "curl/8"); + for browser in &phone { + assert!(browser.since_ms > 0 && browser.expires_ms > browser.since_ms); + } + } + + #[test] + fn revoking_another_browser_ends_its_cookie() { + let (web, mut inputs, _dir) = web(); + let victim = sign_in(&web, &mut inputs); + let owner = sign_in(&web, &mut inputs); + + let mut status = request("GET", "/api/status", HOST); + status.cookie = Some(victim.clone()); + assert_eq!(route(&status, PEER, &web).status, 200); + + let mut post = request("POST", "/api/browsers/sign-out", HOST); + post.cookie = Some(owner); + post.body = serde_json::json!({ "id": &token_key(&victim)[..ID_LEN] }).to_string().into_bytes(); + assert_eq!(route(&post, PEER, &web).status, 200); + assert_eq!(route(&status, PEER, &web).status, 401, "the revoked browser's cookie no longer works"); + + post.body = serde_json::json!({ "id": "0123456789abcdef" }).to_string().into_bytes(); + assert_eq!(route(&post, PEER, &web).status, 404, "an unknown id"); + } + + #[test] + fn the_devices_api_shows_paired_phones_without_any_secret() { + let (web, mut inputs, _dir) = web(); + let token = sign_in(&web, &mut inputs); + let phone = bridge_core::PairedPhone { + npub: "npub1phone".into(), + pubkey_hex: "ab".repeat(32), + label: "Pixel".into(), + paired_at: "2026-01-02T03:04:05Z".into(), + clients: vec![bridge_core::PhoneClient { + key: protocol::commands::SessionKeyGrant { + pubkey_hex: "cd".repeat(32), + bridge_pubkey_hex: "ef".repeat(32), + expires_at: 1_800_000_000, + replaces: None, + label: Some("Pixel 8a".into()), + }, + previous: None, + device: "3f9a1c0e7b2d4a58".into(), + label: Some("Pixel 8a".into()), + present_until_ms: 1_700_000_000_000, + watch: bridge_core::WatchState::default(), + watch_terminal: bridge_core::WatchState::default(), + }], + }; + web.set_devices(&[phone]); + + let mut get = request("GET", "/api/devices", HOST); + get.cookie = Some(token); + let response = route(&get, PEER, &web); + assert_eq!(response.status, 200); + let body = String::from_utf8(response.body).unwrap(); + let json: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(json["devices"][0]["npub"], "npub1phone"); + assert_eq!(json["devices"][0]["label"], "Pixel"); + assert_eq!(json["devices"][0]["clients"][0]["expiresAt"], 1_800_000_000u64); + assert_eq!(json["devices"][0]["clients"][0]["presentUntilMs"], 1_700_000_000_000u64); + assert!(!body.contains(&"ab".repeat(32)), "no identity key"); + assert!(!body.contains(&"cd".repeat(32)), "no session key"); + assert!(!body.contains("pubkey")); + } + + #[test] + fn the_config_api_shows_secrets_only_as_set_or_not_set() { + let dir = tempfile::tempdir().unwrap(); + let fields = vec![ + serde_json::json!({ "key": "machineName", "value": "desktop", "editable": true, "secret": false, "overridden": false }), + serde_json::json!({ "key": "relayRegisterToken", "set": true, "editable": true, "secret": true, "overridden": false }), + ]; + let (web, mut inputs) = web_with_config(dir.path(), fields); + let token = sign_in(&web, &mut inputs); + + let mut get = request("GET", "/api/config", HOST); + get.cookie = Some(token); + let response = route(&get, PEER, &web); + assert_eq!(response.status, 200); + let json: Value = serde_json::from_slice(&response.body).unwrap(); + let fields = json["fields"].as_array().unwrap(); + let secret = fields.iter().find(|f| f["key"] == "relayRegisterToken").unwrap(); + assert_eq!(secret["set"], true); + assert!(secret.get("value").is_none(), "a secret's value is never sent"); + assert!(fields.iter().any(|f| f["key"] == "machineName" && f["value"] == "desktop")); + } + + #[test] + fn a_config_post_outside_the_safe_set_is_refused_and_leaves_the_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + let before = r#"{"machineName":"desktop","workspaceRoots":["/srv/w"],"keepMe":42}"#; + std::fs::write(&path, before).unwrap(); + let (web, mut inputs) = web_with_config(dir.path(), Vec::new()); + let token = sign_in(&web, &mut inputs); + + let mut post = request("POST", "/api/config", HOST); + post.cookie = Some(token); + post.body = serde_json::json!({ "set": { "nodePath": "/evil" } }).to_string().into_bytes(); + let response = route(&post, PEER, &web); + assert_eq!(response.status, 400); + assert_eq!(serde_json::from_slice::(&response.body).unwrap()["key"], "nodePath"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), before, "the file is untouched"); + } + + #[test] + fn an_endpoint_the_bridge_has_no_listener_for_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + std::fs::write(&path, "{}").unwrap(); + let (web, mut inputs) = web_with_config(dir.path(), Vec::new()); + let mut post = request("POST", "/api/config", HOST); + post.cookie = Some(sign_in(&web, &mut inputs)); + post.body = serde_json::json!({ "set": { "direct.endpoints": ["ws://abc.onion:7448"] } }).to_string().into_bytes(); + let response = route(&post, PEER, &web); + assert_eq!(response.status, 400, "the bridge would refuse to start from it"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "{}"); + } + + #[test] + fn a_config_post_writes_the_file_keeping_every_other_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + std::fs::write(&path, r#"{"machineName":"desktop","direct":{"listen":"0.0.0.0:7447"},"keepMe":{"x":1}}"#).unwrap(); + // The bridge runs the direct listener, so a wss:// endpoint is one it can serve. + let listening = serde_json::json!({ "key": "direct.listen", "value": "0.0.0.0:7447", "editable": false, "secret": false, "overridden": false }); + let (web, mut inputs) = web_with_config(dir.path(), vec![listening]); + let token = sign_in(&web, &mut inputs); + + let mut post = request("POST", "/api/config", HOST); + post.cookie = Some(token); + post.body = serde_json::json!({ + "set": { "machineName": "laptop", "relays": ["wss://relay.example"], "direct.endpoints": ["wss://192.168.1.9:7447"] }, + "secrets": { "relayRegisterToken": "tok-123" }, + }) + .to_string() + .into_bytes(); + let mut response = route(&post, PEER, &web); + assert_eq!(response.status, 200); + // The write runs on the blocking pool in `serve`; here, in place. + let write = response.config_write.take().expect("a write to do"); + crate::config::write_web_settings(&write.path, &write.changes).unwrap(); + let json: Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!(json["ok"], true); + assert_eq!(json["restartNeeded"], true); + assert!(!String::from_utf8_lossy(&response.body).contains("tok-123"), "the token is never in a response"); + + let written: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(written["machineName"], "laptop"); + assert_eq!(written["relays"][0], "wss://relay.example"); + assert_eq!(written["direct"]["endpoints"][0], "wss://192.168.1.9:7447"); + assert_eq!(written["direct"]["listen"], "0.0.0.0:7447", "another key in the same object is kept"); + assert_eq!(written["relayRegisterToken"], "tok-123"); + assert_eq!(written["keepMe"]["x"], 1, "an unknown key is kept"); + + // A null secret clears it: the key comes out of the file. + let mut clear = request("POST", "/api/config", HOST); + clear.cookie = Some(sign_in(&web, &mut inputs)); + clear.body = serde_json::json!({ "secrets": { "relayRegisterToken": null } }).to_string().into_bytes(); + let mut cleared = route(&clear, PEER, &web); + assert_eq!(cleared.status, 200); + let write = cleared.config_write.take().expect("a write to do"); + crate::config::write_web_settings(&write.path, &write.changes).unwrap(); + let written: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert!(written.get("relayRegisterToken").is_none(), "cleared"); + } + + #[test] + fn a_config_post_with_a_bad_value_is_refused() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("config.json"), "{}").unwrap(); + let (web, mut inputs) = web_with_config(dir.path(), Vec::new()); + let token = sign_in(&web, &mut inputs); + + for (key, value) in [ + ("machineName", serde_json::json!("")), + ("relays", serde_json::json!(["ws://192.168.1.9:7447"])), + ("direct.endpoints", serde_json::json!(["https://x"])), + ("transcriptKeepLast", serde_json::json!(-1)), + ] { + let mut post = request("POST", "/api/config", HOST); + post.cookie = Some(token.clone()); + post.body = serde_json::json!({ "set": { key: value } }).to_string().into_bytes(); + assert_eq!(route(&post, PEER, &web).status, 400, "{key}"); + } + } + + #[test] + fn every_new_api_needs_a_signed_in_browser() { + let (web, _rx, _dir) = web(); + for (method, path) in [ + ("GET", "/api/browsers"), + ("POST", "/api/browsers/sign-out"), + ("GET", "/api/devices"), + ("GET", "/api/config"), + ("POST", "/api/config"), + ] { + assert_eq!(route(&request(method, path, HOST), PEER, &web).status, 401, "{method} {path}"); + } + } +} diff --git a/crates/bridge-runtime/tests/direct.rs b/crates/bridge-runtime/tests/direct.rs index a722413c..9a21a1d3 100644 --- a/crates/bridge-runtime/tests/direct.rs +++ b/crates/bridge-runtime/tests/direct.rs @@ -87,7 +87,7 @@ async fn a_paired_phone_gets_its_events_and_sends_commands_over_the_pinned_link( let (bridge, phone, stranger) = (generate_keypair(), generate_keypair(), generate_keypair()); let (inputs, mut engine) = mpsc::unbounded_channel(); let config = DirectConfig { listen: Some("127.0.0.1:0".parse().unwrap()), ..Default::default() }; - let mut link = direct::start(&config, home.path(), bridge.pubkey_hex.clone(), inputs).await.unwrap().expect("enabled"); + let mut link = direct::start(&config, home.path(), bridge.pubkey_hex.clone(), inputs, Default::default()).await.unwrap().expect("enabled"); let endpoint = link.info.endpoints[0].clone(); let pin = link.info.cert_sha256.clone().expect("a wss listener advertises its pin"); assert!(endpoint.starts_with("wss://127.0.0.1:"), "{endpoint}"); @@ -156,7 +156,7 @@ async fn a_paired_phone_sends_live_input_and_terminal_output_never_jumps_a_norma let (bridge, phone) = (generate_keypair(), generate_keypair()); let (inputs, mut engine) = mpsc::unbounded_channel(); let config = DirectConfig { listen: Some("127.0.0.1:0".parse().unwrap()), ..Default::default() }; - let mut link = direct::start(&config, home.path(), bridge.pubkey_hex.clone(), inputs).await.unwrap().unwrap(); + let mut link = direct::start(&config, home.path(), bridge.pubkey_hex.clone(), inputs, Default::default()).await.unwrap().unwrap(); let endpoint = link.info.endpoints[0].clone(); let pin = link.info.cert_sha256.clone().unwrap(); link.hub.set_paired(std::slice::from_ref(&phone.pubkey_hex)); @@ -201,7 +201,7 @@ async fn the_phone_link_talks_to_the_bridge() { let (bridge, phone) = (generate_keypair(), generate_keypair()); let (inputs, mut engine) = mpsc::unbounded_channel(); let config = DirectConfig { listen: Some("127.0.0.1:0".parse().unwrap()), ..Default::default() }; - let mut server = direct::start(&config, home.path(), bridge.pubkey_hex.clone(), inputs).await.unwrap().unwrap(); + let mut server = direct::start(&config, home.path(), bridge.pubkey_hex.clone(), inputs, Default::default()).await.unwrap().unwrap(); server.hub.set_paired(std::slice::from_ref(&phone.pubkey_hex)); let start = |pin: String| { diff --git a/crates/bridge-runtime/web/app.css b/crates/bridge-runtime/web/app.css new file mode 100644 index 00000000..2ff4ed5d --- /dev/null +++ b/crates/bridge-runtime/web/app.css @@ -0,0 +1,129 @@ +/* The bridge's web page, in the app's own look: black, monochrome, white the + only accent; colour only for state (running, waiting, failed). */ +:root { + --bg: #000; + --surface: #0f0f10; + --raised: #161618; + --line: #242427; + --text: #ededee; + --dim: #8b8b90; + --muted: #5d5d62; + --ok: #4cc38a; + --busy: #e5b454; + --bad: #f2555a; + --radius: 14px; + font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-size: 15px; + color: var(--text); + background: var(--bg); +} + +* { box-sizing: border-box; } +body { margin: 0; min-height: 100vh; } +[hidden] { display: none !important; } + +.bar { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 24px; border-bottom: 1px solid var(--line); + position: sticky; top: 0; background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(8px); +} +.brand { display: flex; align-items: baseline; gap: 12px; min-width: 0; } +.mark { font-weight: 700; letter-spacing: -0.01em; } +.machine { color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +main { max-width: 1080px; margin: 0 auto; padding: 24px; } +main.center { min-height: calc(100vh - 60px); display: flex; align-items: center; justify-content: center; } + +.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px 22px; margin-bottom: 16px; } +.card.narrow { max-width: 420px; width: 100%; text-align: center; } +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; } +.grid .card { margin-bottom: 0; } +.grid { margin-bottom: 16px; } + +h1 { font-size: 22px; margin: 0 0 10px; letter-spacing: -0.01em; } +h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--dim); margin: 0 0 14px; } +.count { color: var(--muted); font-weight: 500; } +p { line-height: 1.5; } +.dim { color: var(--dim); } +.small { font-size: 13px; } +.error { color: var(--bad); } +.foot { text-align: center; margin-top: 24px; } + +button { font: inherit; border-radius: 999px; padding: 10px 18px; cursor: pointer; border: 1px solid var(--line); } +button.primary { background: var(--text); color: var(--bg); border-color: var(--text); font-weight: 600; margin-top: 8px; } +button.primary:hover { background: #fff; } +button.quiet { background: transparent; color: var(--dim); } +button.quiet:hover { color: var(--text); border-color: var(--muted); } + +.qr { width: 220px; height: 220px; border-radius: 12px; margin: 8px auto 14px; display: block; background: #fff; } +.code { font-family: ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace; font-size: 30px; letter-spacing: 0.12em; margin: 4px 0 12px; } +.steps { text-align: left; padding-left: 20px; line-height: 1.7; margin: 0 0 10px; } + +.facts { display: grid; grid-template-columns: max-content 1fr; gap: 8px 18px; margin: 0; } +.facts dt { color: var(--dim); } +.facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } +.mono { font-family: ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace; font-size: 13px; } +.chips { display: flex; flex-wrap: wrap; gap: 6px; } +.chip { background: var(--raised); border: 1px solid var(--line); border-radius: 999px; padding: 2px 10px; font-size: 13px; color: var(--dim); } + +.agents { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; } +.agent { background: var(--raised); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; } +.agent-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 8px; } +.agent-name { font-weight: 600; } +.agent .creds { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--dim); } + +.state { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--dim); white-space: nowrap; } +.state::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--muted); } +.state.ok::before { background: var(--ok); } +.state.busy::before { background: var(--busy); } +.state.bad::before { background: var(--bad); } + +.table-wrap { overflow-x: auto; } +table.sessions { width: 100%; border-collapse: collapse; } +.sessions th { text-align: left; font-weight: 500; color: var(--muted); font-size: 13px; padding: 0 12px 10px 0; border-bottom: 1px solid var(--line); } +.sessions td { padding: 11px 12px 11px 0; border-bottom: 1px solid var(--line); vertical-align: top; } +.sessions tr:last-child td { border-bottom: none; } +.sessions .title { max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sessions .folder { color: var(--dim); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sessions .when { color: var(--dim); white-space: nowrap; } + +.tabs { display: flex; gap: 4px; margin: -4px 0 20px; border-bottom: 1px solid var(--line); overflow-x: auto; } +.tabs a { color: var(--dim); text-decoration: none; padding: 10px 14px; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; } +.tabs a:hover { color: var(--text); } +.tabs a.on { color: var(--text); border-bottom-color: var(--text); } + +.meters { display: flex; flex-direction: column; gap: 14px; } +.meter-head { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 6px; } +.meter-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } +.meter-head .small { white-space: nowrap; } +.meter-track { height: 6px; background: var(--raised); border-radius: 999px; overflow: hidden; } +.meter-fill { height: 100%; background: var(--text); border-radius: 999px; } +.meter-fill.busy { background: var(--busy); } +.meter-fill.bad { background: var(--bad); } + +.rows { display: flex; flex-direction: column; } +.row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--line); } +.row:last-child { border-bottom: none; } +.row-main { min-width: 0; display: flex; flex-direction: column; gap: 4px; } +.row-main > div { overflow-wrap: anywhere; } +.row-title { display: flex; align-items: center; gap: 8px; font-weight: 500; } +button.danger:hover { color: var(--bad); border-color: var(--bad); } + +.form { display: flex; flex-direction: column; gap: 18px; } +.field { display: flex; flex-direction: column; gap: 6px; } +.field-label { font-weight: 500; } +.field input, .field textarea { font: inherit; color: var(--text); background: var(--raised); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; width: 100%; } +.field textarea { resize: vertical; } +.field input:focus, .field textarea:focus { outline: none; border-color: var(--dim); } +.field input:disabled, .field textarea:disabled { color: var(--muted); } +.check { display: flex; align-items: center; gap: 6px; } +.actions { display: flex; align-items: center; gap: 14px; margin-top: 18px; } +.actions .primary { margin-top: 0; } +.ok { color: var(--ok); } + +@media (max-width: 600px) { + main { padding: 16px; } + .bar { padding: 12px 16px; } + .sessions .folder, .sessions th:nth-child(4) { display: none; } +} + diff --git a/crates/bridge-runtime/web/app.js b/crates/bridge-runtime/web/app.js new file mode 100644 index 00000000..30f150b3 --- /dev/null +++ b/crates/bridge-runtime/web/app.js @@ -0,0 +1,410 @@ +// The bridge's web page. Plain DOM, no dependencies: everything it loads is +// served by the bridge itself (its CSP allows nothing else). Text from the +// bridge is always set as text, never as HTML. +"use strict"; + +const views = ["loading", "signed-out", "pending", "dashboard"]; +const tabs = ["overview", "usage", "browsers", "devices", "settings"]; +let pollTimer = null; +let expiresAt = 0; +// The latest status, so switching tabs renders at once instead of waiting +// for the next poll. +let lastStatus = null; + +const $ = (id) => document.getElementById(id); + +function show(view) { + for (const v of views) $(v).hidden = v !== view; + $("sign-out").hidden = view !== "dashboard"; +} + +async function api(path, method = "GET", payload = {}) { + const response = await fetch(path, { + method, + credentials: "same-origin", + headers: method === "POST" ? { "Content-Type": "application/json" } : {}, + body: method === "POST" ? JSON.stringify(payload) : undefined, + }); + const body = await response.json().catch(() => ({})); + return { status: response.status, body }; +} + +function el(tag, attrs = {}, ...children) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === "class") node.className = v; + else node.setAttribute(k, v); + } + for (const child of children) { + if (child == null) continue; + node.append(child instanceof Node ? child : document.createTextNode(String(child))); + } + return node; +} + +function schedule(fn, ms) { + clearTimeout(pollTimer); + pollTimer = setTimeout(fn, ms); +} + +async function refresh() { + let me; + try { + me = (await api("/api/me")).body; + } catch { + schedule(refresh, 3000); + return; + } + if (me.state === "signed-in") return dashboard(); + if (me.state === "pending") return pending(me); + show("signed-out"); +} + +async function start() { + $("start-error").hidden = true; + const { status, body } = await api("/api/sign-in", "POST"); + if (status === 429) { + $("start-error").textContent = "Too many sign-in attempts from here. Wait a minute and try again."; + $("start-error").hidden = false; + return; + } + if (status !== 200) { + $("start-error").textContent = "The bridge did not start a sign-in."; + $("start-error").hidden = false; + return; + } + pending(body); +} + +function pending(me) { + show("pending"); + if ($("code").textContent !== me.code) { + $("code").textContent = me.code; + $("qr").src = "/api/sign-in/qr.svg?code=" + encodeURIComponent(me.code); + } + expiresAt = Date.now() + me.expiresInMs; + tickExpiry(); + // Waiting for the phone: ask again until the browser is in or the code lapses. + schedule(refresh, 2000); +} + +function tickExpiry() { + const left = Math.max(0, Math.round((expiresAt - Date.now()) / 1000)); + $("expires").textContent = left > 0 ? `The code works for ${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")} more.` : "The code lapsed."; + if (left === 0) show("signed-out"); +} +setInterval(() => { if (!$("pending").hidden) tickExpiry(); }, 1000); + +function relative(iso) { + const at = typeof iso === "number" ? iso : Date.parse(iso); + if (!Number.isFinite(at)) return ""; + const s = Math.round((Date.now() - at) / 1000); + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)} min ago`; + if (s < 86400) return `${Math.floor(s / 3600)} h ago`; + return new Date(at).toLocaleDateString(); +} + +// A session's state as the heartbeat names it, as a word and a colour. +const STATES = { + idle: ["idle", "ok"], + running: ["running", "busy"], + waiting_permission: ["waiting for a permission", "busy"], + waiting_question: ["waiting for an answer", "busy"], + offline: ["offline", ""], +}; + +function stateOf(session) { + const [label, tone] = STATES[session.state || "idle"] ?? [session.state, ""]; + return el("span", { class: `state ${tone}` }, label); +} + +function installOf(agent) { + const install = agent.install || {}; + switch (install.state) { + case "ready": return el("span", { class: "state ok" }, "ready"); + case "installing": return el("span", { class: "state busy" }, "installing"); + case "failed": return el("span", { class: "state bad", title: install.reason || "" }, "install failed"); + case "not_installed": return el("span", { class: "state" }, "not installed"); + default: return el("span", { class: "state" }, install.state || "unknown"); + } +} + +function credentialOf(c) { + const tone = c.present ? (c.valid === false ? "bad" : "ok") : ""; + const what = c.present ? (c.valid === false ? "invalid" : c.fromEnv ? "set (environment)" : "set") : "not set"; + return el("span", { class: `state ${tone}` }, `${c.label}: ${what}`); +} + +function facts(target, rows) { + target.replaceChildren(...rows.flatMap(([k, v]) => [el("dt", {}, k), el("dd", {}, v)])); +} + +function currentTab() { + const tab = location.hash.slice(1); + return tabs.includes(tab) ? tab : "overview"; +} + +async function dashboard() { + const { status, body } = await api("/api/status"); + if (status === 401) return refresh(); + if (status !== 200) return schedule(dashboard, 5000); + lastStatus = body; + show("dashboard"); + render(); + schedule(dashboard, 5000); +} + +/** The dashboard's frame and the open tab, from the latest status. */ +function render() { + const body = lastStatus; + if (!body) return; + const hb = body.heartbeat || {}; + $("machine").textContent = hb.machine || ""; + document.title = `${hb.machine || "Bridge"} · CodeDeck+`; + const tab = currentTab(); + for (const link of document.querySelectorAll("[data-tab]")) link.classList.toggle("on", link.dataset.tab === tab); + for (const pane of document.querySelectorAll("[data-pane]")) pane.hidden = pane.dataset.pane !== tab; + $("signed-in-as").textContent = `Signed in by ${body.signedInBy.slice(0, 16)}… ${relative(body.signedInSince)}.`; + if (tab === "usage") usage(hb); + else if (tab === "browsers") browsers(); + else if (tab === "devices") devices(); + else if (tab === "settings") settings(); + else overview(body, hb); +} + +function overview(body, hb) { + + facts($("bridge-facts"), [ + ["Machine", hb.machine || "—"], + ["Runs as", hb.host || "—"], + ["Identity", el("span", { class: "mono" }, body.machine)], + ["Capabilities", el("span", { class: "chips" }, ...(hb.capabilities || []).map((c) => el("span", { class: "chip" }, c)))], + ["Workspace", (hb.roots || []).join(", ") || "—"], + ...((hb.credentials || []).length ? [["Credentials", el("span", { class: "chips" }, ...hb.credentials.map(credentialOf))]] : []), + ]); + + const direct = hb.direct; + facts($("direct-facts"), direct ? [ + ["Phones dial", el("span", { class: "mono" }, (direct.endpoints || []).join("\n") || "no address advertised")], + ["Certificate", el("span", { class: "mono" }, (direct.certSha256 || "—").replace(/(..)(?!$)/g, "$1:"))], + ] : [["Status", "off"]]); + + $("agents").replaceChildren(...(hb.agents || []).map((agent) => el("div", { class: "agent" }, + el("div", { class: "agent-head" }, el("span", { class: "agent-name" }, agent.displayName), installOf(agent)), + el("div", { class: "creds" }, ...(agent.credentials || []).map(credentialOf)), + ))); + + const sessions = [...(hb.sessions || [])].sort((a, b) => Date.parse(b.lastActivity) - Date.parse(a.lastActivity)); + const agentName = Object.fromEntries((hb.agents || []).map((a) => [a.id, a.displayName])); + $("session-count").textContent = sessions.length ? String(sessions.length) : ""; + $("no-sessions").hidden = sessions.length > 0; + $("sessions").replaceChildren(...sessions.map((s) => el("tr", {}, + el("td", { class: "title", title: s.title || s.slug }, s.title || s.slug), + el("td", {}, agentName[s.agent] || s.agent), + el("td", {}, stateOf(s)), + el("td", { class: "folder", title: s.cwd }, s.project || s.cwd), + el("td", { class: "when" }, relative(s.lastActivity)), + ))); +} + +/** How many of `values` there are of each, most first. */ +function tally(values) { + const counts = new Map(); + for (const v of values) if (v) counts.set(v, (counts.get(v) || 0) + 1); + return [...counts].sort((a, b) => b[1] - a[1]); +} + +function usage(hb) { + const sessions = hb.sessions || []; + const agentName = Object.fromEntries((hb.agents || []).map((a) => [a.id, a.displayName])); + const byAgent = tally(sessions.map((s) => s.agent)); + $("usage-empty").hidden = byAgent.length > 0; + $("usage-agents").replaceChildren(...byAgent.map(([agent, count]) => { + const own = sessions.filter((s) => s.agent === agent); + const running = own.filter((s) => s.state === "running").length; + const waiting = own.filter((s) => s.state === "waiting_permission" || s.state === "waiting_question").length; + const lines = own.reduce((sum, s) => sum + (s.lineCount || 0), 0); + const models = tally(own.map((s) => s.model)); + return el("div", { class: "agent" }, + el("div", { class: "agent-head" }, el("span", { class: "agent-name" }, agentName[agent] || agent), el("span", { class: "dim small" }, `${count} session${count === 1 ? "" : "s"}`)), + el("dl", { class: "facts small" }, + el("dt", {}, "Now"), el("dd", {}, running || waiting ? [running && `${running} running`, waiting && `${waiting} waiting`].filter(Boolean).join(" · ") : "idle"), + el("dt", {}, "Transcripts"), el("dd", {}, `${lines.toLocaleString()} lines`), + ...(models.length ? [el("dt", {}, "Models"), el("dd", {}, el("span", { class: "chips" }, ...models.map(([m, n]) => el("span", { class: "chip" }, n > 1 ? `${m} ×${n}` : m))))] : []), + ), + ); + })); + + const measured = sessions.filter((s) => typeof s.contextPercentage === "number") + .sort((a, b) => b.contextPercentage - a.contextPercentage); + $("usage-context-empty").hidden = measured.length > 0; + $("usage-context").replaceChildren(...measured.map((s) => { + const pct = Math.max(0, Math.min(100, s.contextPercentage)); + const tone = pct >= 90 ? "bad" : pct >= 70 ? "busy" : ""; + const size = s.contextWindow ? ` of ${Math.round(s.contextWindow / 1000)}k` : ""; + // Through the CSSOM: the page's CSP refuses a style attribute. + const fill = el("div", { class: `meter-fill ${tone}` }); + fill.style.width = `${pct}%`; + return el("div", { class: "meter" }, + el("div", { class: "meter-head" }, + el("span", { class: "meter-title", title: s.title || s.slug }, s.title || s.slug), + el("span", { class: "dim small" }, `${agentName[s.agent] || s.agent} · ${Math.round(pct)}%${size}`)), + el("div", { class: "meter-track" }, fill), + ); + })); +} + +/** "Firefox on Windows" from a User-Agent, or the string itself. */ +function browserName(agent) { + if (!agent) return "A browser"; + const name = /Edg\//.test(agent) ? "Edge" : /OPR\//.test(agent) ? "Opera" : /Firefox\//.test(agent) ? "Firefox" + : /Chrome\//.test(agent) ? "Chrome" : /Safari\//.test(agent) ? "Safari" : null; + const os = /Android/.test(agent) ? "Android" : /iPhone|iPad/.test(agent) ? "iOS" : /Windows/.test(agent) ? "Windows" + : /Mac OS X/.test(agent) ? "macOS" : /Linux/.test(agent) ? "Linux" : null; + return name ? (os ? `${name} on ${os}` : name) : agent; +} + +async function browsers() { + const { status, body } = await api("/api/browsers"); + if (status !== 200) return; + $("browsers").replaceChildren(...(body.browsers || []).map((b) => { + const out = el("button", { class: "quiet danger", type: "button" }, "Sign out"); + out.addEventListener("click", async () => { + if (!confirm(b.current ? "Sign this browser out?" : `Sign out ${browserName(b.agent)} at ${b.peer}?`)) return; + const answer = await api("/api/browsers/sign-out", "POST", { id: b.id }); + $("browsers-error").hidden = answer.status === 200; + $("browsers-error").textContent = "The bridge did not sign it out."; + if (b.current && answer.status === 200) return refresh(); + browsers(); + }); + return el("div", { class: "row" }, + el("div", { class: "row-main" }, + el("div", { class: "row-title" }, browserName(b.agent), b.current ? el("span", { class: "chip" }, "this browser") : null), + el("div", { class: "dim small" }, `${b.peer} · signed in ${relative(b.sinceMs)} · seen ${relative(b.lastSeenMs)} · by ${b.by.slice(0, 16)}…`)), + out); + })); +} + +async function devices() { + const { status, body } = await api("/api/devices"); + if (status !== 200) return; + const list = body.devices || []; + $("no-devices").hidden = list.length > 0; + const now = Date.now(); + $("devices").replaceChildren(...list.map((d) => { + const present = d.clients.filter((c) => c.presentUntilMs > now).length; + const clients = d.clients.length; + const lapse = Math.max(0, ...d.clients.map((c) => c.expiresAt * 1000)); + return el("div", { class: "row" }, + el("div", { class: "row-main" }, + el("div", { class: "row-title" }, d.label || "A phone", present ? el("span", { class: "state ok" }, "here now") : null), + el("div", { class: "dim small mono" }, d.npub), + el("div", { class: "dim small" }, + `Paired ${relative(d.pairedAt)} · ${clients} ${clients === 1 ? "copy of the app" : "copies of the app"}` + + (lapse ? ` · keys valid until ${new Date(lapse).toLocaleDateString()}` : "")))); + })); +} + +// What each setting is called on the page, and how it is edited. +const SETTINGS = { + machineName: { label: "Machine name", kind: "text" }, + relays: { label: "Relays", kind: "list", hint: "One per line: wss://, or ws:// only to an .onion address." }, + "direct.endpoints": { label: "Direct link addresses", kind: "list", hint: "Where phones dial the bridge, in order. One per line." }, + transcriptKeepLast: { label: "Transcripts kept", kind: "number", hint: "The newest this many; empty keeps them all." }, + relayRegisterToken: { label: "Relay admin token", kind: "secret", needs: "relayRegisterEndpoint" }, + blossomRegisterToken: { label: "Blossom admin token", kind: "secret", needs: "blossomRegisterEndpoint" }, +}; +const FIXED_LABELS = { + workspaceRoots: "Workspace", torProxyUrl: "Tor proxy", nodePath: "Node", agentHostPath: "Agent host", + testMode: "Test mode", "direct.listen": "Direct link listens on", "direct.onionListen": "Onion listener", + relayRegisterEndpoint: "Relay admin endpoint", blossomRegisterEndpoint: "Blossom admin endpoint", +}; +// The form is built once per visit to the tab, so a poll does not wipe what +// is being typed. +let settingsShown = false; + +function shownValue(v) { + if (v === null || v === undefined || v === "") return "—"; + if (Array.isArray(v)) return v.length ? v.join("\n") : "—"; + return String(v); +} + +async function settings() { + if (settingsShown) return; + const { status, body } = await api("/api/config"); + if (status !== 200) return; + settingsShown = true; + const fields = body.fields || []; + const byKey = Object.fromEntries(fields.map((f) => [f.key, f])); + // A token is offered only beside its endpoint: one without the other does nothing. + const editable = fields.filter((f) => f.editable && SETTINGS[f.key] + && (!SETTINGS[f.key].needs || (byKey[SETTINGS[f.key].needs] && byKey[SETTINGS[f.key].needs].value))); + $("settings-fields").replaceChildren(...editable.map((f) => { + const spec = SETTINGS[f.key]; + let input; + if (spec.kind === "list") { + input = el("textarea", { rows: "3", spellcheck: "false", class: "mono" }); + input.value = (f.value || []).join("\n"); + } else if (spec.kind === "secret") { + input = el("input", { type: "password", placeholder: f.set ? "Set — type to replace" : "Not set" }); + } else { + input = el("input", { type: spec.kind === "number" ? "number" : "text", min: "0" }); + input.value = f.value ?? ""; + } + input.dataset.key = f.key; + input.dataset.kind = spec.kind; + if (f.overridden) input.disabled = true; + const note = f.overridden ? "Set by a flag or an environment variable, which wins over the file." : spec.hint; + const clear = spec.kind === "secret" && f.set && !f.overridden + ? el("label", { class: "check small dim" }, el("input", { type: "checkbox", "data-clear": f.key }), " Clear it") + : null; + return el("label", { class: "field" }, el("span", { class: "field-label" }, spec.label), input, + note ? el("span", { class: "dim small" }, note) : null, clear); + })); + $("settings-fixed").replaceChildren(...fields.filter((f) => !f.editable).flatMap((f) => [ + el("dt", {}, FIXED_LABELS[f.key] || f.key), el("dd", { class: "mono" }, shownValue(f.value)), + ])); +} + +$("settings-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const set = {}; + const secrets = {}; + for (const input of $("settings-fields").querySelectorAll("[data-key]")) { + if (input.disabled) continue; + const { key, kind } = input.dataset; + if (kind === "secret") { + if (input.value) secrets[key] = input.value; + } else if (kind === "list") { + set[key] = input.value.split("\n").map((line) => line.trim()).filter(Boolean); + } else if (kind === "number") { + set[key] = input.value === "" ? null : Number(input.value); + } else { + set[key] = input.value.trim(); + } + } + for (const box of $("settings-fields").querySelectorAll("[data-clear]")) if (box.checked) secrets[box.dataset.clear] = null; + const result = $("settings-result"); + const answer = await api("/api/config", "POST", { set, secrets }); + if (answer.status === 200) { + result.className = "small ok"; + result.textContent = "Saved. Restart the bridge to apply it."; + for (const input of $("settings-fields").querySelectorAll("input[type=password]")) input.value = ""; + } else { + result.className = "small error"; + result.textContent = answer.body.error ? `${SETTINGS[answer.body.key]?.label || answer.body.key}: ${answer.body.error}` : "The bridge did not save it."; + } +}); + +window.addEventListener("hashchange", () => { + settingsShown = false; + render(); +}); + +$("start").addEventListener("click", start); +$("sign-out").addEventListener("click", async () => { + await api("/api/sign-out", "POST"); + show("signed-out"); +}); +refresh(); diff --git a/crates/bridge-runtime/web/index.html b/crates/bridge-runtime/web/index.html new file mode 100644 index 00000000..bd7d1341 --- /dev/null +++ b/crates/bridge-runtime/web/index.html @@ -0,0 +1,130 @@ + + + + + + +CodeDeck+ bridge + + + + +
+
CodeDeck+
+ +
+ + +

Loading…

+ +
+
+

Sign in with your phone

+

This page shows what your bridge is doing. Only a phone paired with it can let a browser in — there is no password.

+ + +
+
+ +
+
+

Sign in with your phone

+ QR code of the sign-in code +

+
    +
  1. Open CodeDeck+ on a phone paired with this bridge.
  2. +
  3. On the machine's page, under Web page, choose Browsers.
  4. +
  5. Scan this code, or type it.
  6. +
+

+
+
+ +
+ + +
+
+
+

Bridge

+
+
+
+

Direct link

+
+
+
+
+

Agents

+
+
+
+

Sessions

+
+ + + +
SessionAgentStateFolderLast activity
+
+ +
+
+ + + + + + + + + +

+
+ + diff --git a/crates/client-core/src/stores/ui.rs b/crates/client-core/src/stores/ui.rs index 2611ec1a..15882b4b 100644 --- a/crates/client-core/src/stores/ui.rs +++ b/crates/client-core/src/stores/ui.rs @@ -11,6 +11,7 @@ use std::collections::{BTreeMap, BTreeSet}; +use protocol::events::WebBrowserInfo; use serde::Serialize; pub use crate::notifications::session_key_of; @@ -56,6 +57,26 @@ pub struct ProviderProfileAck { pub error: Option, } +/// Where a `web-sign-in` round trip for one machine stands. `Sending` with no +/// answer stays `Sending` — the screen showing the code is the one that times +/// itself out, so nothing here turns silence into a verdict. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, specta::Type)] +#[serde(rename_all = "lowercase")] +pub enum WebSignInState { + Sending, + SignedIn, + NotMatched, +} + +/// The code a phone sent to sign a browser in to a machine's web page, kept +/// so the bridge's answer can be matched to the attempt it belongs to. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct WebSignIn { + pub code: String, + pub state: WebSignInState, +} + /// The bottom "Deleted X — Undo" toast for a pending session delete. The /// [`crate::delete_controller`] owns its 4 s lifecycle; this is only what the UI /// renders. @@ -116,6 +137,13 @@ pub struct UiState { pub plan_approval_choices: BTreeMap, pub credentials_status: BTreeMap, pub provider_profile_status: BTreeMap, + /// machine pubkey → the `web-sign-in` the phone last sent there. + pub web_sign_in: BTreeMap, + /// machine pubkey → the browsers signed in to its web page, as the last + /// `web-browsers` listed them. Filled only while a screen asks — the phone + /// sends `web-browsers-request` when the user opens it, never on a timer — + /// so a machine nobody has looked at is simply absent. + pub web_browsers: BTreeMap>, pub undo_toast: Option, } @@ -264,11 +292,75 @@ impl UiState { pub fn set_undo_toast(&mut self, toast: Option) { self.undo_toast = toast; } + + /// A `web-sign-in` left for this machine: the code the phone is asking the + /// bridge to honour, remembered verbatim so the answer can be matched to + /// this attempt and not to a later one. + pub fn note_web_sign_in_sent(&mut self, machine_pubkey: &str, code: &str) { + self.web_sign_in.insert( + machine_pubkey.to_string(), + WebSignIn { + code: code.to_string(), + state: WebSignInState::Sending, + }, + ); + } + + /// The bridge's answer to a `web-sign-in` on this machine, which moves the + /// entry only when it answers the code recorded now — an answer to an + /// older code says nothing about the attempt on screen. Once a code has + /// signed a browser in, a later refusal of the same code does not undo + /// it: a code works once, so the answer to a resend of it (the phone sent + /// it again while the first answer was late) is always a refusal, and + /// says nothing about the browser it already let in. Whether the entry + /// moved. + pub fn apply_web_sign_in_result(&mut self, machine_pubkey: &str, code: &str, signed_in: bool) -> bool { + let Some(entry) = self.web_sign_in.get_mut(machine_pubkey) else { + return false; + }; + if entry.code != code || (!signed_in && entry.state == WebSignInState::SignedIn) { + return false; + } + entry.state = if signed_in { + WebSignInState::SignedIn + } else { + WebSignInState::NotMatched + }; + true + } + + /// The sign-in screen closed: forget this machine's code. Whether there + /// was one. + pub fn clear_web_sign_in(&mut self, machine_pubkey: &str) -> bool { + self.web_sign_in.remove(machine_pubkey).is_some() + } + + /// The bridge's `web-browsers` for this machine: the browsers signed in + /// to its web page, as it last listed them. Replaced whole by every answer + /// — a request's list and a sign-out's fresh list are the same message — + /// so this is never merged with what was there. + pub fn set_web_browsers(&mut self, machine_pubkey: &str, browsers: Vec) { + self.web_browsers.insert(machine_pubkey.to_string(), browsers); + } + + /// Drop one browser from this machine's list the moment a sign-out is + /// sent, so the screen does not keep showing one the user ended. The + /// bridge's answer replaces the list again — with the truth, which still + /// holds the browser if the id matched nothing. Whether the id was there. + pub fn remove_web_browser(&mut self, machine_pubkey: &str, id: &str) -> bool { + let Some(browsers) = self.web_browsers.get_mut(machine_pubkey) else { + return false; + }; + let before = browsers.len(); + browsers.retain(|b| b.id != id); + browsers.len() != before + } } #[cfg(test)] mod tests { use super::*; + use protocol::events::WebBrowserInfo; #[test] fn mark_clear_is_session_unread_round_trip_keyed_per_machine_and_session() { @@ -390,6 +482,93 @@ mod tests { assert_eq!(ack.token_valid, Some(false)); } + #[test] + fn a_web_sign_in_answer_moves_only_the_code_it_answers() { + let mut ui = UiState::default(); + ui.note_web_sign_in_sent("m", "K7QM-4XPA"); + assert_eq!(ui.web_sign_in["m"].state, WebSignInState::Sending); + + assert!(ui.apply_web_sign_in_result("m", "K7QM-4XPA", true)); + assert_eq!(ui.web_sign_in["m"].state, WebSignInState::SignedIn); + assert_eq!(ui.web_sign_in["m"].code, "K7QM-4XPA"); + + // A refusal lands too, and on the same terms. + ui.note_web_sign_in_sent("m", "AAAA-BBBB"); + assert!(ui.apply_web_sign_in_result("m", "AAAA-BBBB", false)); + assert_eq!(ui.web_sign_in["m"].state, WebSignInState::NotMatched); + } + + #[test] + fn a_resent_code_refused_after_it_signed_in_stays_signed_in() { + let mut ui = UiState::default(); + ui.note_web_sign_in_sent("m", "K7QM-4XPA"); + // The first answer was late, so the code went again. + ui.note_web_sign_in_sent("m", "K7QM-4XPA"); + assert!(ui.apply_web_sign_in_result("m", "K7QM-4XPA", true)); + assert!(!ui.apply_web_sign_in_result("m", "K7QM-4XPA", false), "the resend was refused because the code was used"); + assert_eq!(ui.web_sign_in["m"].state, WebSignInState::SignedIn); + } + + #[test] + fn a_late_answer_to_an_older_code_is_ignored() { + let mut ui = UiState::default(); + ui.note_web_sign_in_sent("m", "OLD1-CODE"); + ui.note_web_sign_in_sent("m", "NEW1-CODE"); + + assert!(!ui.apply_web_sign_in_result("m", "OLD1-CODE", true), "not this attempt's answer"); + assert_eq!(ui.web_sign_in["m"].state, WebSignInState::Sending); + assert_eq!(ui.web_sign_in["m"].code, "NEW1-CODE"); + + // An answer with no entry at all (the screen closed) is ignored too. + assert!(!ui.apply_web_sign_in_result("other", "NEW1-CODE", true)); + } + + #[test] + fn clearing_a_web_sign_in_reports_whether_there_was_one() { + let mut ui = UiState::default(); + assert!(!ui.clear_web_sign_in("m")); + ui.note_web_sign_in_sent("m", "K7QM-4XPA"); + assert!(ui.clear_web_sign_in("m")); + assert!(!ui.web_sign_in.contains_key("m")); + assert!(!ui.clear_web_sign_in("m")); + } + + fn a_browser() -> WebBrowserInfo { + WebBrowserInfo { + id: "0123456789abcdef".into(), + by: "npub1owner".into(), + since_ms: 1, + last_seen_ms: 2, + expires_ms: 3, + peer: "192.168.1.20".into(), + agent: "Mozilla/5.0".into(), + } + } + + #[test] + fn a_web_browsers_answer_replaces_the_whole_list_for_its_machine() { + let mut ui = UiState::default(); + let mut second = a_browser(); + second.id = "fedcba9876543210".into(); + ui.set_web_browsers("m", vec![a_browser(), second.clone()]); + assert_eq!(ui.web_browsers["m"].len(), 2); + assert!(!ui.web_browsers.contains_key("other")); + + // A later answer, even a shorter one, is the whole list — never merged. + ui.set_web_browsers("m", vec![second.clone()]); + assert_eq!(ui.web_browsers["m"], vec![second]); + } + + #[test] + fn removing_a_browser_reports_whether_the_id_was_there() { + let mut ui = UiState::default(); + assert!(!ui.remove_web_browser("m", "0123456789abcdef"), "no list at all"); + ui.set_web_browsers("m", vec![a_browser()]); + assert!(ui.remove_web_browser("m", "0123456789abcdef")); + assert!(ui.web_browsers["m"].is_empty()); + assert!(!ui.remove_web_browser("m", "0123456789abcdef"), "already gone"); + } + #[test] fn undo_toast_is_a_plain_slot() { let mut ui = UiState::default(); diff --git a/crates/client-ffi/src/intent.rs b/crates/client-ffi/src/intent.rs index 3e55362a..1c7055d4 100644 --- a/crates/client-ffi/src/intent.rs +++ b/crates/client-ffi/src/intent.rs @@ -329,6 +329,29 @@ pub enum UniffiIntent { profile_id: String, profile: Option, }, + /// Sign in the browser showing `code` on `machine`'s web page. The code + /// is trimmed; surrounding whitespace never reaches the bridge. + WebSignIn { + machine: String, + code: String, + }, + /// The sign-in screen closed: drop this machine's code and its answer, so + /// a late one is not reported to a screen that is gone. + ClearWebSignIn { + machine: String, + }, + /// Ask `machine`'s web page which browsers are signed in to it. Sent when + /// the screen that lists them opens; the answer replaces this machine's + /// list in `UniffiUiView.web_browsers`. + RequestWebBrowsers { + machine: String, + }, + /// End one signed-in browser by the page id the list showed. The row goes + /// at once, and the bridge's answer replaces the list with the truth. + SignOutWebBrowser { + machine: String, + id: String, + }, /// Answer a permission request with one of its options' ids. RespondPermission { machine: String, @@ -565,6 +588,11 @@ pub enum UniffiIntent { pub enum UniffiIntentError { #[error("not a recognized value for this field: {detail}")] BadEnumValue { detail: String }, + /// The intent needs this value and got nothing (whitespace counts as + /// nothing): there is no command to send without it, so it is refused + /// here rather than published as an empty field. + #[error("this intent needs a value for {field}")] + EmptyValue { field: String }, } fn parse_enum(field: &str, s: &str) -> Result { @@ -684,6 +712,22 @@ impl TryFrom for Intent { default_model: p.default_model, }), }, + UniffiIntent::WebSignIn { machine, code } => { + let code = code.trim().to_string(); + if code.is_empty() { + return Err(UniffiIntentError::EmptyValue { field: "code".into() }); + } + Intent::WebSignIn { machine, code } + } + UniffiIntent::ClearWebSignIn { machine } => Intent::ClearWebSignIn { machine }, + UniffiIntent::RequestWebBrowsers { machine } => Intent::RequestWebBrowsers { machine }, + UniffiIntent::SignOutWebBrowser { machine, id } => { + let id = id.trim().to_string(); + if id.is_empty() { + return Err(UniffiIntentError::EmptyValue { field: "id".into() }); + } + Intent::SignOutWebBrowser { machine, id } + } UniffiIntent::RespondPermission { machine, session_id, request_id, option_id } => { Intent::RespondPermission { machine, session_id, request_id, option_id } } @@ -968,6 +1012,39 @@ mod tests { assert_eq!(mapped, Intent::SetProviderProfile { machine: "m".into(), profile_id: "kimi-k3".into(), profile: None }); } + #[test] + fn web_sign_in_trims_the_code_and_refuses_an_empty_one() { + let mapped: Intent = UniffiIntent::WebSignIn { + machine: "m".into(), + code: " k7qm-4xpa ".into(), + } + .try_into() + .unwrap(); + assert_eq!( + mapped, + Intent::WebSignIn { + machine: "m".into(), + code: "k7qm-4xpa".into() + } + ); + + // Whitespace is not a code: there is nothing to ask the bridge for. + for empty in ["", " "] { + let err = Intent::try_from(UniffiIntent::WebSignIn { + machine: "m".into(), + code: empty.into(), + }) + .unwrap_err(); + assert!(matches!(err, UniffiIntentError::EmptyValue { .. }), "{empty:?} -> {err:?}"); + } + } + + #[test] + fn clear_web_sign_in_maps_to_the_store_action() { + let mapped: Intent = UniffiIntent::ClearWebSignIn { machine: "m".into() }.try_into().unwrap(); + assert_eq!(mapped, Intent::ClearWebSignIn { machine: "m".into() }); + } + #[test] fn delete_session_maps_field_for_field() { let intent = UniffiIntent::DeleteSession { diff --git a/crates/client-ffi/src/views.rs b/crates/client-ffi/src/views.rs index 82da86e2..e0458b65 100644 --- a/crates/client-ffi/src/views.rs +++ b/crates/client-ffi/src/views.rs @@ -37,6 +37,7 @@ use client_runtime::client_core::presentation::activity::build_activity; use client_runtime::client_core::presentation::display_entries::{ build_display_entries, find_pending_permission, DisplayEntry, SeqEntry, }; +use client_runtime::client_core::stores::ui::WebSignInState; use client_runtime::{ MachinesView, OutboxView, PairingView, PendingSessionsView, QuickPromptsView, SettingsView, TranscriptRowsView, UiView, @@ -1242,6 +1243,50 @@ pub struct UniffiProviderProfileAck { pub error: Option, } +/// Where a `web-sign-in` round trip for one machine stands — mirrors +/// `client_core::stores::ui::WebSignInState`. `Sending` that is never answered +/// stays `Sending`: the screen showing the code is the one that times itself +/// out, so silence is never reported here as a refusal. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum UniffiWebSignInState { + Sending, + SignedIn, + NotMatched, +} + +/// The code a phone sent to sign a browser in to a machine's web page, and +/// where that round trip stands — mirrors `client_core::stores::ui::WebSignIn` +/// field for field. `code` is the one the phone sent, which is what the +/// bridge's answer has to name for this entry to move. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct UniffiWebSignIn { + pub code: String, + pub state: UniffiWebSignInState, +} + +fn to_uniffi_web_sign_in_state(state: WebSignInState) -> UniffiWebSignInState { + match state { + WebSignInState::Sending => UniffiWebSignInState::Sending, + WebSignInState::SignedIn => UniffiWebSignInState::SignedIn, + WebSignInState::NotMatched => UniffiWebSignInState::NotMatched, + } +} + +/// One browser signed in to a machine's web page — mirrors +/// `protocol::events::WebBrowserInfo` field for field. `id` is the page's +/// short public id (never the session token), and it is what a +/// `SignOutWebBrowser` intent names. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct UniffiWebBrowser { + pub id: String, + pub by: String, + pub since_ms: u64, + pub last_seen_ms: u64, + pub expires_ms: u64, + pub peer: String, + pub agent: String, +} + /// The bottom "Deleted X — Undo" toast after an optimistic session delete — /// mirrors `client_core::stores::ui::UndoToast` field for field. Present /// only while the 4 s undo window is open; the hide lands as the next @@ -1268,6 +1313,15 @@ pub struct UniffiUiView { pub credentials_status: HashMap, /// Keyed by machine pubkey. pub provider_profile_status: HashMap, + /// Keyed by machine pubkey: the code this phone sent to sign a browser in + /// to that machine's web page, and where the round trip stands. Absent + /// until a sign-in screen asks, and again once it closes. + pub web_sign_in: HashMap, + /// Keyed by machine pubkey: the browsers signed in to that machine's web + /// page, as the last `web-browsers` listed them. Absent for a machine + /// nobody has opened the screen for — the phone asks then, never on a + /// timer. + pub web_browsers: HashMap>, /// Present while a delete's undo window is open. pub undo_toast: Option, /// The terminals, low-frequency state only — output frames arrive on @@ -1394,6 +1448,40 @@ pub fn build_uniffi_ui_view(v: &UiView) -> UniffiUiView { ) }) .collect(), + web_sign_in: v + .web_sign_in + .iter() + .map(|(k, w)| { + ( + k.clone(), + UniffiWebSignIn { + code: w.code.clone(), + state: to_uniffi_web_sign_in_state(w.state), + }, + ) + }) + .collect(), + web_browsers: v + .web_browsers + .iter() + .map(|(machine, browsers)| { + ( + machine.clone(), + browsers + .iter() + .map(|b| UniffiWebBrowser { + id: b.id.clone(), + by: b.by.clone(), + since_ms: b.since_ms, + last_seen_ms: b.last_seen_ms, + expires_ms: b.expires_ms, + peer: b.peer.clone(), + agent: b.agent.clone(), + }) + .collect(), + ) + }) + .collect(), undo_toast: v.undo_toast.clone().map(|t| UniffiUndoToast { machine: t.machine, session_id: t.session_id, diff --git a/crates/client-runtime/src/dispatch.rs b/crates/client-runtime/src/dispatch.rs index d5fcdd56..15aaa533 100644 --- a/crates/client-runtime/src/dispatch.rs +++ b/crates/client-runtime/src/dispatch.rs @@ -516,6 +516,25 @@ impl<'a> Router<'a> { r.persist(StoreId::Machines); } } + BridgeToPhone::WebSignInResult(m) => { + // An answer to a code the screen is no longer showing says + // nothing about what it shows now, so the store keeps its + // entry and this asks for no re-fetch. + if self + .stores + .ui + .apply_web_sign_in_result(machine, &m.code, m.signed_in) + { + r.ui_changed = true; + } + } + BridgeToPhone::WebBrowsers(m) => { + // The whole list, replacing whatever this machine showed: a + // request's list and a sign-out's fresh list are the same + // message, so neither is ever merged. + self.stores.ui.set_web_browsers(machine, m.browsers.clone()); + r.ui_changed = true; + } BridgeToPhone::ProviderProfileAck(m) => { self.stores.ui.apply_provider_profile_ack( machine, @@ -1588,6 +1607,72 @@ mod tests { assert_eq!(s.machines.machine(MACHINE).unwrap().credentials[0].valid, Some(true)); } + #[tokio::test] + async fn a_web_sign_in_result_moves_the_code_it_answers_and_no_other() { + let (mut s, ts, kp) = stores().await; + s.ui.note_web_sign_in_sent(MACHINE, "K7QM-4XPA"); + + // The answer names a code other than the one on screen — the screen + // closed and reopened, or the user retyped — so it is not about this + // attempt and the entry stays where it is. + let stale = BridgeToPhone::WebSignInResult(protocol::events::WebSignInResultMsg { + code: "OLD1-CODE".into(), + signed_in: true, + }); + let mut r = Router::new(&mut s, &ts, &kp, 1_000); + let out = r.route(MACHINE, &stale).await; + assert!(!out.ui_changed); + assert_eq!( + s.ui.web_sign_in[MACHINE].state, + client_core::stores::ui::WebSignInState::Sending + ); + + let answered = BridgeToPhone::WebSignInResult(protocol::events::WebSignInResultMsg { + code: "K7QM-4XPA".into(), + signed_in: true, + }); + let mut r = Router::new(&mut s, &ts, &kp, 2_000); + let out = r.route(MACHINE, &answered).await; + assert!(out.ui_changed); + assert_eq!( + s.ui.web_sign_in[MACHINE].state, + client_core::stores::ui::WebSignInState::SignedIn + ); + + // The screen closed before the answer came back: nothing to report. + s.ui.clear_web_sign_in(MACHINE); + let mut r = Router::new(&mut s, &ts, &kp, 3_000); + assert!(!r.route(MACHINE, &answered).await.ui_changed); + } + + #[tokio::test] + async fn a_web_browsers_answer_replaces_that_machines_list_whole() { + let (mut s, ts, kp) = stores().await; + let browser = protocol::events::WebBrowserInfo { + id: "0123456789abcdef".into(), + by: "npub1owner".into(), + since_ms: 1, + last_seen_ms: 2, + expires_ms: 3, + peer: "192.168.1.20".into(), + agent: "Mozilla/5.0".into(), + }; + let message = BridgeToPhone::WebBrowsers(protocol::events::WebBrowsersMsg { + browsers: vec![browser.clone()], + }); + + let mut r = Router::new(&mut s, &ts, &kp, 1_000); + let out = r.route(MACHINE, &message).await; + assert!(out.ui_changed, "the screen redraws from the new list"); + assert_eq!(s.ui.web_browsers[MACHINE], vec![browser]); + + // A later, shorter answer is the whole list, not an addition. + let empty = BridgeToPhone::WebBrowsers(protocol::events::WebBrowsersMsg { browsers: vec![] }); + let mut r = Router::new(&mut s, &ts, &kp, 2_000); + assert!(r.route(MACHINE, &empty).await.ui_changed); + assert!(s.ui.web_browsers[MACHINE].is_empty()); + } + #[tokio::test] async fn models_updates_the_agents_list_and_asks_for_a_persist() { let (mut s, ts, kp) = stores().await; diff --git a/crates/client-runtime/src/intent.rs b/crates/client-runtime/src/intent.rs index 81dc671c..13cc060c 100644 --- a/crates/client-runtime/src/intent.rs +++ b/crates/client-runtime/src/intent.rs @@ -21,7 +21,7 @@ use protocol::commands::{ AccountActionMsg, AccountsRequestMsg, AgentActionMsg, BareMsg, CreateFolderMsg, CreateSessionMsg, InputMsg, McpActionMsg, McpRequestMsg, ModelsRequestMsg, PermissionResponseMsg, PermissionRulesRemoveMsg, PermissionRulesRequestMsg, PersonasRequestMsg, PhoneToBridge, PlanResponseMsg, PluginActionMsg, PluginsRequestMsg, ProviderProfileWrite, QuestionAnswer, QuestionResponseMsg, SessionIdMsg, SessionMcpToggleMsg, SetCredentialsMsg, SetOptionMsg, SetProviderProfileMsg, - StopTaskMsg, TerminalIdMsg, TerminalResizeMsg, + StopTaskMsg, TerminalIdMsg, TerminalResizeMsg, WebBrowserSignOutMsg, WebSignInMsg, }; use protocol::common::{AccountAction, AgentAction, CredentialValues, McpAction, McpServerSpec, PluginAction, SessionOption}; use protocol::events::McpAckMsg; @@ -434,6 +434,36 @@ pub enum Intent { RequestProviderProfiles { machine: String, }, + /// Sign in the browser showing `code` on this machine's web page: the + /// signature on the command is what lets it in, so the code alone is + /// worthless to anyone else. Answered with + /// `BridgeToPhone::WebSignInResult`, taken only when it answers the code + /// the ui store recorded for this attempt. + WebSignIn { + machine: String, + code: String, + }, + /// The sign-in screen closed: forget this machine's code, so a late + /// answer cannot move an entry nothing is showing any more. + ClearWebSignIn { + machine: String, + }, + /// Ask this machine's web page which browsers are signed in to it + /// (`web-browsers-request`). Sent when the screen that lists them opens, + /// never on a timer: the page changes only when a browser signs in or out. + /// The answer, `BridgeToPhone::WebBrowsers`, replaces the ui store's list + /// for the machine. + RequestWebBrowsers { + machine: String, + }, + /// End one signed-in browser by the page id the list showed + /// (`web-browser-sign-out`). The row goes from the store at once — a + /// browser the user ended should not linger on screen — and the bridge's + /// answer replaces the list again, with the truth. + SignOutWebBrowser { + machine: String, + id: String, + }, /// Create a new project folder under a workspace root (and `git init` it, /// bridge-side). Answered with `CoreEvent::FolderAck`, matched by /// `request_id` — the caller mints it (a UUID is fine; the bridge only @@ -966,6 +996,27 @@ pub fn apply( r.send(&machine, PhoneToBridge::ProviderProfilesRequest(BareMsg {})) } } + Intent::WebSignIn { machine, code } => { + stores.ui.note_web_sign_in_sent(&machine, &code); + r.ui_changed = true; + r.send(&machine, PhoneToBridge::WebSignIn(WebSignInMsg { code })); + } + Intent::ClearWebSignIn { machine } => { + if stores.ui.clear_web_sign_in(&machine) { + r.ui_changed = true; + } + } + Intent::RequestWebBrowsers { machine } => { + r.send(&machine, PhoneToBridge::WebBrowsersRequest(BareMsg {})); + } + Intent::SignOutWebBrowser { machine, id } => { + // Optimistic: the row goes now, and the bridge's answer — the + // fresh list — replaces it with what actually happened. + if stores.ui.remove_web_browser(&machine, &id) { + r.ui_changed = true; + } + r.send(&machine, PhoneToBridge::WebBrowserSignOut(WebBrowserSignOutMsg { id })); + } Intent::CreateFolder { machine, path, @@ -1702,6 +1753,116 @@ mod tests { )); } + #[tokio::test] + async fn web_sign_in_sends_the_code_and_records_it_until_answered() { + let (mut s, kp) = stores().await; + let out = apply( + &mut s, + Intent::WebSignIn { + machine: "m".into(), + code: "K7QM-4XPA".into(), + }, + &kp, + ctx(), + ); + assert!(matches!( + out.sends.as_slice(), + [Send { machine, msg: PhoneToBridge::WebSignIn(m) }] + if machine == "m" && m.code == "K7QM-4XPA" + )); + assert!(out.ui_changed, "the sign-in screen reads the entry back"); + assert_eq!(s.ui.web_sign_in["m"].code, "K7QM-4XPA"); + assert_eq!( + s.ui.web_sign_in["m"].state, + client_core::stores::ui::WebSignInState::Sending, + "silence is not a verdict; the screen times itself out" + ); + } + + #[tokio::test] + async fn clearing_a_web_sign_in_sends_nothing_and_reports_its_own_change() { + let (mut s, kp) = stores().await; + apply( + &mut s, + Intent::WebSignIn { + machine: "m".into(), + code: "K7QM-4XPA".into(), + }, + &kp, + ctx(), + ); + + let out = apply(&mut s, Intent::ClearWebSignIn { machine: "m".into() }, &kp, ctx()); + assert!(out.sends.is_empty()); + assert!(out.ui_changed); + assert!(!s.ui.web_sign_in.contains_key("m")); + + // Nothing left to forget: no re-fetch for a screen that is gone. + let out = apply(&mut s, Intent::ClearWebSignIn { machine: "m".into() }, &kp, ctx()); + assert!(!out.ui_changed); + } + + #[tokio::test] + async fn requesting_the_web_browsers_sends_a_bare_request() { + let (mut s, kp) = stores().await; + let out = apply( + &mut s, + Intent::RequestWebBrowsers { machine: "m".into() }, + &kp, + ctx(), + ); + assert!(matches!( + out.sends.as_slice(), + [Send { machine, msg: PhoneToBridge::WebBrowsersRequest(_) }] if machine == "m" + )); + assert!(!out.ui_changed, "nothing changed until the bridge answers"); + } + + #[tokio::test] + async fn signing_out_a_browser_sends_it_and_drops_the_row_at_once() { + let (mut s, kp) = stores().await; + s.ui.set_web_browsers("m", vec![protocol::events::WebBrowserInfo { + id: "0123456789abcdef".into(), + by: "npub1owner".into(), + since_ms: 1, + last_seen_ms: 2, + expires_ms: 3, + peer: "192.168.1.20".into(), + agent: "Mozilla/5.0".into(), + }]); + + let out = apply( + &mut s, + Intent::SignOutWebBrowser { + machine: "m".into(), + id: "0123456789abcdef".into(), + }, + &kp, + ctx(), + ); + assert!(matches!( + out.sends.as_slice(), + [Send { machine, msg: PhoneToBridge::WebBrowserSignOut(m) }] + if machine == "m" && m.id == "0123456789abcdef" + )); + assert!(out.ui_changed, "the row left before the answer"); + assert!(s.ui.web_browsers["m"].is_empty()); + + // An id the list no longer holds still goes to the bridge — its + // answer is what the screen trusts — but moves nothing here. + let out = apply( + &mut s, + Intent::SignOutWebBrowser { + machine: "m".into(), + id: "0123456789abcdef".into(), + }, + &kp, + ctx(), + ); + assert_eq!(out.sends.len(), 1); + assert!(!out.ui_changed); + } + /// Screens ask each time they open; the profiles, which the bridge /// pushes on every change, are not asked for twice. #[tokio::test] diff --git a/crates/client-runtime/src/runtime.rs b/crates/client-runtime/src/runtime.rs index 54b6e413..61b85492 100644 --- a/crates/client-runtime/src/runtime.rs +++ b/crates/client-runtime/src/runtime.rs @@ -710,6 +710,8 @@ impl Core { plan_approval_choices: Default::default(), credentials_status: Default::default(), provider_profile_status: Default::default(), + web_sign_in: Default::default(), + web_browsers: Default::default(), undo_toast: None, terminals: Default::default(), }) diff --git a/crates/client-runtime/src/view.rs b/crates/client-runtime/src/view.rs index 01dfbd51..b5542624 100644 --- a/crates/client-runtime/src/view.rs +++ b/crates/client-runtime/src/view.rs @@ -22,9 +22,9 @@ use client_core::stores::pending_sessions::PendingSessionView; use client_core::stores::quick_prompts::QuickPrompt; use client_core::stores::settings::SettingsData; use client_core::stores::transcript::{SyncState, TranscriptState}; -use client_core::stores::ui::{CredentialsAck, ProviderProfileAck, UndoToast}; +use client_core::stores::ui::{CredentialsAck, ProviderProfileAck, UndoToast, WebSignIn}; use protocol::common::{AgentStyle, TerminalEndReason}; -use protocol::events::TerminalInfo; +use protocol::events::{TerminalInfo, WebBrowserInfo}; use serde::Serialize; use crate::ports::TranscriptStore; @@ -202,6 +202,13 @@ pub struct UiView { pub plan_approval_choices: BTreeMap, pub credentials_status: BTreeMap, pub provider_profile_status: BTreeMap, + /// machine pubkey → the code the phone sent to sign a browser in to that + /// machine's web page, and where the round trip stands. + pub web_sign_in: BTreeMap, + /// machine pubkey → the browsers signed in to its web page, as the last + /// `web-browsers` listed them. Absent for a machine nobody has opened the + /// screen for: the phone asks then, never on a timer. + pub web_browsers: BTreeMap>, pub undo_toast: Option, /// The terminals, low-frequency state only: the frames themselves go to /// the host on `CoreEvent::TerminalOutput`, never through a view. @@ -292,6 +299,8 @@ impl UiView { plan_approval_choices: ui.plan_approval_choices.clone(), credentials_status: ui.credentials_status.clone(), provider_profile_status: ui.provider_profile_status.clone(), + web_sign_in: ui.web_sign_in.clone(), + web_browsers: ui.web_browsers.clone(), undo_toast: ui.undo_toast.clone(), terminals: TerminalsView::from_stores(s), } @@ -558,11 +567,17 @@ mod tests { s.ui.select_machine(Some("m1")); s.ui.mark_session_unread("m1", "s1"); s.ui.set_plan_approval_choice("card1", "2"); + s.ui.note_web_sign_in_sent("m1", "K7QM-4XPA"); let uv = UiView::from_stores(&s); assert_eq!(uv.selected_machine.as_deref(), Some("m1")); assert!(uv.unread_sessions.contains(&client_core::notifications::session_key_of("m1", "s1"))); assert_eq!(uv.plan_approval_choices.get("card1").map(String::as_str), Some("2")); + assert_eq!(uv.web_sign_in["m1"].code, "K7QM-4XPA"); + assert_eq!( + uv.web_sign_in["m1"].state, + client_core::stores::ui::WebSignInState::Sending + ); let json = serde_json::to_string(&uv).unwrap(); assert!(json.contains(r#""selectedMachine":"m1""#)); } diff --git a/crates/protocol/fixtures/corpus.json b/crates/protocol/fixtures/corpus.json index 4d00c697..a4c6cb42 100644 --- a/crates/protocol/fixtures/corpus.json +++ b/crates/protocol/fixtures/corpus.json @@ -81,9 +81,14 @@ { "v": 12, "type": "terminal-resize", "term": "t1", "cols": 100, "rows": 30 }, { "v": 12, "type": "terminal-replay", "term": "t1", "from": 4096 }, { "v": 12, "type": "terminal-takeover", "term": "t1" }, - { "v": 12, "type": "terminal-close", "term": "t1" } + { "v": 12, "type": "terminal-close", "term": "t1" }, + { "v": 12, "type": "web-sign-in", "code": "K7QM-4XPA" }, + { "v": 12, "type": "web-browsers-request" }, + { "v": 12, "type": "web-browser-sign-out", "id": "0123456789abcdef" } ], "rejected": [ + { "v": 12, "type": "web-sign-in" }, + { "v": 12, "type": "web-browser-sign-out" }, { "v": 13, "type": "input", "sessionId": "s", "text": "from a newer phone" }, { "type": "input", "sessionId": "s", "text": "from an unversioned sender" }, { "v": 12, "type": "input", "text": "no session id" }, @@ -216,7 +221,10 @@ { "v": 12, "type": "terminal-exited", "openId": "o2", "reason": "not-allowed", "device": "bf1f111943b3930a4c64a5c06d9153b2" }, { "v": 12, "type": "terminal-event", "term": "t1", "what": "takeover", "device": "bf1f111943b3930a4c64a5c06d9153b2", "name": "Pixel 8", "via": "relay", "at": 1792008058637 }, { "v": 12, "type": "terminal-event", "term": "t1", "what": "closed", "device": "aa11", "via": "direct", "at": 1792008058638 }, - { "v": 12, "type": "sessions", "machine": "m", "sessions": [], "agents": [], "terminals": [{ "term": "t1", "shell": "/bin/bash", "opened": 1792008058637, "device": "bf1f111943b3930a4c64a5c06d9153b2", "name": "Pixel 8", "writer": "bf1f111943b3930a4c64a5c06d9153b2", "cols": 80, "rows": 24 }, { "term": "t2", "shell": "/bin/zsh", "opened": 1792008058638, "device": "aa11", "writer": "aa11", "cols": 132, "rows": 43 }] } + { "v": 12, "type": "sessions", "machine": "m", "sessions": [], "agents": [], "terminals": [{ "term": "t1", "shell": "/bin/bash", "opened": 1792008058637, "device": "bf1f111943b3930a4c64a5c06d9153b2", "name": "Pixel 8", "writer": "bf1f111943b3930a4c64a5c06d9153b2", "cols": 80, "rows": 24 }, { "term": "t2", "shell": "/bin/zsh", "opened": 1792008058638, "device": "aa11", "writer": "aa11", "cols": 132, "rows": 43 }] }, + { "v": 12, "type": "web-sign-in-result", "code": "K7QM-4XPA", "signedIn": true }, + { "v": 12, "type": "web-browsers", "browsers": [] }, + { "v": 12, "type": "web-browsers", "browsers": [{ "id": "0123456789abcdef", "by": "npub1owner", "sinceMs": 1792008058637, "lastSeenMs": 1792008059000, "expiresMs": 1804598458637, "peer": "192.168.1.20", "agent": "Mozilla/5.0 (Android 15)" }] } ], "rejected": [ { "v": 10, "type": "input-ack", "sessionId": "s", "inputId": "i" }, @@ -237,6 +245,7 @@ { "v": 12, "type": "input-failed", "sessionId": "s", "reason": "unknown-reason" }, { "v": 12, "type": "pair-ack", "machine": "m", "ok": true, "reason": "meltdown" }, { "v": 12, "type": "mode-confirmed", "sessionId": "s", "mode": "plan" }, + { "v": 12, "type": "web-browsers" }, { "v": 12, "type": "warp", "sessionId": "s" } ] }, diff --git a/crates/protocol/src/commands.rs b/crates/protocol/src/commands.rs index e459a38b..b08282f5 100644 --- a/crates/protocol/src/commands.rs +++ b/crates/protocol/src/commands.rs @@ -564,6 +564,28 @@ pub struct TerminalIdMsg { pub term: String, } +/// `web-sign-in`: sign in the browser showing `code` on this bridge's web +/// page. The page shows a short code (and a QR of it) to a browser that asks +/// to sign in; a paired phone sending it — signed by the identity, like every +/// command — is what lets that browser in, so the page needs no password. +/// Reply: `web-sign-in-result`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct WebSignInMsg { + pub code: String, +} + +/// `web-browser-sign-out`: end the browser whose page id is `id` — the short +/// id `web-browsers` listed, never the session token. Any paired client may +/// sign out any browser: they are all the owner's. Reply: `web-browsers`, the +/// fresh list with it gone; an id that matches no signed-in browser leaves +/// the list as it was. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct WebBrowserSignOutMsg { + pub id: String, +} + /// The phone→bridge message union. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] #[serde(tag = "type", rename_all = "kebab-case")] @@ -624,6 +646,15 @@ pub enum PhoneToBridge { TerminalTakeover(TerminalIdMsg), /// Hang up a terminal. TerminalClose(TerminalIdMsg), + WebSignIn(WebSignInMsg), + /// List the browsers signed in to this bridge's web page. No fields. + /// Reply: `web-browsers`, to this client alone. Sent when the user opens + /// the screen that shows them, never on a timer: the list changes only + /// when a browser signs in or out. + WebBrowsersRequest(BareMsg), + /// End one signed-in browser by its page id. Reply: `web-browsers`, the + /// fresh list. + WebBrowserSignOut(WebBrowserSignOutMsg), } #[cfg(test)] @@ -696,6 +727,9 @@ mod tests { rt(&json!({"v":12,"type":"pair-request","npub":"npub1","pubkeyHex":"aa","label":"phone","token":"t"})); rt(&json!({"v":12,"type":"pair-request","npub":"npub1","pubkeyHex":"aa","label":"phone","token":"t","sessionKey":{"pubkeyHex":"bb","bridgePubkeyHex":"cc","expiresAt":1800000000}})); rt(&json!({"v":12,"type":"provider-profiles-request"})); + rt(&json!({"v":12,"type":"web-sign-in","code":"K7QM-4XPA"})); + rt(&json!({"v":12,"type":"web-browsers-request"})); + rt(&json!({"v":12,"type":"web-browser-sign-out","id":"0123456789abcdef"})); rt(&json!({"v":12,"type":"session-key","sessionKey":{"pubkeyHex":"bb","bridgePubkeyHex":"cc","expiresAt":1800000000}})); rt(&json!({"v":12,"type":"session-key","sessionKey":{"pubkeyHex":"bb","bridgePubkeyHex":"cc","expiresAt":1800000000,"label":"Pixel 8"}})); rt(&json!({"v":12,"type":"presence","watchingTerminal":"t1"})); @@ -715,6 +749,11 @@ mod tests { assert!(serde_json::from_str::(r#"{"v":12,"type":"terminal-input","term":"t","seq":1}"#).is_err()); } + #[test] + fn a_web_browser_sign_out_must_name_an_id() { + assert!(serde_json::from_str::(r#"{"v":12,"type":"web-browser-sign-out"}"#).is_err()); + } + #[test] fn upload_image_union_disambiguates_by_shape() { let m = rt(&json!({"v":12,"type":"upload-file","sessionId":"s","hash":"h","url":"u","key":"k","iv":"iv","filename":"f","mimeType":"image/png","text":"t","sizeBytes":1})); diff --git a/crates/protocol/src/events.rs b/crates/protocol/src/events.rs index 58c22df8..b512bb95 100644 --- a/crates/protocol/src/events.rs +++ b/crates/protocol/src/events.rs @@ -617,6 +617,54 @@ pub struct TerminalEventMsg { pub at: u64, } +/// The answer to `web-sign-in`, to the client that sent it: whether `code` +/// was one the web page is showing now (and so a browser is signed in), or +/// not — mistyped, expired, or already used. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct WebSignInResultMsg { + pub code: String, + pub signed_in: bool, +} + +/// One browser signed in to a bridge's web page, for `web-browsers`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct WebBrowserInfo { + /// The page's short public id for this browser: the first 16 hex + /// characters of the SHA-256 of its session key. Never the key itself and + /// never the session token, so a copy of the list can be replayed as + /// nothing; a `web-browser-sign-out` names this. + pub id: String, + /// The npub of the phone identity that signed it in. + pub by: String, + /// When it signed in, ms since the Unix epoch. + #[specta(type = specta_typescript::Number)] + pub since_ms: u64, + /// When it last made an authenticated request, ms since the Unix epoch. + #[specta(type = specta_typescript::Number)] + pub last_seen_ms: u64, + /// When its session lapses, ms since the Unix epoch. + #[specta(type = specta_typescript::Number)] + pub expires_ms: u64, + /// The address it signed in from (an IP; the page keeps no host name). + pub peer: String, + /// Its `User-Agent`, cleaned of control characters and bounded. + pub agent: String, +} + +/// The browsers signed in to the bridge's web page: the answer to +/// `web-browsers`, and — after a `web-browser-sign-out` — the fresh list with +/// the ended browser gone. Sent to the asking client alone; another client +/// that did not ask keeps whatever its own screen last read. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct WebBrowsersMsg { + /// Signed-in browsers, oldest signed in first. Empty when the bridge runs + /// no web page, and for a browser id a sign-out did not match. + pub browsers: Vec, +} + /// The bridge→phone message union. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)] #[serde(tag = "type", rename_all = "kebab-case")] @@ -660,6 +708,8 @@ pub enum BridgeToPhone { TerminalExited(TerminalExitedMsg), /// To every present client of the identity. TerminalEvent(TerminalEventMsg), + WebSignInResult(WebSignInResultMsg), + WebBrowsers(WebBrowsersMsg), } #[cfg(test)] @@ -805,6 +855,11 @@ mod tests { rt(&json!({"v":12,"type":"terminal-exited","term":"t1","code":0,"reason":"exit"})); rt(&json!({"v":12,"type":"terminal-exited","openId":"o1","reason":"not-allowed","device":"d1"})); rt(&json!({"v":12,"type":"terminal-event","term":"t1","what":"takeover","device":"d1","name":"Pixel 8","via":"relay","at":1792008058637_u64})); + rt(&json!({"v":12,"type":"web-sign-in-result","code":"K7QM-4XPA","signedIn":true})); + rt(&json!({"v":12,"type":"web-browsers","browsers":[]})); + rt(&json!({"v":12,"type":"web-browsers","browsers":[{ + "id":"0123456789abcdef","by":"npub1owner","sinceMs":1792008058637_u64,"lastSeenMs":1792008059000_u64, + "expiresMs":1804598458637_u64,"peer":"192.168.1.20","agent":"Mozilla/5.0"}]})); } #[test] diff --git a/crates/protocol/tests/codec_conformance.rs b/crates/protocol/tests/codec_conformance.rs index e03df823..b09d8c1b 100644 --- a/crates/protocol/tests/codec_conformance.rs +++ b/crates/protocol/tests/codec_conformance.rs @@ -109,9 +109,12 @@ fn p2b_type(m: &PhoneToBridge) -> &'static str { PhoneToBridge::TerminalReplay(_) => "terminal-replay", PhoneToBridge::TerminalTakeover(_) => "terminal-takeover", PhoneToBridge::TerminalClose(_) => "terminal-close", + PhoneToBridge::WebSignIn(_) => "web-sign-in", + PhoneToBridge::WebBrowsersRequest(_) => "web-browsers-request", + PhoneToBridge::WebBrowserSignOut(_) => "web-browser-sign-out", } } -const P2B_TYPES: usize = 42; +const P2B_TYPES: usize = 45; /// Every bridge→phone message type, by wire name (see [`p2b_type`]). fn b2p_type(m: &BridgeToPhone) -> &'static str { @@ -153,9 +156,11 @@ fn b2p_type(m: &BridgeToPhone) -> &'static str { BridgeToPhone::TerminalInputFrom(_) => "terminal-input-from", BridgeToPhone::TerminalExited(_) => "terminal-exited", BridgeToPhone::TerminalEvent(_) => "terminal-event", + BridgeToPhone::WebSignInResult(_) => "web-sign-in-result", + BridgeToPhone::WebBrowsers(_) => "web-browsers", } } -const B2P_TYPES: usize = 37; +const B2P_TYPES: usize = 39; #[test] fn corpus_covers_every_message_type() { diff --git a/docs/BRIDGE.md b/docs/BRIDGE.md index 80656678..b3e80a7c 100644 --- a/docs/BRIDGE.md +++ b/docs/BRIDGE.md @@ -221,6 +221,49 @@ the heartbeat): add the host's addresses to `direct.endpoints`, or on the phone, in the machine's settings. To turn the link off, remove `direct` from `config.json`. +### Web page + +With the direct link listening on `wss://`, the same port serves a web page +for a browser: open `https://
:7447/` (the bridge logs it at start, +`[Web] The bridge's web page: …`). The browser gets the same self-signed +certificate as the phone, so it shows a certificate warning; compare the +SHA-256 it shows with the one `[Direct] Listening on …` logs before +accepting it. Plain `http://` on the port redirects to +`https://`; the onion listener serves no page. + +A browser signs in with a paired phone: the page shows a code and a QR, and +on the phone the machine's page, "Browsers", scans or types it (the same +page lists the signed-in browsers and signs one out). The +browser then stays signed in for 30 days (a cookie only the bridge can read, +sent only over HTTPS), and it survives a restart: signed-in browsers are kept +in `/web-sessions.json`, keyed by the cookie's SHA-256 so the file +holds no token a copy could be replayed from. Signing out, or revoking one +from the page, ends it; a pending sign-in is never written. Until signed in +it sees only the sign-in page. The page shows what the heartbeat carries — +the bridge, the direct link, the agents and their credential status, the +sessions — and never a secret. + +Signed in, the page also has a few JSON APIs: `GET /api/browsers` lists the +signed-in browsers (each with its id, approver, sign-in and last-seen times, +expiry, address and User-Agent, and which is the caller) and +`POST /api/browsers/sign-out` ends one — the same list a paired phone reads +with `web-browsers-request` and ends one of with `web-browser-sign-out`, both +served from one place so the two paths never disagree; `GET /api/devices` +shows the paired identities and their clients (labels and key expiries, never +a key); +`GET /api/config` shows the config as it is in effect (a secret only as +set/not set, and each field whether a flag or environment variable overrides +the file) and `POST /api/config` changes the safe set — the machine name, +the relays, the direct endpoints, the transcript retention and the relay / +Blossom registration tokens — validating each and rewriting `config.json` +keeping every other key. + +Because a LAN or VPN is not a trusted place, the page refuses a `Host` that +is not an IP address, `localhost` or a host the direct endpoints name, +requires an `Origin` equal to its own on every `POST`, forbids framing, +caching and any script or connection not its own, rate-limits sign-in starts +to 10 a minute per address, and caps pending codes and signed-in browsers. + ## Terminal A phone can open a real terminal on the bridge: a shell in a pseudo-terminal @@ -289,6 +332,8 @@ In ``: - `bridge.lock` — held while a bridge runs, so two never share one identity; `bridge.pid` beside it names the process holding it. - `config.json` — optional; tightened to 0600 when read (it may hold admin tokens). +- `web-sessions.json` — the signed-in browsers of the web page, keyed by the + cookie's SHA-256 (never a token), 0600. Deleting it signs every browser out. - `direct/cert.der`, `direct/key.der` — the direct link's certificate and its key (0600). Deleting them makes a new one; phones pick up the new pin from the next heartbeat. diff --git a/docs/CLIENT.md b/docs/CLIENT.md index 4967bfff..fe79908e 100644 --- a/docs/CLIENT.md +++ b/docs/CLIENT.md @@ -408,6 +408,31 @@ link is an extra path beside the relays, never a replacement. - The machines view reports the endpoint each link is up on; Settings shows it per machine. +### Web page sign-in + +A machine's page has a "Web page" group: the `https://` address of the +bridge's web page on each of its `wss://` direct endpoints (onion and +plain `ws://` ones serve no page), and "Browsers" +(`screens/MachineWebPage.kt`, the wire in +[`PROTOCOL.md`](PROTOCOL.md#web-page-sign-in)). That page scans the +browser's QR in the app's own scanner, or takes the typed code, and +dispatches `WebSignIn`; the UI view's `webSignIn` (by machine) says whether +the code is on its way, signed in, or did not match, and the page says so +when no answer came in 15 s. A QR naming another machine is refused before +anything is sent. The QR has no deep link on purpose: a link opened +elsewhere must not sign a browser in without the user on this page. Closing +the page dispatches `ClearWebSignIn`, so a late answer is dropped. Under +the sign-in it lists the browsers signed in now (`RequestWebBrowsers` when +the page opens and again after a sign-in from it, never polled; the view's +`webBrowsers`), each signed out with `SignOutWebBrowser` after a +confirmation. + +The same group lists the browsers already signed in to the page: opening it +dispatches `RequestWebBrowsers`, and the bridge's `web-browsers` answer fills +the UI view's `webBrowsers` (by machine). Ending one dispatches +`SignOutWebBrowser`, which drops the row at once and lets the bridge's fresh +list — the same message a request is answered with — confirm it. + ## Conventions - A UniFFI error variant must not have a field named `message` (it collides diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index ab8b420e..c207df06 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -767,6 +767,48 @@ spec. each authored by the identity that said `HELLO`; the engine accepts only `terminal-input` on the live kind, so nothing else can ride it in. +### Web page sign-in + +The bridge serves a web page on its direct link's `wss://` port (the +bridge's side is in [`BRIDGE.md`](BRIDGE.md#web-page)). A browser signs in +with a paired phone, never a password: the page shows a code of 8 +characters (`ABCD-EFGH`, from an alphabet without look-alikes) and a QR of +`codedeck://web-sign-in?machine=&code=`. + +- `web-sign-in {code}` (phone → bridge, a signed command like any other). + The bridge reads the code in any case, with or without its dash, and + matches it against the codes its page is showing: each works once, for + 5 minutes. +- `web-sign-in-result {code, signedIn}` to the asking client. `code` is the + code exactly as the phone sent it, so the phone can tell which attempt + this answers; `signedIn` false means mistyped, expired or already used. +- A phone sends a scanned code only to the machine its QR names: a code + shown by one bridge must never sign in another's page. + +The phone can also see and end the browsers already signed in, the same thing +the page's own `GET /api/browsers` and `POST /api/browsers/sign-out` do: + +- `web-browsers-request {}` (phone → bridge, a signed command like any + other). No fields. Sent when the user opens the screen that lists them, + never on a timer: the list changes only when a browser signs in or out, so + nothing else would make it stale. +- `web-browsers {browsers}` to the asking client. Each entry is `{id, by, + sinceMs, lastSeenMs, expiresMs, peer, agent}`: `id` is the page's short + public id — the first 16 hex characters of the stored key's SHA-256, never + the key and never the session token — `by` is the npub that signed it in, + the three times are ms since the Unix epoch, `peer` the address it came + from and `agent` its `User-Agent` (cleaned and bounded). Oldest signed in + first; empty when the bridge runs no web page. +- `web-browser-sign-out {id}` (phone → bridge). Ends the browser whose page + id is `id`; any paired client may end any of the owner's browsers. Answered + with `web-browsers`, the fresh list — a sign-out is confirmed by the list it + produced, and an id that matched nothing is answered with the list as it is, + not an error. + +Cost: two small command-class events per sign-in, one request and one response +per screen opening, and the same again per sign-out; nothing while the screen +is closed. + ### Version and capabilities Every message, both ways, carries `v`: its sender's protocol version (12),