mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(apps): connect multiple gateways simultaneously (#111932)
* feat(apps): support simultaneous gateways * fix(apps): satisfy multi-gateway checks * chore(apps): refresh multi-gateway baselines * style(android): format gateway fleet code
This commit is contained in:
committed by
GitHub
parent
e2bb04328f
commit
a52eb2134b
+390
-350
File diff suppressed because it is too large
Load Diff
@@ -572,6 +572,7 @@ class MainViewModel private constructor(
|
||||
val manualTls: StateFlow<Boolean> = prefs.manualTls
|
||||
val pairedGateways: StateFlow<List<GatewayRegistryEntry>> = prefs.gatewayRegistry.entries
|
||||
val activeGatewayStableId: StateFlow<String?> = prefs.gatewayRegistry.activeStableId
|
||||
val connectedGatewayStableIds: StateFlow<List<String>> = prefs.gatewayRegistry.connectedStableIds
|
||||
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
val installedAppsSharingEnabled: StateFlow<Boolean> = prefs.installedAppsSharingEnabled
|
||||
@@ -1221,6 +1222,13 @@ class MainViewModel private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setGatewayConnectionEnabled(
|
||||
stableId: String,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
ensureRuntime().setGatewayConnectionEnabled(stableId, enabled)
|
||||
}
|
||||
|
||||
fun forgetGateway(stableId: String) {
|
||||
val operation = gatewayConfigOperationSeq.incrementAndGet()
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
|
||||
@@ -1352,6 +1352,15 @@ class NodeRuntime private constructor(
|
||||
customHeadersProvider = prefs::loadGatewayCustomHeaders,
|
||||
)
|
||||
|
||||
private data class SecondaryOperatorRuntime(
|
||||
val endpoint: GatewayEndpoint,
|
||||
val session: GatewaySession,
|
||||
)
|
||||
|
||||
private val secondaryOperatorSessions = ConcurrentHashMap<String, SecondaryOperatorRuntime>()
|
||||
private val _backgroundGatewayStatuses = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val backgroundGatewayStatuses: StateFlow<Map<String, String>> = _backgroundGatewayStatuses.asStateFlow()
|
||||
|
||||
private val wearProxyController by lazy {
|
||||
WearProxyController(
|
||||
requestGateway = ::requestWearGateway,
|
||||
@@ -2684,6 +2693,7 @@ class NodeRuntime private constructor(
|
||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||
val pairedGateways: StateFlow<List<GatewayRegistryEntry>> = prefs.gatewayRegistry.entries
|
||||
val activeGatewayStableId: StateFlow<String?> = prefs.gatewayRegistry.activeStableId
|
||||
val connectedGatewayStableIds: StateFlow<List<String>> = prefs.gatewayRegistry.connectedStableIds
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
val installedAppsSharingEnabled: StateFlow<Boolean> = prefs.installedAppsSharingEnabled
|
||||
val notificationForwardingEnabled: StateFlow<Boolean> = prefs.notificationForwardingEnabled
|
||||
@@ -2701,6 +2711,7 @@ class NodeRuntime private constructor(
|
||||
private var didAutoConnect = false
|
||||
|
||||
@Volatile private var preferredGatewayReconnectSuppressed = initialReconnectSuppressed
|
||||
private val secondaryGatewayConnectionsEnabled = MutableStateFlow(!initialReconnectSuppressed)
|
||||
|
||||
val chatSessionKey: StateFlow<String> = chat.sessionKey
|
||||
val chatSessionOwnerAgentId: StateFlow<String?> = chat.sessionOwnerAgentId
|
||||
@@ -2830,6 +2841,20 @@ class NodeRuntime private constructor(
|
||||
autoConnectIfNeeded()
|
||||
}
|
||||
}
|
||||
scope.launch(Dispatchers.Default) {
|
||||
combine(
|
||||
prefs.gatewayRegistry.entries,
|
||||
prefs.gatewayRegistry.connectedStableIds,
|
||||
prefs.gatewayRegistry.activeStableId,
|
||||
gateways,
|
||||
combine(_isForeground, secondaryGatewayConnectionsEnabled) { foreground, enabled ->
|
||||
foreground && enabled
|
||||
},
|
||||
) { entries, connectedIds, activeId, discovered, shouldRun ->
|
||||
BackgroundGatewayFleetSnapshot(entries, connectedIds, activeId, discovered, shouldRun)
|
||||
}.distinctUntilChanged()
|
||||
.collect(::reconcileBackgroundGatewayFleet)
|
||||
}
|
||||
} else {
|
||||
applyScreenshotFixture()
|
||||
}
|
||||
@@ -2968,8 +2993,92 @@ class NodeRuntime private constructor(
|
||||
prefs.setLastDiscoveredStableId(list.first().stableId)
|
||||
}
|
||||
|
||||
private fun resolvePreferredGatewayEndpoint(): GatewayEndpoint? {
|
||||
val entry = prefs.gatewayRegistry.activeEntry() ?: return null
|
||||
private data class BackgroundGatewayFleetSnapshot(
|
||||
val entries: List<GatewayRegistryEntry>,
|
||||
val connectedIds: List<String>,
|
||||
val activeId: String?,
|
||||
val discovered: List<GatewayEndpoint>,
|
||||
val shouldRun: Boolean,
|
||||
)
|
||||
|
||||
private suspend fun reconcileBackgroundGatewayFleet(snapshot: BackgroundGatewayFleetSnapshot) {
|
||||
val desired =
|
||||
if (!snapshot.shouldRun) {
|
||||
emptyMap()
|
||||
} else {
|
||||
backgroundGatewayStableIds(
|
||||
entries = snapshot.entries,
|
||||
connectedIds = snapshot.connectedIds,
|
||||
activeId = snapshot.activeId,
|
||||
foreground = true,
|
||||
).asSequence()
|
||||
.mapNotNull { stableId ->
|
||||
val entry = snapshot.entries.firstOrNull { it.stableId == stableId } ?: return@mapNotNull null
|
||||
val endpoint = resolveRegistryEndpoint(entry, snapshot.discovered) ?: return@mapNotNull null
|
||||
stableId to endpoint
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
secondaryOperatorSessions.keys
|
||||
.filterNot(desired::containsKey)
|
||||
.forEach { stableId ->
|
||||
secondaryOperatorSessions.remove(stableId)?.session?.disconnectAndJoin()
|
||||
updateBackgroundGatewayStatus(stableId, null)
|
||||
}
|
||||
|
||||
for ((stableId, endpoint) in desired) {
|
||||
val existing = secondaryOperatorSessions[stableId]
|
||||
if (existing?.endpoint == endpoint) continue
|
||||
existing?.session?.disconnectAndJoin()
|
||||
val auth = resolveGatewayConnectAuth(endpoint)
|
||||
val storedOperatorEntry = loadStoredRoleDeviceAuthEntry(endpoint, "operator")
|
||||
val operatorAuth = resolveOperatorSessionConnectAuth(auth, storedOperatorEntry?.token)
|
||||
if (operatorAuth == null) {
|
||||
updateBackgroundGatewayStatus(stableId, "Needs setup")
|
||||
secondaryOperatorSessions.remove(stableId)
|
||||
continue
|
||||
}
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = scope,
|
||||
identityStore = identityStore,
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = {
|
||||
prefs.gatewayRegistry.markConnected(stableId, System.currentTimeMillis())
|
||||
updateBackgroundGatewayStatus(stableId, "Connected")
|
||||
},
|
||||
onDisconnected = { message -> updateBackgroundGatewayStatus(stableId, message) },
|
||||
onConnectFailure = { error, _ -> updateBackgroundGatewayStatus(stableId, error.message) },
|
||||
// Secondary sessions retain authenticated presence only. Focused UI state and
|
||||
// capability commands remain exclusively owned by the active runtime sessions.
|
||||
onEvent = { _, _ -> },
|
||||
customHeadersProvider = prefs::loadGatewayCustomHeaders,
|
||||
)
|
||||
secondaryOperatorSessions[stableId] = SecondaryOperatorRuntime(endpoint, session)
|
||||
updateBackgroundGatewayStatus(stableId, "Connecting…")
|
||||
val usesStoredOperatorDeviceToken =
|
||||
operatorSessionUsesStoredDeviceToken(auth, storedOperatorEntry?.token)
|
||||
session.connect(
|
||||
endpoint,
|
||||
operatorAuth.token,
|
||||
operatorAuth.bootstrapToken,
|
||||
operatorAuth.password,
|
||||
connectionManager.buildOperatorConnectOptions(
|
||||
scopes =
|
||||
operatorConnectScopesForAuth(
|
||||
usesStoredDeviceToken = usesStoredOperatorDeviceToken,
|
||||
storedOperatorScopes = storedOperatorEntry?.scopes,
|
||||
),
|
||||
),
|
||||
connectionManager.resolveTlsParams(endpoint),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveRegistryEndpoint(
|
||||
entry: GatewayRegistryEntry,
|
||||
discovered: List<GatewayEndpoint> = gateways.value,
|
||||
): GatewayEndpoint? {
|
||||
return when (entry.kind) {
|
||||
GatewayRegistryEntryKind.MANUAL -> {
|
||||
val host = entry.host?.trim().orEmpty()
|
||||
@@ -2978,13 +3087,32 @@ class NodeRuntime private constructor(
|
||||
GatewayEndpoint.manual(host = host, port = port)
|
||||
}
|
||||
GatewayRegistryEntryKind.DISCOVERED -> {
|
||||
val endpoint = gateways.value.firstOrNull { it.stableId == entry.stableId } ?: return null
|
||||
val endpoint = discovered.firstOrNull { it.stableId == entry.stableId } ?: return null
|
||||
val storedFingerprint = prefs.loadGatewayTlsFingerprint(endpoint.stableId)?.trim().orEmpty()
|
||||
endpoint.takeIf { storedFingerprint.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateBackgroundGatewayStatus(
|
||||
stableId: String,
|
||||
status: String?,
|
||||
) {
|
||||
synchronized(secondaryOperatorSessions) {
|
||||
_backgroundGatewayStatuses.value =
|
||||
if (status == null) {
|
||||
_backgroundGatewayStatuses.value - stableId
|
||||
} else {
|
||||
_backgroundGatewayStatuses.value + (stableId to status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolvePreferredGatewayEndpoint(): GatewayEndpoint? {
|
||||
val entry = prefs.gatewayRegistry.activeEntry() ?: return null
|
||||
return resolveRegistryEndpoint(entry)
|
||||
}
|
||||
|
||||
suspend fun switchToGateway(stableId: String): Boolean {
|
||||
val entry =
|
||||
prefs.gatewayRegistry.entries.value
|
||||
@@ -3007,11 +3135,20 @@ class NodeRuntime private constructor(
|
||||
return connectSwitchingGateway(endpoint)
|
||||
}
|
||||
|
||||
fun setGatewayConnectionEnabled(
|
||||
stableId: String,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
if (enabled) secondaryGatewayConnectionsEnabled.value = true
|
||||
prefs.gatewayRegistry.setConnectionEnabled(stableId, enabled)
|
||||
}
|
||||
|
||||
suspend fun connectSwitchingGateway(
|
||||
endpoint: GatewayEndpoint,
|
||||
explicitAuth: GatewayConnectAuth? = null,
|
||||
): Boolean {
|
||||
preferredGatewayReconnectSuppressed = false
|
||||
secondaryGatewayConnectionsEnabled.value = true
|
||||
val intent = gatewayLifecycleIntentSeq.incrementAndGet()
|
||||
return gatewaySwitchMutex.withLock {
|
||||
if (intent != gatewayLifecycleIntentSeq.get()) return@withLock false
|
||||
@@ -3960,6 +4097,7 @@ class NodeRuntime private constructor(
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
preferredGatewayReconnectSuppressed = false
|
||||
secondaryGatewayConnectionsEnabled.value = true
|
||||
gatewayLifecycleIntentSeq.incrementAndGet()
|
||||
launchGatewayLifecycle {
|
||||
val endpoint = connectedEndpoint
|
||||
@@ -4222,6 +4360,7 @@ class NodeRuntime private constructor(
|
||||
|
||||
fun connect(endpoint: GatewayEndpoint) {
|
||||
preferredGatewayReconnectSuppressed = false
|
||||
secondaryGatewayConnectionsEnabled.value = true
|
||||
gatewayLifecycleIntentSeq.incrementAndGet()
|
||||
launchConnect(endpoint, explicitAuth = null)
|
||||
}
|
||||
@@ -4231,6 +4370,7 @@ class NodeRuntime private constructor(
|
||||
auth: GatewayConnectAuth,
|
||||
) {
|
||||
preferredGatewayReconnectSuppressed = false
|
||||
secondaryGatewayConnectionsEnabled.value = true
|
||||
gatewayLifecycleIntentSeq.incrementAndGet()
|
||||
launchConnect(endpoint, explicitAuth = auth)
|
||||
}
|
||||
@@ -4393,7 +4533,9 @@ class NodeRuntime private constructor(
|
||||
fun disconnect() {
|
||||
synchronized(gatewayLifecycleIntentLock) {
|
||||
preferredGatewayReconnectSuppressed = true
|
||||
secondaryGatewayConnectionsEnabled.value = false
|
||||
gatewayLifecycleIntentSeq.incrementAndGet()
|
||||
disconnectSecondaryGatewayConnections()
|
||||
disconnect(retireRunState = false)
|
||||
}
|
||||
}
|
||||
@@ -4401,11 +4543,20 @@ class NodeRuntime private constructor(
|
||||
fun prepareForGatewaySetup() {
|
||||
synchronized(gatewayLifecycleIntentLock) {
|
||||
preferredGatewayReconnectSuppressed = true
|
||||
secondaryGatewayConnectionsEnabled.value = false
|
||||
gatewayLifecycleIntentSeq.incrementAndGet()
|
||||
disconnectSecondaryGatewayConnections()
|
||||
disconnect(retireRunState = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun disconnectSecondaryGatewayConnections() {
|
||||
val sessions = secondaryOperatorSessions.values.map { it.session }
|
||||
secondaryOperatorSessions.clear()
|
||||
_backgroundGatewayStatuses.value = emptyMap()
|
||||
sessions.forEach { it.disconnect() }
|
||||
}
|
||||
|
||||
private fun disconnect(retireRunState: Boolean) {
|
||||
if (wearRealtimeTalkControllerLazy.isInitialized()) wearRealtimeTalkController.abort()
|
||||
prepareDisconnect(retireRunState)
|
||||
@@ -4423,6 +4574,8 @@ class NodeRuntime private constructor(
|
||||
private suspend fun forgetGatewayLocked(stableId: String): Boolean {
|
||||
val normalized = stableId.trim()
|
||||
if (normalized.isEmpty()) return false
|
||||
secondaryOperatorSessions.remove(normalized)?.session?.disconnectAndJoin()
|
||||
updateBackgroundGatewayStatus(normalized, null)
|
||||
val wasActive = prefs.gatewayRegistry.activeStableId.value == normalized
|
||||
val connectOperationsDrained =
|
||||
synchronized(gatewayAuthLifecycleLock) {
|
||||
@@ -8108,6 +8261,17 @@ internal fun normalizeOperatorScopes(scopes: List<String>): List<String> =
|
||||
.distinct()
|
||||
.sorted()
|
||||
|
||||
internal fun backgroundGatewayStableIds(
|
||||
entries: List<GatewayRegistryEntry>,
|
||||
connectedIds: List<String>,
|
||||
activeId: String?,
|
||||
foreground: Boolean,
|
||||
): List<String> {
|
||||
if (!foreground) return emptyList()
|
||||
val registered = entries.mapTo(mutableSetOf()) { it.stableId }
|
||||
return connectedIds.distinct().filter { it != activeId && it in registered }
|
||||
}
|
||||
|
||||
private enum class HomeCanvasGatewayState {
|
||||
Connected,
|
||||
Connecting,
|
||||
|
||||
@@ -34,9 +34,15 @@ data class GatewayRegistryEntry(
|
||||
internal data class PersistedGatewayRegistry(
|
||||
val version: Int = 1,
|
||||
val activeStableId: String? = null,
|
||||
val connectedStableIds: List<String>? = null,
|
||||
val entries: List<GatewayRegistryEntry> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class PersistedGatewayRegistryVersion(
|
||||
val version: Int,
|
||||
)
|
||||
|
||||
class GatewayRegistryStore(
|
||||
private val prefs: SecurePrefs,
|
||||
private val onActiveChanged: ((String?) -> Unit)? = null,
|
||||
@@ -51,14 +57,24 @@ class GatewayRegistryStore(
|
||||
encodeDefaults = true
|
||||
}
|
||||
private val mutationLock = Any()
|
||||
private val initial = decode(prefs.getString(STORAGE_KEY))
|
||||
private val initialRaw = prefs.getString(STORAGE_KEY)
|
||||
private val initialDecode = decode(initialRaw)
|
||||
private val initial = initialDecode.registry
|
||||
private val mutationsAllowed = initialRaw == null || initialDecode.canRewrite
|
||||
private val _entries = MutableStateFlow(initial.entries.sortedForStorage())
|
||||
val entries: StateFlow<List<GatewayRegistryEntry>> = _entries.asStateFlow()
|
||||
private val _activeStableId = MutableStateFlow(initial.activeStableId)
|
||||
val activeStableId: StateFlow<String?> = _activeStableId.asStateFlow()
|
||||
private val _connectedStableIds = MutableStateFlow(initial.connectedStableIds.orEmpty())
|
||||
val connectedStableIds: StateFlow<List<String>> = _connectedStableIds.asStateFlow()
|
||||
|
||||
init {
|
||||
if (initialDecode.canRewrite && initialRaw != encodedRegistry()) persist()
|
||||
}
|
||||
|
||||
fun upsert(entry: GatewayRegistryEntry): Unit =
|
||||
synchronized(mutationLock) {
|
||||
if (!mutationsAllowed) return@synchronized
|
||||
val stableId = entry.stableId.trim()
|
||||
require(stableId.isNotEmpty()) { "Gateway stable id cannot be empty" }
|
||||
val existing = _entries.value.firstOrNull { it.stableId == stableId }
|
||||
@@ -80,36 +96,70 @@ class GatewayRegistryStore(
|
||||
|
||||
fun setActive(stableId: String?): Unit =
|
||||
synchronized(mutationLock) {
|
||||
if (!mutationsAllowed) return@synchronized
|
||||
val normalized = stableId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
require(normalized == null || _entries.value.any { it.stableId == normalized }) {
|
||||
"Active gateway must exist in the registry"
|
||||
}
|
||||
_activeStableId.value = normalized
|
||||
if (normalized != null && normalized !in _connectedStableIds.value) {
|
||||
_connectedStableIds.value = _connectedStableIds.value + normalized
|
||||
}
|
||||
persist()
|
||||
onActiveChanged?.invoke(normalized)
|
||||
}
|
||||
|
||||
fun setConnectionEnabled(
|
||||
stableId: String,
|
||||
enabled: Boolean,
|
||||
): Unit =
|
||||
synchronized(mutationLock) {
|
||||
if (!mutationsAllowed) return@synchronized
|
||||
val normalized = stableId.trim()
|
||||
require(_entries.value.any { it.stableId == normalized }) {
|
||||
"Connected gateway must exist in the registry"
|
||||
}
|
||||
_connectedStableIds.value =
|
||||
if (enabled) {
|
||||
(_connectedStableIds.value + normalized).distinct()
|
||||
} else {
|
||||
_connectedStableIds.value.filterNot { it == normalized }
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
fun connectedEntries(): List<GatewayRegistryEntry> =
|
||||
synchronized(mutationLock) {
|
||||
_connectedStableIds.value.mapNotNull { connectedId ->
|
||||
_entries.value.firstOrNull { it.stableId == connectedId }
|
||||
}
|
||||
}
|
||||
|
||||
fun markConnected(
|
||||
stableId: String,
|
||||
atMs: Long,
|
||||
): Unit =
|
||||
synchronized(mutationLock) {
|
||||
if (!mutationsAllowed) return@synchronized
|
||||
val existing = _entries.value.firstOrNull { it.stableId == stableId } ?: return
|
||||
upsert(existing.copy(lastConnectedAtMs = atMs))
|
||||
}
|
||||
|
||||
fun remove(stableId: String): Boolean =
|
||||
synchronized(mutationLock) {
|
||||
if (!mutationsAllowed) return@synchronized false
|
||||
val normalized = stableId.trim()
|
||||
val nextEntries = _entries.value.filterNot { it.stableId == normalized }
|
||||
val previousActiveStableId = _activeStableId.value
|
||||
val nextActiveStableId = previousActiveStableId?.takeUnless { it == normalized }
|
||||
if (!persistSynchronously(nextEntries, nextActiveStableId)) return@synchronized false
|
||||
val nextConnectedStableIds = _connectedStableIds.value.filterNot { it == normalized }
|
||||
if (!persistSynchronously(nextEntries, nextActiveStableId, nextConnectedStableIds)) return@synchronized false
|
||||
|
||||
// Publish only after the durable commit. Notification is post-commit and cannot turn a
|
||||
// successful removal into a failure that would cancel the database recovery marker.
|
||||
_entries.value = nextEntries
|
||||
_activeStableId.value = nextActiveStableId
|
||||
_connectedStableIds.value = nextConnectedStableIds
|
||||
if (previousActiveStableId != nextActiveStableId) {
|
||||
runCatching { onActiveChanged?.invoke(nextActiveStableId) }
|
||||
.onFailure { Log.e("GatewayRegistry", "Active-gateway observer failed after durable removal", it) }
|
||||
@@ -123,33 +173,73 @@ class GatewayRegistryStore(
|
||||
_entries.value.firstOrNull { it.stableId == activeId }
|
||||
}
|
||||
|
||||
internal fun storedActiveStableId(): String? = decode(prefs.getString(STORAGE_KEY)).activeStableId
|
||||
internal fun storedActiveStableId(): String? = decode(prefs.getString(STORAGE_KEY)).registry.activeStableId
|
||||
|
||||
private fun persist() {
|
||||
if (!mutationsAllowed) return
|
||||
prefs.putString(STORAGE_KEY, encodedRegistry())
|
||||
}
|
||||
|
||||
private fun persistSynchronously(
|
||||
entries: List<GatewayRegistryEntry>,
|
||||
activeStableId: String?,
|
||||
): Boolean = prefs.putStringSynchronously(STORAGE_KEY, encodedRegistry(entries, activeStableId))
|
||||
connectedStableIds: List<String>,
|
||||
): Boolean =
|
||||
mutationsAllowed &&
|
||||
prefs.putStringSynchronously(
|
||||
STORAGE_KEY,
|
||||
encodedRegistry(entries, activeStableId, connectedStableIds),
|
||||
)
|
||||
|
||||
private fun encodedRegistry(
|
||||
entries: List<GatewayRegistryEntry> = _entries.value,
|
||||
activeStableId: String? = _activeStableId.value,
|
||||
connectedStableIds: List<String> = _connectedStableIds.value,
|
||||
): String =
|
||||
json.encodeToString(
|
||||
PersistedGatewayRegistry(
|
||||
activeStableId = activeStableId,
|
||||
connectedStableIds =
|
||||
connectedStableIds
|
||||
.distinct()
|
||||
.filter { connectedId -> entries.any { it.stableId == connectedId } },
|
||||
entries = entries.sortedForStorage(),
|
||||
),
|
||||
)
|
||||
|
||||
private fun decode(raw: String?): PersistedGatewayRegistry =
|
||||
raw
|
||||
?.let { runCatching { json.decodeFromString<PersistedGatewayRegistry>(it) }.getOrNull() }
|
||||
?.takeIf { it.version == 1 }
|
||||
?: PersistedGatewayRegistry()
|
||||
private data class DecodedRegistry(
|
||||
val registry: PersistedGatewayRegistry,
|
||||
val canRewrite: Boolean,
|
||||
)
|
||||
|
||||
private fun decode(rawValue: String?): DecodedRegistry {
|
||||
val raw = rawValue ?: return DecodedRegistry(PersistedGatewayRegistry(), canRewrite = false)
|
||||
val version =
|
||||
runCatching { json.decodeFromString<PersistedGatewayRegistryVersion>(raw) }
|
||||
.getOrNull()
|
||||
?.version
|
||||
?.takeIf { it in 1..2 }
|
||||
?: return DecodedRegistry(PersistedGatewayRegistry(), canRewrite = false)
|
||||
val decoded =
|
||||
runCatching { json.decodeFromString<PersistedGatewayRegistry>(raw) }.getOrNull()
|
||||
?: return DecodedRegistry(PersistedGatewayRegistry(), canRewrite = false)
|
||||
val entries = decoded.entries.sortedForStorage()
|
||||
val active = decoded.activeStableId?.takeIf { activeId -> entries.any { it.stableId == activeId } }
|
||||
val connected =
|
||||
(decoded.connectedStableIds ?: if (version == 1) listOfNotNull(active) else emptyList())
|
||||
.distinct()
|
||||
.filter { connectedId -> entries.any { it.stableId == connectedId } }
|
||||
return DecodedRegistry(
|
||||
registry =
|
||||
PersistedGatewayRegistry(
|
||||
version = 1,
|
||||
activeStableId = active,
|
||||
connectedStableIds = connected,
|
||||
entries = entries,
|
||||
),
|
||||
canRewrite = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun List<GatewayRegistryEntry>.sortedForStorage(): List<GatewayRegistryEntry> = sortedWith(compareBy<GatewayRegistryEntry>({ it.name.lowercase() }, { it.stableId }))
|
||||
|
||||
@@ -22,6 +22,7 @@ internal class GatewayStoreMigration(
|
||||
json.encodeToString(
|
||||
PersistedGatewayRegistry(
|
||||
activeStableId = activeEntry?.stableId,
|
||||
connectedStableIds = listOfNotNull(activeEntry?.stableId),
|
||||
entries = listOfNotNull(activeEntry),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1612,6 +1612,7 @@ private fun GatewaySettingsScreen(
|
||||
val manualTls by viewModel.manualTls.collectAsState()
|
||||
val pairedGateways by viewModel.pairedGateways.collectAsState()
|
||||
val activeGatewayStableId by viewModel.activeGatewayStableId.collectAsState()
|
||||
val connectedGatewayStableIds by viewModel.connectedGatewayStableIds.collectAsState()
|
||||
val discoveredGateways by viewModel.gateways.collectAsState()
|
||||
val gatewayAgents by viewModel.gatewayAgents.collectAsState()
|
||||
val gatewayDefaultAgentId by viewModel.gatewayDefaultAgentId.collectAsState()
|
||||
@@ -1879,8 +1880,17 @@ private fun GatewaySettingsScreen(
|
||||
}
|
||||
},
|
||||
trailing = {
|
||||
TextButton(onClick = { pendingForgetStableId = entry.stableId }) {
|
||||
Text(nativeString("Forget"))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(
|
||||
checked = entry.stableId == activeGatewayStableId || entry.stableId in connectedGatewayStableIds,
|
||||
onCheckedChange = { enabled ->
|
||||
viewModel.setGatewayConnectionEnabled(entry.stableId, enabled)
|
||||
},
|
||||
enabled = entry.stableId != activeGatewayStableId,
|
||||
)
|
||||
TextButton(onClick = { pendingForgetStableId = entry.stableId }) {
|
||||
Text(nativeString("Forget"))
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick =
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import ai.openclaw.app.gateway.GatewayRegistryEntry
|
||||
import ai.openclaw.app.gateway.GatewayRegistryEntryKind
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class GatewayFleetSelectionTest {
|
||||
@Test
|
||||
fun focusedGatewayIsExcludedButOtherEnabledGatewaysRemain() {
|
||||
val entries = listOf(entry("alpha"), entry("beta"), entry("gamma"))
|
||||
|
||||
assertEquals(
|
||||
listOf("beta", "gamma"),
|
||||
backgroundGatewayStableIds(
|
||||
entries = entries,
|
||||
connectedIds = listOf("alpha", "beta", "gamma", "beta", "forgotten"),
|
||||
activeId = "alpha",
|
||||
foreground = true,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
emptyList<String>(),
|
||||
backgroundGatewayStableIds(
|
||||
entries = entries,
|
||||
connectedIds = listOf("alpha", "beta"),
|
||||
activeId = "alpha",
|
||||
foreground = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun entry(stableId: String) =
|
||||
GatewayRegistryEntry(
|
||||
stableId = stableId,
|
||||
kind = GatewayRegistryEntryKind.DISCOVERED,
|
||||
name = stableId,
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package ai.openclaw.app.gateway
|
||||
import ai.openclaw.app.SecurePrefs
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -30,11 +31,18 @@ class GatewayRegistryStoreTest {
|
||||
val restored = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs))
|
||||
assertEquals(listOf("alpha", "Beta"), restored.entries.value.map { it.name })
|
||||
assertEquals(alpha.stableId, restored.activeStableId.value)
|
||||
assertEquals(listOf(alpha.stableId), restored.connectedStableIds.value)
|
||||
assertEquals(42L, restored.activeEntry()?.lastConnectedAtMs)
|
||||
|
||||
restored.setConnectionEnabled(beta.stableId, true)
|
||||
assertEquals(listOf(alpha.stableId, beta.stableId), restored.connectedStableIds.value)
|
||||
restored.setConnectionEnabled(alpha.stableId, false)
|
||||
assertEquals(listOf(beta.stableId), restored.connectedStableIds.value)
|
||||
|
||||
assertTrue(restored.remove(alpha.stableId))
|
||||
assertNull(restored.activeStableId.value)
|
||||
assertEquals(listOf(beta.stableId), restored.entries.value.map { it.stableId })
|
||||
assertEquals(listOf(beta.stableId), restored.connectedStableIds.value)
|
||||
|
||||
val afterRemoval = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs))
|
||||
assertNull(afterRemoval.activeStableId.value)
|
||||
@@ -92,6 +100,53 @@ class GatewayRegistryStoreTest {
|
||||
assertFalse(store.remove(alpha.stableId))
|
||||
assertEquals(listOf(alpha.stableId), store.entries.value.map { it.stableId })
|
||||
assertEquals(alpha.stableId, store.activeStableId.value)
|
||||
assertEquals(listOf(alpha.stableId), store.connectedStableIds.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun versionOneRegistryUpgradesActiveGatewayToConnected() {
|
||||
val (_, securePrefs) = freshPrefs()
|
||||
securePrefs
|
||||
.edit()
|
||||
.putString(
|
||||
GatewayRegistryStore.STORAGE_KEY,
|
||||
"""{"version":1,"activeStableId":"manual|alpha.example|18789","entries":[{"stableId":"manual|alpha.example|18789","kind":"manual","name":"Alpha","host":"alpha.example","port":18789}]}""",
|
||||
).commit()
|
||||
|
||||
val restored = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs))
|
||||
|
||||
assertEquals(1, Json.decodeFromString<PersistedGatewayRegistry>(securePrefs.getString(GatewayRegistryStore.STORAGE_KEY, null)!!).version)
|
||||
assertEquals(listOf("manual|alpha.example|18789"), restored.connectedStableIds.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsupportedOrMalformedRegistryIsNotOverwrittenOnLaunch() {
|
||||
val (_, securePrefs) = freshPrefs()
|
||||
val unsupported = """{"version":3,"future":["keep-me"]}"""
|
||||
securePrefs.edit().putString(GatewayRegistryStore.STORAGE_KEY, unsupported).commit()
|
||||
|
||||
val unsupportedStore = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs))
|
||||
|
||||
assertTrue(unsupportedStore.entries.value.isEmpty())
|
||||
unsupportedStore.upsert(manualEntry("new", "new.example"))
|
||||
assertEquals(unsupported, securePrefs.getString(GatewayRegistryStore.STORAGE_KEY, null))
|
||||
|
||||
val malformed = "{not-json"
|
||||
securePrefs.edit().putString(GatewayRegistryStore.STORAGE_KEY, malformed).commit()
|
||||
|
||||
val malformedStore = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs))
|
||||
|
||||
assertTrue(malformedStore.entries.value.isEmpty())
|
||||
malformedStore.upsert(manualEntry("new", "new.example"))
|
||||
assertEquals(malformed, securePrefs.getString(GatewayRegistryStore.STORAGE_KEY, null))
|
||||
|
||||
val missingVersion = """{"entries":[]}"""
|
||||
securePrefs.edit().putString(GatewayRegistryStore.STORAGE_KEY, missingVersion).commit()
|
||||
|
||||
val missingVersionStore = GatewayRegistryStore(SecurePrefs(RuntimeEnvironment.getApplication(), securePrefs))
|
||||
missingVersionStore.upsert(manualEntry("new", "new.example"))
|
||||
|
||||
assertEquals(missingVersion, securePrefs.getString(GatewayRegistryStore.STORAGE_KEY, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1132,7 +1132,7 @@ extension SettingsProTab {
|
||||
Text("Paired Gateways")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
} footer: {
|
||||
Text("Switch gateways without pairing again.")
|
||||
Text("Keep multiple gateways connected and switch which one is in focus.")
|
||||
.font(OpenClawType.footnote)
|
||||
}
|
||||
}
|
||||
@@ -1141,11 +1141,14 @@ extension SettingsProTab {
|
||||
let isActive = GatewayStableIdentifier.matches(
|
||||
entry.stableID,
|
||||
self.gatewayRegistry.activeStableID)
|
||||
return Button {
|
||||
guard !isActive else { return }
|
||||
Task { await self.switchGateway(to: entry) }
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
let keepsConnected = self.gatewayRegistry.connectedStableIDs.contains {
|
||||
GatewayStableIdentifier.matches($0, entry.stableID)
|
||||
}
|
||||
return HStack(spacing: 12) {
|
||||
Button {
|
||||
guard !isActive else { return }
|
||||
Task { await self.switchGateway(to: entry) }
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(entry.name)
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
@@ -1155,20 +1158,36 @@ extension SettingsProTab {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if self.connectingGateway == .gateway(entry.id) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else if isActive {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.accessibilityLabel("Active Gateway")
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.buttonStyle(.plain)
|
||||
.disabled(self.connectingGateway != nil)
|
||||
|
||||
if self.connectingGateway == .gateway(entry.id) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else if isActive {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.accessibilityLabel("Focused Gateway")
|
||||
} else {
|
||||
Button {
|
||||
if self.gatewayController.setGatewayConnectionEnabled(
|
||||
stableID: entry.stableID,
|
||||
enabled: !keepsConnected)
|
||||
{
|
||||
self.refreshGatewayRegistry()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: keepsConnected ? "bolt.horizontal.circle.fill" : "bolt.horizontal.circle")
|
||||
.font(OpenClawType.subheadSemiBold)
|
||||
.foregroundStyle(keepsConnected ? OpenClawBrand.accent : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(keepsConnected ? "Disconnect Gateway" : "Keep Gateway Connected")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(self.connectingGateway != nil)
|
||||
.contentShape(Rectangle())
|
||||
.swipeActions {
|
||||
Button(role: .destructive) {
|
||||
self.pendingForgetGateway = entry
|
||||
|
||||
@@ -9,7 +9,7 @@ import OpenClawKit
|
||||
///
|
||||
/// Both sessions derive routing and authentication ownership from the route's
|
||||
/// `stableID`. TLS certificate pins prove transport trust but are not gateway identity.
|
||||
struct GatewayConnectConfig {
|
||||
struct GatewayConnectConfig: Sendable {
|
||||
let url: URL
|
||||
let stableID: String
|
||||
let tls: GatewayTLSParams?
|
||||
|
||||
@@ -10,6 +10,11 @@ typealias GatewayServiceEndpointResolver = @Sendable (NWEndpoint) async -> (host
|
||||
typealias GatewayForceReconnectReset = @MainActor (NodeAppModel) async -> Void
|
||||
typealias GatewayTLSFingerprintPersist = @Sendable (_ fingerprint: String, _ stableID: String) -> Bool
|
||||
|
||||
private struct GatewayOperatorFleetResolvedConfig: Sendable {
|
||||
let config: GatewayConnectConfig
|
||||
let name: String
|
||||
}
|
||||
|
||||
private enum GatewaySetupRouteProbeBudget {
|
||||
static let tcpConnectTimeoutSeconds = 2.0
|
||||
}
|
||||
@@ -92,6 +97,7 @@ final class GatewayConnectionController {
|
||||
private(set) var discoveryStatusText: String = "Idle"
|
||||
private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = []
|
||||
private(set) var pendingTrustPrompt: TrustPrompt?
|
||||
let operatorFleet = GatewayOperatorFleet()
|
||||
|
||||
private let discovery = GatewayDiscoveryModel()
|
||||
private let discoveryEnabled: Bool
|
||||
@@ -109,6 +115,7 @@ final class GatewayConnectionController {
|
||||
restoresAutoReconnect: Bool,
|
||||
suspendedConfig: GatewayConnectConfig?)?
|
||||
@ObservationIgnored private var pendingAutoConnectTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var operatorFleetReconcileTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var pendingAutoConnectGeneration: UInt64?
|
||||
@ObservationIgnored private var pendingAutoConnectSuppressionGeneration: UInt64?
|
||||
@ObservationIgnored private var pendingForgetCleanups: [
|
||||
@@ -199,6 +206,13 @@ final class GatewayConnectionController {
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
self.currentScenePhase = phase
|
||||
if phase == .active {
|
||||
self.scheduleOperatorFleetReconcile()
|
||||
} else if phase == .background {
|
||||
self.operatorFleetReconcileTask?.cancel()
|
||||
self.operatorFleetReconcileTask = nil
|
||||
self.operatorFleet.stopAll()
|
||||
}
|
||||
guard self.discoveryEnabled else {
|
||||
self.discovery.stop()
|
||||
return
|
||||
@@ -528,6 +542,15 @@ final class GatewayConnectionController {
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setGatewayConnectionEnabled(stableID: String, enabled: Bool) -> Bool {
|
||||
guard GatewaySettingsStore.setGatewayConnectionEnabled(stableID: stableID, enabled: enabled) else {
|
||||
return false
|
||||
}
|
||||
self.scheduleOperatorFleetReconcile()
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func forgetGateway(stableID: String) async -> Bool {
|
||||
guard let stableID = GatewayStableIdentifier.exact(stableID),
|
||||
@@ -550,6 +573,8 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
private func performForgetGateway(stableID: String) async -> Bool {
|
||||
self.cancelOperatorFleetReconcile()
|
||||
self.operatorFleet.stop(stableID: stableID)
|
||||
if GatewayStableIdentifier.matches(self.pendingConnectionStableID, stableID) {
|
||||
let cancellationLease = self.cancelPendingConnectionAttempts()
|
||||
self.releaseAutoConnectSuppression(after: cancellationLease)
|
||||
@@ -572,11 +597,20 @@ final class GatewayConnectionController {
|
||||
// the registry: still registered cancels, absent commits the erasure.
|
||||
guard let appModel = self.appModel,
|
||||
await appModel.stageChatOfflineDataRemoval(gatewayID: stableID)
|
||||
else { return false }
|
||||
guard GatewaySettingsStore.removeGatewayRegistryEntry(stableID: stableID) else {
|
||||
appModel.cancelChatOfflineDataRemoval(gatewayID: stableID)
|
||||
else {
|
||||
self.scheduleOperatorFleetReconcile()
|
||||
return false
|
||||
}
|
||||
guard GatewaySettingsStore.removeGatewayRegistryEntry(stableID: stableID) else {
|
||||
appModel.cancelChatOfflineDataRemoval(gatewayID: stableID)
|
||||
self.scheduleOperatorFleetReconcile()
|
||||
return false
|
||||
}
|
||||
// Discovery can schedule another reconcile while the offline-data stage awaits.
|
||||
// Invalidate its captured registry before erasing credentials, then stop any runtime
|
||||
// it recreated from the pre-commit gateway entry.
|
||||
self.cancelOperatorFleetReconcile()
|
||||
self.operatorFleet.stop(stableID: stableID)
|
||||
// Registry removal is the cross-owner commit point. Clear controller
|
||||
// artifacts before database cleanup, which may fail or be recovered on
|
||||
// a later foreground after the registry row is already gone.
|
||||
@@ -595,6 +629,7 @@ final class GatewayConnectionController {
|
||||
|
||||
Self.clearDeviceAuthTokens(gatewayID: stableID)
|
||||
_ = appModel.commitChatOfflineDataRemoval(gatewayID: stableID)
|
||||
self.scheduleOperatorFleetReconcile()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -868,6 +903,7 @@ extension GatewayConnectionController {
|
||||
self.discoveryStatusText = self.discovery.statusText
|
||||
self.discoveryDebugLog = self.discovery.debugLog
|
||||
self.updateLastDiscoveredGateway(from: newGateways)
|
||||
self.scheduleOperatorFleetReconcile()
|
||||
if allowAutoConnect {
|
||||
self.maybeAutoConnect()
|
||||
}
|
||||
@@ -1146,11 +1182,121 @@ extension GatewayConnectionController {
|
||||
cfg,
|
||||
forceReconnect: forceReconnect,
|
||||
expectedGeneration: generation)
|
||||
self.scheduleOperatorFleetReconcile()
|
||||
}
|
||||
self.pendingAutoConnectTask = task
|
||||
return true
|
||||
}
|
||||
|
||||
private func scheduleOperatorFleetReconcile() {
|
||||
self.cancelOperatorFleetReconcile()
|
||||
guard self.currentScenePhase == .active else { return }
|
||||
self.operatorFleetReconcileTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
await self.reconcileOperatorFleet()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelOperatorFleetReconcile() {
|
||||
self.operatorFleetReconcileTask?.cancel()
|
||||
self.operatorFleetReconcileTask = nil
|
||||
}
|
||||
|
||||
private func reconcileOperatorFleet() async {
|
||||
let registry = GatewaySettingsStore.loadGatewayRegistry()
|
||||
let focusedID = registry.activeStableID
|
||||
let backgroundIDs = GatewayOperatorFleet.backgroundStableIDs(
|
||||
connectedStableIDs: registry.connectedStableIDs,
|
||||
focusedStableID: focusedID)
|
||||
// Explicit focus/connection changes must take effect before unrelated discovery
|
||||
// resolution can suspend this reconciliation. Desired runtimes survive this prune.
|
||||
self.operatorFleet.reconcile(desiredStableIDs: backgroundIDs, configs: [])
|
||||
let connectedEntries = backgroundIDs.compactMap { connectedID in
|
||||
registry.entries.first {
|
||||
GatewayStableIdentifier.matches($0.stableID, connectedID)
|
||||
}
|
||||
}
|
||||
await withTaskGroup(of: GatewayOperatorFleetResolvedConfig?.self) { group in
|
||||
for entry in connectedEntries {
|
||||
group.addTask { [weak self] in
|
||||
guard !Task.isCancelled,
|
||||
let config = await self?.backgroundConnectConfig(for: entry)
|
||||
else { return nil }
|
||||
return GatewayOperatorFleetResolvedConfig(config: config, name: entry.name)
|
||||
}
|
||||
}
|
||||
|
||||
var configs: [(config: GatewayConnectConfig, name: String)] = []
|
||||
for await resolved in group {
|
||||
guard !Task.isCancelled, self.currentScenePhase == .active else {
|
||||
group.cancelAll()
|
||||
return
|
||||
}
|
||||
guard let resolved else { continue }
|
||||
configs.append((resolved.config, resolved.name))
|
||||
// Each route becomes usable independently; one stalled Bonjour resolver must
|
||||
// not hold manual or otherwise-resolved gateways behind it.
|
||||
self.operatorFleet.reconcile(desiredStableIDs: backgroundIDs, configs: configs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func backgroundConnectConfig(
|
||||
for entry: GatewaySettingsStore.GatewayRegistryEntry) async -> GatewayConnectConfig?
|
||||
{
|
||||
let stableID = entry.stableID
|
||||
let route: (URL, GatewayTLSParams?)
|
||||
switch entry.kind {
|
||||
case .manual:
|
||||
guard let host = entry.host, let port = entry.port else { return nil }
|
||||
let useTLS = self.resolveManualUseTLS(host: host, useTLS: entry.useTLS)
|
||||
let tls = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS)
|
||||
guard !useTLS || tls?.expectedFingerprint != nil,
|
||||
let url = self.buildGatewayURL(
|
||||
host: host,
|
||||
port: port,
|
||||
useTLS: tls?.required == true)
|
||||
else { return nil }
|
||||
route = (url, tls)
|
||||
case .discovered:
|
||||
guard let gateway = self.gateways.first(where: {
|
||||
GatewayStableIdentifier.matches($0.stableID, stableID)
|
||||
}), let fingerprint = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
else { return nil }
|
||||
let target = if let serviceEndpointResolver {
|
||||
await serviceEndpointResolver(gateway.endpoint)
|
||||
} else {
|
||||
await self.resolveServiceEndpoint(gateway.endpoint)
|
||||
}
|
||||
guard let target,
|
||||
let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true)
|
||||
else { return nil }
|
||||
route = (
|
||||
url,
|
||||
GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: fingerprint,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID))
|
||||
}
|
||||
|
||||
let credentials = GatewaySettingsStore.loadGatewayCredentials(
|
||||
instanceId: GatewaySettingsStore.currentInstanceID(),
|
||||
gatewayStableID: stableID)
|
||||
let nodeOptions = await self.makeConnectOptions(
|
||||
stableID: stableID,
|
||||
deviceAuthGatewayID: GatewaySettingsStore.authenticationOwnerID(routeStableID: stableID),
|
||||
allowStoredDeviceAuth: !credentials.suppressStoredDeviceAuth)
|
||||
return GatewayConnectConfig(
|
||||
url: route.0,
|
||||
stableID: stableID,
|
||||
tls: route.1,
|
||||
token: credentials.token,
|
||||
bootstrapToken: credentials.bootstrapToken,
|
||||
password: credentials.password,
|
||||
nodeOptions: nodeOptions)
|
||||
}
|
||||
|
||||
private func resolveDiscoveredTLSParams(
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
|
||||
{
|
||||
@@ -1375,6 +1521,10 @@ extension GatewayConnectionController {
|
||||
self.autoConnectSuppressionGeneration != nil
|
||||
}
|
||||
|
||||
func _test_hasOperatorFleetReconcileTask() -> Bool {
|
||||
self.operatorFleetReconcileTask != nil
|
||||
}
|
||||
|
||||
func _test_resolveDiscoveredTLSParams(
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
|
||||
{
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
|
||||
/// Keeps operator sessions for non-focused gateways live in the foreground.
|
||||
/// The focused gateway remains owned by `NodeAppModel`, including its capability-bearing
|
||||
/// node session. This fleet therefore cannot route camera, screen, or device commands.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GatewayOperatorFleet {
|
||||
nonisolated static func backgroundStableIDs(
|
||||
connectedStableIDs: [String],
|
||||
focusedStableID: String?) -> [String]
|
||||
{
|
||||
var seen = Set<GatewayStableIdentifier.Key>()
|
||||
return connectedStableIDs.filter { stableID in
|
||||
guard !GatewayStableIdentifier.matches(stableID, focusedStableID),
|
||||
let key = GatewayStableIdentifier.key(stableID)
|
||||
else { return false }
|
||||
return seen.insert(key).inserted
|
||||
}
|
||||
}
|
||||
|
||||
enum ConnectionState: String, Sendable {
|
||||
case connecting
|
||||
case connected
|
||||
case offline
|
||||
case needsAttention
|
||||
}
|
||||
|
||||
struct Status: Identifiable, Sendable, Equatable {
|
||||
let stableID: String
|
||||
var name: String
|
||||
var state: ConnectionState
|
||||
var detail: String?
|
||||
|
||||
var id: String {
|
||||
self.stableID
|
||||
}
|
||||
}
|
||||
|
||||
private final class Runtime {
|
||||
let id = UUID()
|
||||
let session = GatewayNodeSession()
|
||||
var config: GatewayConnectConfig
|
||||
var name: String
|
||||
var task: Task<Void, Never>?
|
||||
|
||||
init(config: GatewayConnectConfig, name: String) {
|
||||
self.config = config
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var statuses: [Status] = []
|
||||
@ObservationIgnored private var runtimes: [GatewayStableIdentifier.Key: Runtime] = [:]
|
||||
|
||||
func reconcile(
|
||||
desiredStableIDs: [String],
|
||||
configs: [(config: GatewayConnectConfig, name: String)])
|
||||
{
|
||||
let desiredKeys = Set(desiredStableIDs.compactMap(GatewayStableIdentifier.key))
|
||||
var desired: [GatewayStableIdentifier.Key: (GatewayConnectConfig, String)] = [:]
|
||||
for item in configs {
|
||||
guard let key = GatewayStableIdentifier.key(item.config.effectiveStableID),
|
||||
desiredKeys.contains(key)
|
||||
else { continue }
|
||||
desired[key] = (item.config, item.name)
|
||||
}
|
||||
|
||||
// Endpoint resolution is transient for discovered gateways. Keep a healthy runtime on
|
||||
// its last proven route until the user disables, forgets, or focuses that gateway.
|
||||
for key in self.runtimes.keys where !desiredKeys.contains(key) {
|
||||
self.stopRuntime(key: key)
|
||||
}
|
||||
for (key, item) in desired {
|
||||
if let runtime = self.runtimes[key],
|
||||
runtime.task != nil,
|
||||
runtime.config.hasSameConnectionInputs(as: item.0)
|
||||
{
|
||||
runtime.name = item.1
|
||||
self.setStatus(
|
||||
stableID: item.0.effectiveStableID,
|
||||
name: item.1,
|
||||
preservingState: true)
|
||||
continue
|
||||
}
|
||||
self.stopRuntime(key: key)
|
||||
self.startRuntime(config: item.0, name: item.1, key: key)
|
||||
}
|
||||
self.sortStatuses()
|
||||
}
|
||||
|
||||
func stop(stableID: String) {
|
||||
guard let key = GatewayStableIdentifier.key(stableID) else { return }
|
||||
self.stopRuntime(key: key)
|
||||
}
|
||||
|
||||
func stopAll() {
|
||||
for key in Array(self.runtimes.keys) {
|
||||
self.stopRuntime(key: key)
|
||||
}
|
||||
}
|
||||
|
||||
private func startRuntime(
|
||||
config: GatewayConnectConfig,
|
||||
name: String,
|
||||
key: GatewayStableIdentifier.Key)
|
||||
{
|
||||
let runtime = Runtime(config: config, name: name)
|
||||
self.runtimes[key] = runtime
|
||||
self.setStatus(
|
||||
stableID: config.effectiveStableID,
|
||||
name: name,
|
||||
state: .connecting,
|
||||
detail: nil)
|
||||
runtime.task = Task { @MainActor [weak self, weak runtime] in
|
||||
guard let self, let runtime else { return }
|
||||
await self.run(runtime: runtime, key: key)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopRuntime(key: GatewayStableIdentifier.Key) {
|
||||
guard let runtime = self.runtimes.removeValue(forKey: key) else { return }
|
||||
runtime.task?.cancel()
|
||||
runtime.task = nil
|
||||
self.statuses.removeAll { GatewayStableIdentifier.matches($0.stableID, runtime.config.effectiveStableID) }
|
||||
Task {
|
||||
await runtime.session.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func run(runtime: Runtime, key: GatewayStableIdentifier.Key) async {
|
||||
var attempt = 0
|
||||
while !Task.isCancelled, self.runtimes[key]?.id == runtime.id {
|
||||
let config = runtime.config
|
||||
self.setStatus(
|
||||
stableID: config.effectiveStableID,
|
||||
name: runtime.name,
|
||||
state: attempt == 0 ? .connecting : .offline,
|
||||
detail: attempt == 0 ? nil : String(localized: "Reconnecting…"))
|
||||
|
||||
let options = Self.operatorOptions(from: config.nodeOptions)
|
||||
let sessionBox = config.tls.map {
|
||||
WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0))
|
||||
}
|
||||
let runtimeID = runtime.id
|
||||
do {
|
||||
try await runtime.session.connect(
|
||||
url: config.url,
|
||||
credentials: GatewayNodeSessionCredentials(
|
||||
token: config.token,
|
||||
bootstrapToken: config.bootstrapToken,
|
||||
password: config.password),
|
||||
connectOptions: options,
|
||||
sessionBox: sessionBox,
|
||||
extraHeadersProvider: {
|
||||
GatewaySettingsStore.loadGatewayCustomHeaders(
|
||||
gatewayStableID: config.effectiveStableID)
|
||||
},
|
||||
onConnected: { [weak self] in
|
||||
await MainActor.run {
|
||||
guard let self, let runtime = self.runtimes[key], runtime.id == runtimeID else { return }
|
||||
self.setStatus(
|
||||
stableID: config.effectiveStableID,
|
||||
name: runtime.name,
|
||||
state: .connected,
|
||||
detail: nil)
|
||||
_ = GatewaySettingsStore.markGatewayConnected(
|
||||
stableID: config.effectiveStableID,
|
||||
atMs: Int(Date().timeIntervalSince1970 * 1000))
|
||||
}
|
||||
},
|
||||
onDisconnected: { [weak self] reason in
|
||||
await MainActor.run {
|
||||
guard let self, let runtime = self.runtimes[key], runtime.id == runtimeID else { return }
|
||||
self.setStatus(
|
||||
stableID: config.effectiveStableID,
|
||||
name: runtime.name,
|
||||
state: .offline,
|
||||
detail: reason)
|
||||
}
|
||||
},
|
||||
onInvoke: { request in
|
||||
BridgeInvokeResponse(
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: background operator sessions cannot invoke node commands"))
|
||||
})
|
||||
attempt = 0
|
||||
try await Task.sleep(for: .seconds(1))
|
||||
} catch is CancellationError {
|
||||
break
|
||||
} catch {
|
||||
guard !Task.isCancelled, self.runtimes[key]?.id == runtime.id else { break }
|
||||
attempt += 1
|
||||
let problem = GatewayConnectionProblemMapper.map(error: error)
|
||||
let pauses = problem?.pauseReconnect == true || problem?.needsPairingApproval == true
|
||||
self.setStatus(
|
||||
stableID: config.effectiveStableID,
|
||||
name: runtime.name,
|
||||
state: pauses ? .needsAttention : .offline,
|
||||
detail: problem?.message ?? error.localizedDescription)
|
||||
if pauses { break }
|
||||
let delay = min(pow(2.0, Double(min(attempt, 5))), 30.0)
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
}
|
||||
}
|
||||
if self.runtimes[key]?.id == runtime.id {
|
||||
// A paused auth failure deliberately leaves its status visible, but the
|
||||
// finished task must not make a later reconciliation look connected.
|
||||
runtime.task = nil
|
||||
}
|
||||
await runtime.session.disconnect()
|
||||
}
|
||||
|
||||
private static func operatorOptions(from nodeOptions: GatewayConnectOptions) -> GatewayConnectOptions {
|
||||
GatewayConnectOptions(
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
|
||||
caps: [OpenClawGatewayClientCapability.inlineWidgets],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: nodeOptions.clientId,
|
||||
clientMode: "ui",
|
||||
clientDisplayName: nodeOptions.clientDisplayName,
|
||||
includeDeviceIdentity: true,
|
||||
allowStoredDeviceAuth: nodeOptions.allowStoredDeviceAuth,
|
||||
deviceAuthGatewayID: nodeOptions.deviceAuthGatewayID)
|
||||
}
|
||||
|
||||
private func setStatus(
|
||||
stableID: String,
|
||||
name: String,
|
||||
state: ConnectionState? = nil,
|
||||
detail: String? = nil,
|
||||
preservingState: Bool = false)
|
||||
{
|
||||
if let index = self.statuses.firstIndex(where: {
|
||||
GatewayStableIdentifier.matches($0.stableID, stableID)
|
||||
}) {
|
||||
self.statuses[index].name = name
|
||||
if !preservingState, let state {
|
||||
self.statuses[index].state = state
|
||||
self.statuses[index].detail = detail
|
||||
}
|
||||
} else {
|
||||
self.statuses.append(Status(
|
||||
stableID: stableID,
|
||||
name: name,
|
||||
state: state ?? .offline,
|
||||
detail: detail))
|
||||
}
|
||||
self.sortStatuses()
|
||||
}
|
||||
|
||||
private func sortStatuses() {
|
||||
self.statuses.sort { lhs, rhs in
|
||||
if lhs.name != rhs.name { return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending }
|
||||
return GatewayStableIdentifier.sortsBefore(lhs.stableID, rhs.stableID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import OpenClawKit
|
||||
|
||||
extension GatewaySettingsStore {
|
||||
struct GatewayRegistry: Codable, Equatable {
|
||||
var version: Int = 1
|
||||
var activeStableID: String?
|
||||
/// Gateways whose operator sessions should stay live. `activeStableID`
|
||||
/// is only the UI focus and does not imply exclusive connectivity.
|
||||
var connectedStableIDs: [String] = []
|
||||
var entries: [GatewayRegistryEntry] = []
|
||||
|
||||
static let empty = GatewayRegistry()
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case version
|
||||
case activeStableID
|
||||
case connectedStableIDs
|
||||
case entries
|
||||
}
|
||||
|
||||
init(
|
||||
version: Int = 1,
|
||||
activeStableID: String? = nil,
|
||||
connectedStableIDs: [String] = [],
|
||||
entries: [GatewayRegistryEntry] = [])
|
||||
{
|
||||
self.version = version
|
||||
self.activeStableID = activeStableID
|
||||
self.connectedStableIDs = connectedStableIDs
|
||||
self.entries = entries
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let version = try values.decode(Int.self, forKey: .version)
|
||||
let activeStableID = try values.decodeIfPresent(String.self, forKey: .activeStableID)
|
||||
self.version = version
|
||||
self.activeStableID = activeStableID
|
||||
self.connectedStableIDs = try values.decodeIfPresent(
|
||||
[String].self,
|
||||
forKey: .connectedStableIDs) ?? (version == 1 ? activeStableID.map { [$0] } ?? [] : [])
|
||||
self.entries = try values.decodeIfPresent([GatewayRegistryEntry].self, forKey: .entries) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func setActiveGateway(stableID: String) -> Bool {
|
||||
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return false }
|
||||
var registry = self.loadGatewayRegistry()
|
||||
guard let storedID = registry.entries.first(where: {
|
||||
GatewayStableIdentifier.matches($0.stableID, stableID)
|
||||
})?.stableID else { return false }
|
||||
registry.activeStableID = storedID
|
||||
if !registry.connectedStableIDs.contains(where: {
|
||||
GatewayStableIdentifier.matches($0, storedID)
|
||||
}) {
|
||||
registry.connectedStableIDs.append(storedID)
|
||||
}
|
||||
return self.saveGatewayRegistry(registry)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func setGatewayConnectionEnabled(stableID: String, enabled: Bool) -> Bool {
|
||||
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return false }
|
||||
var registry = self.loadGatewayRegistry()
|
||||
guard let storedID = registry.entries.first(where: {
|
||||
GatewayStableIdentifier.matches($0.stableID, stableID)
|
||||
})?.stableID else { return false }
|
||||
registry.connectedStableIDs.removeAll {
|
||||
GatewayStableIdentifier.matches($0, storedID)
|
||||
}
|
||||
if enabled {
|
||||
registry.connectedStableIDs.append(storedID)
|
||||
}
|
||||
return self.saveGatewayRegistry(registry)
|
||||
}
|
||||
|
||||
static func connectedGatewayEntries() -> [GatewayRegistryEntry] {
|
||||
let registry = self.loadGatewayRegistry()
|
||||
return registry.connectedStableIDs.compactMap { connectedID in
|
||||
registry.entries.first {
|
||||
GatewayStableIdentifier.matches($0.stableID, connectedID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,8 @@ enum GatewaySettingsStore {
|
||||
private static let gatewayCustomHeadersService = "ai.openclawfoundation.app.gateway.custom-headers"
|
||||
private static let talkProviderApiKeyAccountPrefix = "provider.apiKey." // pragma: allowlist secret
|
||||
|
||||
struct GatewayRegistryEntry: Codable, Equatable, Identifiable {
|
||||
enum Kind: String, Codable {
|
||||
struct GatewayRegistryEntry: Codable, Equatable, Identifiable, Sendable {
|
||||
enum Kind: String, Codable, Sendable {
|
||||
case manual
|
||||
case discovered
|
||||
}
|
||||
@@ -87,14 +87,6 @@ enum GatewaySettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayRegistry: Codable, Equatable {
|
||||
var version: Int = 1
|
||||
var activeStableID: String?
|
||||
var entries: [GatewayRegistryEntry] = []
|
||||
|
||||
static let empty = GatewayRegistry()
|
||||
}
|
||||
|
||||
struct GatewayCredentialMetadata: Codable, Equatable {
|
||||
let gatewayStableID: String
|
||||
let suppressStoredDeviceAuth: Bool
|
||||
@@ -480,7 +472,7 @@ enum GatewaySettingsStore {
|
||||
account: self.gatewayRegistryAccount),
|
||||
let data = json.data(using: .utf8),
|
||||
let registry = try? JSONDecoder().decode(GatewayRegistry.self, from: data),
|
||||
registry.version == 1
|
||||
(1...2).contains(registry.version)
|
||||
else { return .empty }
|
||||
return self.normalizedGatewayRegistry(registry)
|
||||
}
|
||||
@@ -507,21 +499,15 @@ enum GatewaySettingsStore {
|
||||
}
|
||||
if activate {
|
||||
registry.activeStableID = normalized.stableID
|
||||
if !registry.connectedStableIDs.contains(where: {
|
||||
GatewayStableIdentifier.matches($0, normalized.stableID)
|
||||
}) {
|
||||
registry.connectedStableIDs.append(normalized.stableID)
|
||||
}
|
||||
}
|
||||
return self.saveGatewayRegistry(registry)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func setActiveGateway(stableID: String) -> Bool {
|
||||
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return false }
|
||||
var registry = self.loadGatewayRegistry()
|
||||
guard let storedID = registry.entries.first(where: {
|
||||
GatewayStableIdentifier.matches($0.stableID, stableID)
|
||||
})?.stableID else { return false }
|
||||
registry.activeStableID = storedID
|
||||
return self.saveGatewayRegistry(registry)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func markGatewayConnected(stableID: String, atMs: Int) -> Bool {
|
||||
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return false }
|
||||
@@ -538,6 +524,7 @@ enum GatewaySettingsStore {
|
||||
guard let stableID = GatewayStableIdentifier.exact(stableID) else { return false }
|
||||
var registry = self.loadGatewayRegistry()
|
||||
registry.entries.removeAll { GatewayStableIdentifier.matches($0.stableID, stableID) }
|
||||
registry.connectedStableIDs.removeAll { GatewayStableIdentifier.matches($0, stableID) }
|
||||
if GatewayStableIdentifier.matches(registry.activeStableID, stableID) {
|
||||
registry.activeStableID = nil
|
||||
}
|
||||
@@ -576,7 +563,8 @@ enum GatewaySettingsStore {
|
||||
self.removeLastGatewayDefaults(defaults)
|
||||
}
|
||||
|
||||
private static func saveGatewayRegistry(_ registry: GatewayRegistry) -> Bool {
|
||||
static func saveGatewayRegistry(_ registry: GatewayRegistry) -> Bool {
|
||||
guard self.gatewayRegistryMutationsAllowed() else { return false }
|
||||
let normalized = self.normalizedGatewayRegistry(registry)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
@@ -589,6 +577,17 @@ enum GatewaySettingsStore {
|
||||
account: self.gatewayRegistryAccount)
|
||||
}
|
||||
|
||||
private static func gatewayRegistryMutationsAllowed() -> Bool {
|
||||
guard let json = KeychainStore.loadString(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayRegistryAccount)
|
||||
else { return true }
|
||||
guard let data = json.data(using: .utf8),
|
||||
let registry = try? JSONDecoder().decode(GatewayRegistry.self, from: data)
|
||||
else { return false }
|
||||
return (1...2).contains(registry.version)
|
||||
}
|
||||
|
||||
private static func normalizedGatewayRegistry(_ registry: GatewayRegistry) -> GatewayRegistry {
|
||||
var seen = Set<GatewayStableIdentifier.Key>()
|
||||
let entries = registry.entries
|
||||
@@ -606,7 +605,19 @@ enum GatewaySettingsStore {
|
||||
GatewayStableIdentifier.matches($0.stableID, activeID)
|
||||
})?.stableID
|
||||
}
|
||||
return GatewayRegistry(version: 1, activeStableID: activeStableID, entries: entries)
|
||||
var seenConnected = Set<GatewayStableIdentifier.Key>()
|
||||
let connectedStableIDs: [String] = registry.connectedStableIDs.compactMap { connectedID in
|
||||
guard let entry = entries.first(where: {
|
||||
GatewayStableIdentifier.matches($0.stableID, connectedID)
|
||||
}), let key = GatewayStableIdentifier.key(entry.stableID), seenConnected.insert(key).inserted
|
||||
else { return nil }
|
||||
return entry.stableID
|
||||
}
|
||||
return GatewayRegistry(
|
||||
version: 1,
|
||||
activeStableID: activeStableID,
|
||||
connectedStableIDs: connectedStableIDs,
|
||||
entries: entries)
|
||||
}
|
||||
|
||||
private static func normalizedGatewayRegistryEntry(
|
||||
@@ -637,7 +648,15 @@ enum GatewaySettingsStore {
|
||||
}
|
||||
|
||||
private static func migrateGatewayRegistryIfNeeded(defaults: UserDefaults = .standard) {
|
||||
if KeychainStore.loadString(service: self.gatewayService, account: self.gatewayRegistryAccount) != nil {
|
||||
if let json = KeychainStore.loadString(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayRegistryAccount)
|
||||
{
|
||||
guard let data = json.data(using: .utf8),
|
||||
let registry = try? JSONDecoder().decode(GatewayRegistry.self, from: data),
|
||||
(1...2).contains(registry.version)
|
||||
else { return }
|
||||
_ = self.saveGatewayRegistry(registry)
|
||||
_ = KeychainStore.delete(service: self.gatewayService, account: self.lastGatewayConnectionAccount)
|
||||
self.removeLastGatewayDefaults(defaults)
|
||||
return
|
||||
@@ -645,7 +664,10 @@ enum GatewaySettingsStore {
|
||||
|
||||
let legacy = self.loadLegacyLastGatewayConnection(defaults: defaults)
|
||||
guard let entry = legacy.flatMap(self.gatewayRegistryEntry(from:)) else { return }
|
||||
let registry = GatewayRegistry(activeStableID: entry.stableID, entries: [entry])
|
||||
let registry = GatewayRegistry(
|
||||
activeStableID: entry.stableID,
|
||||
connectedStableIDs: [entry.stableID],
|
||||
entries: [entry])
|
||||
guard self.saveGatewayRegistry(registry) else { return }
|
||||
_ = KeychainStore.delete(service: self.gatewayService, account: self.lastGatewayConnectionAccount)
|
||||
self.removeLastGatewayDefaults(defaults)
|
||||
|
||||
@@ -162,6 +162,19 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct GatewayConnectionControllerTests {
|
||||
@Test @MainActor func `background cancels operator fleet reconciliation`() {
|
||||
let appModel = NodeAppModel()
|
||||
defer { appModel.disconnectGateway() }
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
controller.setScenePhase(.active)
|
||||
#expect(controller._test_hasOperatorFleetReconcileTask())
|
||||
controller.setScenePhase(.background)
|
||||
|
||||
#expect(!controller._test_hasOperatorFleetReconcileTask())
|
||||
#expect(controller.operatorFleet.statuses.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func `chat owner survives reconnect while session refresh identity changes`() {
|
||||
let appModel = NodeAppModel()
|
||||
let disconnectedOwner = appModel.chatViewModelOwnerID
|
||||
@@ -576,6 +589,23 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn
|
||||
#expect(lhs.hasSameConnectionInputs(as: rhs))
|
||||
}
|
||||
|
||||
@Test @MainActor func `operator fleet retains enabled runtime during endpoint gap`() {
|
||||
let fleet = GatewayOperatorFleet()
|
||||
let config = Self.makeGatewayConnectConfig(stableID: "bonjour|secondary")
|
||||
defer { fleet.stopAll() }
|
||||
|
||||
fleet.reconcile(
|
||||
desiredStableIDs: [config.stableID],
|
||||
configs: [(config: config, name: "Secondary")])
|
||||
#expect(fleet.statuses.map(\.stableID) == [config.stableID])
|
||||
|
||||
fleet.reconcile(desiredStableIDs: [config.stableID], configs: [])
|
||||
#expect(fleet.statuses.map(\.stableID) == [config.stableID])
|
||||
|
||||
fleet.reconcile(desiredStableIDs: [], configs: [])
|
||||
#expect(fleet.statuses.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func `same target retry unpauses retained pairing problem`() {
|
||||
let appModel = NodeAppModel()
|
||||
defer { appModel.disconnectGateway() }
|
||||
|
||||
@@ -710,18 +710,110 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
|
||||
#expect(GatewaySettingsStore.markGatewayConnected(stableID: gatewayB.stableID, atMs: 1234))
|
||||
let firstJSON = KeychainStore.loadString(service: gatewayService, account: "gateway-registry")
|
||||
let registry = GatewaySettingsStore.loadGatewayRegistry()
|
||||
#expect(registry.version == 1)
|
||||
#expect(registry.entries.map(\.stableID) == [gatewayA.stableID, gatewayB.stableID])
|
||||
#expect(registry.activeStableID == gatewayB.stableID)
|
||||
#expect(registry.connectedStableIDs == [gatewayB.stableID])
|
||||
#expect(GatewaySettingsStore.connectedGatewayEntries().map(\.stableID) == [gatewayB.stableID])
|
||||
#expect(registry.entries.last?.lastConnectedAtMs == 1234)
|
||||
|
||||
#expect(GatewaySettingsStore.upsertGatewayRegistryEntry(gatewayA))
|
||||
#expect(KeychainStore.loadString(service: gatewayService, account: "gateway-registry") == firstJSON)
|
||||
|
||||
#expect(GatewaySettingsStore.setActiveGateway(stableID: gatewayA.stableID))
|
||||
#expect(GatewaySettingsStore.loadGatewayRegistry().connectedStableIDs == [
|
||||
gatewayB.stableID,
|
||||
gatewayA.stableID,
|
||||
])
|
||||
#expect(GatewaySettingsStore.setGatewayConnectionEnabled(
|
||||
stableID: gatewayB.stableID,
|
||||
enabled: false))
|
||||
#expect(GatewaySettingsStore.connectedGatewayEntries() == [gatewayA])
|
||||
|
||||
#expect(GatewaySettingsStore.removeGatewayRegistryEntry(stableID: gatewayB.stableID))
|
||||
#expect(GatewaySettingsStore.loadGatewayRegistry().entries == [gatewayA])
|
||||
#expect(GatewaySettingsStore.activeGatewayEntry() == nil)
|
||||
#expect(GatewaySettingsStore.activeGatewayEntry() == gatewayA)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `version one registry upgrades focused gateway to connected`() {
|
||||
withLastGatewaySnapshot {
|
||||
applyKeychain([
|
||||
gatewayRegistryKeychainEntry:
|
||||
#"{"version":1,"activeStableID":"bonjour|alpha","entries":[{"stableID":"bonjour|alpha","kind":"discovered","name":"Alpha","useTLS":true}]}"#,
|
||||
lastGatewayKeychainEntry: nil,
|
||||
])
|
||||
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
|
||||
let registry = GatewaySettingsStore.loadGatewayRegistry()
|
||||
#expect(registry.version == 1)
|
||||
#expect(registry.activeStableID == "bonjour|alpha")
|
||||
#expect(registry.connectedStableIDs == ["bonjour|alpha"])
|
||||
#expect(KeychainStore.loadString(
|
||||
service: gatewayService,
|
||||
account: "gateway-registry")?.contains("connectedStableIDs") == true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `version two registry without connectivity does not enable focus`() {
|
||||
withLastGatewaySnapshot {
|
||||
applyKeychain([
|
||||
gatewayRegistryKeychainEntry:
|
||||
#"{"version":2,"activeStableID":"bonjour|alpha","entries":[{"stableID":"bonjour|alpha","kind":"discovered","name":"Alpha","useTLS":true}]}"#,
|
||||
lastGatewayKeychainEntry: nil,
|
||||
])
|
||||
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
|
||||
let registry = GatewaySettingsStore.loadGatewayRegistry()
|
||||
#expect(registry.version == 1)
|
||||
#expect(registry.activeStableID == "bonjour|alpha")
|
||||
#expect(registry.connectedStableIDs.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `newer registry blocks pairing mutations without overwriting`() {
|
||||
withLastGatewaySnapshot {
|
||||
let unsupported = #"{"version":3,"future":["keep-me"]}"#
|
||||
applyKeychain([
|
||||
gatewayRegistryKeychainEntry: unsupported,
|
||||
lastGatewayKeychainEntry: nil,
|
||||
])
|
||||
|
||||
#expect(!GatewaySettingsStore.upsertGatewayRegistryEntry(.init(
|
||||
stableID: "bonjour|new",
|
||||
kind: .discovered,
|
||||
name: "New",
|
||||
host: nil,
|
||||
port: nil,
|
||||
useTLS: true,
|
||||
lastConnectedAtMs: nil)))
|
||||
#expect(KeychainStore.loadString(
|
||||
service: gatewayService,
|
||||
account: "gateway-registry") == unsupported)
|
||||
|
||||
let missingVersion = #"{"entries":[]}"#
|
||||
applyKeychain([gatewayRegistryKeychainEntry: missingVersion])
|
||||
#expect(!GatewaySettingsStore.upsertGatewayRegistryEntry(.init(
|
||||
stableID: "bonjour|new",
|
||||
kind: .discovered,
|
||||
name: "New",
|
||||
host: nil,
|
||||
port: nil,
|
||||
useTLS: true,
|
||||
lastConnectedAtMs: nil)))
|
||||
#expect(KeychainStore.loadString(
|
||||
service: gatewayService,
|
||||
account: "gateway-registry") == missingVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `operator fleet excludes focus and deduplicates background gateways`() {
|
||||
#expect(GatewayOperatorFleet.backgroundStableIDs(
|
||||
connectedStableIDs: ["alpha", "beta", "beta", "gamma"],
|
||||
focusedStableID: "alpha") == ["beta", "gamma"])
|
||||
}
|
||||
|
||||
@Test func `registry preserves byte-distinct unicode gateway owners`() {
|
||||
withLastGatewaySnapshot {
|
||||
applyKeychain([gatewayRegistryKeychainEntry: nil, lastGatewayKeychainEntry: nil])
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use mdns_sd::{ResolvedService, ServiceDaemon, ServiceEvent};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use tauri::Url;
|
||||
use tauri::{Manager, Url, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
const GATEWAY_SERVICE_TYPE: &str = "_openclaw-gw._tcp.local.";
|
||||
|
||||
@@ -216,6 +217,38 @@ impl GatewayDiscovery {
|
||||
.map_err(|_| "The discovered gateway returned an invalid port.".to_string())?;
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn gateway_name(&self, host: &str, port: u16, tls: bool) -> Result<String, String> {
|
||||
let host = validated_service_host(host)
|
||||
.ok_or_else(|| "The discovered gateway returned an invalid host.".to_string())?;
|
||||
self.gateways
|
||||
.lock()
|
||||
.map_err(|_| "Gateway discovery snapshot is unavailable.".to_string())?
|
||||
.values()
|
||||
.find(|gateway| gateway.host == host && gateway.port == port && gateway.tls == tls)
|
||||
.map(|gateway| gateway.name.clone())
|
||||
.ok_or_else(|| "The discovered gateway is no longer available.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn gateway_window_label(url: &Url) -> String {
|
||||
let host = url
|
||||
.host_str()
|
||||
.unwrap_or_default()
|
||||
.trim_end_matches('.')
|
||||
.to_ascii_lowercase();
|
||||
let route = format!(
|
||||
"{}://{}:{}",
|
||||
url.scheme(),
|
||||
host,
|
||||
url.port_or_known_default().unwrap_or_default()
|
||||
);
|
||||
let digest = Sha256::digest(route.as_bytes());
|
||||
let suffix = digest[..12]
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
format!("gateway-{suffix}")
|
||||
}
|
||||
|
||||
fn apply_event(gateways: &GatewayMap, event: ServiceEvent) -> bool {
|
||||
@@ -422,14 +455,34 @@ pub fn discover_gateways(
|
||||
#[tauri::command]
|
||||
pub fn connect_discovered_gateway(
|
||||
app: tauri::AppHandle,
|
||||
desktop: tauri::State<'_, crate::DesktopState>,
|
||||
discovery: tauri::State<'_, GatewayDiscovery>,
|
||||
host: String,
|
||||
port: u16,
|
||||
tls: bool,
|
||||
) -> Result<(), String> {
|
||||
let url = discovery.dashboard_url(&host, port, tls)?;
|
||||
desktop.navigate_remote(&app, url)
|
||||
let name = discovery.gateway_name(&host, port, tls)?;
|
||||
let label = gateway_window_label(&url);
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
window
|
||||
.navigate(url)
|
||||
.map_err(|error| format!("Could not refresh Gateway window: {error}"))?;
|
||||
window
|
||||
.show()
|
||||
.map_err(|error| format!("Could not show Gateway window: {error}"))?;
|
||||
window
|
||||
.set_focus()
|
||||
.map_err(|error| format!("Could not focus Gateway window: {error}"))?;
|
||||
return Ok(());
|
||||
}
|
||||
WebviewWindowBuilder::new(&app, &label, WebviewUrl::External(url))
|
||||
.title(format!("{name} — OpenClaw"))
|
||||
.inner_size(1080.0, 720.0)
|
||||
.min_inner_size(720.0, 520.0)
|
||||
.center()
|
||||
.build()
|
||||
.map_err(|error| format!("Could not open Gateway window: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -474,6 +527,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_window_labels_are_route_scoped_and_stable() {
|
||||
let mixed_case = Url::parse("https://Studio.Local:18789/").unwrap();
|
||||
let canonical = Url::parse("https://studio.local:18789/").unwrap();
|
||||
let trailing_dot = Url::parse("https://studio.local.:18789/").unwrap();
|
||||
let other_port = Url::parse("https://studio.local:18790/").unwrap();
|
||||
let plaintext = Url::parse("http://studio.local:18789/").unwrap();
|
||||
assert_eq!(
|
||||
gateway_window_label(&mixed_case),
|
||||
gateway_window_label(&canonical),
|
||||
);
|
||||
assert_eq!(
|
||||
gateway_window_label(&canonical),
|
||||
gateway_window_label(&trailing_dot)
|
||||
);
|
||||
assert_ne!(
|
||||
gateway_window_label(&canonical),
|
||||
gateway_window_label(&other_port),
|
||||
);
|
||||
assert_ne!(
|
||||
gateway_window_label(&canonical),
|
||||
gateway_window_label(&plaintext),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_dashboard_url_from_resolved_endpoint() {
|
||||
let discovery = GatewayDiscovery::default();
|
||||
|
||||
@@ -836,6 +836,9 @@ fn main() {
|
||||
}
|
||||
}
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
if window.label().starts_with("gateway-") {
|
||||
return;
|
||||
}
|
||||
let state = window.app_handle().state::<DesktopState>();
|
||||
if !state.is_quitting() {
|
||||
api.prevent_close();
|
||||
|
||||
@@ -151,10 +151,15 @@ function renderGateways(gateways) {
|
||||
host: gateway.host,
|
||||
port: gateway.port,
|
||||
tls: gateway.tls,
|
||||
}).catch(() => {
|
||||
button.disabled = false;
|
||||
elements.discoveryStatus.textContent = "CONNECT FAILED";
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
button.disabled = false;
|
||||
elements.discoveryStatus.textContent = "WINDOW OPENED";
|
||||
})
|
||||
.catch(() => {
|
||||
button.disabled = false;
|
||||
elements.discoveryStatus.textContent = "CONNECT FAILED";
|
||||
});
|
||||
});
|
||||
elements.gatewayList.append(button);
|
||||
}
|
||||
|
||||
@@ -107,6 +107,12 @@ enum MacChatTranscriptCache {
|
||||
@MainActor
|
||||
static func makeContext() -> Context? {
|
||||
guard let gatewayID = currentGatewayID() else { return nil }
|
||||
return self.makeContext(gatewayID: gatewayID)
|
||||
}
|
||||
|
||||
/// Explicit profile context for windows whose route is independent of the app-wide gateway.
|
||||
@MainActor
|
||||
static func makeContext(gatewayID: String) -> Context? {
|
||||
guard let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
else {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
private let gatewayCronLogger = Logger(subsystem: "ai.openclaw", category: "gateway.connection")
|
||||
|
||||
extension GatewayConnection {
|
||||
private struct LossyDecodable<Value: Decodable>: Decodable {
|
||||
let value: Value?
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
do {
|
||||
self.value = try Value(from: decoder)
|
||||
} catch {
|
||||
self.value = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LossyCronListResponse: Decodable {
|
||||
let jobs: [LossyDecodable<CronJob>]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case jobs
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.jobs = try container.decodeIfPresent([LossyDecodable<CronJob>].self, forKey: .jobs) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private struct LossyCronRunsResponse: Decodable {
|
||||
let entries: [LossyDecodable<CronRunLogEntry>]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case entries
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.entries = try container.decodeIfPresent([LossyDecodable<CronRunLogEntry>].self, forKey: .entries) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func decodeCronListResponse(_ data: Data) throws -> [CronJob] {
|
||||
let decoded = try JSONDecoder().decode(LossyCronListResponse.self, from: data)
|
||||
let jobs = decoded.jobs.compactMap(\.value)
|
||||
let skipped = decoded.jobs.count - jobs.count
|
||||
if skipped > 0 {
|
||||
gatewayCronLogger.warning("cron.list skipped \(skipped, privacy: .public) malformed jobs")
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
nonisolated static func decodeCronRunsResponse(_ data: Data) throws -> [CronRunLogEntry] {
|
||||
let decoded = try JSONDecoder().decode(LossyCronRunsResponse.self, from: data)
|
||||
let entries = decoded.entries.compactMap(\.value)
|
||||
let skipped = decoded.entries.count - entries.count
|
||||
if skipped > 0 {
|
||||
gatewayCronLogger.warning("cron.runs skipped \(skipped, privacy: .public) malformed entries")
|
||||
}
|
||||
return entries
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,7 @@ actor GatewayConnection {
|
||||
}
|
||||
|
||||
private let endpointProvider: EndpointProvider
|
||||
private let supportsSharedEndpointRecovery: Bool
|
||||
private let activationBindingKeyProvider: @Sendable () -> SymmetricKey?
|
||||
private let sessionBox: WebSocketSessionBox?
|
||||
private let clientShutdown: @Sendable (GatewayChannelActor) async -> Void
|
||||
@@ -218,46 +219,9 @@ actor GatewayConnection {
|
||||
|
||||
var canvasPluginSurfaceRefresh: CanvasPluginSurfaceRefresh?
|
||||
|
||||
private struct LossyDecodable<Value: Decodable>: Decodable {
|
||||
let value: Value?
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
do {
|
||||
self.value = try Value(from: decoder)
|
||||
} catch {
|
||||
self.value = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LossyCronListResponse: Decodable {
|
||||
let jobs: [LossyDecodable<CronJob>]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case jobs
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.jobs = try container.decodeIfPresent([LossyDecodable<CronJob>].self, forKey: .jobs) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private struct LossyCronRunsResponse: Decodable {
|
||||
let entries: [LossyDecodable<CronRunLogEntry>]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case entries
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.entries = try container.decodeIfPresent([LossyDecodable<CronRunLogEntry>].self, forKey: .entries) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
endpointProvider: @escaping EndpointProvider = GatewayConnection.defaultEndpointProvider,
|
||||
supportsSharedEndpointRecovery: Bool = true,
|
||||
activationBindingKeyProvider: @escaping @Sendable () -> SymmetricKey? =
|
||||
GatewayConnection.defaultActivationBindingKey,
|
||||
sessionBox: WebSocketSessionBox? = nil,
|
||||
@@ -266,6 +230,7 @@ actor GatewayConnection {
|
||||
})
|
||||
{
|
||||
self.endpointProvider = endpointProvider
|
||||
self.supportsSharedEndpointRecovery = supportsSharedEndpointRecovery
|
||||
self.activationBindingKeyProvider = activationBindingKeyProvider
|
||||
self.sessionBox = sessionBox
|
||||
self.clientShutdown = clientShutdown
|
||||
@@ -287,6 +252,7 @@ actor GatewayConnection {
|
||||
self.endpointProvider = {
|
||||
try await EndpointSnapshot(config: configProvider(), routeAuthority: nil)
|
||||
}
|
||||
self.supportsSharedEndpointRecovery = false
|
||||
self.activationBindingKeyProvider = activationBindingKeyProvider
|
||||
self.sessionBox = sessionBox
|
||||
self.clientShutdown = clientShutdown
|
||||
@@ -319,6 +285,9 @@ actor GatewayConnection {
|
||||
throw error
|
||||
}
|
||||
try requireCurrentShutdownGeneration(shutdownGeneration)
|
||||
// Profile-bound windows own a fixed endpoint. Shared recovery reads global
|
||||
// connection-mode state and may legitimately retarget only the primary app route.
|
||||
guard self.supportsSharedEndpointRecovery else { throw error }
|
||||
|
||||
// Auto-recover in local mode by spawning/attaching a gateway and retrying a few times.
|
||||
// Canvas interactions should "just work" even if the local gateway isn't running yet.
|
||||
@@ -1679,24 +1648,4 @@ extension GatewayConnection {
|
||||
func cronAdd(payload: [String: AnyCodable]) async throws {
|
||||
try await self.requestVoid(method: .cronAdd, params: payload)
|
||||
}
|
||||
|
||||
nonisolated static func decodeCronListResponse(_ data: Data) throws -> [CronJob] {
|
||||
let decoded = try JSONDecoder().decode(LossyCronListResponse.self, from: data)
|
||||
let jobs = decoded.jobs.compactMap(\.value)
|
||||
let skipped = decoded.jobs.count - jobs.count
|
||||
if skipped > 0 {
|
||||
gatewayConnectionLogger.warning("cron.list skipped \(skipped, privacy: .public) malformed jobs")
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
nonisolated static func decodeCronRunsResponse(_ data: Data) throws -> [CronRunLogEntry] {
|
||||
let decoded = try JSONDecoder().decode(LossyCronRunsResponse.self, from: data)
|
||||
let entries = decoded.entries.compactMap(\.value)
|
||||
let skipped = decoded.entries.count - entries.count
|
||||
if skipped > 0 {
|
||||
gatewayConnectionLogger.warning("cron.runs skipped \(skipped, privacy: .public) malformed entries")
|
||||
}
|
||||
return entries
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,10 @@ import OpenClawProtocol
|
||||
|
||||
extension MacGatewayChatTransport {
|
||||
func acquireNewSessionRouteLease() async -> OpenClawChatNewSessionRouteLease? {
|
||||
guard let serverLease = await GatewayConnection.shared.captureServerLease() else { return nil }
|
||||
if let outboxGatewayID {
|
||||
let currentGatewayID = await MainActor.run { MacChatTranscriptCache.currentGatewayID() }
|
||||
guard currentGatewayID == outboxGatewayID else { return nil }
|
||||
}
|
||||
guard let serverLease = await self.connection.captureServerLease() else { return nil }
|
||||
guard await self.currentOutboxGatewayMatchesConnection() else { return nil }
|
||||
let request: @Sendable (OpenClawChatGatewayRequest) async throws -> Data = { request in
|
||||
try await GatewayConnection.shared.request(
|
||||
try await self.connection.request(
|
||||
method: request.method,
|
||||
params: request.params,
|
||||
timeoutMs: request.timeoutMs,
|
||||
@@ -46,13 +43,10 @@ extension MacGatewayChatTransport {
|
||||
}
|
||||
|
||||
func acquireSessionGroupsRouteLease() async -> OpenClawChatSessionGroupsRouteLease? {
|
||||
guard let serverLease = await GatewayConnection.shared.captureServerLease() else { return nil }
|
||||
if let outboxGatewayID {
|
||||
let currentGatewayID = await MainActor.run { MacChatTranscriptCache.currentGatewayID() }
|
||||
guard currentGatewayID == outboxGatewayID else { return nil }
|
||||
}
|
||||
guard let serverLease = await self.connection.captureServerLease() else { return nil }
|
||||
guard await self.currentOutboxGatewayMatchesConnection() else { return nil }
|
||||
let request: @Sendable (OpenClawChatGatewayRequest) async throws -> Data = { request in
|
||||
try await GatewayConnection.shared.request(
|
||||
try await self.connection.request(
|
||||
method: request.method,
|
||||
params: request.params,
|
||||
timeoutMs: request.timeoutMs,
|
||||
@@ -78,11 +72,8 @@ extension MacGatewayChatTransport {
|
||||
}
|
||||
|
||||
func acquireSessionMutationRouteLease() async -> OpenClawChatSessionMutationRouteLease? {
|
||||
guard let serverLease = await GatewayConnection.shared.captureServerLease() else { return nil }
|
||||
if let outboxGatewayID {
|
||||
let currentGatewayID = await MainActor.run { MacChatTranscriptCache.currentGatewayID() }
|
||||
guard currentGatewayID == outboxGatewayID else { return nil }
|
||||
}
|
||||
guard let serverLease = await self.connection.captureServerLease() else { return nil }
|
||||
guard await self.currentOutboxGatewayMatchesConnection() else { return nil }
|
||||
let transport = self
|
||||
return OpenClawChatSessionMutationRouteLease(
|
||||
patchSession: { key, label, category, pinned, archived, unread in
|
||||
@@ -95,7 +86,7 @@ extension MacGatewayChatTransport {
|
||||
pinned: pinned,
|
||||
archived: archived,
|
||||
unread: unread)
|
||||
_ = try await GatewayConnection.shared.request(
|
||||
_ = try await self.connection.request(
|
||||
method: request.method,
|
||||
params: request.params,
|
||||
timeoutMs: request.timeoutMs,
|
||||
@@ -106,7 +97,7 @@ extension MacGatewayChatTransport {
|
||||
let request = OpenClawChatGatewayRequests.deleteSession(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID)
|
||||
_ = try await GatewayConnection.shared.request(
|
||||
_ = try await self.connection.request(
|
||||
method: request.method,
|
||||
params: request.params,
|
||||
timeoutMs: request.timeoutMs,
|
||||
@@ -115,16 +106,11 @@ extension MacGatewayChatTransport {
|
||||
}
|
||||
|
||||
private func requestSessionAction(_ request: OpenClawChatGatewayRequest) async throws -> Data {
|
||||
guard let serverLease = await GatewayConnection.shared.captureServerLease() else {
|
||||
guard let serverLease = await self.connection.captureServerLease() else {
|
||||
throw OpenClawChatTransportSendError.notDispatched
|
||||
}
|
||||
if let outboxGatewayID {
|
||||
let currentGatewayID = await MainActor.run { MacChatTranscriptCache.currentGatewayID() }
|
||||
guard currentGatewayID == outboxGatewayID else {
|
||||
throw OpenClawChatTransportSendError.notDispatched
|
||||
}
|
||||
}
|
||||
return try await GatewayConnection.shared.request(
|
||||
try await self.requireCurrentOutboxGateway()
|
||||
return try await self.connection.request(
|
||||
method: request.method,
|
||||
params: request.params,
|
||||
timeoutMs: request.timeoutMs,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
struct MacGatewayProfile: Codable, Equatable, Identifiable, Sendable {
|
||||
let id: String
|
||||
var name: String
|
||||
var url: URL
|
||||
}
|
||||
|
||||
enum MacGatewayProfileError: LocalizedError {
|
||||
case invalidURL
|
||||
case profileNotFound
|
||||
case unsupportedRegistryVersion(Int)
|
||||
case keychain(OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
"Enter a ws:// or wss:// Gateway URL."
|
||||
case .profileNotFound:
|
||||
"That Gateway profile no longer exists."
|
||||
case let .unsupportedRegistryVersion(version):
|
||||
"Gateway profiles were written by a newer OpenClaw version (schema \(version))."
|
||||
case let .keychain(status):
|
||||
"Could not save Gateway settings in Keychain (\(status))."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persistent gateway identities and credentials for independently routed windows.
|
||||
/// Profiles are Keychain-backed so endpoint ownership and its secrets commit together.
|
||||
actor MacGatewayProfileStore {
|
||||
static let shared = MacGatewayProfileStore()
|
||||
|
||||
private struct StoredProfile: Codable {
|
||||
var profile: MacGatewayProfile
|
||||
var credentials: Credentials
|
||||
}
|
||||
|
||||
private struct Registry: Codable {
|
||||
var version = 1
|
||||
var profiles: [StoredProfile] = []
|
||||
}
|
||||
|
||||
struct Credentials: Codable, Equatable {
|
||||
var token: String?
|
||||
var password: String?
|
||||
}
|
||||
|
||||
private static let service = "ai.openclaw.gateway-profiles"
|
||||
private static let registryAccount = "registry-v1"
|
||||
|
||||
func upsert(
|
||||
name: String,
|
||||
url: URL,
|
||||
token: String?,
|
||||
password: String?) throws -> MacGatewayProfile
|
||||
{
|
||||
let canonicalURL = try Self.canonicalURL(url)
|
||||
let id = Self.profileID(url: canonicalURL)
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let profile = MacGatewayProfile(
|
||||
id: id,
|
||||
name: trimmedName.isEmpty ? (canonicalURL.host ?? canonicalURL.absoluteString) : trimmedName,
|
||||
url: canonicalURL)
|
||||
var registry = try self.loadRegistry()
|
||||
let savedCredentials = registry.profiles.first { $0.profile.id == id }?.credentials
|
||||
let credentials = Self.resolvedCredentials(
|
||||
saved: savedCredentials,
|
||||
submittedToken: token,
|
||||
submittedPassword: password)
|
||||
registry.profiles.removeAll { $0.profile.id == id }
|
||||
registry.profiles.append(StoredProfile(profile: profile, credentials: credentials))
|
||||
// Metadata and secrets share one Keychain value, so the profile becomes
|
||||
// reachable only when the complete record commits.
|
||||
try Self.save(JSONEncoder().encode(registry), account: Self.registryAccount)
|
||||
return profile
|
||||
}
|
||||
|
||||
func endpoint(profileID: String) throws -> GatewayConnection.EndpointSnapshot {
|
||||
let registry = try self.loadRegistry()
|
||||
guard let stored = registry.profiles.first(where: { $0.profile.id == profileID }) else {
|
||||
throw MacGatewayProfileError.profileNotFound
|
||||
}
|
||||
return GatewayConnection.EndpointSnapshot(
|
||||
config: (
|
||||
url: stored.profile.url,
|
||||
token: stored.credentials.token,
|
||||
password: stored.credentials.password),
|
||||
routeAuthority: nil,
|
||||
deviceAuthGatewayID: stored.profile.id)
|
||||
}
|
||||
|
||||
private func loadRegistry() throws -> Registry {
|
||||
guard let data = try Self.load(account: Self.registryAccount) else { return Registry() }
|
||||
return try Self.decodeRegistry(data)
|
||||
}
|
||||
|
||||
private static func decodeRegistry(_ data: Data) throws -> Registry {
|
||||
let registry = try JSONDecoder().decode(Registry.self, from: data)
|
||||
guard registry.version == 1 else {
|
||||
throw MacGatewayProfileError.unsupportedRegistryVersion(registry.version)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
static func validateRegistryData(_ data: Data) throws {
|
||||
_ = try MacGatewayProfileStore.decodeRegistry(data)
|
||||
}
|
||||
|
||||
static func canonicalURL(_ url: URL) throws -> URL {
|
||||
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
["ws", "wss"].contains(scheme),
|
||||
let host = components.host?.lowercased(),
|
||||
!host.isEmpty
|
||||
else { throw MacGatewayProfileError.invalidURL }
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
if components.port == nil {
|
||||
components.port = scheme == "wss" ? 443 : 18789
|
||||
}
|
||||
if components.percentEncodedPath.isEmpty {
|
||||
components.percentEncodedPath = "/"
|
||||
}
|
||||
components.fragment = nil
|
||||
guard let canonical = components.url else { throw MacGatewayProfileError.invalidURL }
|
||||
return canonical
|
||||
}
|
||||
|
||||
static func profileID(url: URL) -> String {
|
||||
let digest = SHA256.hash(data: Data(url.absoluteString.utf8))
|
||||
return "manual-" + digest.prefix(16).map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
static func resolvedCredentials(
|
||||
saved: Credentials?,
|
||||
submittedToken: String?,
|
||||
submittedPassword: String?) -> Credentials
|
||||
{
|
||||
let submitted = Credentials(
|
||||
token: Self.normalizedSecret(submittedToken),
|
||||
password: Self.normalizedSecret(submittedPassword))
|
||||
// An empty New Gateway form means "reuse this saved route", not
|
||||
// "erase its authentication". Supplying either field replaces both.
|
||||
if submitted.token == nil, submitted.password == nil {
|
||||
return saved ?? submitted
|
||||
}
|
||||
return submitted
|
||||
}
|
||||
|
||||
private static func normalizedSecret(_ value: String?) -> String? {
|
||||
let value = value?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value?.isEmpty == false ? value : nil
|
||||
}
|
||||
|
||||
private static func load(account: String) throws -> Data? {
|
||||
var query = self.baseQuery(account: account)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
throw MacGatewayProfileError.keychain(status)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private static func save(_ data: Data, account: String) throws {
|
||||
let query = self.baseQuery(account: account)
|
||||
let update = SecItemUpdate(
|
||||
query as CFDictionary,
|
||||
[kSecValueData as String: data] as CFDictionary)
|
||||
if update == errSecSuccess { return }
|
||||
guard update == errSecItemNotFound else { throw MacGatewayProfileError.keychain(update) }
|
||||
var add = query
|
||||
add[kSecValueData as String] = data
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let status = SecItemAdd(add as CFDictionary, nil)
|
||||
guard status == errSecSuccess else { throw MacGatewayProfileError.keychain(status) }
|
||||
}
|
||||
|
||||
private static func baseQuery(account: String) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrSynchronizable as String: false,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
actor MacGatewayConnectionFleet {
|
||||
static let shared = MacGatewayConnectionFleet()
|
||||
|
||||
private var connections: [String: GatewayConnection] = [:]
|
||||
|
||||
func connection(profileID: String) -> GatewayConnection {
|
||||
if let connection = self.connections[profileID] { return connection }
|
||||
let connection = GatewayConnection(
|
||||
endpointProvider: {
|
||||
try await MacGatewayProfileStore.shared.endpoint(profileID: profileID)
|
||||
},
|
||||
supportsSharedEndpointRecovery: false)
|
||||
self.connections[profileID] = connection
|
||||
return connection
|
||||
}
|
||||
|
||||
func shutdown() async {
|
||||
let connections = self.connections.values
|
||||
self.connections.removeAll()
|
||||
for connection in connections {
|
||||
await connection.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,10 +103,15 @@ struct OpenClawApp: App {
|
||||
.windowResizability(.contentSize)
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Gateway Window…") {
|
||||
WebChatManager.shared.newGatewayWindow()
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
|
||||
Button("New Thread") {
|
||||
DashboardManager.shared.dispatchNativeCommand(.newSession)
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||
}
|
||||
CommandGroup(replacing: .appSettings) {
|
||||
Button("Settings...") {
|
||||
|
||||
@@ -46,6 +46,8 @@ final class WebChatManager {
|
||||
private var panelRoute: WebChatRoute?
|
||||
private var currentChatRoute: WebChatRoute?
|
||||
private var cachedPreferredSessionKey: String?
|
||||
private var profileWindowControllers: [String: WebChatSwiftUIWindowController] = [:]
|
||||
private var profileWindowRoutes: [String: WebChatRoute] = [:]
|
||||
|
||||
var onPanelVisibilityChanged: ((Bool) -> Void)?
|
||||
|
||||
@@ -91,6 +93,61 @@ final class WebChatManager {
|
||||
controller.show()
|
||||
}
|
||||
|
||||
func newGatewayWindow() {
|
||||
guard let draft = Self.promptForGatewayProfile() else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let profile = try await MacGatewayProfileStore.shared.upsert(
|
||||
name: draft.name,
|
||||
url: draft.url,
|
||||
token: draft.token,
|
||||
password: draft.password)
|
||||
try await self.show(profile: profile)
|
||||
} catch {
|
||||
Self.showProfileError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func show(profile: MacGatewayProfile) async throws {
|
||||
let connection = await MacGatewayConnectionFleet.shared.connection(profileID: profile.id)
|
||||
if let existing = self.profileWindowControllers[profile.id] {
|
||||
existing.show()
|
||||
Task {
|
||||
try? await connection.refresh()
|
||||
}
|
||||
return
|
||||
}
|
||||
let sessionKey = await connection.mainSessionKey()
|
||||
// MainActor methods are reentrant across the fleet and connection awaits.
|
||||
// A concurrent Cmd-N for the same profile must reuse the first completed window.
|
||||
if let existing = self.profileWindowControllers[profile.id] {
|
||||
existing.show()
|
||||
Task {
|
||||
try? await connection.refresh()
|
||||
}
|
||||
return
|
||||
}
|
||||
let route = WebChatRoute(sessionKey: sessionKey, agentID: nil)
|
||||
let controller = WebChatSwiftUIWindowController(
|
||||
sessionKey: route.sessionKey,
|
||||
agentID: route.agentID,
|
||||
presentation: .window,
|
||||
connection: connection,
|
||||
gatewayID: profile.id,
|
||||
windowTitle: "\(profile.name) — OpenClaw",
|
||||
windowAutosaveName: "OpenClawChatWindow-\(profile.id)")
|
||||
controller.onSessionKeyChanged = { [weak self, weak controller] key in
|
||||
guard let self, let controller, self.profileWindowControllers[profile.id] === controller else { return }
|
||||
self.profileWindowRoutes[profile.id] = (self.profileWindowRoutes[profile.id] ?? route)
|
||||
.replacingSessionKey(key)
|
||||
}
|
||||
self.profileWindowControllers[profile.id] = controller
|
||||
self.profileWindowRoutes[profile.id] = route
|
||||
controller.show()
|
||||
}
|
||||
|
||||
func togglePanel(
|
||||
sessionKey: String,
|
||||
agentID: String? = nil,
|
||||
@@ -162,6 +219,12 @@ final class WebChatManager {
|
||||
self.panelRoute = nil
|
||||
self.currentChatRoute = nil
|
||||
self.cachedPreferredSessionKey = nil
|
||||
for controller in self.profileWindowControllers.values {
|
||||
controller.close()
|
||||
}
|
||||
self.profileWindowControllers.removeAll()
|
||||
self.profileWindowRoutes.removeAll()
|
||||
Task { await MacGatewayConnectionFleet.shared.shutdown() }
|
||||
}
|
||||
|
||||
func close() {
|
||||
@@ -179,4 +242,52 @@ final class WebChatManager {
|
||||
{
|
||||
currentRoute == requestedRoute
|
||||
}
|
||||
|
||||
private struct GatewayProfileDraft {
|
||||
let name: String
|
||||
let url: URL
|
||||
let token: String?
|
||||
let password: String?
|
||||
}
|
||||
|
||||
private static func promptForGatewayProfile() -> GatewayProfileDraft? {
|
||||
let nameField = NSTextField(string: "")
|
||||
nameField.placeholderString = "Gateway name"
|
||||
let urlField = NSTextField(string: "wss://")
|
||||
urlField.placeholderString = "wss://gateway.example.com"
|
||||
let tokenField = NSSecureTextField(string: "")
|
||||
tokenField.placeholderString = "Token (optional)"
|
||||
let passwordField = NSSecureTextField(string: "")
|
||||
passwordField.placeholderString = "Password (optional)"
|
||||
let grid = NSGridView(views: [
|
||||
[NSTextField(labelWithString: "Name"), nameField],
|
||||
[NSTextField(labelWithString: "Gateway URL"), urlField],
|
||||
[NSTextField(labelWithString: "Token"), tokenField],
|
||||
[NSTextField(labelWithString: "Password"), passwordField],
|
||||
])
|
||||
grid.column(at: 0).xPlacement = .trailing
|
||||
grid.column(at: 1).width = 320
|
||||
grid.rowSpacing = 8
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "New Gateway Window"
|
||||
alert.informativeText = "This window keeps an independent connection to its Gateway."
|
||||
alert.accessoryView = grid
|
||||
alert.addButton(withTitle: "Open Window")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
guard alert.runModal() == .alertFirstButtonReturn,
|
||||
let url = URL(string: urlField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
else { return nil }
|
||||
return GatewayProfileDraft(
|
||||
name: nameField.stringValue,
|
||||
url: url,
|
||||
token: tokenField.stringValue,
|
||||
password: passwordField.stringValue)
|
||||
}
|
||||
|
||||
private static func showProfileError(_ error: Error) {
|
||||
let alert = NSAlert(error: error)
|
||||
alert.messageText = "Could Not Open Gateway Window"
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,10 +84,16 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
|
||||
typealias SessionTarget = OpenClawChatSessionTarget
|
||||
|
||||
let connection: GatewayConnection
|
||||
let outboxGatewayID: String?
|
||||
private let routingIdentity: RoutingIdentity
|
||||
|
||||
init(outboxGatewayID: String? = nil, defaultGlobalAgentID: String? = nil) {
|
||||
init(
|
||||
connection: GatewayConnection = .shared,
|
||||
outboxGatewayID: String? = nil,
|
||||
defaultGlobalAgentID: String? = nil)
|
||||
{
|
||||
self.connection = connection
|
||||
self.outboxGatewayID = outboxGatewayID
|
||||
self.routingIdentity = RoutingIdentity(defaultGlobalAgentID: defaultGlobalAgentID)
|
||||
}
|
||||
@@ -96,6 +102,20 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
self.routingIdentity.update(defaultGlobalAgentID: agentID)
|
||||
}
|
||||
|
||||
func currentOutboxGatewayMatchesConnection() async -> Bool {
|
||||
guard self.connection === GatewayConnection.shared,
|
||||
let outboxGatewayID
|
||||
else { return true }
|
||||
let currentGatewayID = await MainActor.run { MacChatTranscriptCache.currentGatewayID() }
|
||||
return currentGatewayID == outboxGatewayID
|
||||
}
|
||||
|
||||
func requireCurrentOutboxGateway() async throws {
|
||||
guard await self.currentOutboxGatewayMatchesConnection() else {
|
||||
throw OpenClawChatTransportSendError.notDispatched
|
||||
}
|
||||
}
|
||||
|
||||
func sessionTarget(for sessionKey: String) -> SessionTarget {
|
||||
OpenClawChatSessionTarget.resolve(
|
||||
sessionKey,
|
||||
@@ -109,7 +129,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
let target = self.sessionTarget(for: sessionKey)
|
||||
return try await GatewayConnection.shared.chatHistory(
|
||||
return try await self.connection.chatHistory(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID)
|
||||
}
|
||||
@@ -120,7 +140,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID,
|
||||
messageID: messageID)
|
||||
let data = try await GatewayConnection.shared.request(request)
|
||||
let data = try await self.connection.request(request)
|
||||
let result = try JSONDecoder().decode(ChatMessageGetResult.self, from: data)
|
||||
guard result.ok, let encodedMessage = result.message else { return nil }
|
||||
return try JSONDecoder().decode(
|
||||
@@ -154,7 +174,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
replacing: failedResource,
|
||||
currentSurfaceRoutes: {
|
||||
let node = await MacNodeModeCoordinator.shared.currentCanvasPluginSurfaceRoute()
|
||||
let operatorSurface = await GatewayConnection.shared.canvasPluginSurfaceRoute()
|
||||
let operatorSurface = await self.connection.canvasPluginSurfaceRoute()
|
||||
return (node: node, operatorSurface: operatorSurface)
|
||||
},
|
||||
// Prefer the local node route; operator rotation keeps chat usable
|
||||
@@ -163,7 +183,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
await MacNodeModeCoordinator.shared.refreshCanvasPluginSurfaceRoute(replacing: observed?.url)
|
||||
},
|
||||
refreshOperatorSurfaceRoute: { observed in
|
||||
await GatewayConnection.shared.refreshCanvasPluginSurfaceRoute(replacing: observed?.url)
|
||||
await self.connection.refreshCanvasPluginSurfaceRoute(replacing: observed?.url)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -175,7 +195,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
|
||||
func listModels() async throws -> [OpenClawChatModelChoice] {
|
||||
do {
|
||||
let data = try await GatewayConnection.shared.request(OpenClawChatGatewayRequests.modelsList())
|
||||
let data = try await self.connection.request(OpenClawChatGatewayRequests.modelsList())
|
||||
return try OpenClawChatGatewayPayloadCodec.decodeModelChoices(data)
|
||||
} catch {
|
||||
webChatSwiftLogger.warning(
|
||||
@@ -190,7 +210,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID,
|
||||
runID: runId)
|
||||
_ = try await GatewayConnection.shared.request(request)
|
||||
_ = try await self.connection.request(request)
|
||||
}
|
||||
|
||||
func listSessions(
|
||||
@@ -202,9 +222,9 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
limit: limit,
|
||||
search: search,
|
||||
archived: archived)
|
||||
let data = try await GatewayConnection.shared.request(request)
|
||||
let data = try await self.connection.request(request)
|
||||
let decoded = try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data)
|
||||
let mainSessionKey = await GatewayConnection.shared.cachedMainSessionKey()
|
||||
let mainSessionKey = await self.connection.cachedMainSessionKey()
|
||||
let defaults = decoded.defaults.map {
|
||||
OpenClawChatSessionsDefaults(
|
||||
modelProvider: $0.modelProvider,
|
||||
@@ -227,7 +247,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
}
|
||||
|
||||
func listAgents() async throws -> OpenClawChatAgentsListResponse? {
|
||||
let data = try await GatewayConnection.shared.request(OpenClawChatGatewayRequests.agentsList())
|
||||
let data = try await self.connection.request(OpenClawChatGatewayRequests.agentsList())
|
||||
let result = try JSONDecoder().decode(AgentsListResult.self, from: data)
|
||||
return OpenClawChatAgentsListResponse(
|
||||
defaultId: result.defaultid,
|
||||
@@ -240,13 +260,13 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
}
|
||||
|
||||
func listSessionGroups() async throws -> OpenClawChatSessionGroupsResponse? {
|
||||
let data = try await GatewayConnection.shared.request(OpenClawChatGatewayRequests.sessionGroupsList())
|
||||
let data = try await self.connection.request(OpenClawChatGatewayRequests.sessionGroupsList())
|
||||
return try JSONDecoder().decode(OpenClawChatSessionGroupsResponse.self, from: data)
|
||||
}
|
||||
|
||||
func putSessionGroups(names: [String]) async throws -> OpenClawChatSessionGroupsMutationResponse {
|
||||
let request = OpenClawChatGatewayRequests.sessionGroupsPut(names: names)
|
||||
let data = try await GatewayConnection.shared.request(request)
|
||||
let data = try await self.connection.request(request)
|
||||
return try JSONDecoder().decode(OpenClawChatSessionGroupsMutationResponse.self, from: data)
|
||||
}
|
||||
|
||||
@@ -255,13 +275,13 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
to: String) async throws -> OpenClawChatSessionGroupsMutationResponse
|
||||
{
|
||||
let request = OpenClawChatGatewayRequests.sessionGroupsRename(name: name, to: to)
|
||||
let data = try await GatewayConnection.shared.request(request)
|
||||
let data = try await self.connection.request(request)
|
||||
return try JSONDecoder().decode(OpenClawChatSessionGroupsMutationResponse.self, from: data)
|
||||
}
|
||||
|
||||
func deleteSessionGroup(name: String) async throws -> OpenClawChatSessionGroupsMutationResponse {
|
||||
let request = OpenClawChatGatewayRequests.sessionGroupsDelete(name: name)
|
||||
let data = try await GatewayConnection.shared.request(request)
|
||||
let data = try await self.connection.request(request)
|
||||
return try JSONDecoder().decode(OpenClawChatSessionGroupsMutationResponse.self, from: data)
|
||||
}
|
||||
|
||||
@@ -312,13 +332,13 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
agentID: target.agentID,
|
||||
patch: patch)
|
||||
let data: Data = if let serverLease {
|
||||
try await GatewayConnection.shared.request(
|
||||
try await self.connection.request(
|
||||
method: request.method,
|
||||
params: request.params,
|
||||
timeoutMs: request.timeoutMs,
|
||||
ifCurrentServerLease: serverLease)
|
||||
} else {
|
||||
try await GatewayConnection.shared.request(request)
|
||||
try await self.connection.request(request)
|
||||
}
|
||||
return try JSONDecoder().decode(OpenClawChatModelPatchResult.self, from: data)
|
||||
}
|
||||
@@ -338,16 +358,11 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
}
|
||||
|
||||
func acquireSessionSettingsRouteLease() async -> OpenClawChatSessionSettingsRouteLease? {
|
||||
if let outboxGatewayID {
|
||||
let currentGatewayID = await MainActor.run { MacChatTranscriptCache.currentGatewayID() }
|
||||
guard currentGatewayID == outboxGatewayID else { return nil }
|
||||
}
|
||||
guard let serverLease = await GatewayConnection.shared.captureServerLease() else { return nil }
|
||||
guard await self.currentOutboxGatewayMatchesConnection() else { return nil }
|
||||
guard let serverLease = await self.connection.captureServerLease() else { return nil }
|
||||
let transport = self
|
||||
return OpenClawChatSessionSettingsRouteLease { sessionKey, agentID, patch in
|
||||
if let outboxGatewayID = transport.outboxGatewayID {
|
||||
try await Self.requireGateway(outboxGatewayID)
|
||||
}
|
||||
try await transport.requireCurrentOutboxGateway()
|
||||
return try await transport.patchSessionSettings(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
@@ -372,7 +387,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
let target = self.sessionTarget(for: sessionKey)
|
||||
return try await GatewayConnection.shared.chatSend(
|
||||
return try await self.connection.chatSend(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID,
|
||||
message: message,
|
||||
@@ -391,11 +406,9 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
let target = self.sessionTarget(for: sessionKey)
|
||||
if let outboxGatewayID {
|
||||
try await Self.requireGateway(outboxGatewayID)
|
||||
}
|
||||
guard let route = await GatewayConnection.shared.captureRoute(),
|
||||
let supportsRoutingContract = await GatewayConnection.shared.supportsServerCapability(
|
||||
try await self.requireCurrentOutboxGateway()
|
||||
guard let route = await self.connection.captureRoute(),
|
||||
let supportsRoutingContract = await self.connection.supportsServerCapability(
|
||||
.chatSendRoutingContract,
|
||||
ifCurrentRoute: route)
|
||||
else { throw OpenClawChatTransportSendError.notDispatched }
|
||||
@@ -405,7 +418,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
let guardedContract = OpenClawChatSessionRoutingContract.expectedValue(
|
||||
expectedSessionRoutingContract,
|
||||
serverSupportsGuard: supportsRoutingContract)
|
||||
return try await GatewayConnection.shared.chatSend(
|
||||
return try await self.connection.chatSend(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: agentID ?? target.agentID,
|
||||
expectedSessionRoutingContract: guardedContract,
|
||||
@@ -418,26 +431,25 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
}
|
||||
|
||||
func acquireOutboxRouteLease() async -> OpenClawChatTransportRouteLeaseResult {
|
||||
guard let outboxGatewayID else { return .unavailable(reason: nil) }
|
||||
let currentGatewayID = await MainActor.run { MacChatTranscriptCache.currentGatewayID() }
|
||||
guard currentGatewayID == outboxGatewayID,
|
||||
let route = await GatewayConnection.shared.captureRoute()
|
||||
guard self.outboxGatewayID != nil,
|
||||
await self.currentOutboxGatewayMatchesConnection()
|
||||
else { return .unavailable(reason: nil) }
|
||||
guard let supportsRoutingContract = await GatewayConnection.shared.supportsServerCapability(
|
||||
guard let route = await self.connection.captureRoute() else { return .unavailable(reason: nil) }
|
||||
guard let supportsRoutingContract = await self.connection.supportsServerCapability(
|
||||
.chatSendRoutingContract,
|
||||
ifCurrentRoute: route)
|
||||
else { return .unavailable(reason: nil) }
|
||||
guard supportsRoutingContract else {
|
||||
return .unavailable(reason: OpenClawChatTransportUpgradeMessage.routingContract)
|
||||
}
|
||||
guard let routingIdentity = try? await GatewayConnection.shared.sessionRoutingIdentity(
|
||||
guard let routingIdentity = try? await self.connection.sessionRoutingIdentity(
|
||||
ifCurrentRoute: route)
|
||||
else { return .unavailable(reason: nil) }
|
||||
let routingContract = routingIdentity.contract
|
||||
return .available(OpenClawChatTransportRouteLease(
|
||||
sendTargetedMessage: { sessionKey, agentID, message, thinking, idempotencyKey, attachments in
|
||||
try await Self.requireGateway(outboxGatewayID)
|
||||
return try await GatewayConnection.shared.chatSend(
|
||||
try await self.requireCurrentOutboxGateway()
|
||||
return try await self.connection.chatSend(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
expectedSessionRoutingContract: routingContract,
|
||||
@@ -449,8 +461,8 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
distinguishPreDispatchRouteChange: true)
|
||||
},
|
||||
requestTargetedHistory: { sessionKey, agentID in
|
||||
try await Self.requireGateway(outboxGatewayID)
|
||||
return try await GatewayConnection.shared.chatHistory(
|
||||
try await self.requireCurrentOutboxGateway()
|
||||
return try await self.connection.chatHistory(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
ifCurrentRoute: route)
|
||||
@@ -458,26 +470,18 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
sessionRoutingContract: routingContract))
|
||||
}
|
||||
|
||||
private static func requireGateway(_ gatewayID: String) async throws {
|
||||
let currentGatewayID = await MainActor.run { MacChatTranscriptCache.currentGatewayID() }
|
||||
guard currentGatewayID == gatewayID else {
|
||||
throw OpenClawChatTransportSendError.notDispatched
|
||||
}
|
||||
}
|
||||
|
||||
func synthesizeSpeech(text: String) async throws -> OpenClawChatSpeechClip {
|
||||
// Capture the lease before validating the pinned gateway: a gateway
|
||||
// switch after validation then fails the request via the lease guard
|
||||
// instead of re-routing the text to the newly selected gateway.
|
||||
guard let serverLease = await GatewayConnection.shared.captureServerLease() else {
|
||||
guard let serverLease = await self.connection.captureServerLease() else {
|
||||
throw OpenClawChatTransportSendError.notDispatched
|
||||
}
|
||||
if let outboxGatewayID {
|
||||
try await Self.requireGateway(outboxGatewayID)
|
||||
}
|
||||
try await self.requireCurrentOutboxGateway()
|
||||
return try await MacChatMessageSpeechClient.synthesize(
|
||||
text: text,
|
||||
serverLease: serverLease)
|
||||
serverLease: serverLease,
|
||||
connection: self.connection)
|
||||
}
|
||||
|
||||
var supportsSlashCommandCatalog: Bool {
|
||||
@@ -488,7 +492,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
let request = OpenClawChatGatewayRequests.commandsList(
|
||||
sessionKey: sessionKey,
|
||||
fallbackAgentID: self.routingIdentity.currentAgentID())
|
||||
let data = try await GatewayConnection.shared.request(request)
|
||||
let data = try await self.connection.request(request)
|
||||
let decoded = try JSONDecoder().decode(CommandsListResult.self, from: data)
|
||||
return decoded.commands.map(OpenClawChatGatewayPayloadCodec.commandChoice)
|
||||
}
|
||||
@@ -527,7 +531,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
parentSessionKey: parentSessionKey,
|
||||
worktree: worktree,
|
||||
worktreeBaseRef: worktreeBaseRef)
|
||||
let data = try await GatewayConnection.shared.request(request)
|
||||
let data = try await self.connection.request(request)
|
||||
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: data)
|
||||
}
|
||||
|
||||
@@ -548,7 +552,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
pinned: pinned,
|
||||
archived: archived,
|
||||
unread: unread)
|
||||
_ = try await GatewayConnection.shared.request(request)
|
||||
_ = try await self.connection.request(request)
|
||||
}
|
||||
|
||||
func deleteSession(key: String) async throws {
|
||||
@@ -556,30 +560,30 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
let request = OpenClawChatGatewayRequests.deleteSession(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID)
|
||||
_ = try await GatewayConnection.shared.request(request)
|
||||
_ = try await self.connection.request(request)
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||
try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
|
||||
try await self.connection.healthOK(timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func listQuestions() async throws -> [QuestionRecord] {
|
||||
let data = try await GatewayConnection.shared.request(OpenClawChatGatewayRequests.questionList())
|
||||
let data = try await self.connection.request(OpenClawChatGatewayRequests.questionList())
|
||||
return try JSONDecoder().decode(QuestionListResult.self, from: data).questions
|
||||
}
|
||||
|
||||
func getQuestion(id: String) async throws -> QuestionRecord {
|
||||
let data = try await GatewayConnection.shared.request(OpenClawChatGatewayRequests.questionGet(id: id))
|
||||
let data = try await self.connection.request(OpenClawChatGatewayRequests.questionGet(id: id))
|
||||
return try JSONDecoder().decode(QuestionGetResult.self, from: data).question
|
||||
}
|
||||
|
||||
func resolveQuestion(id: String, answers: [String: [String]]) async throws {
|
||||
_ = try await GatewayConnection.shared.request(
|
||||
_ = try await self.connection.request(
|
||||
OpenClawChatGatewayRequests.resolveQuestion(id: id, answers: answers))
|
||||
}
|
||||
|
||||
func cancelQuestion(id: String) async throws {
|
||||
_ = try await GatewayConnection.shared.request(
|
||||
_ = try await self.connection.request(
|
||||
OpenClawChatGatewayRequests.cancelQuestion(id: id))
|
||||
}
|
||||
|
||||
@@ -589,11 +593,11 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
{
|
||||
let runId = rawRunId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !runId.isEmpty,
|
||||
let route = await GatewayConnection.shared.captureRoute()
|
||||
let route = await self.connection.captureRoute()
|
||||
else { return .unavailable }
|
||||
do {
|
||||
let request = OpenClawChatGatewayRequests.agentWait(runID: runId, timeoutMs: timeoutMs)
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
let data = try await self.connection.request(
|
||||
request,
|
||||
ifCurrentRoute: route)
|
||||
return try OpenClawChatGatewayPayloadCodec.decodeAgentWaitObservation(data)
|
||||
@@ -610,7 +614,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
let request = OpenClawChatGatewayRequests.resetSession(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID)
|
||||
_ = try await GatewayConnection.shared.request(request)
|
||||
_ = try await self.connection.request(request)
|
||||
}
|
||||
|
||||
func compactSession(sessionKey: String) async throws {
|
||||
@@ -618,7 +622,7 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
let request = OpenClawChatGatewayRequests.compactSession(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID)
|
||||
let response = try await GatewayConnection.shared.request(request, retryTransportFailures: false)
|
||||
let response = try await self.connection.request(request, retryTransportFailures: false)
|
||||
try OpenClawSessionsCompactResponse.requireSuccess(from: response)
|
||||
}
|
||||
|
||||
@@ -630,19 +634,19 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||
let request = OpenClawChatGatewayRequests.subscribeSessionMessages(
|
||||
sessionKey: target.sessionKey,
|
||||
agentID: target.agentID)
|
||||
_ = try await GatewayConnection.shared.request(request)
|
||||
_ = try await self.connection.request(request)
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
||||
AsyncStream { continuation in
|
||||
let task = Task {
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
try await self.connection.refresh()
|
||||
} catch {
|
||||
webChatSwiftLogger.error("gateway refresh failed \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
let stream = await GatewayConnection.shared.subscribe()
|
||||
let stream = await self.connection.subscribe()
|
||||
for await push in stream {
|
||||
if Task.isCancelled {
|
||||
return
|
||||
@@ -700,13 +704,14 @@ private enum MacChatMessageSpeechClient {
|
||||
|
||||
static func synthesize(
|
||||
text: String,
|
||||
serverLease: GatewayConnection.ServerLease) async throws -> OpenClawChatSpeechClip
|
||||
serverLease: GatewayConnection.ServerLease,
|
||||
connection: GatewayConnection) async throws -> OpenClawChatSpeechClip
|
||||
{
|
||||
let encoded = try JSONEncoder().encode(TtsSpeakParams(text: text))
|
||||
guard let params = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else {
|
||||
throw MacChatMessageSpeechError.invalidRequest
|
||||
}
|
||||
let responseData = try await GatewayConnection.shared.request(
|
||||
let responseData = try await connection.request(
|
||||
method: "tts.speak",
|
||||
params: params.mapValues(AnyCodable.init),
|
||||
timeoutMs: self.requestTimeoutMs,
|
||||
@@ -736,6 +741,7 @@ private struct MacChatSurface: View {
|
||||
|
||||
private let isFullWindow: Bool
|
||||
private let userAccent: Color?
|
||||
private let usesPrimaryAppRuntime: Bool
|
||||
private let speech: OpenClawChatSpeechController
|
||||
private let voiceNoteRecorder: OpenClawVoiceNoteRecorder
|
||||
|
||||
@@ -743,12 +749,14 @@ private struct MacChatSurface: View {
|
||||
viewModel: OpenClawChatViewModel,
|
||||
isFullWindow: Bool,
|
||||
userAccent: Color?,
|
||||
usesPrimaryAppRuntime: Bool,
|
||||
speech: OpenClawChatSpeechController,
|
||||
voiceNoteRecorder: OpenClawVoiceNoteRecorder)
|
||||
{
|
||||
_viewModel = State(initialValue: viewModel)
|
||||
self.isFullWindow = isFullWindow
|
||||
self.userAccent = userAccent
|
||||
self.usesPrimaryAppRuntime = usesPrimaryAppRuntime
|
||||
self.speech = speech
|
||||
self.voiceNoteRecorder = voiceNoteRecorder
|
||||
}
|
||||
@@ -783,9 +791,11 @@ private struct MacChatSurface: View {
|
||||
|
||||
private var talkControl: OpenClawChatTalkControl {
|
||||
OpenClawChatTalkControl(
|
||||
isEnabled: self.appState.talkEnabled,
|
||||
isListening: !self.talkController.isPaused && self.talkController.phase == .listening,
|
||||
isSpeaking: !self.talkController.isPaused && self.talkController.phase == .speaking,
|
||||
isEnabled: self.usesPrimaryAppRuntime && self.appState.talkEnabled,
|
||||
isListening: self.usesPrimaryAppRuntime &&
|
||||
!self.talkController.isPaused && self.talkController.phase == .listening,
|
||||
isSpeaking: self.usesPrimaryAppRuntime &&
|
||||
!self.talkController.isPaused && self.talkController.phase == .speaking,
|
||||
isGatewayConnected: self.viewModel.healthOK,
|
||||
statusText: self.talkStatusText,
|
||||
// macOS exposes live phase but not the runtime's resolved TTS provider.
|
||||
@@ -800,6 +810,7 @@ private struct MacChatSurface: View {
|
||||
self.audioInputCatalog.select(deviceID, state: self.appState)
|
||||
},
|
||||
toggle: { sessionKey in
|
||||
guard self.usesPrimaryAppRuntime else { return }
|
||||
WebChatManager.shared.recordActiveSessionKey(sessionKey)
|
||||
Task {
|
||||
await AppStateStore.shared.setTalkEnabled(!AppStateStore.shared.talkEnabled)
|
||||
@@ -823,6 +834,9 @@ private struct MacChatSurface: View {
|
||||
}
|
||||
|
||||
private var talkStatusText: String {
|
||||
guard self.usesPrimaryAppRuntime else {
|
||||
return String(localized: "Talk mode uses the primary Gateway window")
|
||||
}
|
||||
guard self.appState.talkEnabled else { return String(localized: "Talk mode off") }
|
||||
if self.talkController.isPaused { return String(localized: "Talk mode paused") }
|
||||
return switch self.talkController.phase {
|
||||
@@ -899,20 +913,31 @@ final class WebChatSwiftUIWindowController {
|
||||
sessionKey: String,
|
||||
agentID: String? = nil,
|
||||
initialDraft: String? = nil,
|
||||
presentation: WebChatPresentation)
|
||||
presentation: WebChatPresentation,
|
||||
connection: GatewayConnection = .shared,
|
||||
gatewayID: String? = nil,
|
||||
windowTitle: String = "OpenClaw Chat",
|
||||
windowAutosaveName: String = WebChatSwiftUILayout.windowFrameAutosaveName)
|
||||
{
|
||||
// Connection-mode changes tear chat windows down via resetTunnels(),
|
||||
// so binding the cache identity at construction stays correct. One
|
||||
// store instance backs both the transcript cache and the offline
|
||||
// command outbox.
|
||||
let context = MacChatTranscriptCache.makeContext()
|
||||
let context: MacChatTranscriptCache.Context? = if let gatewayID {
|
||||
MacChatTranscriptCache.makeContext(gatewayID: gatewayID)
|
||||
} else {
|
||||
MacChatTranscriptCache.makeContext()
|
||||
}
|
||||
self.init(
|
||||
sessionKey: sessionKey,
|
||||
agentID: agentID,
|
||||
initialDraft: initialDraft,
|
||||
presentation: presentation,
|
||||
connection: connection,
|
||||
cachedRoutingIdentity: context?.routingIdentity,
|
||||
store: context?.store)
|
||||
store: context?.store,
|
||||
windowTitle: windowTitle,
|
||||
windowAutosaveName: windowAutosaveName)
|
||||
}
|
||||
|
||||
convenience init(
|
||||
@@ -920,8 +945,11 @@ final class WebChatSwiftUIWindowController {
|
||||
agentID: String?,
|
||||
initialDraft: String? = nil,
|
||||
presentation: WebChatPresentation,
|
||||
connection: GatewayConnection = .shared,
|
||||
cachedRoutingIdentity: OpenClawChatSessionRoutingIdentity?,
|
||||
store: OpenClawChatSQLiteTranscriptCache?)
|
||||
store: OpenClawChatSQLiteTranscriptCache?,
|
||||
windowTitle: String = "OpenClaw Chat",
|
||||
windowAutosaveName: String = WebChatSwiftUILayout.windowFrameAutosaveName)
|
||||
{
|
||||
let explicitAgentID = WebChatRoute.normalizedAgentID(agentID)
|
||||
let effectiveAgentID = Self.effectiveAgentID(
|
||||
@@ -932,13 +960,16 @@ final class WebChatSwiftUIWindowController {
|
||||
initialDraft: initialDraft,
|
||||
presentation: presentation,
|
||||
transport: MacGatewayChatTransport(
|
||||
connection: connection,
|
||||
outboxGatewayID: store?.gatewayID,
|
||||
defaultGlobalAgentID: effectiveAgentID),
|
||||
initialActiveAgentID: effectiveAgentID,
|
||||
explicitAgentID: explicitAgentID,
|
||||
initialSessionRoutingContract: cachedRoutingIdentity?.contract,
|
||||
transcriptCache: store,
|
||||
outbox: store)
|
||||
outbox: store,
|
||||
windowTitle: windowTitle,
|
||||
windowAutosaveName: windowAutosaveName)
|
||||
}
|
||||
|
||||
init(
|
||||
@@ -950,7 +981,9 @@ final class WebChatSwiftUIWindowController {
|
||||
explicitAgentID: String? = nil,
|
||||
initialSessionRoutingContract: String? = nil,
|
||||
transcriptCache: (any OpenClawChatTranscriptCache)? = nil,
|
||||
outbox: (any OpenClawChatCommandOutbox)? = nil)
|
||||
outbox: (any OpenClawChatCommandOutbox)? = nil,
|
||||
windowTitle: String = "OpenClaw Chat",
|
||||
windowAutosaveName: String = WebChatSwiftUILayout.windowFrameAutosaveName)
|
||||
{
|
||||
self.sessionKey = sessionKey
|
||||
self.presentation = presentation
|
||||
@@ -1000,14 +1033,16 @@ final class WebChatSwiftUIWindowController {
|
||||
}
|
||||
self.viewModel = vm
|
||||
let explicitAgentID = WebChatRoute.normalizedAgentID(explicitAgentID)
|
||||
let chatConnection = (transport as? MacGatewayChatTransport)?.connection ?? .shared
|
||||
let usesPrimaryAppRuntime = chatConnection === GatewayConnection.shared
|
||||
Task { @MainActor [weak vm] in
|
||||
let pushes = await GatewayConnection.shared.subscribe()
|
||||
let pushes = await chatConnection.subscribe()
|
||||
for await push in pushes {
|
||||
guard let vm else { return }
|
||||
guard case .snapshot = push else { continue }
|
||||
let route = await GatewayConnection.shared.captureRoute()
|
||||
let route = await chatConnection.captureRoute()
|
||||
let routingIdentity: OpenClawChatSessionRoutingIdentity? = if let route {
|
||||
try? await GatewayConnection.shared.sessionRoutingIdentity(
|
||||
try? await chatConnection.sessionRoutingIdentity(
|
||||
ifCurrentRoute: route)
|
||||
} else {
|
||||
nil
|
||||
@@ -1021,7 +1056,7 @@ final class WebChatSwiftUIWindowController {
|
||||
(transport as? MacGatewayChatTransport)?
|
||||
.updateDefaultGlobalAgentID(effectiveAgentID)
|
||||
if let store = transcriptCache as? OpenClawChatSQLiteTranscriptCache,
|
||||
store.gatewayID == MacChatTranscriptCache.currentGatewayID(),
|
||||
!usesPrimaryAppRuntime || store.gatewayID == MacChatTranscriptCache.currentGatewayID(),
|
||||
let persistedIdentity = OpenClawChatSessionRoutingIdentity(
|
||||
contract: routingIdentity.contract)
|
||||
{
|
||||
@@ -1042,6 +1077,7 @@ final class WebChatSwiftUIWindowController {
|
||||
viewModel: vm,
|
||||
isFullWindow: true,
|
||||
userAccent: accent,
|
||||
usesPrimaryAppRuntime: usesPrimaryAppRuntime,
|
||||
speech: speech,
|
||||
voiceNoteRecorder: voiceNoteRecorder))
|
||||
self.contentController = hosting
|
||||
@@ -1051,11 +1087,16 @@ final class WebChatSwiftUIWindowController {
|
||||
viewModel: vm,
|
||||
isFullWindow: false,
|
||||
userAccent: accent,
|
||||
usesPrimaryAppRuntime: usesPrimaryAppRuntime,
|
||||
speech: speech,
|
||||
voiceNoteRecorder: voiceNoteRecorder))
|
||||
self.contentController = Self.makePanelContentController(hosting: hosting)
|
||||
}
|
||||
self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController)
|
||||
self.window = Self.makeWindow(
|
||||
for: presentation,
|
||||
contentViewController: self.contentController,
|
||||
title: windowTitle,
|
||||
autosaveName: windowAutosaveName)
|
||||
sessionKeyRelay.onChange = { [weak self] key in
|
||||
self?.onSessionKeyChanged?(key)
|
||||
}
|
||||
@@ -1197,7 +1238,9 @@ final class WebChatSwiftUIWindowController {
|
||||
|
||||
private static func makeWindow(
|
||||
for presentation: WebChatPresentation,
|
||||
contentViewController: NSViewController) -> NSWindow
|
||||
contentViewController: NSViewController,
|
||||
title: String,
|
||||
autosaveName: String) -> NSWindow
|
||||
{
|
||||
switch presentation {
|
||||
case .window:
|
||||
@@ -1206,7 +1249,7 @@ final class WebChatSwiftUIWindowController {
|
||||
styleMask: [.titled, .closable, .resizable, .miniaturizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = "OpenClaw Chat"
|
||||
window.title = title
|
||||
window.contentViewController = contentViewController
|
||||
// Attaching an NSHostingController resets scene bridging to `.all`;
|
||||
// opt back into toolbar items only so SwiftUI cannot restore the title.
|
||||
@@ -1221,7 +1264,7 @@ final class WebChatSwiftUIWindowController {
|
||||
window.titlebarSeparatorStyle = .none
|
||||
window.isMovableByWindowBackground = true
|
||||
window.center()
|
||||
window.setFrameAutosaveName(WebChatSwiftUILayout.windowFrameAutosaveName)
|
||||
window.setFrameAutosaveName(autosaveName)
|
||||
WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize)
|
||||
window.minSize = WebChatSwiftUILayout.windowMinSize
|
||||
return window
|
||||
|
||||
@@ -38,6 +38,19 @@ struct MacGatewayChatTransportMappingTests {
|
||||
agentID: nil))
|
||||
}
|
||||
|
||||
@Test func `fixed connection does not inherit app wide cache routing`() async throws {
|
||||
let url = try #require(URL(string: "wss://fixed.example"))
|
||||
let connection = GatewayConnection(configProvider: {
|
||||
(url: url, token: nil, password: nil)
|
||||
})
|
||||
let transport = MacGatewayChatTransport(
|
||||
connection: connection,
|
||||
outboxGatewayID: "manual-fixed")
|
||||
|
||||
#expect(await transport.currentOutboxGatewayMatchesConnection())
|
||||
await connection.shutdown()
|
||||
}
|
||||
|
||||
@Test func `session settings request preserves verbosity patch`() {
|
||||
let request = MacGatewayChatTransport.sessionSettingsRequest(
|
||||
sessionKey: "global",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite struct MacGatewayProfilesTests {
|
||||
@Test func `canonical route identity normalizes authority but preserves path`() throws {
|
||||
let implicit = try MacGatewayProfileStore.canonicalURL(
|
||||
#require(URL(string: "WSS://Studio.Example/alpha")))
|
||||
let explicit = try MacGatewayProfileStore.canonicalURL(
|
||||
#require(URL(string: "wss://studio.example:443/alpha")))
|
||||
let otherPath = try MacGatewayProfileStore.canonicalURL(
|
||||
#require(URL(string: "wss://studio.example:443/beta")))
|
||||
|
||||
#expect(implicit == explicit)
|
||||
#expect(MacGatewayProfileStore.profileID(url: implicit) ==
|
||||
MacGatewayProfileStore.profileID(url: explicit))
|
||||
#expect(MacGatewayProfileStore.profileID(url: implicit) !=
|
||||
MacGatewayProfileStore.profileID(url: otherPath))
|
||||
|
||||
let emptyPath = try MacGatewayProfileStore.canonicalURL(
|
||||
#require(URL(string: "wss://studio.example")))
|
||||
let rootPath = try MacGatewayProfileStore.canonicalURL(
|
||||
#require(URL(string: "wss://studio.example/")))
|
||||
#expect(emptyPath == rootPath)
|
||||
#expect(MacGatewayProfileStore.profileID(url: emptyPath) ==
|
||||
MacGatewayProfileStore.profileID(url: rootPath))
|
||||
}
|
||||
|
||||
@Test func `profile URL rejects dashboard schemes`() {
|
||||
#expect(throws: MacGatewayProfileError.self) {
|
||||
try MacGatewayProfileStore.canonicalURL(
|
||||
#require(URL(string: "https://studio.example")))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `blank profile form preserves saved credentials`() {
|
||||
let saved = MacGatewayProfileStore.Credentials(token: "saved-token", password: "saved-password")
|
||||
|
||||
#expect(MacGatewayProfileStore.resolvedCredentials(
|
||||
saved: saved,
|
||||
submittedToken: " ",
|
||||
submittedPassword: nil) == saved)
|
||||
#expect(MacGatewayProfileStore.resolvedCredentials(
|
||||
saved: saved,
|
||||
submittedToken: "replacement",
|
||||
submittedPassword: nil) == .init(token: "replacement", password: nil))
|
||||
}
|
||||
|
||||
@Test func `newer profile registry is rejected`() throws {
|
||||
let data = Data(#"{"version":2,"profiles":[]}"#.utf8)
|
||||
|
||||
#expect(throws: MacGatewayProfileError.self) {
|
||||
try MacGatewayProfileStore.validateRegistryData(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -5181,6 +5181,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- Route: /platforms/android
|
||||
- Headings:
|
||||
- H2: Support snapshot
|
||||
- H2: Simultaneous gateway sessions
|
||||
- H2: Wear OS companion
|
||||
- H2: Install outside Google Play
|
||||
- H2: Mirror and control Android from a remote Mac
|
||||
@@ -5195,7 +5196,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H3: 2. Verify discovery (optional)
|
||||
- H4: Cross-network discovery via unicast DNS-SD
|
||||
- H3: 3. Connect from Android
|
||||
- H3: Multiple gateways
|
||||
- H3: Manage paired gateways
|
||||
- H3: Presence alive beacons
|
||||
- H3: 4. Approve pairing (CLI)
|
||||
- H3: 5. Verify the node is connected
|
||||
@@ -5487,6 +5488,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
|
||||
- Route: /platforms/mac/webchat
|
||||
- Headings:
|
||||
- H2: Multiple Gateway windows
|
||||
- H2: Quick Chat bar
|
||||
- H2: Launch and debugging
|
||||
- H2: How it is wired
|
||||
|
||||
@@ -22,6 +22,17 @@ The official Android app is available on [Google Play](https://play.google.com/s
|
||||
|
||||
System control (launchd/systemd) lives on the Gateway host — see [Gateway](/gateway).
|
||||
|
||||
## Simultaneous gateway sessions
|
||||
|
||||
Pair each Gateway once, then open **Settings → Gateway**. The checkmark marks
|
||||
the focused Gateway and each switch controls whether a non-focused Gateway's
|
||||
operator session stays connected. Enabled Gateways reconnect independently
|
||||
while the app is in the foreground, so switching focus does not tear down the
|
||||
others. The focused Gateway alone owns the Android node session and device
|
||||
capabilities; this prevents simultaneous Gateways from issuing camera,
|
||||
location, screen, or notification commands to the same phone. Android can
|
||||
suspend the secondary connections after the app leaves the foreground.
|
||||
|
||||
## Wear OS companion
|
||||
|
||||
The Wear OS companion uses the paired Android phone's authenticated Gateway connection; the watch never receives or stores Gateway credentials. It can select agents and sessions, read bounded transcripts, send text or dictated replies, abort an active run, start realtime Talk inside the selected session, and connect or disconnect the paired phone's Gateway. It also offers local reply notifications, dark or light appearance, and optional automatic speech for replies. Agent and Gateway controls are capability-negotiated for staggered phone/watch updates. Realtime Talk streams microphone and playback audio over a temporary Wear OS Data Layer channel and stops when the selected phone, Gateway connection, or audio channel is lost.
|
||||
@@ -231,13 +242,14 @@ with `openclaw qr`, then scan or paste it on that page and reconnect. Operators
|
||||
who want the reduced profile can select **Limited access** in Control UI or run
|
||||
`openclaw qr --limited`.
|
||||
|
||||
### Multiple gateways
|
||||
### Manage paired gateways
|
||||
|
||||
The app keeps a registry of every gateway it has paired with, so you can switch between them without pairing again:
|
||||
The app keeps a registry of every gateway it has paired with, so you can keep operator sessions connected and change focus without pairing again:
|
||||
|
||||
- **Settings -> Gateways** lists paired gateways with the active one marked. Tap an entry to switch; the app tears down the current sessions and reconnects to the selected gateway.
|
||||
- **Settings → Gateway** lists paired gateways with the focused one marked. Tap an entry to focus it; the other enabled operator sessions remain connected.
|
||||
- Each switch controls whether that non-focused Gateway stays connected while the app is in the foreground. The focused Gateway remains enabled and owns the phone's node connection and device capabilities.
|
||||
- The **Connect** tab shows a quick switcher when more than one gateway is paired.
|
||||
- Credentials, device tokens, TLS trust, chat history, and queued offline messages are stored per gateway. Switching never mixes state between gateways, and messages queued while offline are delivered only to the gateway they were written for.
|
||||
- Credentials, device tokens, TLS trust, chat history, and queued offline messages are stored per Gateway. Changing focus never mixes state between Gateways, and messages queued while offline are delivered only to the Gateway they were written for.
|
||||
- **Forget** removes a gateway's registry entry together with its credentials, device tokens, TLS pin, and cached chats.
|
||||
|
||||
### Presence alive beacons
|
||||
|
||||
@@ -59,6 +59,14 @@ creation has a token or password auth path.
|
||||
If the setup code contains both LAN and Tailscale Serve routes, the app
|
||||
probes them in order and saves the first reachable endpoint.
|
||||
|
||||
Paired gateways remain in the **Gateways** list. The checkmark identifies
|
||||
the focused gateway; use the bolt control on another row to keep its
|
||||
operator session connected at the same time. Switching focus does not
|
||||
disconnect other enabled gateways. Only the focused gateway receives the
|
||||
iPhone's capability-bearing node session, so camera, screen, location, and
|
||||
other device commands always have one unambiguous owner. iOS may suspend
|
||||
these foreground connections after the app enters the background.
|
||||
|
||||
4. The official app connects automatically. If **Pending approval** shows a
|
||||
request, review its role and scopes before approving it.
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ The OpenClaw Linux companion is a Tauri desktop app for a local Gateway. It:
|
||||
- installs the OpenClaw CLI and managed Node runtime when they are missing; release builds install the stable channel automatically, while development builds ask for the channel first
|
||||
- attaches to a healthy Gateway before attempting service changes
|
||||
- delegates install, start, stop, and restart operations to the CLI-managed systemd user service
|
||||
- discovers nearby Bonjour Gateways and opens their Control UI from the resolved service endpoint
|
||||
- discovers nearby Bonjour Gateways and opens each Control UI in a route-scoped window, so several
|
||||
Gateway dashboards can stay connected and be used simultaneously
|
||||
- opens the Gateway-served Control UI with its resolved authentication URL
|
||||
- opens the Control UI in onboarding mode after its first-run install, which
|
||||
offers to import detected Claude Code, Codex, or Hermes memories into the
|
||||
|
||||
@@ -9,13 +9,27 @@ The macOS menu bar app embeds the WebChat UI as a native SwiftUI view. It connec
|
||||
|
||||
The full chat window is a native split view:
|
||||
|
||||
- **Sessions sidebar**: searchable session list with pinned, gateway-backed group, and recent sections. Spawned child sessions nest beneath their parent inside each section; collapsed parents summarize running, failed, and unread descendants. Context menus support session info, rename, pin, fork, read/unread, archive/restore, copy session key, and delete. The primary new-session action (or Cmd-N) creates immediately via `sessions.create`; its adjacent options popover can select an agent and request a managed worktree with an optional base ref.
|
||||
- **Sessions sidebar**: searchable session list with pinned, gateway-backed group, and recent sections. Spawned child sessions nest beneath their parent inside each section; collapsed parents summarize running, failed, and unread descendants. Context menus support session info, rename, pin, fork, read/unread, archive/restore, copy session key, and delete. The primary new-session action (or Shift-Cmd-N) creates immediately via `sessions.create`; its adjacent options popover can select an agent and request a managed worktree with an optional base ref.
|
||||
- **Window toolbar**: context-usage ring (tokens and session cost, with a compact action), model controls, and a session actions menu. Models are grouped by provider with the default provider first, while pinned and recent models remain at the top. The controls can inherit or override the model's thinking level, choose tool-call verbosity, and toggle Fast responses. The menu can rename or fork the current session and update its pin, read, or archive state. **Sessions…** (Shift-Cmd-S) opens the Active/Archived manager for gateway search, group management, session inspection, rename, pin, archive, and restore. Select mode applies pin, unpin, archive, or delete to several active sessions while keeping individual failures visible. Separate menu checkmarks show or hide assistant reasoning and tool activity; both are on by default and remembered across launches.
|
||||
- **Transcript and composer**: assistant messages render as plain text with an avatar, user messages as accent bubbles. Pending agent questions render as native cards with single- or multi-select options, free-text **Other** answers, expiry countdowns, and shared terminal state. Empty chats offer desktop starter prompts. Typing `/` opens slash-command autocomplete backed by `commands.list`, with arrow/Tab/Return/Escape keyboard navigation. Right-click a message to copy its visible Markdown without hidden reasoning. Truncated assistant messages also offer **Open Full Message**, which loads a selectable Markdown reader. Use **Listen** for gateway TTS with a local speech fallback.
|
||||
- **Voice controls**: the composer can start or stop the existing macOS Talk Mode without replacing its menu-bar overlay. While Talk Mode is active, the composer shows its listening/thinking/speaking state, live audio activity, and an expandable rolling transcript. Right-click the Talk button to choose **System Default** or a connected microphone; this is the same microphone selection used by Voice Wake and push-to-talk. If a selected microphone disconnects, the active Talk session falls back to the system default and tries the selection again the next time Talk Mode starts. A separate microphone action records a voice note when Talk Mode does not own audio capture.
|
||||
|
||||
The anchored compact chat panel from the menu bar keeps the compact single-column layout with the same model, thinking, verbosity, and Fast controls inline, plus starter prompts, Talk Mode, voice notes, and Listen. Assistant reasoning and tool activity remain hidden in this compact surface.
|
||||
|
||||
## Multiple Gateway windows
|
||||
|
||||
Choose **File → New Gateway Window…** or press Cmd-N, then enter a `ws://` or
|
||||
`wss://` endpoint and its optional token or password. Each saved profile owns
|
||||
an independent Gateway connection, device-auth scope, transcript cache,
|
||||
offline outbox, route leases, and window restoration key. These windows can
|
||||
stay connected and run chats simultaneously; opening the same profile again
|
||||
focuses its existing window.
|
||||
|
||||
The menu-bar app's configured Gateway remains the owner of Mac node
|
||||
capabilities and Talk Mode. Additional Gateway windows are operator-only, so a
|
||||
second Gateway cannot silently retarget global microphone or device controls.
|
||||
Listen/TTS and normal chat actions use the window's own Gateway connection.
|
||||
|
||||
## Quick Chat bar
|
||||
|
||||
Press Option-Space (⌥Space) or choose **Quick Chat** from the menu bar menu to open a floating composer for the main session. Change the global shortcut with the recorder in **Settings → General → Quick Chat shortcut**.
|
||||
|
||||
Reference in New Issue
Block a user