From a52eb2134b25935955aca086e334573807c5129d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Jul 2026 13:06:29 -0700 Subject: [PATCH] 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 --- apps/.i18n/native-source.json | 740 +++++++++--------- .../java/ai/openclaw/app/MainViewModel.kt | 8 + .../main/java/ai/openclaw/app/NodeRuntime.kt | 170 +++- .../openclaw/app/gateway/GatewayRegistry.kt | 108 ++- .../app/gateway/GatewayStoreMigration.kt | 1 + .../ai/openclaw/app/ui/SettingsScreens.kt | 14 +- .../openclaw/app/GatewayFleetSelectionTest.kt | 39 + .../app/gateway/GatewayRegistryStoreTest.kt | 55 ++ .../Design/SettingsProTabSections.swift | 55 +- .../Gateway/GatewayConnectConfig.swift | 2 +- .../Gateway/GatewayConnectionController.swift | 156 +++- .../Gateway/GatewayOperatorFleet.swift | 265 +++++++ .../GatewaySettingsStore+Connections.swift | 86 ++ .../Gateway/GatewaySettingsStore.swift | 74 +- .../GatewayConnectionControllerTests.swift | 30 + .../ios/Tests/GatewaySettingsStoreTests.swift | 96 ++- apps/linux/src-tauri/src/discovery.rs | 84 +- apps/linux/src-tauri/src/main.rs | 3 + apps/linux/ui/main.js | 13 +- .../OpenClaw/ChatTranscriptCacheSupport.swift | 6 + .../OpenClaw/GatewayConnection+Cron.swift | 64 ++ .../Sources/OpenClaw/GatewayConnection.swift | 65 +- ...cGatewayChatTransport+SessionActions.swift | 40 +- .../Sources/OpenClaw/MacGatewayProfiles.swift | 218 ++++++ apps/macos/Sources/OpenClaw/MenuBar.swift | 7 +- .../Sources/OpenClaw/WebChatManager.swift | 111 +++ .../Sources/OpenClaw/WebChatSwiftUI.swift | 219 +++--- .../MacGatewayChatTransportMappingTests.swift | 13 + .../MacGatewayProfilesTests.swift | 56 ++ docs/docs_map.md | 4 +- docs/platforms/android.md | 20 +- docs/platforms/ios.md | 8 + docs/platforms/linux.md | 3 +- docs/platforms/mac/webchat.md | 16 +- 34 files changed, 2247 insertions(+), 602 deletions(-) create mode 100644 apps/android/app/src/test/java/ai/openclaw/app/GatewayFleetSelectionTest.kt create mode 100644 apps/ios/Sources/Gateway/GatewayOperatorFleet.swift create mode 100644 apps/ios/Sources/Gateway/GatewaySettingsStore+Connections.swift create mode 100644 apps/macos/Sources/OpenClaw/GatewayConnection+Cron.swift create mode 100644 apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift create mode 100644 apps/macos/Tests/OpenClawIPCTests/MacGatewayProfilesTests.swift diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index eec975299a9e..09fee72f4371 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -547,7 +547,7 @@ }, { "kind": "ui-state-text", - "line": 597, + "line": 598, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Mic off", "surface": "android", @@ -555,7 +555,7 @@ }, { "kind": "ui-state-text", - "line": 611, + "line": 612, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Off", "surface": "android", @@ -859,7 +859,7 @@ }, { "kind": "ui-call", - "line": 2198, + "line": 2207, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This automation already has a queued run.", "surface": "android", @@ -867,7 +867,7 @@ }, { "kind": "ui-call", - "line": 2228, + "line": 2237, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation run queued.", "surface": "android", @@ -875,7 +875,7 @@ }, { "kind": "ui-call", - "line": 2228, + "line": 2237, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation started.", "surface": "android", @@ -883,7 +883,7 @@ }, { "kind": "ui-call", - "line": 2241, + "line": 2250, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway rejected the automation run.", "surface": "android", @@ -891,7 +891,7 @@ }, { "kind": "ui-call", - "line": 2272, + "line": 2281, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation enabled.", "surface": "android", @@ -899,7 +899,7 @@ }, { "kind": "ui-call", - "line": 2272, + "line": 2281, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation paused.", "surface": "android", @@ -907,7 +907,7 @@ }, { "kind": "ui-call", - "line": 2294, + "line": 2303, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This automation changed on the gateway. Review the latest version before saving again.", "surface": "android", @@ -915,7 +915,7 @@ }, { "kind": "ui-call", - "line": 2300, + "line": 2309, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation updated.", "surface": "android", @@ -923,7 +923,7 @@ }, { "kind": "ui-call", - "line": 2315, + "line": 2324, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Automation deleted.", "surface": "android", @@ -931,7 +931,7 @@ }, { "kind": "ui-call", - "line": 2565, + "line": 2574, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Node offline. Reconnect and retry.", "surface": "android", @@ -939,7 +939,7 @@ }, { "kind": "ui-named-argument-concatenated", - "line": 2576, + "line": 2585, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Restore canvas now for session=$sessionKey source=$source. If existing A2UI state exists, replay it immediately. If not, create and render a compact mobile-friendly dashboard in Canvas.", "surface": "android", @@ -947,7 +947,7 @@ }, { "kind": "ui-call", - "line": 2597, + "line": 2606, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed to request restore. Tap to retry.", "surface": "android", @@ -955,7 +955,7 @@ }, { "kind": "ui-call", - "line": 2608, + "line": 2617, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "No canvas update yet. Tap to retry.", "surface": "android", @@ -963,7 +963,7 @@ }, { "kind": "ui-call", - "line": 3570, + "line": 3707, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect to a Gateway to save wake words", "surface": "android", @@ -971,7 +971,7 @@ }, { "kind": "ui-call", - "line": 3598, + "line": 3735, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Wake words saved", "surface": "android", @@ -979,7 +979,7 @@ }, { "kind": "ui-call", - "line": 3607, + "line": 3744, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not save wake words", "surface": "android", @@ -987,7 +987,7 @@ }, { "kind": "ui-call", - "line": 4321, + "line": 4461, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: no secure gateway endpoint was detected. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.", "surface": "android", @@ -995,7 +995,7 @@ }, { "kind": "ui-call", - "line": 4325, + "line": 4465, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.", "surface": "android", @@ -1003,7 +1003,7 @@ }, { "kind": "ui-call", - "line": 4329, + "line": 4469, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: couldn't reach the secure gateway endpoint for this host.", "surface": "android", @@ -1011,7 +1011,7 @@ }, { "kind": "ui-named-argument", - "line": 4373, + "line": 4513, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connecting…", "surface": "android", @@ -1019,7 +1019,7 @@ }, { "kind": "ui-named-argument", - "line": 4589, + "line": 4742, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Offline", "surface": "android", @@ -1027,7 +1027,7 @@ }, { "kind": "ui-call", - "line": 5323, + "line": 5476, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load provider catalog.", "surface": "android", @@ -1035,7 +1035,7 @@ }, { "kind": "ui-call", - "line": 5354, + "line": 5507, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Update your Gateway to view provider model config.", "surface": "android", @@ -1043,7 +1043,7 @@ }, { "kind": "ui-call", - "line": 5356, + "line": 5509, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load provider model config.", "surface": "android", @@ -1051,7 +1051,7 @@ }, { "kind": "ui-call", - "line": 5372, + "line": 5525, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Provider models loaded, but readiness is unavailable.", "surface": "android", @@ -1059,7 +1059,7 @@ }, { "kind": "ui-call", - "line": 5459, + "line": 5612, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automations.", "surface": "android", @@ -1067,7 +1067,7 @@ }, { "kind": "ui-call", - "line": 5545, + "line": 5698, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect automations.", "surface": "android", @@ -1075,7 +1075,7 @@ }, { "kind": "ui-call", - "line": 5555, + "line": 5708, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway returned an invalid automation.", "surface": "android", @@ -1083,7 +1083,7 @@ }, { "kind": "ui-call", - "line": 5559, + "line": 5712, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automation.", "surface": "android", @@ -1091,7 +1091,7 @@ }, { "kind": "ui-call", - "line": 5571, + "line": 5724, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect automation run history.", "surface": "android", @@ -1099,7 +1099,7 @@ }, { "kind": "ui-call", - "line": 5602, + "line": 5755, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load automation run history.", "surface": "android", @@ -1107,7 +1107,7 @@ }, { "kind": "ui-call", - "line": 5619, + "line": 5772, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron changes require operator.admin access.", "surface": "android", @@ -1115,7 +1115,7 @@ }, { "kind": "ui-call", - "line": 5628, + "line": 5781, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to manage automations.", "surface": "android", @@ -1123,7 +1123,7 @@ }, { "kind": "ui-call", - "line": 5640, + "line": 5793, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Another cron action is still finishing.", "surface": "android", @@ -1131,7 +1131,7 @@ }, { "kind": "ui-call", - "line": 5687, + "line": 5840, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron action failed.", "surface": "android", @@ -1139,7 +1139,7 @@ }, { "kind": "ui-call", - "line": 5814, + "line": 5967, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load usage.", "surface": "android", @@ -1147,7 +1147,7 @@ }, { "kind": "ui-call", - "line": 5849, + "line": 6002, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load skills.", "surface": "android", @@ -1155,7 +1155,7 @@ }, { "kind": "ui-call", - "line": 5862, + "line": 6015, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to update skills.", "surface": "android", @@ -1163,7 +1163,7 @@ }, { "kind": "ui-call", - "line": 5866, + "line": 6019, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This gateway connection needs operator.admin to update skills.", "surface": "android", @@ -1171,7 +1171,7 @@ }, { "kind": "conditional-branch", - "line": 5881, + "line": 6034, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not disable skill.", "surface": "android", @@ -1179,7 +1179,7 @@ }, { "kind": "conditional-branch", - "line": 5881, + "line": 6034, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not enable skill.", "surface": "android", @@ -1187,7 +1187,7 @@ }, { "kind": "ui-call", - "line": 5899, + "line": 6052, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to search ClawHub skills.", "surface": "android", @@ -1195,7 +1195,7 @@ }, { "kind": "ui-call", - "line": 5945, + "line": 6098, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not search ClawHub skills.", "surface": "android", @@ -1203,7 +1203,7 @@ }, { "kind": "ui-call", - "line": 5958, + "line": 6111, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect ClawHub skills.", "surface": "android", @@ -1211,7 +1211,7 @@ }, { "kind": "conditional-branch", - "line": 5991, + "line": 6144, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "ClawHub did not return an installable version for ${skill.slug}.", "surface": "android", @@ -1219,7 +1219,7 @@ }, { "kind": "ui-call", - "line": 6007, + "line": 6160, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load ClawHub details for ${skill.slug}.", "surface": "android", @@ -1227,7 +1227,7 @@ }, { "kind": "ui-call", - "line": 6023, + "line": 6176, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to install ClawHub skills.", "surface": "android", @@ -1235,7 +1235,7 @@ }, { "kind": "ui-call", - "line": 6039, + "line": 6192, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This gateway connection needs operator.admin to install ClawHub skills.", "surface": "android", @@ -1243,7 +1243,7 @@ }, { "kind": "conditional-branch", - "line": 6129, + "line": 6282, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Installed $slug.", "surface": "android", @@ -1251,7 +1251,7 @@ }, { "kind": "ui-call", - "line": 6136, + "line": 6289, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not install ${slug} from ClawHub.", "surface": "android", @@ -1259,7 +1259,7 @@ }, { "kind": "ui-call", - "line": 6176, + "line": 6329, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to load Skill Workshop proposals.", "surface": "android", @@ -1267,7 +1267,7 @@ }, { "kind": "ui-call", - "line": 6216, + "line": 6369, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load Skill Workshop proposals.", "surface": "android", @@ -1275,7 +1275,7 @@ }, { "kind": "ui-call", - "line": 6236, + "line": 6389, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to inspect Skill Workshop proposals.", "surface": "android", @@ -1283,7 +1283,7 @@ }, { "kind": "ui-call", - "line": 6288, + "line": 6441, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not inspect Skill Workshop proposal.", "surface": "android", @@ -1291,7 +1291,7 @@ }, { "kind": "ui-call", - "line": 6308, + "line": 6461, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Skill Workshop proposal actions require operator.admin scope.", "surface": "android", @@ -1299,7 +1299,7 @@ }, { "kind": "ui-call", - "line": 6313, + "line": 6466, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect the gateway to update Skill Workshop proposals.", "surface": "android", @@ -1307,7 +1307,7 @@ }, { "kind": "ui-call", - "line": 6503, + "line": 6656, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not verify the device pairing change. Refresh and try again.", "surface": "android", @@ -1315,7 +1315,7 @@ }, { "kind": "ui-named-argument", - "line": 6585, + "line": 6738, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected", "surface": "android", @@ -1323,7 +1323,7 @@ }, { "kind": "ui-call", - "line": 6615, + "line": 6768, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load nodes and devices.", "surface": "android", @@ -1331,7 +1331,7 @@ }, { "kind": "ui-call", - "line": 7273, + "line": 7426, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load channels.", "surface": "android", @@ -1339,7 +1339,7 @@ }, { "kind": "ui-call", - "line": 7303, + "line": 7456, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load dreaming.", "surface": "android", @@ -1347,7 +1347,7 @@ }, { "kind": "ui-call", - "line": 7340, + "line": 7493, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Could not load gateway logs.", "surface": "android", @@ -1355,7 +1355,7 @@ }, { "kind": "ui-call", - "line": 7809, + "line": 7962, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "One time", "surface": "android", @@ -1363,7 +1363,7 @@ }, { "kind": "ui-call", - "line": 7818, + "line": 7971, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Cron", "surface": "android", @@ -1371,7 +1371,7 @@ }, { "kind": "ui-call", - "line": 7819, + "line": 7972, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Scheduled", "surface": "android", @@ -1379,7 +1379,7 @@ }, { "kind": "ui-call", - "line": 7827, + "line": 7980, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${days}d", "surface": "android", @@ -1387,7 +1387,7 @@ }, { "kind": "ui-call", - "line": 7828, + "line": 7981, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${hours}h", "surface": "android", @@ -1395,7 +1395,7 @@ }, { "kind": "ui-call", - "line": 7829, + "line": 7982, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Every ${minutes}m", "surface": "android", @@ -1403,7 +1403,7 @@ }, { "kind": "ui-call", - "line": 7830, + "line": 7983, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Repeating", "surface": "android", @@ -1411,7 +1411,7 @@ }, { "kind": "ui-call", - "line": 7846, + "line": 7999, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "No prompt", "surface": "android", @@ -1419,7 +1419,7 @@ }, { "kind": "ui-call", - "line": 7863, + "line": 8016, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway", "surface": "android", @@ -1427,7 +1427,7 @@ }, { "kind": "ui-call", - "line": 7871, + "line": 8024, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connected to $gatewayLabel", "surface": "android", @@ -1435,7 +1435,7 @@ }, { "kind": "ui-call", - "line": 7872, + "line": 8025, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Your agents are ready", "surface": "android", @@ -1443,7 +1443,7 @@ }, { "kind": "ui-call", - "line": 7874, + "line": 8027, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.", "surface": "android", @@ -1451,7 +1451,7 @@ }, { "kind": "ui-call", - "line": 7878, + "line": 8031, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Selected on this phone", "surface": "android", @@ -1459,7 +1459,7 @@ }, { "kind": "ui-call", - "line": 7881, + "line": 8034, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "The overview refreshes on reconnect and when this screen opens.", "surface": "android", @@ -1467,7 +1467,7 @@ }, { "kind": "ui-call", - "line": 7886, + "line": 8039, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Reconnecting", "surface": "android", @@ -1475,7 +1475,7 @@ }, { "kind": "ui-call", - "line": 7887, + "line": 8040, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "OpenClaw is syncing back up", "surface": "android", @@ -1483,7 +1483,7 @@ }, { "kind": "ui-call", - "line": 7889, + "line": 8042, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "The gateway session is coming back online. Agent shortcuts should settle automatically in a moment.", "surface": "android", @@ -1491,7 +1491,7 @@ }, { "kind": "ui-call", - "line": 7893, + "line": 8046, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Gateway session in progress", "surface": "android", @@ -1499,7 +1499,7 @@ }, { "kind": "ui-call", - "line": 7896, + "line": 8049, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "If the gateway is reachable, reconnect should complete without intervention.", "surface": "android", @@ -1507,7 +1507,7 @@ }, { "kind": "ui-call", - "line": 7901, + "line": 8054, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Welcome to OpenClaw", "surface": "android", @@ -1515,7 +1515,7 @@ }, { "kind": "ui-call", - "line": 7902, + "line": 8055, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Your phone stays quiet until it is needed", "surface": "android", @@ -1523,7 +1523,7 @@ }, { "kind": "ui-call", - "line": 7904, + "line": 8057, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops.", "surface": "android", @@ -1531,7 +1531,7 @@ }, { "kind": "ui-call", - "line": 7908, + "line": 8061, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Connect to load your agents", "surface": "android", @@ -1539,7 +1539,7 @@ }, { "kind": "ui-call", - "line": 7911, + "line": 8064, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "When connected, the gateway can wake the phone with a silent push instead of holding an always-on session.", "surface": "android", @@ -1547,7 +1547,7 @@ }, { "kind": "ui-call", - "line": 7943, + "line": 8096, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Main", "surface": "android", @@ -1555,7 +1555,7 @@ }, { "kind": "ui-call", - "line": 7958, + "line": 8111, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Active on this phone", "surface": "android", @@ -1563,7 +1563,7 @@ }, { "kind": "ui-call", - "line": 7959, + "line": 8112, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Default agent", "surface": "android", @@ -1571,7 +1571,7 @@ }, { "kind": "ui-call", - "line": 7960, + "line": 8113, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Ready", "surface": "android", @@ -1579,7 +1579,7 @@ }, { "kind": "ui-call", - "line": 8525, + "line": 8689, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Dream", "surface": "android", @@ -7467,7 +7467,7 @@ }, { "kind": "ui-call", - "line": 1646, + "line": 1647, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replace gateway setup?", "surface": "android", @@ -7475,7 +7475,7 @@ }, { "kind": "ui-call", - "line": 1661, + "line": 1662, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replace setup", "surface": "android", @@ -7483,7 +7483,7 @@ }, { "kind": "ui-call", - "line": 1675, + "line": 1676, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "this gateway", "surface": "android", @@ -7491,7 +7491,7 @@ }, { "kind": "ui-call", - "line": 1678, + "line": 1679, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forget gateway?", "surface": "android", @@ -7499,7 +7499,7 @@ }, { "kind": "ui-call", - "line": 1681, + "line": 1682, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Remove $gatewayName and its saved credentials from this phone?", "surface": "android", @@ -7507,7 +7507,7 @@ }, { "kind": "ui-call", - "line": 1698, + "line": 1699, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cancel", "surface": "android", @@ -7515,7 +7515,7 @@ }, { "kind": "ui-call", - "line": 1736, + "line": 1737, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection between this phone and OpenClaw.", "surface": "android", @@ -7523,7 +7523,7 @@ }, { "kind": "ui-call", - "line": 1750, + "line": 1751, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connected", "surface": "android", @@ -7531,7 +7531,7 @@ }, { "kind": "ui-call", - "line": 1750, + "line": 1751, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection", "surface": "android", @@ -7539,7 +7539,7 @@ }, { "kind": "ui-call", - "line": 1750, + "line": 1751, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Offline", "surface": "android", @@ -7547,7 +7547,7 @@ }, { "kind": "ui-call", - "line": 1751, + "line": 1752, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Not paired", "surface": "android", @@ -7555,7 +7555,7 @@ }, { "kind": "ui-call", - "line": 1751, + "line": 1752, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Online", "surface": "android", @@ -7563,7 +7563,7 @@ }, { "kind": "ui-call", - "line": 1753, + "line": 1754, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Access", "surface": "android", @@ -7571,7 +7571,7 @@ }, { "kind": "ui-call", - "line": 1760, + "line": 1761, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Address", "surface": "android", @@ -7579,7 +7579,7 @@ }, { "kind": "ui-call", - "line": 1765, + "line": 1766, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Discovered", "surface": "android", @@ -7587,7 +7587,7 @@ }, { "kind": "ui-call", - "line": 1766, + "line": 1767, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Default Agent", "surface": "android", @@ -7595,7 +7595,7 @@ }, { "kind": "ui-call", - "line": 1767, + "line": 1768, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Agents", "surface": "android", @@ -7603,7 +7603,7 @@ }, { "kind": "ui-call", - "line": 1768, + "line": 1769, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Instance ID", "surface": "android", @@ -7611,7 +7611,7 @@ }, { "kind": "ui-call", - "line": 1774, + "line": 1775, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scan QR to Pair", "surface": "android", @@ -7619,7 +7619,7 @@ }, { "kind": "ui-call", - "line": 1784, + "line": 1785, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Limited Gateway access", "surface": "android", @@ -7627,7 +7627,7 @@ }, { "kind": "ui-call", - "line": 1797, + "line": 1798, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Reconnect", "surface": "android", @@ -7635,7 +7635,7 @@ }, { "kind": "ui-call", - "line": 1798, + "line": 1799, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Disconnect", "surface": "android", @@ -7643,7 +7643,7 @@ }, { "kind": "ui-call", - "line": 1801, + "line": 1802, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Diagnose", "surface": "android", @@ -7651,7 +7651,7 @@ }, { "kind": "ui-call", - "line": 1814, + "line": 1815, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Add Gateway", "surface": "android", @@ -7659,7 +7659,7 @@ }, { "kind": "ui-call", - "line": 1816, + "line": 1817, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scan or paste a setup code to add another gateway.", "surface": "android", @@ -7667,7 +7667,7 @@ }, { "kind": "ui-call", - "line": 1822, + "line": 1823, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scan QR", "surface": "android", @@ -7675,7 +7675,7 @@ }, { "kind": "ui-call", - "line": 1823, + "line": 1824, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup code", "surface": "android", @@ -7683,7 +7683,7 @@ }, { "kind": "ui-call", - "line": 1826, + "line": 1827, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Where do I get a setup code?", "surface": "android", @@ -7691,7 +7691,7 @@ }, { "kind": "ui-call", - "line": 1830, + "line": 1831, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below.", "surface": "android", @@ -7699,7 +7699,7 @@ }, { "kind": "ui-call", - "line": 1837, + "line": 1838, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No gateways found yet. Use manual setup if discovery is blocked.", "surface": "android", @@ -7707,7 +7707,7 @@ }, { "kind": "ui-call", - "line": 1850, + "line": 1851, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect", "surface": "android", @@ -7715,7 +7715,7 @@ }, { "kind": "ui-call", - "line": 1861, + "line": 1862, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateways", "surface": "android", @@ -7723,7 +7723,7 @@ }, { "kind": "ui-call", - "line": 1863, + "line": 1864, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No paired gateways.", "surface": "android", @@ -7731,7 +7731,7 @@ }, { "kind": "ui-call", - "line": 1883, + "line": 1892, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forget", "surface": "android", @@ -7739,7 +7739,7 @@ }, { "kind": "ui-call", - "line": 1899, + "line": 1909, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Manual Gateway", "surface": "android", @@ -7747,7 +7747,7 @@ }, { "kind": "ui-call", - "line": 1901, + "line": 1911, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Host", "surface": "android", @@ -7755,7 +7755,7 @@ }, { "kind": "ui-call", - "line": 1902, + "line": 1912, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Port", "surface": "android", @@ -7763,7 +7763,7 @@ }, { "kind": "ui-call", - "line": 1904, + "line": 1914, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection security", "surface": "android", @@ -7771,7 +7771,7 @@ }, { "kind": "ui-call", - "line": 1908, + "line": 1918, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Unencrypted", "surface": "android", @@ -7779,7 +7779,7 @@ }, { "kind": "ui-call", - "line": 1912, + "line": 1922, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Secure (TLS)", "surface": "android", @@ -7787,7 +7787,7 @@ }, { "kind": "ui-call", - "line": 1925, + "line": 1935, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Token", "surface": "android", @@ -7795,7 +7795,7 @@ }, { "kind": "ui-call", - "line": 1926, + "line": 1936, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Bootstrap", "surface": "android", @@ -7803,7 +7803,7 @@ }, { "kind": "ui-call", - "line": 1928, + "line": 1938, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Password", "surface": "android", @@ -7811,7 +7811,7 @@ }, { "kind": "ui-call", - "line": 1933, + "line": 1943, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Save & Connect", "surface": "android", @@ -7819,7 +7819,7 @@ }, { "kind": "ui-call", - "line": 1950, + "line": 1960, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enter a valid setup code or gateway address.", "surface": "android", @@ -7827,7 +7827,7 @@ }, { "kind": "ui-call", - "line": 1971, + "line": 1981, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Not available", "surface": "android", @@ -7835,7 +7835,7 @@ }, { "kind": "ui-call", - "line": 1972, + "line": 1982, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Full", "surface": "android", @@ -7843,7 +7843,7 @@ }, { "kind": "ui-call", - "line": 1973, + "line": 1983, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Limited", "surface": "android", @@ -7851,7 +7851,7 @@ }, { "kind": "ui-call", - "line": 1977, + "line": 1987, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Use a secure wss:// or Tailscale Serve Gateway, generate a full-access setup code in the Control UI or with openclaw qr, then scan or paste it below and reconnect to enable settings and upgrades.", "surface": "android", @@ -7859,7 +7859,7 @@ }, { "kind": "ui-call", - "line": 1991, + "line": 2001, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Appearance", "surface": "android", @@ -7867,7 +7867,7 @@ }, { "kind": "ui-call", - "line": 1991, + "line": 2001, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Theme and translated Android text.", "surface": "android", @@ -7875,7 +7875,7 @@ }, { "kind": "ui-call", - "line": 1996, + "line": 2006, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Language", "surface": "android", @@ -7883,7 +7883,7 @@ }, { "kind": "ui-call", - "line": 1997, + "line": 2007, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Contrast", "surface": "android", @@ -7891,7 +7891,7 @@ }, { "kind": "ui-call", - "line": 1997, + "line": 2007, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "High", "surface": "android", @@ -7899,7 +7899,7 @@ }, { "kind": "ui-call", - "line": 1998, + "line": 2008, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Readable", "surface": "android", @@ -7907,7 +7907,7 @@ }, { "kind": "ui-call", - "line": 1998, + "line": 2008, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Typography", "surface": "android", @@ -7915,7 +7915,7 @@ }, { "kind": "ui-call", - "line": 2003, + "line": 2013, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Theme", "surface": "android", @@ -7923,7 +7923,7 @@ }, { "kind": "ui-call", - "line": 2013, + "line": 2023, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App language", "surface": "android", @@ -7931,7 +7931,7 @@ }, { "kind": "ui-call", - "line": 2047, + "line": 2057, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Selected", "surface": "android", @@ -7939,7 +7939,7 @@ }, { "kind": "ui-call", - "line": 2059, + "line": 2069, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "System", "surface": "android", @@ -7947,7 +7947,7 @@ }, { "kind": "ui-call", - "line": 2087, + "line": 2097, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "While Using", "surface": "android", @@ -7955,7 +7955,7 @@ }, { "kind": "ui-call", - "line": 2088, + "line": 2098, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Always", "surface": "android", @@ -7963,7 +7963,7 @@ }, { "kind": "ui-call", - "line": 2100, + "line": 2110, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connecting...", "surface": "android", @@ -7971,7 +7971,7 @@ }, { "kind": "ui-call", - "line": 2101, + "line": 2111, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Pairing needed", "surface": "android", @@ -7979,7 +7979,7 @@ }, { "kind": "ui-call", - "line": 2102, + "line": 2112, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Authentication needed", "surface": "android", @@ -7987,7 +7987,7 @@ }, { "kind": "ui-call", - "line": 2103, + "line": 2113, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "TLS timed out", "surface": "android", @@ -7995,7 +7995,7 @@ }, { "kind": "ui-call", - "line": 2104, + "line": 2114, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No TLS endpoint", "surface": "android", @@ -8003,7 +8003,7 @@ }, { "kind": "ui-call", - "line": 2105, + "line": 2115, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Certificate review needed", "surface": "android", @@ -8011,7 +8011,7 @@ }, { "kind": "ui-call", - "line": 2106, + "line": 2116, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cannot reach gateway", "surface": "android", @@ -8019,7 +8019,7 @@ }, { "kind": "ui-call", - "line": 2127, + "line": 2137, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "About", "surface": "android", @@ -8027,7 +8027,7 @@ }, { "kind": "ui-call", - "line": 2127, + "line": 2137, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw for Android.", "surface": "android", @@ -8035,7 +8035,7 @@ }, { "kind": "ui-call", - "line": 2139, + "line": 2149, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Channel", "surface": "android", @@ -8043,7 +8043,7 @@ }, { "kind": "ui-call", - "line": 2140, + "line": 2150, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Not connected", "surface": "android", @@ -8051,7 +8051,7 @@ }, { "kind": "ui-call", - "line": 2145, + "line": 2155, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Home Gateway", "surface": "android", @@ -8059,7 +8059,7 @@ }, { "kind": "ui-call", - "line": 2147, + "line": 2157, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Runtime", "surface": "android", @@ -8067,7 +8067,7 @@ }, { "kind": "ui-call", - "line": 2147, + "line": 2157, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Waiting", "surface": "android", @@ -8075,7 +8075,7 @@ }, { "kind": "ui-call", - "line": 2150, + "line": 2160, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Update", "surface": "android", @@ -8083,7 +8083,7 @@ }, { "kind": "ui-call", - "line": 2151, + "line": 2161, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Up to date", "surface": "android", @@ -8091,7 +8091,7 @@ }, { "kind": "ui-call", - "line": 2151, + "line": 2161, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "v$it available", "surface": "android", @@ -8099,7 +8099,7 @@ }, { "kind": "ui-call", - "line": 2161, + "line": 2171, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "© 2026 OpenClaw Foundation — MIT License.", "surface": "android", @@ -8107,7 +8107,7 @@ }, { "kind": "ui-call", - "line": 2178, + "line": 2188, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw logo", "surface": "android", @@ -8115,7 +8115,7 @@ }, { "kind": "ui-call", - "line": 2180, + "line": 2190, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw", "surface": "android", @@ -8123,7 +8123,7 @@ }, { "kind": "ui-call", - "line": 2181, + "line": 2191, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Personal AI on your devices", "surface": "android", @@ -8131,7 +8131,7 @@ }, { "kind": "ui-call", - "line": 2240, + "line": 2250, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Licenses", "surface": "android", @@ -8139,7 +8139,7 @@ }, { "kind": "ui-call", - "line": 2241, + "line": 2251, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw appreciates its partners in the open-source community.", "surface": "android", @@ -8147,7 +8147,7 @@ }, { "kind": "ui-call", - "line": 2250, + "line": 2260, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No license notices are packaged in this build.", "surface": "android", @@ -8155,7 +8155,7 @@ }, { "kind": "ui-call", - "line": 2276, + "line": 2286, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open ${license.title}", "surface": "android", @@ -8163,7 +8163,7 @@ }, { "kind": "ui-call", - "line": 2287, + "line": 2297, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Third-party", "surface": "android", @@ -8171,7 +8171,7 @@ }, { "kind": "ui-call", - "line": 2288, + "line": 2298, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Unknown", "surface": "android", @@ -8179,7 +8179,7 @@ }, { "kind": "ui-call", - "line": 2294, + "line": 2304, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Website", "surface": "android", @@ -8187,7 +8187,7 @@ }, { "kind": "ui-call", - "line": 2295, + "line": 2305, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Docs", "surface": "android", @@ -8195,7 +8195,7 @@ }, { "kind": "ui-call", - "line": 2299, + "line": 2309, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow all the time", "surface": "android", @@ -8203,7 +8203,7 @@ }, { "kind": "ui-call", - "line": 2302, + "line": 2312, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replacing the setup code clears this phone's saved setup credentials and device tokens before reconnecting. This phone may need node capability approval again; continue only when you mean to pair with a fresh gateway setup code.", "surface": "android", @@ -8211,7 +8211,7 @@ }, { "kind": "ui-call", - "line": 2321, + "line": 2331, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check", "surface": "android", @@ -8219,7 +8219,7 @@ }, { "kind": "ui-call", - "line": 2328, + "line": 2338, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw turns this phone into a clean mobile command surface for threads, voice, providers, and Gateway.", "surface": "android", @@ -8227,7 +8227,7 @@ }, { "kind": "ui-call", - "line": 2330, + "line": 2340, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "A Gateway update is available. Run the update from the Web UI or CLI when you are ready.", "surface": "android", @@ -8235,7 +8235,7 @@ }, { "kind": "ui-call", - "line": 2355, + "line": 2365, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Back", "surface": "android", @@ -8243,7 +8243,7 @@ }, { "kind": "ui-call", - "line": 2429, + "line": 2439, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Command approval", "surface": "android", @@ -8251,7 +8251,7 @@ }, { "kind": "ui-call", - "line": 2434, + "line": 2444, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Sending", "surface": "android", @@ -8259,7 +8259,7 @@ }, { "kind": "ui-call", - "line": 2494, + "line": 2504, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow Once", "surface": "android", @@ -8267,7 +8267,7 @@ }, { "kind": "ui-call", - "line": 2495, + "line": 2505, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow Always", "surface": "android", @@ -8275,7 +8275,7 @@ }, { "kind": "ui-call", - "line": 2496, + "line": 2506, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Deny", "surface": "android", @@ -8283,7 +8283,7 @@ }, { "kind": "ui-call", - "line": 2517, + "line": 2527, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Approval ${notice.approvalId}", "surface": "android", @@ -8291,7 +8291,7 @@ }, { "kind": "ui-call", - "line": 2524, + "line": 2534, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Dismiss approval notice", "surface": "android", @@ -8299,7 +8299,7 @@ }, { "kind": "ui-call", - "line": 2543, + "line": 2553, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Review", "surface": "android", @@ -8307,7 +8307,7 @@ }, { "kind": "ui-call", - "line": 2583, + "line": 2593, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open automation detail", "surface": "android", @@ -8315,7 +8315,7 @@ }, { "kind": "ui-call", - "line": 2626, + "line": 2636, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enabled", "surface": "android", @@ -8323,7 +8323,7 @@ }, { "kind": "ui-call", - "line": 2626, + "line": 2636, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Status", "surface": "android", @@ -8331,7 +8331,7 @@ }, { "kind": "ui-call", - "line": 2627, + "line": 2637, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Schedule", "surface": "android", @@ -8339,7 +8339,7 @@ }, { "kind": "ui-call", - "line": 2628, + "line": 2638, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Next Wake", "surface": "android", @@ -8347,7 +8347,7 @@ }, { "kind": "ui-call", - "line": 2629, + "line": 2639, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Last Run", "surface": "android", @@ -8355,7 +8355,7 @@ }, { "kind": "ui-call", - "line": 2636, + "line": 2646, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Description", "surface": "android", @@ -8363,7 +8363,7 @@ }, { "kind": "ui-call", - "line": 2637, + "line": 2647, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Schedule Detail", "surface": "android", @@ -8371,7 +8371,7 @@ }, { "kind": "ui-call", - "line": 2638, + "line": 2648, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Session Target", "surface": "android", @@ -8379,7 +8379,7 @@ }, { "kind": "ui-call", - "line": 2639, + "line": 2649, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Wake Mode", "surface": "android", @@ -8387,7 +8387,7 @@ }, { "kind": "ui-call", - "line": 2640, + "line": 2650, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Delete After Run", "surface": "android", @@ -8395,7 +8395,7 @@ }, { "kind": "ui-call", - "line": 2640, + "line": 2650, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No", "surface": "android", @@ -8403,7 +8403,7 @@ }, { "kind": "ui-call", - "line": 2640, + "line": 2650, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Yes", "surface": "android", @@ -8411,7 +8411,7 @@ }, { "kind": "ui-call", - "line": 2641, + "line": 2651, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Payload", "surface": "android", @@ -8419,7 +8419,7 @@ }, { "kind": "ui-call", - "line": 2642, + "line": 2652, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Delivery", "surface": "android", @@ -8427,7 +8427,7 @@ }, { "kind": "ui-call", - "line": 2643, + "line": 2653, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Failure Alert", "surface": "android", @@ -8435,7 +8435,7 @@ }, { "kind": "ui-call", - "line": 2644, + "line": 2654, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Created", "surface": "android", @@ -8443,7 +8443,7 @@ }, { "kind": "ui-call", - "line": 2645, + "line": 2655, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Updated", "surface": "android", @@ -8451,7 +8451,7 @@ }, { "kind": "ui-call", - "line": 2646, + "line": 2656, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Running Since", "surface": "android", @@ -8459,7 +8459,7 @@ }, { "kind": "ui-call", - "line": 2647, + "line": 2657, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Last Status", "surface": "android", @@ -8467,7 +8467,7 @@ }, { "kind": "ui-call", - "line": 2649, + "line": 2659, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Last Duration", "surface": "android", @@ -8475,7 +8475,7 @@ }, { "kind": "ui-call", - "line": 2650, + "line": 2660, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "${durationMs}ms", "surface": "android", @@ -8483,7 +8483,7 @@ }, { "kind": "ui-call", - "line": 2652, + "line": 2662, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Consecutive Errors", "surface": "android", @@ -8491,7 +8491,7 @@ }, { "kind": "ui-call", - "line": 2653, + "line": 2663, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Consecutive Skips", "surface": "android", @@ -8499,7 +8499,7 @@ }, { "kind": "ui-call", - "line": 2654, + "line": 2664, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Delivery Status", "surface": "android", @@ -8507,7 +8507,7 @@ }, { "kind": "ui-call", - "line": 2661, + "line": 2671, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Last Error", "surface": "android", @@ -8515,7 +8515,7 @@ }, { "kind": "ui-call", - "line": 2664, + "line": 2674, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Delivery Error", "surface": "android", @@ -8523,7 +8523,7 @@ }, { "kind": "ui-call", - "line": 2680, + "line": 2690, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Copy ${row.title}", "surface": "android", @@ -8531,7 +8531,7 @@ }, { "kind": "ui-call", - "line": 2705, + "line": 2715, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Tap to copy", "surface": "android", @@ -8539,7 +8539,7 @@ }, { "kind": "ui-call", - "line": 2720, + "line": 2730, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "$title copied", "surface": "android", @@ -8547,7 +8547,7 @@ }, { "kind": "ui-call", - "line": 2758, + "line": 2768, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Default assistant", "surface": "android", @@ -8555,7 +8555,7 @@ }, { "kind": "ui-call", - "line": 2764, + "line": 2774, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Default", "surface": "android", @@ -8563,7 +8563,7 @@ }, { "kind": "conditional-branch", - "line": 2772, + "line": 2782, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "${endpoint.host}:${endpoint.port}", "surface": "android", @@ -8571,7 +8571,7 @@ }, { "kind": "ui-call", - "line": 2818, + "line": 2828, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Action Request", "surface": "android", @@ -8579,7 +8579,7 @@ }, { "kind": "ui-call", - "line": 2826, + "line": 2836, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Needs attention", "surface": "android", @@ -8587,7 +8587,7 @@ }, { "kind": "ui-call", - "line": 2829, + "line": 2839, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Waiting ${minutes}m", "surface": "android", @@ -8595,7 +8595,7 @@ }, { "kind": "ui-call", - "line": 2829, + "line": 2839, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Waiting for review", "surface": "android", @@ -8603,7 +8603,7 @@ }, { "kind": "ui-call", - "line": 2840, + "line": 2850, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Node ${nodeId}", "surface": "android", @@ -8611,7 +8611,7 @@ }, { "kind": "ui-call", - "line": 2842, + "line": 2852, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Node", "surface": "android", @@ -8619,7 +8619,7 @@ }, { "kind": "ui-call", - "line": 2845, + "line": 2855, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway", "surface": "android", @@ -8627,7 +8627,7 @@ }, { "kind": "ui-call", - "line": 2850, + "line": 2860, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Agent ${agentId}", "surface": "android", @@ -8635,7 +8635,7 @@ }, { "kind": "ui-call", - "line": 2855, + "line": 2865, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Waiting ${duration}", "surface": "android", @@ -8643,7 +8643,7 @@ }, { "kind": "ui-call", - "line": 2860, + "line": 2870, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Expires ${duration}", "surface": "android", @@ -8651,7 +8651,7 @@ }, { "kind": "ui-call", - "line": 2870, + "line": 2880, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "soon", "surface": "android", @@ -8659,7 +8659,7 @@ }, { "kind": "ui-call", - "line": 2878, + "line": 2888, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Main", "surface": "android", @@ -8667,7 +8667,7 @@ }, { "kind": "ui-call", - "line": 2879, + "line": 2889, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Isolated", "surface": "android", @@ -8675,7 +8675,7 @@ }, { "kind": "ui-call", - "line": 2880, + "line": 2890, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Current", "surface": "android", @@ -8683,7 +8683,7 @@ }, { "kind": "ui-call", - "line": 2886, + "line": 2896, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "${job.scheduleLabel} · ${formatCronWake(job.nextRunAtMs)} · ${job.promptPreview}", "surface": "android", @@ -8691,7 +8691,7 @@ }, { "kind": "ui-call", - "line": 2902, + "line": 2912, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "All", "surface": "android", @@ -8699,7 +8699,7 @@ }, { "kind": "ui-call", - "line": 2903, + "line": 2913, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Active", "surface": "android", @@ -8707,7 +8707,7 @@ }, { "kind": "ui-call", - "line": 2904, + "line": 2914, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Paused", "surface": "android", @@ -8715,7 +8715,7 @@ }, { "kind": "ui-call", - "line": 2937, + "line": 2947, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "${remaining}% left ${label}", "surface": "android", @@ -8723,7 +8723,7 @@ }, { "kind": "ui-call", - "line": 2940, + "line": 2950, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No limits reported", "surface": "android", @@ -8731,7 +8731,7 @@ }, { "kind": "ui-call", - "line": 2951, + "line": 2961, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Never", "surface": "android", @@ -8739,7 +8739,7 @@ }, { "kind": "ui-call", - "line": 2956, + "line": 2966, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Now", "surface": "android", @@ -8747,7 +8747,7 @@ }, { "kind": "ui-call", - "line": 2978, + "line": 2988, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Off", "surface": "android", @@ -8755,7 +8755,7 @@ }, { "kind": "ui-call", - "line": 2980, + "line": 2990, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Issue", "surface": "android", @@ -8763,7 +8763,7 @@ }, { "kind": "ui-call", - "line": 2982, + "line": 2992, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Skipped", "surface": "android", @@ -8771,7 +8771,7 @@ }, { "kind": "ui-call", - "line": 2983, + "line": 2993, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Ready", "surface": "android", @@ -8779,7 +8779,7 @@ }, { "kind": "ui-call", - "line": 2999, + "line": 3009, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "System Event Text", "surface": "android", @@ -8787,7 +8787,7 @@ }, { "kind": "ui-call", - "line": 3000, + "line": 3010, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Agent Prompt", "surface": "android", @@ -8795,7 +8795,7 @@ }, { "kind": "ui-call", - "line": 3001, + "line": 3011, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Command", "surface": "android", @@ -8803,7 +8803,7 @@ }, { "kind": "ui-call", - "line": 3002, + "line": 3012, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Payload Text", "surface": "android", @@ -8811,7 +8811,7 @@ }, { "kind": "ui-call", - "line": 3032, + "line": 3042, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No apps selected. Nothing forwards until you add apps.", "surface": "android", @@ -8819,7 +8819,7 @@ }, { "kind": "ui-call", - "line": 3034, + "line": 3044, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "$selectedCount app allowed to forward.", "surface": "android", @@ -8827,7 +8827,7 @@ }, { "kind": "ui-call", - "line": 3036, + "line": 3046, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "$selectedCount apps allowed to forward.", "surface": "android", @@ -8835,7 +8835,7 @@ }, { "kind": "ui-call", - "line": 3040, + "line": 3050, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No apps blocked. Apps can forward unless you add blocks.", "surface": "android", @@ -8843,7 +8843,7 @@ }, { "kind": "ui-call", - "line": 3042, + "line": 3052, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "$selectedCount app blocked from forwarding.", "surface": "android", @@ -8851,7 +8851,7 @@ }, { "kind": "ui-call", - "line": 3044, + "line": 3054, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "$selectedCount apps blocked from forwarding.", "surface": "android", @@ -8859,7 +8859,7 @@ }, { "kind": "ui-call", - "line": 3070, + "line": 3080, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Due", "surface": "android", @@ -8867,7 +8867,7 @@ }, { "kind": "ui-call", - "line": 3075, + "line": 3085, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "${days}d", "surface": "android", @@ -8875,7 +8875,7 @@ }, { "kind": "ui-call", - "line": 3076, + "line": 3086, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "${hours}h", "surface": "android", @@ -8883,7 +8883,7 @@ }, { "kind": "ui-call", - "line": 3077, + "line": 3087, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "${minutes}m", "surface": "android", @@ -8891,7 +8891,7 @@ }, { "kind": "ui-call", - "line": 3078, + "line": 3088, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Soon", "surface": "android", @@ -8899,7 +8899,7 @@ }, { "kind": "ui-call", - "line": 3083, + "line": 3093, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "None", "surface": "android", @@ -21437,21 +21437,37 @@ "kind": "ui-call", "line": 1135, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", - "source": "Switch gateways without pairing again.", + "source": "Keep multiple gateways connected and switch which one is in focus.", "surface": "apple", - "id": "native.apple.9a6bd720a544ec04" + "id": "native.apple.0c5dae9bc264aaa1" }, { "kind": "ui-modifier", - "line": 1165, + "line": 1172, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", - "source": "Active Gateway", + "source": "Focused Gateway", "surface": "apple", - "id": "native.apple.5f33cfe2d21e6dc6" + "id": "native.apple.a1d2ffbfe64ef4ce" + }, + { + "kind": "conditional-branch", + "line": 1187, + "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", + "source": "Disconnect Gateway", + "surface": "apple", + "id": "native.apple.f819d2ea384fe6ed" + }, + { + "kind": "conditional-branch", + "line": 1187, + "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", + "source": "Keep Gateway Connected", + "surface": "apple", + "id": "native.apple.10d41627b8261536" }, { "kind": "ui-call", - "line": 1177, + "line": 1196, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Forget", "surface": "apple", @@ -21459,7 +21475,7 @@ }, { "kind": "ui-call", - "line": 1189, + "line": 1208, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Forget Gateway", "surface": "apple", @@ -21467,7 +21483,7 @@ }, { "kind": "ui-call", - "line": 1242, + "line": 1261, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Manual Gateway", "surface": "apple", @@ -21475,7 +21491,7 @@ }, { "kind": "ui-call", - "line": 1243, + "line": 1262, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Use Manual Gateway", "surface": "apple", @@ -21483,7 +21499,7 @@ }, { "kind": "ui-call", - "line": 1244, + "line": 1263, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Host", "surface": "apple", @@ -21491,7 +21507,7 @@ }, { "kind": "ui-call", - "line": 1248, + "line": 1267, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Port", "surface": "apple", @@ -21499,7 +21515,7 @@ }, { "kind": "ui-call", - "line": 1252, + "line": 1271, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Unencrypted", "surface": "apple", @@ -21507,7 +21523,7 @@ }, { "kind": "ui-call", - "line": 1255, + "line": 1274, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Secure (TLS)", "surface": "apple", @@ -21515,7 +21531,7 @@ }, { "kind": "ui-call", - "line": 1259, + "line": 1278, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Connection security", "surface": "apple", @@ -21523,7 +21539,7 @@ }, { "kind": "ui-named-argument", - "line": 1270, + "line": 1289, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Connect Manual", "surface": "apple", @@ -21531,7 +21547,7 @@ }, { "kind": "ui-call", - "line": 1300, + "line": 1319, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Auto-connect on launch", "surface": "apple", @@ -21539,7 +21555,7 @@ }, { "kind": "ui-call", - "line": 1301, + "line": 1320, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Auth Token", "surface": "apple", @@ -21547,7 +21563,7 @@ }, { "kind": "ui-call", - "line": 1302, + "line": 1321, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Password", "surface": "apple", @@ -21555,7 +21571,7 @@ }, { "kind": "ui-call", - "line": 1307, + "line": 1326, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Custom Headers", "surface": "apple", @@ -21563,7 +21579,7 @@ }, { "kind": "ui-call", - "line": 1314, + "line": 1333, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Reset Onboarding", "surface": "apple", @@ -21571,7 +21587,7 @@ }, { "kind": "ui-call", - "line": 1344, + "line": 1363, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice Wake", "surface": "apple", @@ -21579,7 +21595,7 @@ }, { "kind": "ui-call", - "line": 1347, + "line": 1366, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Talk Mode", "surface": "apple", @@ -21587,7 +21603,7 @@ }, { "kind": "ui-call", - "line": 1355, + "line": 1374, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Speech Language", "surface": "apple", @@ -21595,7 +21611,7 @@ }, { "kind": "ui-call", - "line": 1362, + "line": 1381, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Speakerphone", "surface": "apple", @@ -21603,7 +21619,7 @@ }, { "kind": "ui-call", - "line": 1366, + "line": 1385, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Wake Words", "surface": "apple", @@ -21611,7 +21627,7 @@ }, { "kind": "ui-call", - "line": 1388, + "line": 1407, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice", "surface": "apple", @@ -21619,7 +21635,7 @@ }, { "kind": "ui-call", - "line": 1389, + "line": 1408, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Provider", "surface": "apple", @@ -21627,7 +21643,7 @@ }, { "kind": "ui-call", - "line": 1396, + "line": 1415, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Realtime Voice", "surface": "apple", @@ -21635,7 +21651,7 @@ }, { "kind": "ui-call", - "line": 1397, + "line": 1416, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Default", "surface": "apple", @@ -21643,7 +21659,7 @@ }, { "kind": "ui-call", - "line": 1404, + "line": 1423, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice Mode", "surface": "apple", @@ -21651,7 +21667,7 @@ }, { "kind": "ui-call", - "line": 1407, + "line": 1426, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Active Voice", "surface": "apple", @@ -21659,7 +21675,7 @@ }, { "kind": "ui-call", - "line": 1411, + "line": 1430, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Last Voice Issue", "surface": "apple", @@ -21667,7 +21683,7 @@ }, { "kind": "ui-call", - "line": 1413, + "line": 1432, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Transport", "surface": "apple", @@ -21675,7 +21691,7 @@ }, { "kind": "ui-call", - "line": 1416, + "line": 1435, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "API Key", "surface": "apple", @@ -21683,7 +21699,7 @@ }, { "kind": "ui-call", - "line": 1423, + "line": 1442, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Show Talk Control", "surface": "apple", @@ -21691,7 +21707,7 @@ }, { "kind": "ui-call", - "line": 1424, + "line": 1443, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Default Share Instruction", "surface": "apple", @@ -21699,7 +21715,7 @@ }, { "kind": "ui-call", - "line": 1431, + "line": 1450, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Run Share Self-Test", "surface": "apple", @@ -21707,7 +21723,7 @@ }, { "kind": "ui-call", - "line": 1450, + "line": 1469, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Apple Health", "surface": "apple", @@ -21715,7 +21731,7 @@ }, { "kind": "ui-call", - "line": 1458, + "line": 1477, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovery Debug Logs", "surface": "apple", @@ -21723,7 +21739,7 @@ }, { "kind": "ui-call", - "line": 1461, + "line": 1480, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Debug Screen Status", "surface": "apple", @@ -21731,7 +21747,7 @@ }, { "kind": "ui-call", - "line": 1465, + "line": 1484, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovery Logs", "surface": "apple", @@ -21739,7 +21755,7 @@ }, { "kind": "ui-call", - "line": 1473, + "line": 1492, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Device", "surface": "apple", @@ -21747,7 +21763,7 @@ }, { "kind": "ui-call", - "line": 1474, + "line": 1493, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Device Name", "surface": "apple", @@ -21755,7 +21771,7 @@ }, { "kind": "ui-call", - "line": 1476, + "line": 1495, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Instance ID", "surface": "apple", @@ -21763,7 +21779,7 @@ }, { "kind": "ui-localized-call", - "line": 1500, + "line": 1519, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "On", "surface": "apple", @@ -21771,7 +21787,7 @@ }, { "kind": "ui-localized-call", - "line": 1501, + "line": 1520, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Off", "surface": "apple", @@ -22923,7 +22939,7 @@ }, { "kind": "ui-localized-call", - "line": 40, + "line": 45, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "Connect", "surface": "apple", @@ -22931,7 +22947,7 @@ }, { "kind": "ui-localized-call", - "line": 42, + "line": 47, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "TLS required", "surface": "apple", @@ -22939,7 +22955,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 51, + "line": 56, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "Enable Gateway TLS, or enter your Tailscale Serve HTTPS host in Manual Setup. Use Unencrypted only with a trusted private-LAN address.", "surface": "apple", @@ -22947,7 +22963,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 1291, + "line": 1437, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "Can't reach gateway at %1$@:%2$@. Verify Tailscale Serve is enabled and publishes this Gateway.", "surface": "apple", @@ -22955,7 +22971,7 @@ }, { "kind": "ui-localized-call", - "line": 1300, + "line": 1446, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "Can't reach gateway at %1$@:%2$@. Check Tailscale or LAN.", "surface": "apple", @@ -22963,7 +22979,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 1306, + "line": 1452, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "TLS fingerprint verification timed out for %1$@:%2$@. Secure endpoint was reached, but TLS did not finish in time.", "surface": "apple", @@ -22971,7 +22987,7 @@ }, { "kind": "ui-localized-call-multiline", - "line": 1314, + "line": 1460, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "No secure gateway endpoint was detected at %1$@:%2$@. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.", "surface": "apple", @@ -22979,7 +22995,7 @@ }, { "kind": "ui-localized-call", - "line": 1324, + "line": 1470, "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", "source": "Could not read the TLS certificate from %1$@:%2$@.", "surface": "apple", @@ -23017,6 +23033,14 @@ "surface": "apple", "id": "native.apple.f21a8e304b607dc8" }, + { + "kind": "ui-localized-call", + "line": 141, + "path": "apps/ios/Sources/Gateway/GatewayOperatorFleet.swift", + "source": "Reconnecting…", + "surface": "apple", + "id": "native.apple.7896bc893de41eae" + }, { "kind": "ui-localized-call", "line": 15, @@ -23307,7 +23331,7 @@ }, { "kind": "conditional-branch", - "line": 623, + "line": 634, "path": "apps/ios/Sources/Gateway/GatewaySettingsStore.swift", "source": "\\(host):\\(port)", "surface": "apple", @@ -23315,7 +23339,7 @@ }, { "kind": "conditional-branch", - "line": 686, + "line": 708, "path": "apps/ios/Sources/Gateway/GatewaySettingsStore.swift", "source": "\\(legacy.host ?? \"\"):\\(legacy.port ?? 0)", "surface": "apple", @@ -31581,13 +31605,21 @@ "kind": "ui-call", "line": 106, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", + "source": "New Gateway Window…", + "surface": "apple", + "id": "native.apple.130664b1dd61cbbf" + }, + { + "kind": "ui-call", + "line": 111, + "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "New Thread", "surface": "apple", "id": "native.apple.13f96467014cf344" }, { "kind": "ui-call", - "line": 112, + "line": 117, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Settings...", "surface": "apple", @@ -31595,7 +31627,7 @@ }, { "kind": "ui-call", - "line": 119, + "line": 124, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Back", "surface": "apple", @@ -31603,7 +31635,7 @@ }, { "kind": "ui-call", - "line": 124, + "line": 129, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Forward", "surface": "apple", @@ -31611,7 +31643,7 @@ }, { "kind": "ui-call", - "line": 131, + "line": 136, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Command Palette…", "surface": "apple", @@ -31619,7 +31651,7 @@ }, { "kind": "conditional-branch", - "line": 149, + "line": 154, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "OpenClaw - Voice Wake live meter active", "surface": "apple", @@ -31627,7 +31659,7 @@ }, { "kind": "conditional-branch", - "line": 150, + "line": 155, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "OpenClaw", "surface": "apple", @@ -31635,7 +31667,7 @@ }, { "kind": "conditional-branch", - "line": 447, + "line": 452, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Close Canvas", "surface": "apple", @@ -31643,7 +31675,7 @@ }, { "kind": "conditional-branch", - "line": 447, + "line": 452, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Open Canvas", "surface": "apple", @@ -36907,7 +36939,15 @@ }, { "kind": "ui-localized-call", - "line": 826, + "line": 838, + "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", + "source": "Talk mode uses the primary Gateway window", + "surface": "apple", + "id": "native.apple.93ad62dac611bdcd" + }, + { + "kind": "ui-localized-call", + "line": 840, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Talk mode off", "surface": "apple", @@ -36915,7 +36955,7 @@ }, { "kind": "ui-localized-call", - "line": 827, + "line": 841, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Talk mode paused", "surface": "apple", @@ -36923,7 +36963,7 @@ }, { "kind": "ui-localized-call", - "line": 829, + "line": 843, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Talk mode ready", "surface": "apple", @@ -36931,7 +36971,7 @@ }, { "kind": "ui-localized-call", - "line": 830, + "line": 844, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Listening", "surface": "apple", @@ -36939,7 +36979,7 @@ }, { "kind": "ui-localized-call", - "line": 831, + "line": 845, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Thinking", "surface": "apple", @@ -36947,7 +36987,7 @@ }, { "kind": "ui-localized-call", - "line": 832, + "line": 846, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Speaking", "surface": "apple", @@ -36955,7 +36995,7 @@ }, { "kind": "ui-localized-call", - "line": 836, + "line": 850, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "What would you like to work on?", "surface": "apple", @@ -36963,7 +37003,7 @@ }, { "kind": "ui-localized-call", - "line": 840, + "line": 854, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Check OpenClaw status", "surface": "apple", @@ -36971,7 +37011,7 @@ }, { "kind": "ui-localized-call", - "line": 841, + "line": 855, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Summarize the current OpenClaw status and tell me what needs attention.", "surface": "apple", @@ -36979,7 +37019,7 @@ }, { "kind": "ui-localized-call", - "line": 844, + "line": 858, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "What can you do?", "surface": "apple", @@ -36987,7 +37027,7 @@ }, { "kind": "ui-localized-call", - "line": 845, + "line": 859, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Show me what you can help with on this Mac right now.", "surface": "apple", @@ -36995,7 +37035,7 @@ }, { "kind": "ui-localized-call", - "line": 848, + "line": 862, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Catch me up", "surface": "apple", @@ -37003,7 +37043,7 @@ }, { "kind": "ui-localized-call", - "line": 849, + "line": 863, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Summarize what happened in my threads since yesterday.", "surface": "apple", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 843ebd8cf9ae..a67f2de21d35 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -572,6 +572,7 @@ class MainViewModel private constructor( val manualTls: StateFlow = prefs.manualTls val pairedGateways: StateFlow> = prefs.gatewayRegistry.entries val activeGatewayStableId: StateFlow = prefs.gatewayRegistry.activeStableId + val connectedGatewayStableIds: StateFlow> = prefs.gatewayRegistry.connectedStableIds val onboardingCompleted: StateFlow = prefs.onboardingCompleted val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled val installedAppsSharingEnabled: StateFlow = 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) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index b18e63b5b34e..4e66ad5e99f1 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -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() + private val _backgroundGatewayStatuses = MutableStateFlow>(emptyMap()) + val backgroundGatewayStatuses: StateFlow> = _backgroundGatewayStatuses.asStateFlow() + private val wearProxyController by lazy { WearProxyController( requestGateway = ::requestWearGateway, @@ -2684,6 +2693,7 @@ class NodeRuntime private constructor( val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId val pairedGateways: StateFlow> = prefs.gatewayRegistry.entries val activeGatewayStableId: StateFlow = prefs.gatewayRegistry.activeStableId + val connectedGatewayStableIds: StateFlow> = prefs.gatewayRegistry.connectedStableIds val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled val installedAppsSharingEnabled: StateFlow = prefs.installedAppsSharingEnabled val notificationForwardingEnabled: StateFlow = 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 = chat.sessionKey val chatSessionOwnerAgentId: StateFlow = 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, + val connectedIds: List, + val activeId: String?, + val discovered: List, + 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 = 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): List = .distinct() .sorted() +internal fun backgroundGatewayStableIds( + entries: List, + connectedIds: List, + activeId: String?, + foreground: Boolean, +): List { + 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, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayRegistry.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayRegistry.kt index e726750ac3fc..da64d5104dce 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayRegistry.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayRegistry.kt @@ -34,9 +34,15 @@ data class GatewayRegistryEntry( internal data class PersistedGatewayRegistry( val version: Int = 1, val activeStableId: String? = null, + val connectedStableIds: List? = null, val entries: List = 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> = _entries.asStateFlow() private val _activeStableId = MutableStateFlow(initial.activeStableId) val activeStableId: StateFlow = _activeStableId.asStateFlow() + private val _connectedStableIds = MutableStateFlow(initial.connectedStableIds.orEmpty()) + val connectedStableIds: StateFlow> = _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 = + 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, activeStableId: String?, - ): Boolean = prefs.putStringSynchronously(STORAGE_KEY, encodedRegistry(entries, activeStableId)) + connectedStableIds: List, + ): Boolean = + mutationsAllowed && + prefs.putStringSynchronously( + STORAGE_KEY, + encodedRegistry(entries, activeStableId, connectedStableIds), + ) private fun encodedRegistry( entries: List = _entries.value, activeStableId: String? = _activeStableId.value, + connectedStableIds: List = _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(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(raw) } + .getOrNull() + ?.version + ?.takeIf { it in 1..2 } + ?: return DecodedRegistry(PersistedGatewayRegistry(), canRewrite = false) + val decoded = + runCatching { json.decodeFromString(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.sortedForStorage(): List = sortedWith(compareBy({ it.name.lowercase() }, { it.stableId })) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayStoreMigration.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayStoreMigration.kt index 678bcad36497..d34165ffddfc 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayStoreMigration.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayStoreMigration.kt @@ -22,6 +22,7 @@ internal class GatewayStoreMigration( json.encodeToString( PersistedGatewayRegistry( activeStableId = activeEntry?.stableId, + connectedStableIds = listOfNotNull(activeEntry?.stableId), entries = listOfNotNull(activeEntry), ), ), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt index c8508af2e7f6..54c395c0aa42 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt @@ -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 = diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayFleetSelectionTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayFleetSelectionTest.kt new file mode 100644 index 000000000000..84a3fb356d38 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayFleetSelectionTest.kt @@ -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(), + backgroundGatewayStableIds( + entries = entries, + connectedIds = listOf("alpha", "beta"), + activeId = "alpha", + foreground = false, + ), + ) + } + + private fun entry(stableId: String) = + GatewayRegistryEntry( + stableId = stableId, + kind = GatewayRegistryEntryKind.DISCOVERED, + name = stableId, + ) +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayRegistryStoreTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayRegistryStoreTest.kt index b4394234f450..599298d927c3 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayRegistryStoreTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayRegistryStoreTest.kt @@ -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(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 diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index 9184103d197b..912206122920 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -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 diff --git a/apps/ios/Sources/Gateway/GatewayConnectConfig.swift b/apps/ios/Sources/Gateway/GatewayConnectConfig.swift index 52055ab2c0a9..bac6984be14e 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectConfig.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectConfig.swift @@ -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? diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index e78b39c27617..e034ebcfe886 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -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? + @ObservationIgnored private var operatorFleetReconcileTask: Task? @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? { diff --git a/apps/ios/Sources/Gateway/GatewayOperatorFleet.swift b/apps/ios/Sources/Gateway/GatewayOperatorFleet.swift new file mode 100644 index 000000000000..d8f20dc32106 --- /dev/null +++ b/apps/ios/Sources/Gateway/GatewayOperatorFleet.swift @@ -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() + 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? + + 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) + } + } +} diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore+Connections.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore+Connections.swift new file mode 100644 index 000000000000..3ad9b1941192 --- /dev/null +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore+Connections.swift @@ -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) + } + } + } +} diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index ce87876f94a9..88bb108fa330 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -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() 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() + 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) diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index 7165c6923676..e65762937d1b 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -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() } diff --git a/apps/ios/Tests/GatewaySettingsStoreTests.swift b/apps/ios/Tests/GatewaySettingsStoreTests.swift index f187aa528c0c..5f1ad6c49534 100644 --- a/apps/ios/Tests/GatewaySettingsStoreTests.swift +++ b/apps/ios/Tests/GatewaySettingsStoreTests.swift @@ -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]) diff --git a/apps/linux/src-tauri/src/discovery.rs b/apps/linux/src-tauri/src/discovery.rs index 26bd918e835e..2ab4972d031a 100644 --- a/apps/linux/src-tauri/src/discovery.rs +++ b/apps/linux/src-tauri/src/discovery.rs @@ -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 { + 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::(); + 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(); diff --git a/apps/linux/src-tauri/src/main.rs b/apps/linux/src-tauri/src/main.rs index 41c56294d186..f1dcfad3d5c5 100644 --- a/apps/linux/src-tauri/src/main.rs +++ b/apps/linux/src-tauri/src/main.rs @@ -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::(); if !state.is_quitting() { api.prevent_close(); diff --git a/apps/linux/ui/main.js b/apps/linux/ui/main.js index f6049f35d38c..74f48c68f4c2 100644 --- a/apps/linux/ui/main.js +++ b/apps/linux/ui/main.js @@ -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); } diff --git a/apps/macos/Sources/OpenClaw/ChatTranscriptCacheSupport.swift b/apps/macos/Sources/OpenClaw/ChatTranscriptCacheSupport.swift index 1f6493d1a2f7..327df0da8adb 100644 --- a/apps/macos/Sources/OpenClaw/ChatTranscriptCacheSupport.swift +++ b/apps/macos/Sources/OpenClaw/ChatTranscriptCacheSupport.swift @@ -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 diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection+Cron.swift b/apps/macos/Sources/OpenClaw/GatewayConnection+Cron.swift new file mode 100644 index 000000000000..c3ac1e77588b --- /dev/null +++ b/apps/macos/Sources/OpenClaw/GatewayConnection+Cron.swift @@ -0,0 +1,64 @@ +import Foundation +import OSLog + +private let gatewayCronLogger = Logger(subsystem: "ai.openclaw", category: "gateway.connection") + +extension GatewayConnection { + private struct LossyDecodable: 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] + + 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].self, forKey: .jobs) ?? [] + } + } + + private struct LossyCronRunsResponse: Decodable { + let entries: [LossyDecodable] + + 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].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 + } +} diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift index 5111056bb11c..6bd009f6afa9 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -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: 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] - - 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].self, forKey: .jobs) ?? [] - } - } - - private struct LossyCronRunsResponse: Decodable { - let entries: [LossyDecodable] - - 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].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 - } } diff --git a/apps/macos/Sources/OpenClaw/MacGatewayChatTransport+SessionActions.swift b/apps/macos/Sources/OpenClaw/MacGatewayChatTransport+SessionActions.swift index 0b6bfd561e7a..c7fe293507ea 100644 --- a/apps/macos/Sources/OpenClaw/MacGatewayChatTransport+SessionActions.swift +++ b/apps/macos/Sources/OpenClaw/MacGatewayChatTransport+SessionActions.swift @@ -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, diff --git a/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift b/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift new file mode 100644 index 000000000000..e0b8d0bbe103 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/MacGatewayProfiles.swift @@ -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() + } + } +} diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index 3bee20bd6ea2..6452ea2b2ff5 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -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...") { diff --git a/apps/macos/Sources/OpenClaw/WebChatManager.swift b/apps/macos/Sources/OpenClaw/WebChatManager.swift index a2f02724e374..65f4f585efec 100644 --- a/apps/macos/Sources/OpenClaw/WebChatManager.swift +++ b/apps/macos/Sources/OpenClaw/WebChatManager.swift @@ -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() + } } diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index 789faa3afb53..79a142bc9d45 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -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 { 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 diff --git a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift index 973bbed3bac7..c9ccca38dfef 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift @@ -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", diff --git a/apps/macos/Tests/OpenClawIPCTests/MacGatewayProfilesTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacGatewayProfilesTests.swift new file mode 100644 index 000000000000..c7d36e83ff22 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/MacGatewayProfilesTests.swift @@ -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) + } + } +} diff --git a/docs/docs_map.md b/docs/docs_map.md index 25ae33e4cc3a..af47cdc101da 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/docs/platforms/android.md b/docs/platforms/android.md index 53bbc7353cf0..222fb273e28e 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -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 diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index 91d79b867ad1..6409bbe273b2 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -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. diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md index d6375597ccba..70068cf398f0 100644 --- a/docs/platforms/linux.md +++ b/docs/platforms/linux.md @@ -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 diff --git a/docs/platforms/mac/webchat.md b/docs/platforms/mac/webchat.md index 014ed6ec0717..4fbafcd29ddc 100644 --- a/docs/platforms/mac/webchat.md +++ b/docs/platforms/mac/webchat.md @@ -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**.