


























@@ -69,6 +69,7 @@ import kotlinx.coroutines.withTimeout
6969import kotlinx.serialization.Serializable
7070import kotlinx.serialization.json.Json
7171import kotlinx.serialization.json.JsonArray
72+import kotlinx.serialization.json.JsonElement
7273import kotlinx.serialization.json.JsonObject
7374import kotlinx.serialization.json.JsonPrimitive
7475import kotlinx.serialization.json.buildJsonObject
@@ -301,6 +302,8 @@ class NodeRuntime(
301302val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
302303private val _nodeConnected = MutableStateFlow(false)
303304val nodeConnected: StateFlow<Boolean> = _nodeConnected.asStateFlow()
305+private val _nodeCapabilityApprovalState = MutableStateFlow(GatewayNodeApprovalState.Loading)
306+val nodeCapabilityApprovalState: StateFlow<GatewayNodeApprovalState> = _nodeCapabilityApprovalState.asStateFlow()
304307305308private val _statusText = MutableStateFlow("Offline")
306309val statusText: StateFlow<String> = _statusText.asStateFlow()
@@ -395,6 +398,7 @@ class NodeRuntime(
395398val nodesDevicesRefreshing: StateFlow<Boolean> = _nodesDevicesRefreshing.asStateFlow()
396399private val _nodesDevicesErrorText = MutableStateFlow<String?>(null)
397400val nodesDevicesErrorText: StateFlow<String?> = _nodesDevicesErrorText.asStateFlow()
401+private val nodeApprovalRefreshGuard = GatewayNodeApprovalRefreshGuard()
398402private val _channelsSummary = MutableStateFlow(GatewayChannelsSummary(channels = emptyList()))
399403val channelsSummary: StateFlow<GatewayChannelsSummary> = _channelsSummary.asStateFlow()
400404private val _channelsRefreshing = MutableStateFlow(false)
@@ -452,6 +456,7 @@ class NodeRuntime(
452456 },
453457 onDisconnected = { message ->
454458 operatorConnected = false
459+ invalidateNodeCapabilityApprovalState()
455460 operatorStatusText = message
456461_serverName.value = null
457462_remoteAddress.value = null
@@ -512,12 +517,15 @@ class NodeRuntime(
512517 publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Connect)
513518val endpoint = connectedEndpoint
514519val auth = activeGatewayAuth
515-if (endpoint != null && auth != null) {
520+if (operatorConnected) {
521+ scope.launch { refreshNodesDevicesFromGateway() }
522+ } else if (endpoint != null && auth != null) {
516523 maybeStartOperatorSessionAfterNodeConnect(endpoint, auth)
517524 }
518525 },
519526 onDisconnected = { message ->
520527_nodeConnected.value = false
528+ invalidateNodeCapabilityApprovalState()
521529 nodeStatusText = message
522530 didAutoRequestCanvasRehydrate = false
523531_canvasA2uiHydrated.value = false
@@ -2009,38 +2017,73 @@ class NodeRuntime(
20092017 }
2010201820112019private suspend fun refreshNodesDevicesFromGateway() {
2012-_nodesDevicesRefreshing.value = true
2013-_nodesDevicesErrorText.value = null
2020+val refreshGeneration = nodeApprovalRefreshGuard.begin()
2021+val refreshStarted =
2022+ nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
2023+_nodesDevicesRefreshing.value = true
2024+_nodesDevicesErrorText.value = null
2025+_nodeCapabilityApprovalState.value = GatewayNodeApprovalState.Loading
2026+ }
2027+if (!refreshStarted) return
20142028if (!operatorConnected) {
2015-_nodesDevicesSummary.value =
2016-GatewayNodesDevicesSummary(
2017- nodes = emptyList(),
2018- pendingDevices = emptyList(),
2019- pairedDevices = emptyList(),
2020- )
2021-_nodesDevicesRefreshing.value = false
2029+ nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
2030+_nodesDevicesSummary.value =
2031+GatewayNodesDevicesSummary(
2032+ nodes = emptyList(),
2033+ pendingDevices = emptyList(),
2034+ pairedDevices = emptyList(),
2035+ )
2036+_nodesDevicesRefreshing.value = false
2037+ }
20222038return
20232039 }
20242040try {
20252041val nodesRes = operatorSession.request("node.list", "{}")
20262042val nodesRoot = json.parseToJsonElement(nodesRes).asObjectOrNull()
2043+val nodes = parseGatewayNodes(nodesRoot?.get("nodes") as? JsonArray)
2044+val approvalState =
2045+ currentNodeCapabilityApprovalState(
2046+ nodes = nodes,
2047+ selfNodeId = identityStore.loadOrCreate().deviceId,
2048+ )
2049+val publishedApproval =
2050+ nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
2051+_nodeCapabilityApprovalState.value = approvalState
2052+ }
2053+if (!publishedApproval) {
2054+return
2055+ }
20272056val devicesRoot =
20282057try {
20292058val devicesRes = operatorSession.request("device.pair.list", "{}")
20302059 json.parseToJsonElement(devicesRes).asObjectOrNull()
20312060 } catch (_: Throwable) {
20322061null
20332062 }
2034-_nodesDevicesSummary.value =
2035-GatewayNodesDevicesSummary(
2036- nodes = parseGatewayNodes(nodesRoot?.get("nodes") as? JsonArray),
2037- pendingDevices = parsePendingDevices(devicesRoot?.get("pending") as? JsonArray),
2038- pairedDevices = parsePairedDevices(devicesRoot?.get("paired") as? JsonArray),
2039- devicePairingAvailable = devicesRoot != null,
2040- )
2063+ nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
2064+_nodesDevicesSummary.value =
2065+GatewayNodesDevicesSummary(
2066+ nodes = nodes,
2067+ pendingDevices = parsePendingDevices(devicesRoot?.get("pending") as? JsonArray),
2068+ pairedDevices = parsePairedDevices(devicesRoot?.get("paired") as? JsonArray),
2069+ devicePairingAvailable = devicesRoot != null,
2070+ )
2071+ }
20412072 } catch (_: Throwable) {
2042-_nodesDevicesErrorText.value = "Could not load nodes and devices."
2073+ nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
2074+_nodesDevicesErrorText.value = "Could not load nodes and devices."
2075+ }
20432076 } finally {
2077+ nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
2078+_nodesDevicesRefreshing.value = false
2079+ }
2080+ }
2081+ }
2082+2083+private fun invalidateNodeCapabilityApprovalState() {
2084+val refreshGeneration = nodeApprovalRefreshGuard.begin()
2085+ nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
2086+_nodeCapabilityApprovalState.value = GatewayNodeApprovalState.Loading
20442087_nodesDevicesRefreshing.value = false
20452088 }
20462089 }
@@ -2289,22 +2332,8 @@ class NodeRuntime(
2289233222902333private fun parseGatewayNodes(nodes: JsonArray?): List<GatewayNodeSummary> =
22912334 nodes
2292- ?.mapNotNull { item ->
2293-val obj = item.asObjectOrNull() ?: return@mapNotNull null
2294-val id = obj["nodeId"].asStringOrNull()?.trim().orEmpty()
2295-if (id.isEmpty()) return@mapNotNull null
2296-GatewayNodeSummary(
2297- id = id,
2298- displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
2299- remoteIp = obj["remoteIp"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
2300- version = obj["version"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
2301- deviceFamily = obj["deviceFamily"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
2302- paired = obj.boolean("paired"),
2303- connected = obj.boolean("connected"),
2304- capabilities = parseStringArray(obj["caps"] as? JsonArray),
2305- commands = parseStringArray(obj["commands"] as? JsonArray),
2306- )
2307- }.orEmpty()
2335+ ?.mapNotNull(::parseGatewayNodeSummary)
2336+ .orEmpty()
2308233723092338private fun parsePendingDevices(devices: JsonArray?): List<GatewayPendingDeviceSummary> =
23102339 devices
@@ -2832,6 +2861,81 @@ data class GatewayNodesDevicesSummary(
28322861val devicePairingAvailable: Boolean = true,
28332862)
283428632864+enum class GatewayNodeApprovalState {
2865+Loading,
2866+Unsupported,
2867+Approved,
2868+PendingApproval,
2869+PendingReapproval,
2870+Unapproved,
2871+}
2872+2873+/** Prevents older node.list responses from overwriting newer approval state. */
2874+internal class GatewayNodeApprovalRefreshGuard {
2875+private val lock = Any()
2876+private var generation = 0L
2877+2878+fun begin(): Long =
2879+ synchronized(lock) {
2880+ generation += 1
2881+ generation
2882+ }
2883+2884+fun publishIfCurrent(
2885+refreshGeneration: Long,
2886+publish: () -> Unit,
2887+ ): Boolean =
2888+ synchronized(lock) {
2889+if (refreshGeneration != generation) return@synchronized false
2890+ publish()
2891+true
2892+ }
2893+}
2894+2895+internal fun parseGatewayNodeApprovalState(raw: String?): GatewayNodeApprovalState =
2896+when (raw?.trim()?.lowercase()) {
2897+null, "" -> GatewayNodeApprovalState.Loading
2898+"approved" -> GatewayNodeApprovalState.Approved
2899+"pending-approval" -> GatewayNodeApprovalState.PendingApproval
2900+"pending-reapproval" -> GatewayNodeApprovalState.PendingReapproval
2901+"unapproved" -> GatewayNodeApprovalState.Unapproved
2902+else -> GatewayNodeApprovalState.Loading
2903+ }
2904+2905+internal fun currentNodeCapabilityApprovalState(
2906+nodes: List<GatewayNodeSummary>,
2907+selfNodeId: String,
2908+): GatewayNodeApprovalState =
2909+ nodes
2910+ .firstOrNull { it.id == selfNodeId }
2911+ ?.approvalState
2912+?: GatewayNodeApprovalState.Loading
2913+2914+internal fun parseGatewayNodeSummary(item: JsonElement): GatewayNodeSummary? {
2915+val obj = item.asObjectOrNull() ?: return null
2916+val id = obj["nodeId"].asStringOrNull()?.trim().orEmpty()
2917+if (id.isEmpty()) return null
2918+return GatewayNodeSummary(
2919+ id = id,
2920+ displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
2921+ remoteIp = obj["remoteIp"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
2922+ version = obj["version"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
2923+ deviceFamily = obj["deviceFamily"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
2924+ paired = obj.boolean("paired"),
2925+ connected = obj.boolean("connected"),
2926+// Only an omitted field identifies a legacy gateway; malformed and future values stay fail-closed.
2927+ approvalState =
2928+if (obj.containsKey("approvalState")) {
2929+ parseGatewayNodeApprovalState(obj["approvalState"].asStringOrNull())
2930+ } else {
2931+GatewayNodeApprovalState.Unsupported
2932+ },
2933+ pendingRequestId = obj["pendingRequestId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
2934+ capabilities = parseGatewayStringArray(obj["caps"] as? JsonArray),
2935+ commands = parseGatewayStringArray(obj["commands"] as? JsonArray),
2936+ )
2937+}
2938+28352939data class GatewayNodeSummary(
28362940val id: String,
28372941val displayName: String?,
@@ -2840,6 +2944,8 @@ data class GatewayNodeSummary(
28402944val deviceFamily: String?,
28412945val paired: Boolean,
28422946val connected: Boolean,
2947+val approvalState: GatewayNodeApprovalState,
2948+val pendingRequestId: String?,
28432949val capabilities: List<String>,
28442950val commands: List<String>,
28452951)
@@ -2962,6 +3068,11 @@ private fun JsonObject?.cronStatus(key: String): String? =
29623068 ?.trim()
29633069 ?.takeIf { it.isNotEmpty() }
296430703071+private fun parseGatewayStringArray(items: JsonArray?): List<String> =
3072+ items
3073+ ?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf { value -> value.isNotEmpty() } }
3074+ .orEmpty()
3075+29653076fun providerDisplayName(provider: String): String =
29663077when (provider.trim().lowercase()) {
29673078"openai" -> "OpenAI"
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。