Add QR pairing (terminal QR, in-app scanner, iris://pair deep link)
This commit is contained in:
1 parent
27dc7917f2
commit
7a6d922d12
63 files changed
+2073
-630
No files matched your search
@@ -10,6 +10,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
@@ -26,6 +27,7 @@ import iris.ui.theme.IrisColors
|
||||
import iris.ui.theme.IrisTheme
|
||||
import iris.ui.theme.LocalUserTheme
|
||||
import iris.ui.theme.rememberBackgroundImage
|
||||
import iris.util.PairLink
|
||||
|
||||
/**
|
||||
* Root composable shared by the Android and Desktop shells.
|
||||
@@ -39,6 +41,7 @@ fun IrisApp(
|
||||
store: SecureStore,
|
||||
deepLinkChatId: String? = null,
|
||||
deepLinkThreadId: String? = null,
|
||||
deepLinkPair: PairLink? = null,
|
||||
) {
|
||||
val controller = remember(store) { IrisController(store) }
|
||||
DisposableEffect(controller) {
|
||||
@@ -66,10 +69,11 @@ fun IrisApp(
|
||||
// untouched, so the UI keeps its proportions at any size.
|
||||
CompositionLocalProvider(
|
||||
LocalUserTheme provides theme,
|
||||
LocalDensity provides Density(
|
||||
density = baseDensity.density,
|
||||
fontScale = baseDensity.fontScale * fontScale,
|
||||
),
|
||||
LocalDensity provides
|
||||
Density(
|
||||
density = baseDensity.density,
|
||||
fontScale = baseDensity.fontScale * fontScale,
|
||||
),
|
||||
) {
|
||||
// The color goes through Surface's `color` parameter: a
|
||||
// Modifier.background on the Surface would be painted *under* the
|
||||
@@ -95,20 +99,34 @@ fun IrisApp(
|
||||
)
|
||||
}
|
||||
val s = state
|
||||
// QR pairing (docs/20): an iris://pair deep link prefills
|
||||
// the Connect screen, taking priority over stored creds.
|
||||
val pairUrl = deepLinkPair?.url ?: store.serverUrl
|
||||
val pairToken = deepLinkPair?.token ?: store.token
|
||||
when (s) {
|
||||
GatewayClient.State.Disconnected ->
|
||||
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token)
|
||||
is GatewayClient.State.AuthFailed ->
|
||||
ConnectScreen(
|
||||
controller,
|
||||
prefillUrl = store.serverUrl,
|
||||
prefillToken = store.token,
|
||||
initialError = "Pairing rejected: ${s.message}",
|
||||
)
|
||||
GatewayClient.State.Disconnected -> {
|
||||
key(deepLinkPair) {
|
||||
ConnectScreen(controller, prefillUrl = pairUrl, prefillToken = pairToken)
|
||||
}
|
||||
}
|
||||
|
||||
is GatewayClient.State.AuthFailed -> {
|
||||
key(deepLinkPair) {
|
||||
ConnectScreen(
|
||||
controller,
|
||||
prefillUrl = pairUrl,
|
||||
prefillToken = pairToken,
|
||||
initialError = "Pairing rejected: ${s.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Connecting / Reconnecting / Connected all render the chat; the header
|
||||
// status bubble + connection banner show the link state
|
||||
// without blocking the view (M7's full-screen spinner is gone).
|
||||
else -> ChatScreen(controller)
|
||||
else -> {
|
||||
ChatScreen(controller)
|
||||
}
|
||||
}
|
||||
// M9: full-screen HTML artifact preview (opened from an
|
||||
// artifact card in the chat); covers everything while open.
|
||||
@@ -117,4 +135,4 @@ fun IrisApp(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,15 +132,16 @@ class ChatStore {
|
||||
var streamingEnabled: Boolean = true
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_LANE = "android:default"
|
||||
const val DEFAULT_LANE = "default"
|
||||
|
||||
fun randomId(prefix: String): String = "${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
|
||||
}
|
||||
|
||||
// ── Lane helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Lane key for a (chat, thread) pair. Uses `::` as the separator because
|
||||
* chat ids already contain a single `:` (e.g. `android:chan_1`). */
|
||||
/** Lane key for a (chat, thread) pair. Uses `::` as the separator so a
|
||||
* thread lane can never collide with a chat id (chat ids are direct,
|
||||
* e.g. `chan_1`, and never contain `:`). */
|
||||
fun laneKey(
|
||||
chatId: String,
|
||||
threadId: String?,
|
||||
|
||||
@@ -9,7 +9,7 @@ interface SecureStore {
|
||||
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
|
||||
var serverUrl: String
|
||||
|
||||
/** ANDROID_TOKEN presented in the auth header. */
|
||||
/** IRIS_TOKEN presented in the auth header. */
|
||||
var token: String
|
||||
|
||||
/** Stable app-generated device id (persisted). */
|
||||
|
||||
@@ -33,7 +33,7 @@ import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* HTTP client for the hermes android gateway (docs/19).
|
||||
* HTTP client for the hermes iris gateway (docs/19).
|
||||
*
|
||||
* HTTP is the only transport: send via `POST /v1/frame`, receive over SSE
|
||||
* `/v1/events` (long-poll fallback), media via `POST/GET /v1/media`.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package iris.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* A "Scan QR" button that launches the platform QR scanner and delivers the
|
||||
* scanned text (or null if the user cancelled) to [onResult] (docs/20).
|
||||
*
|
||||
* Android: opens [QrScanActivity] (CameraX + ML Kit). Desktop: renders nothing
|
||||
* (the Connect screen hides it there).
|
||||
*/
|
||||
@Composable
|
||||
expect fun QrScanButton(onResult: (String?) -> Unit)
|
||||
@@ -134,7 +134,7 @@ class IrisController(
|
||||
}
|
||||
|
||||
/** Home channel id (from hello.ack; default until then). */
|
||||
private val _homeChannel = MutableStateFlow("android:default")
|
||||
private val _homeChannel = MutableStateFlow("default")
|
||||
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
|
||||
|
||||
/** Lanes whose full history has been loaded this session (in-memory; reset
|
||||
@@ -408,7 +408,7 @@ class IrisController(
|
||||
) {
|
||||
if (isAppForeground()) return
|
||||
if (text.isBlank()) return
|
||||
val id = chatId ?: "android:default"
|
||||
val id = chatId ?: "default"
|
||||
val chatName = channels.byId(id)?.name
|
||||
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
|
||||
}
|
||||
|
||||
@@ -1314,7 +1314,7 @@ private fun SystemMessage(msg: MessageItem) {
|
||||
|
||||
/** M7: header title pill — channel avatar + channel name. Automation
|
||||
* channels show their chat_id as a subtitle (tap the pill to copy it) so
|
||||
* the user can target them for cron delivery (`deliver="android:<chat_id>"`)
|
||||
* the user can target them for cron delivery (`deliver="iris:<chat_id>"`)
|
||||
* without asking the agent which channel is in use. */
|
||||
@Composable
|
||||
private fun TitlePill(
|
||||
|
||||
@@ -27,8 +27,11 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import iris.platform.QrScanButton
|
||||
import iris.platform.isDesktop
|
||||
import iris.state.IrisController
|
||||
import iris.ui.theme.IrisColors
|
||||
import iris.util.PairLink
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -91,11 +94,25 @@ fun ConnectScreen(
|
||||
value = token,
|
||||
onValueChange = { token = it },
|
||||
label = { Text("Pairing token") },
|
||||
placeholder = { Text("ANDROID_TOKEN (64 hex)") },
|
||||
placeholder = { Text("IRIS_TOKEN (64 hex)") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (!isDesktop) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
QrScanButton { raw ->
|
||||
if (raw != null) {
|
||||
val link = PairLink.parse(raw)
|
||||
if (link != null) {
|
||||
url = link.url
|
||||
token = link.token
|
||||
} else {
|
||||
error = "Couldn't read that QR code."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
@@ -129,7 +146,7 @@ fun ConnectScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
"Find the token in ~/.hermes/.env (ANDROID_TOKEN) or run\n" +
|
||||
"Find the token in ~/.hermes/.env (IRIS_TOKEN) or run\n" +
|
||||
"hermes gateway setup on the gateway host.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package iris.util
|
||||
|
||||
/**
|
||||
* A parsed `iris://pair` deep link: the gateway URL to connect to plus the
|
||||
* one-time pairing token. Produced by [PairLink.parse] from a scanned QR or a
|
||||
* tapped `iris://pair` link (docs/20).
|
||||
*/
|
||||
data class PairLink(
|
||||
val url: String,
|
||||
val token: String,
|
||||
) {
|
||||
companion object {
|
||||
/** Default gateway HTTP port (docs/19). */
|
||||
const val DEFAULT_PORT = 8791
|
||||
|
||||
/**
|
||||
* Parse a pairing link into a [PairLink], or return null on any
|
||||
* malformation.
|
||||
*
|
||||
* - scheme must be `iris` (case-insensitive); the URI host must be `pair`.
|
||||
* - required query params: `host` (non-empty) and `token` (non-empty).
|
||||
* - `port` defaults to [DEFAULT_PORT]; must be 1–65535 when present.
|
||||
* - `secure` defaults to `0`; `1` selects https.
|
||||
* - `host`/`token` are percent-decoded (the Python side `quote()`s them).
|
||||
*/
|
||||
fun parse(raw: String): PairLink? {
|
||||
val qIdx = raw.indexOf('?')
|
||||
val authority = if (qIdx >= 0) raw.substring(0, qIdx) else raw
|
||||
val query = if (qIdx >= 0) raw.substring(qIdx + 1) else ""
|
||||
|
||||
// authority is "iris://pair": scheme, then "://", then the URI host.
|
||||
val sep = authority.indexOf("://")
|
||||
if (sep <= 0) return null
|
||||
val scheme = authority.substring(0, sep)
|
||||
val uriHost = authority.substring(sep + 3)
|
||||
if (scheme.lowercase() != "iris") return null
|
||||
if (uriHost.lowercase() != "pair") return null
|
||||
|
||||
val params = parseQuery(query)
|
||||
val host = params["host"]?.let { percentDecode(it) }?.trim().orEmpty()
|
||||
val token = params["token"]?.let { percentDecode(it) }?.trim().orEmpty()
|
||||
if (host.isEmpty() || token.isEmpty()) return null
|
||||
|
||||
val portRaw = params["port"]
|
||||
val port =
|
||||
if (portRaw.isNullOrEmpty()) {
|
||||
DEFAULT_PORT
|
||||
} else {
|
||||
portRaw.toIntOrNull() ?: return null
|
||||
}
|
||||
if (port !in 1..65535) return null
|
||||
val secure = params["secure"]?.toIntOrNull() ?: 0
|
||||
val urlScheme = if (secure == 1) "https" else "http"
|
||||
return PairLink("$urlScheme://$host:$port", token)
|
||||
}
|
||||
|
||||
/** Split a `k=v&k=v` query string into a map (values may be empty). */
|
||||
private fun parseQuery(query: String): Map<String, String> {
|
||||
if (query.isEmpty()) return emptyMap()
|
||||
val map = LinkedHashMap<String, String>()
|
||||
for (pair in query.split('&')) {
|
||||
if (pair.isEmpty()) continue
|
||||
val eq = pair.indexOf('=')
|
||||
if (eq < 0) {
|
||||
map[pair] = ""
|
||||
} else {
|
||||
map[pair.substring(0, eq)] = pair.substring(eq + 1)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent-decode `%XX` sequences (byte-wise; pairing payloads are ASCII
|
||||
* — IPs and 64-hex tokens). A `%` that does not start a valid
|
||||
* two-hex-digit escape is kept literally.
|
||||
*/
|
||||
private fun percentDecode(s: String): String {
|
||||
val sb = StringBuilder(s.length)
|
||||
var i = 0
|
||||
while (i < s.length) {
|
||||
val c = s[i]
|
||||
if (c == '%' && i + 2 < s.length) {
|
||||
val code = s.substring(i + 1, i + 3).toIntOrNull(16)
|
||||
if (code != null) {
|
||||
sb.append(code.toChar())
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
sb.append(c)
|
||||
i++
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user