diff --git a/CHANGELOG.md b/CHANGELOG.md index 1353cdda9157..d7723db28242 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **Mobile pairing routes:** advertise verified persistent Tailscale Serve fallbacks alongside `gateway.bind=lan` setup URLs, show every route in the Control UI and CLI, and let iOS save the first reachable endpoint. (#100280) - **Control UI terminal tabs:** vertically center the new-session button in the terminal tab strip. - **Control UI composer scrollbar:** show the message-field scrollbar only when the draft actually overflows its autosized height. - **Control UI terminal cursor:** hide the browser-native contenteditable caret so the integrated terminal shows only its canvas-rendered cursor. diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 9e100374299d..8a8d365398ad 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -8691,7 +8691,7 @@ }, { "kind": "ui-modifier", - "line": 131, + "line": 132, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Settings", "surface": "apple", @@ -8699,7 +8699,7 @@ }, { "kind": "ui-modifier", - "line": 224, + "line": 229, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Scan QR Code", "surface": "apple", @@ -8707,7 +8707,7 @@ }, { "kind": "ui-modifier", - "line": 245, + "line": 250, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Reset Onboarding?", "surface": "apple", @@ -8715,7 +8715,7 @@ }, { "kind": "ui-call", - "line": 249, + "line": 254, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Reset", "surface": "apple", @@ -8723,7 +8723,7 @@ }, { "kind": "ui-call", - "line": 253, + "line": 258, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Cancel", "surface": "apple", @@ -8731,7 +8731,7 @@ }, { "kind": "ui-call", - "line": 257, + "line": 262, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "This disconnects, clears saved gateway credentials, and reopens onboarding.", "surface": "apple", @@ -8739,7 +8739,7 @@ }, { "kind": "ui-modifier", - "line": 260, + "line": 265, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "QR Scanner Unavailable", "surface": "apple", @@ -8747,7 +8747,7 @@ }, { "kind": "ui-call", - "line": 267, + "line": 272, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "OK", "surface": "apple", @@ -8755,7 +8755,7 @@ }, { "kind": "ui-call", - "line": 321, + "line": 326, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Enable OpenClaw Hosted Push Relay?", "surface": "apple", @@ -8763,7 +8763,7 @@ }, { "kind": "ui-call", - "line": 335, + "line": 340, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Continue", "surface": "apple", @@ -8771,7 +8771,7 @@ }, { "kind": "ui-call", - "line": 343, + "line": 348, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Not Now", "surface": "apple", @@ -8859,7 +8859,7 @@ }, { "kind": "conditional-branch", - "line": 633, + "line": 664, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Configured", "surface": "apple", @@ -8867,7 +8867,7 @@ }, { "kind": "conditional-branch", - "line": 633, + "line": 664, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Not configured", "surface": "apple", @@ -8875,7 +8875,7 @@ }, { "kind": "conditional-branch", - "line": 698, + "line": 729, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Connect to the gateway.", "surface": "apple", @@ -8883,7 +8883,7 @@ }, { "kind": "conditional-branch", - "line": 698, + "line": 729, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Gateway requests will appear here.", "surface": "apple", @@ -8891,7 +8891,7 @@ }, { "kind": "conditional-branch", - "line": 745, + "line": 776, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "High", "surface": "apple", @@ -8899,7 +8899,7 @@ }, { "kind": "conditional-branch", - "line": 745, + "line": 776, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Resolving", "surface": "apple", @@ -8907,7 +8907,7 @@ }, { "kind": "conditional-branch", - "line": 750, + "line": 781, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "One-time approval", "surface": "apple", @@ -8915,7 +8915,7 @@ }, { "kind": "conditional-branch", - "line": 750, + "line": 781, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Permission can be saved", "surface": "apple", @@ -8923,7 +8923,7 @@ }, { "kind": "conditional-branch", - "line": 752, + "line": 783, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Medium", "surface": "apple", @@ -8931,7 +8931,7 @@ }, { "kind": "conditional-branch", - "line": 752, + "line": 783, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Review", "surface": "apple", @@ -8939,7 +8939,7 @@ }, { "kind": "conditional-branch", - "line": 773, + "line": 804, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "\\(diagnosticsIssueCount)", "surface": "apple", @@ -8947,7 +8947,7 @@ }, { "kind": "conditional-branch", - "line": 784, + "line": 815, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location off", "surface": "apple", @@ -8955,7 +8955,7 @@ }, { "kind": "conditional-branch", - "line": 786, + "line": 817, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location While Using", "surface": "apple", @@ -8963,7 +8963,7 @@ }, { "kind": "conditional-branch", - "line": 788, + "line": 819, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location While Using, effective Off", "surface": "apple", @@ -8971,7 +8971,7 @@ }, { "kind": "conditional-branch", - "line": 790, + "line": 821, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location While Using, effective Always", "surface": "apple", @@ -8979,7 +8979,7 @@ }, { "kind": "conditional-branch", - "line": 792, + "line": 823, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location Always", "surface": "apple", @@ -8987,7 +8987,7 @@ }, { "kind": "conditional-branch", - "line": 794, + "line": 825, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location Always, effective While Using", "surface": "apple", @@ -8995,7 +8995,7 @@ }, { "kind": "conditional-branch", - "line": 796, + "line": 827, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location Always, effective Off", "surface": "apple", @@ -9003,7 +9003,7 @@ }, { "kind": "conditional-branch", - "line": 824, + "line": 855, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Checking iOS notification permission.", "surface": "apple", @@ -9011,7 +9011,7 @@ }, { "kind": "conditional-branch", - "line": 826, + "line": 857, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "OpenClaw can show approval prompts and event alerts when the app is not active.", "surface": "apple", @@ -9019,7 +9019,7 @@ }, { "kind": "conditional-branch", - "line": 828, + "line": 859, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Notifications have been denied. Enable them in iOS Settings.", "surface": "apple", @@ -9027,7 +9027,7 @@ }, { "kind": "conditional-branch", - "line": 830, + "line": 861, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Enable notifications to receive approval prompts and event alerts outside the app.", "surface": "apple", @@ -9035,7 +9035,7 @@ }, { "kind": "conditional-branch", - "line": 832, + "line": 863, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "OpenClaw cannot determine the current notification permission state.", "surface": "apple", @@ -9483,7 +9483,7 @@ }, { "kind": "ui-named-argument", - "line": 712, + "line": 713, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Scan QR", "surface": "apple", @@ -9491,7 +9491,7 @@ }, { "kind": "ui-named-argument", - "line": 722, + "line": 723, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Connect", "surface": "apple", @@ -9499,7 +9499,7 @@ }, { "kind": "ui-call", - "line": 731, + "line": 732, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Setup Code", "surface": "apple", @@ -9507,7 +9507,7 @@ }, { "kind": "ui-call", - "line": 744, + "line": 745, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovered Gateways", "surface": "apple", @@ -9515,7 +9515,7 @@ }, { "kind": "ui-call", - "line": 746, + "line": 747, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "No gateways found yet. Use manual setup if Bonjour is blocked.", "surface": "apple", @@ -9523,7 +9523,7 @@ }, { "kind": "ui-call", - "line": 785, + "line": 786, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Manual Gateway", "surface": "apple", @@ -9531,7 +9531,7 @@ }, { "kind": "ui-call", - "line": 787, + "line": 788, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Use Manual Gateway", "surface": "apple", @@ -9539,7 +9539,7 @@ }, { "kind": "ui-call", - "line": 790, + "line": 791, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Host", "surface": "apple", @@ -9547,7 +9547,7 @@ }, { "kind": "ui-call", - "line": 794, + "line": 795, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Port", "surface": "apple", @@ -9555,7 +9555,7 @@ }, { "kind": "ui-call", - "line": 798, + "line": 799, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Use TLS", "surface": "apple", @@ -9563,7 +9563,7 @@ }, { "kind": "ui-named-argument", - "line": 802, + "line": 803, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Connect Manual", "surface": "apple", @@ -9571,7 +9571,7 @@ }, { "kind": "ui-call", - "line": 817, + "line": 819, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Auto-connect on launch", "surface": "apple", @@ -9579,7 +9579,7 @@ }, { "kind": "ui-call", - "line": 820, + "line": 822, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Auth Token", "surface": "apple", @@ -9587,7 +9587,7 @@ }, { "kind": "ui-call", - "line": 821, + "line": 823, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Password", "surface": "apple", @@ -9595,7 +9595,7 @@ }, { "kind": "ui-call", - "line": 825, + "line": 827, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Reset Onboarding", "surface": "apple", @@ -9603,7 +9603,7 @@ }, { "kind": "ui-call", - "line": 852, + "line": 854, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice Wake", "surface": "apple", @@ -9611,7 +9611,7 @@ }, { "kind": "ui-call", - "line": 855, + "line": 857, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Talk Mode", "surface": "apple", @@ -9619,7 +9619,7 @@ }, { "kind": "ui-call", - "line": 863, + "line": 865, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Speech Language", "surface": "apple", @@ -9627,7 +9627,7 @@ }, { "kind": "ui-call", - "line": 870, + "line": 872, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Speakerphone", "surface": "apple", @@ -9635,7 +9635,7 @@ }, { "kind": "ui-named-argument", - "line": 875, + "line": 877, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Wake Words", "surface": "apple", @@ -9643,7 +9643,7 @@ }, { "kind": "ui-call", - "line": 895, + "line": 897, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice", "surface": "apple", @@ -9651,7 +9651,7 @@ }, { "kind": "ui-call", - "line": 896, + "line": 898, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Provider", "surface": "apple", @@ -9659,7 +9659,7 @@ }, { "kind": "ui-call", - "line": 903, + "line": 905, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Realtime Voice", "surface": "apple", @@ -9667,7 +9667,7 @@ }, { "kind": "ui-call", - "line": 904, + "line": 906, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Default", "surface": "apple", @@ -9675,7 +9675,7 @@ }, { "kind": "ui-call", - "line": 911, + "line": 913, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice Mode", "surface": "apple", @@ -9683,7 +9683,7 @@ }, { "kind": "ui-call", - "line": 912, + "line": 914, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Active Voice", "surface": "apple", @@ -9691,7 +9691,7 @@ }, { "kind": "ui-call", - "line": 914, + "line": 916, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Last Voice Issue", "surface": "apple", @@ -9699,7 +9699,7 @@ }, { "kind": "ui-call", - "line": 916, + "line": 918, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Transport", "surface": "apple", @@ -9707,7 +9707,7 @@ }, { "kind": "ui-call", - "line": 917, + "line": 919, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "API Key", "surface": "apple", @@ -9715,7 +9715,7 @@ }, { "kind": "ui-call", - "line": 925, + "line": 927, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Show Talk Control", "surface": "apple", @@ -9723,7 +9723,7 @@ }, { "kind": "ui-call", - "line": 928, + "line": 930, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Default Share Instruction", "surface": "apple", @@ -9731,7 +9731,7 @@ }, { "kind": "ui-call", - "line": 935, + "line": 937, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Run Share Self-Test", "surface": "apple", @@ -9739,7 +9739,7 @@ }, { "kind": "ui-call", - "line": 952, + "line": 954, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovery Debug Logs", "surface": "apple", @@ -9747,7 +9747,7 @@ }, { "kind": "ui-call", - "line": 955, + "line": 957, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Debug Screen Status", "surface": "apple", @@ -9755,7 +9755,7 @@ }, { "kind": "ui-named-argument", - "line": 959, + "line": 961, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovery Logs", "surface": "apple", @@ -9763,7 +9763,7 @@ }, { "kind": "ui-call", - "line": 965, + "line": 967, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Device", "surface": "apple", @@ -9771,7 +9771,7 @@ }, { "kind": "ui-call", - "line": 966, + "line": 968, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Device Name", "surface": "apple", @@ -9779,7 +9779,7 @@ }, { "kind": "ui-call", - "line": 968, + "line": 970, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Instance ID", "surface": "apple", @@ -11243,7 +11243,7 @@ }, { "kind": "ui-call", - "line": 147, + "line": 148, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Back", "surface": "apple", @@ -11251,7 +11251,7 @@ }, { "kind": "ui-call", - "line": 155, + "line": 157, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Close", "surface": "apple", @@ -11259,7 +11259,7 @@ }, { "kind": "ui-call", - "line": 170, + "line": 172, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Done", "surface": "apple", @@ -11267,7 +11267,7 @@ }, { "kind": "ui-modifier", - "line": 178, + "line": 180, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "QR Scanner Unavailable", "surface": "apple", @@ -11275,7 +11275,7 @@ }, { "kind": "ui-call", - "line": 183, + "line": 185, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "OK", "surface": "apple", @@ -11283,7 +11283,7 @@ }, { "kind": "ui-call", - "line": 212, + "line": 214, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Scan QR Code", "surface": "apple", @@ -11291,7 +11291,7 @@ }, { "kind": "ui-call", - "line": 219, + "line": 221, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Cancel", "surface": "apple", @@ -11299,7 +11299,7 @@ }, { "kind": "ui-call", - "line": 226, + "line": 228, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Photos", "surface": "apple", @@ -11307,7 +11307,7 @@ }, { "kind": "ui-named-argument", - "line": 350, + "line": 353, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "LAN or Tailscale host", "surface": "apple", @@ -11315,7 +11315,7 @@ }, { "kind": "ui-named-argument", - "line": 358, + "line": 361, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "VPS with domain", "surface": "apple", @@ -11323,7 +11323,7 @@ }, { "kind": "ui-named-argument", - "line": 369, + "line": 372, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "For local iOS app development", "surface": "apple", @@ -11331,7 +11331,7 @@ }, { "kind": "ui-call", - "line": 384, + "line": 388, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Continue", "surface": "apple", @@ -11339,7 +11339,7 @@ }, { "kind": "ui-call", - "line": 393, + "line": 397, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Developer mode", "surface": "apple", @@ -11347,7 +11347,7 @@ }, { "kind": "conditional-branch", - "line": 421, + "line": 425, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Off", "surface": "apple", @@ -11355,7 +11355,7 @@ }, { "kind": "conditional-branch", - "line": 421, + "line": 425, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "On", "surface": "apple", @@ -11363,7 +11363,7 @@ }, { "kind": "ui-call", - "line": 428, + "line": 432, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Mode", "surface": "apple", @@ -11371,7 +11371,7 @@ }, { "kind": "ui-call", - "line": 429, + "line": 433, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Discovery", "surface": "apple", @@ -11379,7 +11379,7 @@ }, { "kind": "ui-call", - "line": 431, + "line": 435, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Progress", "surface": "apple", @@ -11387,7 +11387,7 @@ }, { "kind": "ui-call", - "line": 433, + "line": 437, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Status", "surface": "apple", @@ -11395,7 +11395,7 @@ }, { "kind": "ui-call", - "line": 452, + "line": 456, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Choose a mode first.", "surface": "apple", @@ -11403,7 +11403,7 @@ }, { "kind": "ui-call", - "line": 457, + "line": 461, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Back to Mode Selection", "surface": "apple", @@ -11411,7 +11411,7 @@ }, { "kind": "ui-call", - "line": 469, + "line": 473, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "No gateways found yet.", "surface": "apple", @@ -11419,7 +11419,7 @@ }, { "kind": "ui-call", - "line": 494, + "line": 498, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Resolving…", "surface": "apple", @@ -11427,7 +11427,7 @@ }, { "kind": "ui-call", - "line": 510, + "line": 514, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Restart Discovery", "surface": "apple", @@ -11435,7 +11435,7 @@ }, { "kind": "ui-call", - "line": 516, + "line": 520, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Discovered Gateways", "surface": "apple", @@ -11443,7 +11443,7 @@ }, { "kind": "ui-named-argument", - "line": 520, + "line": 524, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Manual Fallback", "surface": "apple", @@ -11451,7 +11451,7 @@ }, { "kind": "ui-named-argument", - "line": 525, + "line": 529, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Domain Settings", "surface": "apple", @@ -11459,7 +11459,7 @@ }, { "kind": "ui-call", - "line": 540, + "line": 544, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Developer Local", "surface": "apple", @@ -11467,7 +11467,7 @@ }, { "kind": "ui-call", - "line": 543, + "line": 547, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Default host is localhost. Use your Mac LAN IP if simulator networking requires it.", "surface": "apple", @@ -11475,7 +11475,7 @@ }, { "kind": "ui-call", - "line": 567, + "line": 571, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway rejected credentials. Scan a fresh QR code or update token/password.", "surface": "apple", @@ -11483,7 +11483,7 @@ }, { "kind": "ui-call", - "line": 571, + "line": 575, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Auth token looks valid.", "surface": "apple", @@ -11491,7 +11491,7 @@ }, { "kind": "ui-call", - "line": 585, + "line": 589, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Resume After Approval", "surface": "apple", @@ -11499,7 +11499,7 @@ }, { "kind": "ui-call", - "line": 591, + "line": 595, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Pairing Approval", "surface": "apple", @@ -11507,7 +11507,7 @@ }, { "kind": "ui-call-concatenated", - "line": 601, + "line": 605, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Approve this device on the gateway.\n1) `\\(commandLine)`\n2) `/pair approve` in your OpenClaw chat\n\\(requestLine)\nOpenClaw will also retry automatically when you return to this app.", "surface": "apple", @@ -11515,7 +11515,7 @@ }, { "kind": "ui-call", - "line": 615, + "line": 619, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Scan QR Code Again", "surface": "apple", @@ -11523,7 +11523,7 @@ }, { "kind": "ui-call", - "line": 628, + "line": 632, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Retry Connection", "surface": "apple", @@ -11531,7 +11531,7 @@ }, { "kind": "ui-call", - "line": 661, + "line": 665, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Go to Chat", "surface": "apple", @@ -11539,7 +11539,7 @@ }, { "kind": "ui-call", - "line": 675, + "line": 679, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Paste setup code", "surface": "apple", @@ -11547,7 +11547,7 @@ }, { "kind": "ui-call", - "line": 690, + "line": 695, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Applying...", "surface": "apple", @@ -11555,7 +11555,7 @@ }, { "kind": "ui-call", - "line": 694, + "line": 699, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Apply Setup Code", "surface": "apple", @@ -11563,7 +11563,7 @@ }, { "kind": "ui-call", - "line": 709, + "line": 714, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Setup Code", "surface": "apple", @@ -11571,7 +11571,7 @@ }, { "kind": "ui-call", - "line": 712, + "line": 717, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Use this if you received a setup code instead of a QR code.", "surface": "apple", @@ -11579,7 +11579,7 @@ }, { "kind": "ui-call", - "line": 719, + "line": 724, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Host", "surface": "apple", @@ -11587,7 +11587,7 @@ }, { "kind": "ui-call", - "line": 723, + "line": 728, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Port", "surface": "apple", @@ -11595,7 +11595,7 @@ }, { "kind": "ui-call", - "line": 726, + "line": 731, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Use TLS", "surface": "apple", @@ -11603,7 +11603,7 @@ }, { "kind": "ui-call", - "line": 727, + "line": 732, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Discovery Domain (optional)", "surface": "apple", @@ -11611,7 +11611,7 @@ }, { "kind": "ui-call", - "line": 732, + "line": 737, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway Auth Token", "surface": "apple", @@ -11619,7 +11619,7 @@ }, { "kind": "ui-call", - "line": 735, + "line": 740, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway Password", "surface": "apple", @@ -11627,7 +11627,7 @@ }, { "kind": "ui-call", - "line": 779, + "line": 784, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Connecting…", "surface": "apple", diff --git a/apps/ios/Sources/Design/SettingsProTab.swift b/apps/ios/Sources/Design/SettingsProTab.swift index a939eb814d83..abd3c9d56204 100644 --- a/apps/ios/Sources/Design/SettingsProTab.swift +++ b/apps/ios/Sources/Design/SettingsProTab.swift @@ -46,6 +46,7 @@ struct SettingsProTab: View { @State var gatewayPassword = "" @State var manualGatewayPortText = "" @State var setupStatusText: String? + @State var setupAttemptID: UUID? @State var stagedGatewaySetupLink: GatewayConnectDeepLink? @State var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride? @State var defaultShareInstruction = "" @@ -143,6 +144,9 @@ struct SettingsProTab: View { private func settingsLifecycle(_ content: some View) -> some View { content + .onDisappear { + self.invalidateGatewaySetupAttempt() + } .task { self.previousLocationModeRaw = self.locationModeRaw self.syncSettingsState() @@ -192,6 +196,7 @@ struct SettingsProTab: View { self.syncAfterOnboardingReset() } .onChange(of: self.navigationPath) { _, _ in + self.invalidateGatewaySetupAttempt() self.notifyRouteChange() } } diff --git a/apps/ios/Sources/Design/SettingsProTabActions.swift b/apps/ios/Sources/Design/SettingsProTabActions.swift index f8c3dba0f55a..b283f91e2421 100644 --- a/apps/ios/Sources/Design/SettingsProTabActions.swift +++ b/apps/ios/Sources/Design/SettingsProTabActions.swift @@ -191,7 +191,7 @@ extension SettingsProTab { } func syncAfterOnboardingReset() { - self.connectingGatewayID = nil + self.invalidateGatewaySetupAttempt() self.setupStatusText = nil self.stagedGatewaySetupLink = nil self.pendingManualAuthOverride = nil @@ -210,8 +210,10 @@ extension SettingsProTab { } func applySetupCodeAndConnect() async { + guard let attemptID = self.beginGatewaySetupAttempt() else { return } + defer { self.finishGatewaySetupAttempt(attemptID) } self.setupStatusText = nil - guard self.applySetupCode() else { return } + guard await self.applySetupCode(attemptID: attemptID) else { return } let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard self.resolvedManualPort(host: host) != nil else { self.setupStatusText = "Failed: invalid port" @@ -219,7 +221,7 @@ extension SettingsProTab { } guard await self.preflightGateway(host: host) else { return } self.setupStatusText = "Setup code applied. Connecting..." - await self.connectManual() + await self.connectManual(setupAttemptID: attemptID) } func applyGatewaySetupLink(_ link: GatewayConnectDeepLink) { @@ -231,7 +233,7 @@ extension SettingsProTab { } @discardableResult - func applySetupCode() -> Bool { + func applySetupCode(attemptID: UUID) async -> Bool { let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines) let stagedLink = self.stagedGatewaySetupLink guard !raw.isEmpty || stagedLink != nil else { @@ -247,10 +249,12 @@ extension SettingsProTab { return false } - guard let link = raw.isEmpty ? stagedLink : GatewayConnectDeepLink.fromSetupInput(raw) else { + guard let parsedLink = raw.isEmpty ? stagedLink : GatewayConnectDeepLink.fromSetupInput(raw) else { self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL." return false } + let link = await self.gatewayController.selectReachableSetupLink(parsedLink) + guard self.setupAttemptID == attemptID else { return false } self.stagedGatewaySetupLink = nil self.applyGatewayLink(link) return true @@ -286,17 +290,16 @@ extension SettingsProTab { func openGatewayQRScanner() { self.appModel.disconnectGateway() - self.connectingGatewayID = nil + self.invalidateGatewaySetupAttempt() self.setupStatusText = "Opening QR scanner..." self.showQRScanner = true } func handleScannedGatewayLink(_ link: GatewayConnectDeepLink) { self.showQRScanner = false + guard let attemptID = self.beginGatewaySetupAttempt() else { return } self.setupCode = "" - self.applyGatewayLink(link) - self.setupStatusText = "QR loaded. Connecting to \(link.host):\(link.port)..." - Task { await self.connectAfterScannedGatewayLink() } + Task { await self.connectAfterScannedGatewayLink(link, attemptID: attemptID) } } func handleScannedSetupCode(_ code: String) { @@ -308,17 +311,27 @@ extension SettingsProTab { self.appModel.enterAppleReviewDemoMode() } - func connectAfterScannedGatewayLink() async { + func connectAfterScannedGatewayLink(_ parsedLink: GatewayConnectDeepLink, attemptID: UUID) async { + defer { self.finishGatewaySetupAttempt(attemptID) } + let link = await self.gatewayController.selectReachableSetupLink(parsedLink) + guard self.setupAttemptID == attemptID else { return } + self.applyGatewayLink(link) + self.setupStatusText = "QR loaded. Connecting to \(link.host):\(link.port)..." let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard self.resolvedManualPort(host: host) != nil else { self.setupStatusText = "Failed: invalid port" return } guard await self.preflightGateway(host: host) else { return } - await self.connectManual() + await self.connectManual(setupAttemptID: attemptID) } - func connectManual() async { + func connectManual(setupAttemptID: UUID? = nil) async { + if let setupAttemptID { + guard self.setupAttemptID == setupAttemptID else { return } + } else { + self.invalidateGatewaySetupAttempt() + } let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) guard !host.isEmpty else { self.setupStatusText = "Failed: host required" @@ -355,7 +368,7 @@ extension SettingsProTab { } func resetOnboarding() { - self.connectingGatewayID = nil + self.invalidateGatewaySetupAttempt() self.setupStatusText = nil self.setupCode = "" self.gatewayAutoConnect = false @@ -371,6 +384,24 @@ extension SettingsProTab { self.onboardingRequestID += 1 } + func beginGatewaySetupAttempt() -> UUID? { + guard self.connectingGatewayID == nil else { return nil } + let attemptID = UUID() + self.setupAttemptID = attemptID + self.connectingGatewayID = "setup-code" + return attemptID + } + + func finishGatewaySetupAttempt(_ attemptID: UUID) { + guard self.setupAttemptID == attemptID else { return } + self.invalidateGatewaySetupAttempt() + } + + func invalidateGatewaySetupAttempt() { + self.setupAttemptID = nil + self.connectingGatewayID = nil + } + func handleLocationModeChange(_ newValue: String) { guard !self.isChangingLocationMode else { return } guard newValue != self.previousLocationModeRaw else { return } diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index 5c9f992a9507..64206ddc2149 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -708,6 +708,7 @@ extension SettingsProTab { .font(OpenClawType.body) .textInputAutocapitalization(.never) .autocorrectionDisabled() + .disabled(self.connectingGatewayID != nil) self.gatewayActionButton( title: "Scan QR", icon: "qrcode.viewfinder", @@ -809,6 +810,7 @@ extension SettingsProTab { Task { await self.connectManual() } } } + .disabled(self.setupAttemptID != nil) } var gatewayAdvancedCard: some View { diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index b75fafa11cb4..9b324bfd219a 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -37,6 +37,10 @@ private enum GatewayTLSFingerprintProbeBudget { static let tlsHandshakeTimeoutSeconds = 10.0 } +private enum GatewaySetupRouteProbeBudget { + static let tcpConnectTimeoutSeconds = 2.0 +} + private func defaultGatewayTCPReachabilityProbe( host: String, port: Int, @@ -215,6 +219,25 @@ final class GatewayConnectionController { self.discovery.setDebugLoggingEnabled(enabled) } + func selectReachableSetupLink(_ link: GatewayConnectDeepLink) async -> GatewayConnectDeepLink { + let endpoints = link.connectionEndpoints + guard endpoints.count > 1 else { return link } + // Probe before persisting: a setup code may carry LAN and Tailnet routes, + // but only the route reachable from the phone should become its saved endpoint. + self.requestLocalNetworkAccess(reason: "setup_route_probe") + for (index, endpoint) in endpoints.enumerated() { + let reachable = await self.tcpReachabilityProbe( + endpoint.host, + endpoint.port, + GatewaySetupRouteProbeBudget.tcpConnectTimeoutSeconds, + "ai.openclaw.gateway.setup-route-\(index)") + if reachable { + return link.selectingEndpoint(endpoint) + } + } + return link + } + func requestLocalNetworkAccess(reason: String) { guard self.discoveryEnabled else { self.discovery.stop() diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 8c81044fb380..d29d5bb41bac 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -70,6 +70,7 @@ struct OnboardingWizardView: View { @State private var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride? @State private var setupCode: String = "" @State private var setupCodeStatus: String? + @State private var setupAttemptID: UUID? private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect() let allowSkip: Bool @@ -150,6 +151,7 @@ struct OnboardingWizardView: View { .font(OpenClawType.subheadSemiBold) } else if self.allowSkip { Button { + self.invalidateSetupAttempt() self.onClose() } label: { Text("Close") @@ -269,6 +271,7 @@ struct OnboardingWizardView: View { self.requestLocalNetworkAccessIfPastIntro(reason: "onboarding_appear") } .onDisappear { + self.invalidateSetupAttempt() self.discoveryRestartTask?.cancel() self.discoveryRestartTask = nil } @@ -332,10 +335,10 @@ struct OnboardingWizardView: View { OnboardingWelcomeStep( statusLine: self.statusLine, onScanQRCode: { - self.statusLine = "Opening QR scanner…" - self.showQRScanner = true + self.openQRScannerFromOnboarding() }, onManualSetup: { + self.invalidateSetupAttempt() self.step = .mode }) } @@ -376,6 +379,7 @@ struct OnboardingWizardView: View { Text("Connection Mode") .font(OpenClawType.captionSemiBold) } + .disabled(self.connectingGatewayID != nil) Section { Button { @@ -385,7 +389,7 @@ struct OnboardingWizardView: View { .font(OpenClawType.subheadSemiBold) } .font(OpenClawType.subheadSemiBold) - .disabled(self.selectedMode == nil) + .disabled(self.selectedMode == nil || self.connectingGatewayID != nil) } } @@ -676,6 +680,7 @@ extension OnboardingWizardView { .textInputAutocapitalization(.never) .autocorrectionDisabled() .font(OpenClawType.subhead) + .disabled(self.connectingGatewayID != nil) .onSubmit { Task { await self.applySetupCodeAndConnect() } } @@ -803,29 +808,41 @@ extension OnboardingWizardView { return } - guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else { + guard let parsedLink = GatewayConnectDeepLink.fromSetupInput(raw) else { self.setupCodeStatus = "Setup code not recognized or uses an insecure ws:// gateway URL." return } - self.connectingGatewayID = "setup-code" + guard let attemptID = self.beginSetupAttempt() else { return } + defer { self.finishSetupAttempt(attemptID) } + let link = await self.gatewayController.selectReachableSetupLink(parsedLink) + guard self.setupAttemptID == attemptID else { return } + self.applyGatewayLink(link) self.setupCode = "" self.setupCodeStatus = "Setup code applied. Connecting..." self.connectMessage = "Connecting via setup code..." self.statusLine = "Setup code loaded. Connecting to \(link.host):\(link.port)..." self.step = .connect - await self.connectManual() + await self.connectManual(setupAttemptID: attemptID) } private func handleScannedLink(_ link: GatewayConnectDeepLink) { - self.applyGatewayLink(link) - self.setupCodeStatus = nil self.showQRScanner = false + guard let attemptID = self.beginSetupAttempt() else { return } + self.setupCodeStatus = nil + Task { await self.connectScannedLink(link, attemptID: attemptID) } + } + + private func connectScannedLink(_ parsedLink: GatewayConnectDeepLink, attemptID: UUID) async { + defer { self.finishSetupAttempt(attemptID) } + let link = await self.gatewayController.selectReachableSetupLink(parsedLink) + guard self.setupAttemptID == attemptID else { return } + self.applyGatewayLink(link) self.connectMessage = "Connecting via QR code..." self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)..." self.step = .connect - Task { await self.connectManual() } + await self.connectManual(setupAttemptID: attemptID) } private func applyGatewayLink(_ link: GatewayConnectDeepLink) { @@ -856,7 +873,7 @@ extension OnboardingWizardView { private func handleScannedSetupCode(_ code: String) { guard AppleReviewDemoMode.isSetupCode(code) else { return } self.showQRScanner = false - self.connectingGatewayID = nil + self.invalidateSetupAttempt() self.connectMessage = "Apple Review demo mode enabled." self.statusLine = "Apple Review demo mode enabled." self.selectedMode = .homeNetwork @@ -866,7 +883,7 @@ extension OnboardingWizardView { private func openQRScannerFromOnboarding() { // Stop active reconnect loops before scanning new credentials. self.appModel.disconnectGateway() - self.connectingGatewayID = nil + self.invalidateSetupAttempt() self.connectMessage = nil self.issue = .none self.pairingRequestId = nil @@ -982,11 +999,29 @@ extension OnboardingWizardView { private func navigateBack() { guard let target = step.previous else { return } - self.connectingGatewayID = nil + self.invalidateSetupAttempt() self.connectMessage = nil self.step = target } + private func beginSetupAttempt() -> UUID? { + guard self.connectingGatewayID == nil else { return nil } + let attemptID = UUID() + self.setupAttemptID = attemptID + self.connectingGatewayID = "setup-code" + return attemptID + } + + private func finishSetupAttempt(_ attemptID: UUID) { + guard self.setupAttemptID == attemptID else { return } + self.invalidateSetupAttempt() + } + + private func invalidateSetupAttempt() { + self.setupAttemptID = nil + self.connectingGatewayID = nil + } + private var canConnectManual: Bool { let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines) return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535 @@ -1109,7 +1144,12 @@ extension OnboardingWizardView { return !tailnetDns.isEmpty } - private func connectManual() async { + private func connectManual(setupAttemptID: UUID? = nil) async { + if let setupAttemptID { + guard self.setupAttemptID == setupAttemptID else { return } + } else { + self.invalidateSetupAttempt() + } let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines) guard !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 else { return } self.connectingGatewayID = "manual" diff --git a/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/apps/ios/Tests/GatewayConnectionSecurityTests.swift index de5684bfe6d1..f41f89886828 100644 --- a/apps/ios/Tests/GatewayConnectionSecurityTests.swift +++ b/apps/ios/Tests/GatewayConnectionSecurityTests.swift @@ -1,8 +1,8 @@ import Foundation import Network import OpenClawKit -import Testing import os +import Testing @testable import OpenClaw @Suite(.serialized) struct GatewayConnectionSecurityTests { @@ -37,7 +37,7 @@ import os GatewayTLSStore.clearFingerprint(stableID: stableID) } - @Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async { + @Test @MainActor func `discovered TLS params prefers stored pin over advertised TXT`() { let stableID = "test|\(UUID().uuidString)" defer { clearTLSFingerprint(stableID: stableID) } self.clearTLSFingerprint(stableID: stableID) @@ -57,7 +57,7 @@ import os #expect(params?.allowTOFU == false) } - @Test @MainActor func discoveredTLSParams_doesNotTrustAdvertisedFingerprint() async { + @Test @MainActor func `discovered TLS params does not trust advertised fingerprint`() { let stableID = "test|\(UUID().uuidString)" defer { clearTLSFingerprint(stableID: stableID) } self.clearTLSFingerprint(stableID: stableID) @@ -75,7 +75,7 @@ import os #expect(params?.allowTOFU == false) } - @Test @MainActor func autoconnectRequiresStoredPinForDiscoveredGateways() async { + @Test @MainActor func `autoconnect requires stored pin for discovered gateways`() { let stableID = "test|\(UUID().uuidString)" defer { clearTLSFingerprint(stableID: stableID) } self.clearTLSFingerprint(stableID: stableID) @@ -104,7 +104,7 @@ import os #expect(controller._test_didAutoConnect() == false) } - @Test @MainActor func manualConnectionsForceTLSForNonLoopbackHosts() async { + @Test @MainActor func `manual connections force TLS for non loopback hosts`() { let controller = self.makeController() #expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true) @@ -120,7 +120,7 @@ import os #expect(controller._test_resolveManualUseTLS(host: "0.0.0.0", useTLS: false) == false) } - @Test @MainActor func manualConnectionsAllowPrivateLanPlaintext() async { + @Test @MainActor func `manual connections allow private lan plaintext`() { let controller = self.makeController() #expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == false) @@ -131,7 +131,7 @@ import os #expect(controller._test_resolveManualUseTLS(host: "fd00::1", useTLS: false) == false) } - @Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async { + @Test @MainActor func `manual default port uses 443 only for tailnet TLS hosts`() { let controller = self.makeController() #expect(controller._test_resolveManualPort(host: "gateway.example.com", port: 0, useTLS: true) == 18789) @@ -140,7 +140,55 @@ import os #expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 18789, useTLS: true) == 18789) } - @Test @MainActor func manualFirstUseTLSProbeShowsTrustPromptAfterFingerprintCapture() async { + @Test @MainActor func `setup route selection falls back to reachable tailnet endpoint`() async { + let probes = OSAllocatedUnfairLock(initialState: [(String, Int)]()) + let controller = GatewayConnectionController( + appModel: NodeAppModel(), + startDiscovery: false, + tcpReachabilityProbe: { host, port, _, _ in + probes.withLock { $0.append((host, port)) } + return host.hasSuffix(".ts.net") + }) + let link = GatewayConnectDeepLink( + host: "192.168.139.3", + port: 18789, + tls: false, + bootstrapToken: "boot", + token: nil, + password: nil, + fallbackEndpoints: [ + .init(host: "clawmac.tail.ts.net", port: 8443, tls: true), + ]) + + let selected = await controller.selectReachableSetupLink(link) + + #expect(selected.host == "clawmac.tail.ts.net") + #expect(selected.port == 8443) + #expect(selected.tls) + #expect(selected.bootstrapToken == "boot") + #expect(probes.withLock { $0.map(\.0) } == ["192.168.139.3", "clawmac.tail.ts.net"]) + } + + @Test @MainActor func `setup route selection keeps legacy endpoint when every probe fails`() async { + let controller = GatewayConnectionController( + appModel: NodeAppModel(), + startDiscovery: false, + tcpReachabilityProbe: { _, _, _, _ in false }) + let link = GatewayConnectDeepLink( + host: "192.168.139.3", + port: 18789, + tls: false, + bootstrapToken: "boot", + token: nil, + password: nil, + fallbackEndpoints: [ + .init(host: "clawmac.tail.ts.net", port: 8443, tls: true), + ]) + + #expect(await controller.selectReachableSetupLink(link) == link) + } + + @Test @MainActor func `manual first use TLS probe shows trust prompt after fingerprint capture`() async { let host = "gateway-\(UUID().uuidString).example.com" let port = 18789 let stableID = "manual|\(host.lowercased())|\(port)" @@ -162,7 +210,7 @@ import os #expect(appModel.gatewayStatusText == "Verify gateway TLS fingerprint") } - @Test @MainActor func manualFirstUseTLSProbeSkipsTLSWhenTCPIsUnreachable() async { + @Test @MainActor func `manual first use TLS probe skips TLS when TCP is unreachable`() async { let host = "gateway-\(UUID().uuidString).example.com" let port = 18789 let stableID = "manual|\(host.lowercased())|\(port)" @@ -187,7 +235,7 @@ import os #expect(appModel.gatewayStatusText == "Can't reach gateway at \(host):\(port). Check Tailscale or LAN.") } - @Test @MainActor func manualFirstUseTLSProbeReportsHandshakeTimeoutWithoutTrustPrompt() async { + @Test @MainActor func `manual first use TLS probe reports handshake timeout without trust prompt`() async { let host = "gateway-\(UUID().uuidString).example.com" let port = 18789 let stableID = "manual|\(host.lowercased())|\(port)" @@ -218,7 +266,7 @@ import os #expect(appModel.gatewayStatusText.contains("\(host):\(port)")) } - @Test @MainActor func discoveredFirstUseTLSProbeFailureClearsStaleTrustPrompt() async { + @Test @MainActor func `discovered first use TLS probe failure clears stale trust prompt`() async { let staleHost = "stale-\(UUID().uuidString).example.com" let stalePort = 18789 let staleStableID = "manual|\(staleHost.lowercased())|\(stalePort)" @@ -261,7 +309,7 @@ import os #expect(appModel.gatewayStatusText == message) } - @Test @MainActor func clearAllTLSFingerprints_removesStoredPins() async { + @Test @MainActor func `clear all TLS fingerprints removes stored pins`() { let stableID1 = "test|\(UUID().uuidString)" let stableID2 = "test|\(UUID().uuidString)" defer { GatewayTLSStore.clearAllFingerprints() } @@ -278,7 +326,7 @@ import os #expect(GatewayTLSStore.loadFingerprint(stableID: stableID2) == nil) } - @Test func trustedPinMismatchCanBeRecoveredByReplacingStoredPin() { + @Test func `trusted pin mismatch can be recovered by replacing stored pin`() { let stableID = "test|\(UUID().uuidString)" defer { GatewayTLSStore.clearFingerprint(stableID: stableID) } GatewayTLSStore.saveFingerprint("old", stableID: stableID) @@ -305,7 +353,7 @@ import os #expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "new") } - @Test func untrustedPinMismatchCannotBeRecoveredInApp() { + @Test func `untrusted pin mismatch cannot be recovered in app`() { let error = GatewayTLSValidationError( failure: GatewayTLSValidationFailure( kind: .pinMismatch, diff --git a/apps/ios/Tests/RootTabsSourceGuardTests.swift b/apps/ios/Tests/RootTabsSourceGuardTests.swift index b6769d5667e6..731878150874 100644 --- a/apps/ios/Tests/RootTabsSourceGuardTests.swift +++ b/apps/ios/Tests/RootTabsSourceGuardTests.swift @@ -836,6 +836,36 @@ struct RootTabsSourceGuardTests { #expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)")) } + @Test func `setup route probes yield to newer manual actions`() throws { + let onboardingSource = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8) + let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8) + let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8) + + let welcomeStep = try Self.extract( + onboardingSource, + from: "private var welcomeStep: some View", + to: "@ViewBuilder\n private var modeStep") + #expect(welcomeStep.contains("self.openQRScannerFromOnboarding()")) + #expect(welcomeStep.contains("self.invalidateSetupAttempt()")) + + let onboardingManualConnect = try Self.extract( + onboardingSource, + from: "private func connectManual(setupAttemptID: UUID? = nil) async", + to: "private func connectCurrentManualGateway") + #expect(onboardingManualConnect.contains("guard self.setupAttemptID == setupAttemptID else { return }")) + #expect(onboardingManualConnect.contains("self.invalidateSetupAttempt()")) + #expect(onboardingSource.contains("await self.connectManual(setupAttemptID: attemptID)")) + + let settingsManualConnect = try Self.extract( + actionsSource, + from: "func connectManual(setupAttemptID: UUID? = nil) async", + to: "func preflightGateway") + #expect(settingsManualConnect.contains("guard self.setupAttemptID == setupAttemptID else { return }")) + #expect(settingsManualConnect.contains("self.invalidateGatewaySetupAttempt()")) + #expect(actionsSource.contains("await self.connectManual(setupAttemptID: attemptID)")) + #expect(sectionsSource.contains(".disabled(self.setupAttemptID != nil)")) + } + @Test func `local network access is requested from visible gateway flows`() throws { let appSource = try String(contentsOf: Self.openClawAppSourceURL(), encoding: .utf8) let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift index 1901da207479..da3209e9c001 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift @@ -11,8 +11,21 @@ public enum DeepLinkRoute: Sendable, Equatable { } public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { + private static let maximumSetupEndpoints = 8 + + private enum CodingKeys: String, CodingKey { + case host + case port + case tls + case bootstrapToken + case token + case password + case fallbackEndpoints + } + private struct SetupPayload: Decodable { let url: String? + let urls: [String]? let host: String? let port: Int? let tls: Bool? @@ -27,14 +40,37 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { public let bootstrapToken: String? public let token: String? public let password: String? + public let fallbackEndpoints: [GatewayConnectEndpoint] - public init(host: String, port: Int, tls: Bool, bootstrapToken: String?, token: String?, password: String?) { + public init( + host: String, + port: Int, + tls: Bool, + bootstrapToken: String?, + token: String?, + password: String?, + fallbackEndpoints: [GatewayConnectEndpoint] = []) + { self.host = host self.port = port self.tls = tls self.bootstrapToken = bootstrapToken self.token = token self.password = password + self.fallbackEndpoints = fallbackEndpoints + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.host = try container.decode(String.self, forKey: .host) + self.port = try container.decode(Int.self, forKey: .port) + self.tls = try container.decode(Bool.self, forKey: .tls) + self.bootstrapToken = try container.decodeIfPresent(String.self, forKey: .bootstrapToken) + self.token = try container.decodeIfPresent(String.self, forKey: .token) + self.password = try container.decodeIfPresent(String.self, forKey: .password) + self.fallbackEndpoints = try container.decodeIfPresent( + [GatewayConnectEndpoint].self, + forKey: .fallbackEndpoints) ?? [] } public var websocketURL: URL? { @@ -42,6 +78,20 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { return URL(string: "\(scheme)://\(self.host):\(self.port)") } + public var connectionEndpoints: [GatewayConnectEndpoint] { + [.init(host: self.host, port: self.port, tls: self.tls)] + self.fallbackEndpoints + } + + public func selectingEndpoint(_ endpoint: GatewayConnectEndpoint) -> GatewayConnectDeepLink { + .init( + host: endpoint.host, + port: endpoint.port, + tls: endpoint.tls, + bootstrapToken: self.bootstrapToken, + token: self.token, + password: self.password) + } + /// Parse a gateway setup input from the QR/scanner/manual entry surfaces. /// /// Accepted inputs are: @@ -77,12 +127,13 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { /// - copied text/message content containing one or more extractable setup-code candidates /// /// Accepted payload shapes are: - /// - `{url, bootstrapToken?, token?, password?}` + /// - `{url, urls?, bootstrapToken?, token?, password?}` /// - `{host, port?, tls?, bootstrapToken?, token?, password?}` /// - /// URL-based payloads provide the gateway WebSocket URL via `url`. Host-based payloads - /// provide `host` plus optional `port` and `tls`. In both cases, the optional - /// `bootstrapToken`, `token`, and `password` fields are also supported. + /// URL-based payloads provide the primary gateway WebSocket URL via `url`, with optional + /// ordered candidates in `urls`. Host-based payloads provide `host` plus optional `port` + /// and `tls`. In both cases, the optional `bootstrapToken`, `token`, and `password` fields + /// are also supported. public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? { let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } @@ -106,15 +157,36 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { private static func decodeSetupPayload(from data: Data) -> GatewayConnectDeepLink? { guard let payload = try? JSONDecoder().decode(SetupPayload.self, from: data) else { return nil } - if let urlString = payload.url?.trimmingCharacters(in: .whitespacesAndNewlines), - !urlString.isEmpty - { + var urlCandidates = payload.url.map { [$0] } ?? [] + for candidate in payload.urls ?? [] { + guard urlCandidates.count < self.maximumSetupEndpoints else { break } + if !urlCandidates.contains(candidate) { + urlCandidates.append(candidate) + } + } + var seenURLs = Set() + let links = urlCandidates.compactMap { rawURL -> GatewayConnectDeepLink? in + let url = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !url.isEmpty, seenURLs.insert(url).inserted else { return nil } return self.fromGatewayURLString( - urlString, + url, bootstrapToken: payload.bootstrapToken, token: payload.token, password: payload.password) } + if let primary = links.first { + let fallbacks = links.dropFirst().map { + GatewayConnectEndpoint(host: $0.host, port: $0.port, tls: $0.tls) + } + return GatewayConnectDeepLink( + host: primary.host, + port: primary.port, + tls: primary.tls, + bootstrapToken: primary.bootstrapToken, + token: primary.token, + password: primary.password, + fallbackEndpoints: fallbacks) + } guard let host = payload.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else { @@ -185,6 +257,18 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { } } +public struct GatewayConnectEndpoint: Codable, Sendable, Equatable { + public let host: String + public let port: Int + public let tls: Bool + + public init(host: String, port: Int, tls: Bool) { + self.host = host + self.port = port + self.tls = tls + } +} + public struct AgentDeepLink: Codable, Sendable, Equatable { public let message: String public let sessionKey: String? diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index ba6afea57f9e..bd64c4f8d8d4 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -7541,6 +7541,7 @@ public struct DevicePairSetupCodeResult: Codable, Sendable { public let setupcode: String public let qrdataurl: String? public let gatewayurl: String + public let gatewayurls: [String]? public let auth: AnyCodable public let urlsource: String @@ -7548,12 +7549,14 @@ public struct DevicePairSetupCodeResult: Codable, Sendable { setupcode: String, qrdataurl: String?, gatewayurl: String, + gatewayurls: [String]? = nil, auth: AnyCodable, urlsource: String) { self.setupcode = setupcode self.qrdataurl = qrdataurl self.gatewayurl = gatewayurl + self.gatewayurls = gatewayurls self.auth = auth self.urlsource = urlsource } @@ -7562,6 +7565,7 @@ public struct DevicePairSetupCodeResult: Codable, Sendable { case setupcode = "setupCode" case qrdataurl = "qrDataUrl" case gatewayurl = "gatewayUrl" + case gatewayurls = "gatewayUrls" case auth case urlsource = "urlSource" } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift index 6d6a5644a52b..9bcb8357f238 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift @@ -18,6 +18,17 @@ private func gatewayLink(from raw: String) -> GatewayConnectDeepLink? { } @Suite struct DeepLinksSecurityTests { + @Test func setupResultInitializerKeepsLegacySignature() { + let result = DevicePairSetupCodeResult( + setupcode: "code", + qrdataurl: nil, + gatewayurl: "wss://gateway.example.com", + auth: AnyCodable("token"), + urlsource: "config") + + #expect(result.gatewayurls == nil) + } + @Test func dashboardDeepLinkParses() { let url = URL(string: "openclaw://dashboard")! #expect(DeepLinkParser.parse(url) == .dashboard) @@ -118,6 +129,56 @@ private func gatewayLink(from raw: String) -> GatewayConnectDeepLink? { password: nil)) } + @Test func setupCodeParsesOrderedGatewayFallbacks() throws { + let payload = #"{"url":"ws://192.168.1.20:18789","urls":["ws://192.168.1.20:18789","wss://gateway.tailnet.ts.net:8443"],"bootstrapToken":"tok"}"# + let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) + + #expect(link?.connectionEndpoints == [ + .init(host: "192.168.1.20", port: 18789, tls: false), + .init(host: "gateway.tailnet.ts.net", port: 8443, tls: true), + ]) + #expect(try link?.selectingEndpoint(#require(link?.connectionEndpoints[1])) == .init( + host: "gateway.tailnet.ts.net", + port: 8443, + tls: true, + bootstrapToken: "tok", + token: nil, + password: nil)) + } + + @Test func legacyEncodedGatewayLinkDecodesWithoutFallbacks() throws { + let payload = #"{"host":"gateway.tailnet.ts.net","port":443,"tls":true}"# + + let link = try JSONDecoder().decode( + GatewayConnectDeepLink.self, + from: Data(payload.utf8)) + + #expect(link.fallbackEndpoints.isEmpty) + } + + @Test func setupCodeDropsInsecureGatewayFallbacks() { + let payload = #"{"url":"ws://attacker.example:18789","urls":["ws://attacker.example:18789","wss://gateway.tailnet.ts.net"],"bootstrapToken":"tok"}"# + + #expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == .init( + host: "gateway.tailnet.ts.net", + port: 443, + tls: true, + bootstrapToken: "tok", + token: nil, + password: nil)) + } + + @Test func setupCodeCapsGatewayEndpoints() throws { + let urls = (0..<10).map { "wss://gateway-\($0).example.com" } + let data = try JSONSerialization.data(withJSONObject: ["url": urls[0], "urls": urls]) + let payload = try #require(String(data: data, encoding: .utf8)) + + let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) + + #expect(link?.connectionEndpoints.count == 8) + #expect(link?.connectionEndpoints.last?.host == "gateway-7.example.com") + } + @Test func setupCodeRejectsTailnetPlaintextWs() { let payload = #"{"url":"ws://gateway.tailnet.ts.net:18789","bootstrapToken":"tok"}"# #expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil) diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index e1e6729c5401..7c6b68f967eb 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -05e2bd38087611d3696275fd1c6ae52ab2843fe983fe8bf104c66e54e2ce205a plugin-sdk-api-baseline.json -5a94d62be4cd9f509593c67d9df7988c1878596d0010ffb11072b29a27301e9a plugin-sdk-api-baseline.jsonl +f333778230502df8e9f417cdb81997b5baff12755ffc7148f2d1503cdc835616 plugin-sdk-api-baseline.json +666e5be7db9cb2708267e0e0d509cedd19daf3188abd83171e74f5c2aee3f68e plugin-sdk-api-baseline.jsonl diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index d6768a1db8a1..1e57597200fb 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -140,6 +140,7 @@ If you use the `device-pair` plugin, you can do first-time device pairing entire The setup code is a base64-encoded JSON payload that contains: - `url`: the Gateway WebSocket URL (`ws://...` or `wss://...`) +- `urls`: when available, the ordered LAN/Tailnet routes the mobile app can try - `bootstrapToken`: a single-use bootstrap token for the initial pairing handshake (expires after 10 minutes; `expiresAtMs` is included in the payload) Run `/pair cleanup` to invalidate unused setup codes once pairing finishes. @@ -164,6 +165,13 @@ for loopback, private LAN addresses, `.local` Bonjour hosts, and the Android emulator host. Tailnet CGNAT addresses, `.ts.net` names, and public hosts still fail closed before QR/setup-code issuance. +For `gateway.bind=lan` setup URLs, OpenClaw detects persistent Tailscale Serve +HTTPS roots that proxy the active Gateway's loopback port and advertises them +alongside the LAN route. Specific-interface `custom` and `tailnet` binds do not +receive that fallback because a loopback Serve proxy cannot reach those +listeners. The iOS app probes the advertised routes in order and saves the first +reachable endpoint. + ### Approve a node device ```bash diff --git a/docs/cli/qr.md b/docs/cli/qr.md index 631c61bbb803..566d158aeba5 100644 --- a/docs/cli/qr.md +++ b/docs/cli/qr.md @@ -36,7 +36,7 @@ openclaw devices approve - `--password `: override the gateway password the bootstrap flow authenticates against - `--setup-code-only`: print only the setup code - `--no-ascii`: skip ASCII QR rendering -- `--json`: emit JSON (`setupCode`, `gatewayUrl`, `auth`, `urlSource`) +- `--json`: emit JSON (`setupCode`, `gatewayUrl`, optional `gatewayUrls`, `auth`, `urlSource`) `--token` and `--password` are mutually exclusive. @@ -53,6 +53,8 @@ Pairing-mutation scopes and `operator.admin` still require a separate approved o Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs: use Tailscale Serve/Funnel or a `wss://` gateway URL for those. Private LAN addresses and `.local` Bonjour hosts remain supported over plain `ws://`. +When the selected Gateway URL comes from `gateway.bind=lan`, OpenClaw also checks persistent `tailscale serve status --json` routes. Any HTTPS Serve root that proxies the active Gateway's loopback port is included as a fallback. Specific-interface `custom` and `tailnet` binds do not receive that fallback because a loopback Serve proxy cannot reach those listeners. Current iOS clients probe the advertised routes in order and save the first reachable one; the legacy `url` field remains unchanged for older clients. + With `--remote`, one of `gateway.remote.url` or `gateway.tailscale.mode=serve|funnel` is required. ## Auth resolution (no `--remote`) diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index f5d4b3784645..50e1677ef7a1 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -43,6 +43,9 @@ creation has a token or password auth path. 3. In the iOS app, open **Settings** -> **Gateway**, scan the QR code (or paste the setup code), and connect. + If the setup code contains both LAN and Tailscale Serve routes, the app + probes them in order and saves the first reachable endpoint. + 4. The official app connects automatically. If **Devices** shows a pending request, review its role and scopes before approving it. diff --git a/extensions/device-pair/api.ts b/extensions/device-pair/api.ts index 3cf12f4f5aa6..45e58b966163 100644 --- a/extensions/device-pair/api.ts +++ b/extensions/device-pair/api.ts @@ -13,6 +13,7 @@ export { resolveGatewayBindUrl, resolveGatewayPort, resolveTailnetHostWithRunner, + resolveTailscaleServeGatewayUrlsWithRunner, } from "openclaw/plugin-sdk/core"; export { resolveAdvertisedLanHost } from "openclaw/plugin-sdk/gateway-runtime"; export { diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index 326746f79f09..d297fab7aaf2 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -19,6 +19,7 @@ const pluginApiMocks = vi.hoisted(() => ({ revokeDeviceBootstrapToken: vi.fn(async () => ({ removed: true })), renderQrPngDataUrl: vi.fn(async () => "data:image/png;base64,ZmFrZXBuZw=="), resolveGatewayPort: vi.fn(() => 18789), + resolveTailscaleServeGatewayUrlsWithRunner: vi.fn(async () => []), resolvePreferredOpenClawTmpDir: vi.fn(() => path.join(os.tmpdir(), "openclaw-device-pair-tests")), writeQrPngTempFile: vi.fn(async (dataValue: string, opts: { tmpRoot: string }) => { const dirPath = await fs.mkdtemp(path.join(opts.tmpRoot, "device-pair-qr-")); @@ -46,6 +47,8 @@ vi.mock("./api.js", () => { resolveGatewayBindUrl: vi.fn(), resolveGatewayPort: pluginApiMocks.resolveGatewayPort, resolveTailnetHostWithRunner: vi.fn(), + resolveTailscaleServeGatewayUrlsWithRunner: + pluginApiMocks.resolveTailscaleServeGatewayUrlsWithRunner, runPluginCommandWithTimeout: vi.fn(), writeQrPngTempFile: pluginApiMocks.writeQrPngTempFile, }; @@ -63,6 +66,7 @@ import { resolveAdvertisedLanHost, resolveGatewayBindUrl, resolveTailnetHostWithRunner, + resolveTailscaleServeGatewayUrlsWithRunner, } from "./api.js"; import registerDevicePair from "./index.js"; @@ -896,6 +900,68 @@ describe("device-pair /pair default setup code", () => { expect(requireText(result)).toContain("Gateway: ws://10.211.55.3:18789"); }); + it("includes a Tailscale Serve fallback for LAN bind-derived setup urls", async () => { + vi.mocked(resolveAdvertisedLanHost).mockResolvedValueOnce("192.168.139.3"); + vi.mocked(resolveGatewayBindUrl).mockImplementationOnce((params) => ({ + url: `ws://${params.pickLanHost()}:18789`, + source: "gateway.bind=lan", + })); + vi.mocked(resolveTailscaleServeGatewayUrlsWithRunner).mockResolvedValueOnce([ + "wss://clawmac.tail.ts.net:8443", + ]); + const command = registerPairCommand({ + config: { + gateway: { + bind: "lan", + auth: { mode: "token", token: "gateway-token" }, + }, + }, + pluginConfig: { publicUrl: undefined }, + }); + + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "", + commandBody: "/pair", + gatewayClientScopes: INTERNAL_SETUP_SCOPES, + }), + ); + + expect(requireText(result)).toContain("Gateway: ws://192.168.139.3:18789"); + expect(requireText(result)).toContain("Fallback: wss://clawmac.tail.ts.net:8443"); + }); + + it("does not advertise a loopback Serve route for a custom bind", async () => { + vi.mocked(resolveGatewayBindUrl).mockReturnValueOnce({ + url: "ws://192.168.139.3:18789", + source: "gateway.bind=custom", + }); + const command = registerPairCommand({ + config: { + gateway: { + bind: "custom", + customBindHost: "192.168.139.3", + auth: { mode: "token", token: "gateway-token" }, + }, + }, + pluginConfig: { publicUrl: undefined }, + }); + + const result = await command.handler( + createCommandContext({ + channel: "webchat", + args: "", + commandBody: "/pair", + gatewayClientScopes: INTERNAL_SETUP_SCOPES, + }), + ); + + expect(resolveTailscaleServeGatewayUrlsWithRunner).not.toHaveBeenCalled(); + expect(requireText(result)).toContain("Gateway: ws://192.168.139.3:18789"); + expect(requireText(result)).not.toContain("Fallback:"); + }); + it("rejects public cleartext setup urls before issuing setup codes", async () => { const command = registerPairCommand({ pluginConfig: { diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 1a634f335eb7..2011c96e1c46 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -33,12 +33,14 @@ type DevicePairPluginConfig = { type SetupPayload = { url: string; + urls?: string[]; bootstrapToken: string; expiresAtMs: number; }; type ResolveUrlResult = { url?: string; + urls?: string[]; source?: string; error?: string; }; @@ -418,6 +420,18 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise advertisedLanHost, }); + if (bindResult && "url" in bindResult && bindResult.source === "gateway.bind=lan") { + const { resolveTailscaleServeGatewayUrlsWithRunner, runPluginCommandWithTimeout } = + await loadDevicePairApiModule(); + const serveUrls = await resolveTailscaleServeGatewayUrlsWithRunner(port, (argv, opts) => + runPluginCommandWithTimeout({ argv, timeoutMs: opts.timeoutMs }), + ); + const urls = [...new Set([bindResult.url, ...serveUrls])].slice(0, 8); + return { + ...bindResult, + ...(urls.length > 1 ? { urls } : {}), + }; + } if (bindResult) { return bindResult; } @@ -437,7 +451,13 @@ async function resolveMobilePairingGatewayUrl(api: OpenClawPluginApi): Promise !validateMobilePairingUrl(url, "tailscale serve status"), + ); + return { + ...result, + ...(urls && urls.length > 1 ? { urls } : {}), + }; } function encodeSetupCode(payload: SetupPayload): string { @@ -494,7 +514,7 @@ function formatSetupReply(payload: SetupPayload, authLabel: string): string { "Setup code:", setupCode, "", - `Gateway: ${payload.url}`, + ...formatGatewayLines(payload), `Auth: ${authLabel}`, ...buildSecurityNoticeLines({ kind: "setup code", @@ -523,7 +543,7 @@ function buildQrInfoLines(params: { expiresAtMs: number; }): string[] { return [ - `Gateway: ${params.payload.url}`, + ...formatGatewayLines(params.payload), `Auth: ${params.authLabel}`, ...buildSecurityNoticeLines({ kind: "QR code", @@ -543,7 +563,7 @@ function formatQrInfoMarkdown(params: { expiresAtMs: number; }): string { return [ - `- Gateway: ${params.payload.url}`, + ...formatGatewayLines(params.payload).map((line) => `- ${line}`), `- Auth: ${params.authLabel}`, ...buildSecurityNoticeLines({ kind: "QR code", @@ -578,7 +598,13 @@ function resolveQrReplyTarget(ctx: QrCommandContext): string { ); } -async function issueSetupPayload(url: string): Promise { +function formatGatewayLines(payload: SetupPayload): string[] { + return (payload.urls ?? [payload.url]).map((url, index) => + index === 0 ? `Gateway: ${url}` : `Fallback: ${url}`, + ); +} + +async function issueSetupPayload(url: string, urls?: string[]): Promise { const { issueDeviceBootstrapToken, PAIRING_SETUP_BOOTSTRAP_PROFILE } = await loadDevicePairApiModule(); const issuedBootstrap = await issueDeviceBootstrapToken({ @@ -586,6 +612,7 @@ async function issueSetupPayload(url: string): Promise { }); return { url, + ...(urls ? { urls } : {}), bootstrapToken: issuedBootstrap.token, expiresAtMs: issuedBootstrap.expiresAtMs, }; @@ -757,7 +784,7 @@ export default definePluginEntry({ } } - let payload = await issueSetupPayload(urlResult.url); + let payload = await issueSetupPayload(urlResult.url, urlResult.urls); let setupCode = encodeSetupCode(payload); const infoLines = buildQrInfoLines({ @@ -802,7 +829,7 @@ export default definePluginEntry({ `device-pair: QR image send failed channel=${channel}, falling back (${(err as Error)?.message ?? err})`, ); await revokeDeviceBootstrapToken({ token: payload.bootstrapToken }).catch(() => {}); - payload = await issueSetupPayload(urlResult.url); + payload = await issueSetupPayload(urlResult.url, urlResult.urls); setupCode = encodeSetupCode(payload); } finally { if (qrFilePath) { @@ -824,7 +851,7 @@ export default definePluginEntry({ `device-pair: webchat QR render failed, falling back (${(err as Error)?.message ?? err})`, ); await revokeDeviceBootstrapToken({ token: payload.bootstrapToken }).catch(() => {}); - payload = await issueSetupPayload(urlResult.url); + payload = await issueSetupPayload(urlResult.url, urlResult.urls); return { text: "QR image delivery is not available on this channel right now, so I generated a pasteable setup code instead.\n\n" + @@ -862,7 +889,7 @@ export default definePluginEntry({ normalizeOptionalString(ctx.from) || normalizeOptionalString(ctx.to) || ""; - const payload = await issueSetupPayload(urlResult.url); + const payload = await issueSetupPayload(urlResult.url, urlResult.urls); if (channel === "telegram" && target) { try { diff --git a/packages/gateway-protocol/src/schema/devices.ts b/packages/gateway-protocol/src/schema/devices.ts index 00ff02be910c..183281963ec6 100644 --- a/packages/gateway-protocol/src/schema/devices.ts +++ b/packages/gateway-protocol/src/schema/devices.ts @@ -112,6 +112,9 @@ export const DevicePairSetupCodeResultSchema = Type.Object( setupCode: NonEmptyString, qrDataUrl: Type.Optional(SetupCodeQrDataUrlSchema), gatewayUrl: NonEmptyString, + gatewayUrls: Type.Optional( + Type.Array(NonEmptyString, { minItems: 2, maxItems: 8, uniqueItems: true }), + ), auth: Type.Union([Type.Literal("token"), Type.Literal("password")]), urlSource: NonEmptyString, }, diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index a311f0806ae2..a560397d3dd1 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -192,12 +192,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { ), publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", - 10429, + 10430, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", - 5206, + 5207, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index 98b0f0338e60..0b1d77eeaa04 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -86,6 +86,7 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[] ["CronListParams", ["compact"]], ["CronRunLogEntry", ["errorReason", "failureNotificationDelivery"]], ["ExecApprovalRequestParams", ["requireDeliveryRoute", "suppressDelivery"]], + ["DevicePairSetupCodeResult", ["gatewayUrls"]], ["AgentSummary", ["thinkingLevels", "thinkingOptions", "thinkingDefault"]], ["ModelChoice", ["available"]], ]; diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts index 7c188a6338bb..8f3272455ccd 100644 --- a/src/cli/qr-cli.ts +++ b/src/cli/qr-cli.ts @@ -220,6 +220,7 @@ export function registerQrCli(program: Command) { defaultRuntime.writeJson({ setupCode, gatewayUrl: resolved.payload.url, + ...(resolved.payload.urls ? { gatewayUrls: resolved.payload.urls } : {}), auth: resolved.authLabel, urlSource: resolved.urlSource, }); @@ -240,6 +241,8 @@ export function registerQrCli(program: Command) { lines.push( `${theme.muted("Setup code:")} ${setupCode}`, `${theme.muted("Gateway:")} ${resolved.payload.url}`, + ...(resolved.payload.urls?.slice(1).map((url) => `${theme.muted("Fallback:")} ${url}`) ?? + []), `${theme.muted("Auth:")} ${resolved.authLabel}`, `${theme.muted("Source:")} ${resolved.urlSource}`, "", diff --git a/src/gateway/server-methods/device-pair-setup.test.ts b/src/gateway/server-methods/device-pair-setup.test.ts index be9590c04e44..601501da2604 100644 --- a/src/gateway/server-methods/device-pair-setup.test.ts +++ b/src/gateway/server-methods/device-pair-setup.test.ts @@ -48,7 +48,11 @@ function createOptions( const okResolution = { ok: true as const, - payload: { url: "wss://gw.example:8443", bootstrapToken: "boot-123" }, + payload: { + url: "wss://gw.example:8443", + urls: ["wss://gw.example:8443", "ws://192.168.1.20:18789"], + bootstrapToken: "boot-123", + }, authLabel: "token" as const, urlSource: "remote", }; @@ -77,6 +81,7 @@ describe("device.pair.setupCode", () => { setupCode: "SETUP-CODE-XYZ", qrDataUrl: "data:image/png;base64,qr", gatewayUrl: "wss://gw.example:8443", + gatewayUrls: ["wss://gw.example:8443", "ws://192.168.1.20:18789"], auth: "token", urlSource: "remote", }); diff --git a/src/gateway/server-methods/device-pair-setup.ts b/src/gateway/server-methods/device-pair-setup.ts index 353bf3ee44ad..ab560fa35f29 100644 --- a/src/gateway/server-methods/device-pair-setup.ts +++ b/src/gateway/server-methods/device-pair-setup.ts @@ -72,6 +72,7 @@ export const devicePairSetupHandlers: GatewayRequestHandlers = { setupCode, ...(qrDataUrl ? { qrDataUrl } : {}), gatewayUrl: resolved.payload.url, + ...(resolved.payload.urls ? { gatewayUrls: resolved.payload.urls } : {}), // Label only — never the raw gateway token/password. auth: resolved.authLabel, urlSource: requestPublicUrl ? "request.publicUrl" : resolved.urlSource, diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index d48aa5daabd8..6dae4e1a552f 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -111,6 +111,7 @@ describe("pairing setup code", () => { params: { authLabel: string; url?: string; + urls?: string[]; urlSource?: string; }, ) { @@ -130,6 +131,9 @@ describe("pairing setup code", () => { if (params.url) { expect(resolved.payload.url).toBe(params.url); } + if (params.urls) { + expect(resolved.payload.urls).toEqual(params.urls); + } if (params.urlSource) { expect(resolved.urlSource).toBe(params.urlSource); } @@ -149,6 +153,7 @@ describe("pairing setup code", () => { expected: { authLabel: string; url: string; + urls?: string[]; urlSource: string; }; runCommandWithTimeout?: ReturnType; @@ -591,7 +596,7 @@ describe("pairing setup code", () => { urlSource: "gateway.bind=lan", }, runCommandWithTimeout, - expectedRunCommandCalls: 1, + expectedRunCommandCalls: 3, }); }); @@ -636,7 +641,73 @@ describe("pairing setup code", () => { urlSource: "gateway.bind=lan", }, runCommandWithTimeout, - expectedRunCommandCalls: 1, + expectedRunCommandCalls: 3, + }); + }); + + it("adds a configured Tailscale Serve route to a LAN setup code", async () => { + const defaultRoute = createDefaultRouteRunner("en0"); + const runCommandWithTimeout = vi.fn(async (argv: string[]) => { + if (argv.includes("serve")) { + return { + code: 0, + stdout: JSON.stringify({ + TCP: { "8443": { HTTPS: true } }, + Web: { + "clawmac.tail.ts.net:8443": { + Handlers: { "/": { Proxy: "http://127.0.0.1:18789" } }, + }, + }, + }), + stderr: "", + }; + } + return defaultRoute(); + }); + + await expectResolvedSetupSuccessCase({ + config: { + gateway: { + bind: "lan", + auth: { mode: "token", token: "tok_123" }, + }, + } satisfies ResolveSetupConfig, + options: { + networkInterfaces: () => createIpv4NetworkInterfaces("192.168.139.3"), + runCommandWithTimeout, + } satisfies ResolveSetupOptions, + expected: { + authLabel: "token", + url: "ws://192.168.139.3:18789", + urls: ["ws://192.168.139.3:18789", "wss://clawmac.tail.ts.net:8443"], + urlSource: "gateway.bind=lan", + }, + runCommandWithTimeout, + expectedRunCommandCalls: 2, + }); + }); + + it("does not advertise a loopback Serve route for a custom bind", async () => { + const runCommandWithTimeout = vi.fn(async () => { + throw new Error("Tailscale Serve discovery must not run for a custom bind"); + }); + + await expectResolvedSetupSuccessCase({ + config: { + gateway: { + bind: "custom", + customBindHost: "192.168.139.3", + auth: { mode: "token", token: "tok_123" }, + }, + } satisfies ResolveSetupConfig, + options: { runCommandWithTimeout } satisfies ResolveSetupOptions, + expected: { + authLabel: "token", + url: "ws://192.168.139.3:18789", + urlSource: "gateway.bind=custom", + }, + runCommandWithTimeout, + expectedRunCommandCalls: 0, }); }); diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index 7946ae0738de..ecb654607440 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -27,14 +27,18 @@ import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-prof import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; import { resolveTailnetHostWithRunner, + resolveTailscaleServeGatewayUrlsWithRunner, resolveTailscalePublishedHost, } from "../shared/tailscale-status.js"; export type PairingSetupPayload = { url: string; + urls?: string[]; bootstrapToken: string; }; +const PAIRING_SETUP_MAX_URLS = 8; + export type PairingSetupCommandResult = { code: number | null; stdout: string; @@ -409,10 +413,25 @@ export async function resolvePairingSetupFromConfig( return { ok: false, error: "Gateway auth is not configured (no token or password)." }; } + const urls = [urlResult.url]; + if (urlResult.source === "gateway.bind=lan") { + const serveUrls = await resolveTailscaleServeGatewayUrlsWithRunner( + resolveGatewayPort(cfgForAuth, env), + options.runCommandWithTimeout, + ); + for (const serveUrl of serveUrls) { + if (!validateMobilePairingUrl(serveUrl, "tailscale serve status")) { + urls.push(serveUrl); + } + } + } + const uniqueUrls = [...new Set(urls)].slice(0, PAIRING_SETUP_MAX_URLS); + return { ok: true, payload: { url: urlResult.url, + ...(uniqueUrls.length > 1 ? { urls: uniqueUrls } : {}), bootstrapToken: ( await issueDeviceBootstrapToken({ baseDir: options.pairingBaseDir, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 3553b25749dc..45f438ac700a 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -285,7 +285,10 @@ export async function ensureConfiguredAcpBindingReady(params: { return runtime.ensureConfiguredAcpBindingReady(params); } -export { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js"; +export { + resolveTailnetHostWithRunner, + resolveTailscaleServeGatewayUrlsWithRunner, +} from "../shared/tailscale-status.js"; export type { TailscaleStatusCommandResult, TailscaleStatusCommandRunner, diff --git a/src/shared/tailscale-status.test.ts b/src/shared/tailscale-status.test.ts index 67012cd8d172..303ed2cb7c44 100644 --- a/src/shared/tailscale-status.test.ts +++ b/src/shared/tailscale-status.test.ts @@ -1,6 +1,9 @@ // Tailscale status tests cover status parsing and validation. import { describe, expect, it, vi } from "vitest"; -import { resolveTailnetHostWithRunner } from "./tailscale-status.js"; +import { + resolveTailnetHostWithRunner, + resolveTailscaleServeGatewayUrlsWithRunner, +} from "./tailscale-status.js"; describe("shared/tailscale-status", () => { it("returns null when no runner is provided", async () => { @@ -83,4 +86,77 @@ describe("shared/tailscale-status", () => { }); await expect(resolveTailnetHostWithRunner(invalid)).resolves.toBeNull(); }); + + it("finds persistent HTTPS Serve routes that proxy the gateway root", async () => { + const run = vi.fn().mockResolvedValue({ + code: 0, + stdout: JSON.stringify({ + TCP: { "443": { HTTPS: true }, "8443": { HTTPS: true } }, + Web: { + "mac.tail.ts.net:443": { + Handlers: { "/": { Proxy: "http://127.0.0.1:8096" } }, + }, + "mac.tail.ts.net:8443": { + Handlers: { "/": { Proxy: "http://127.0.0.1:18789" } }, + }, + }, + }), + }); + + await expect(resolveTailscaleServeGatewayUrlsWithRunner(18789, run)).resolves.toEqual([ + "wss://mac.tail.ts.net:8443", + ]); + expect(run).toHaveBeenCalledWith(["tailscale", "serve", "status", "--json"], { + timeoutMs: 5000, + }); + }); + + it("ignores non-root, non-HTTPS, and non-loopback Serve handlers", async () => { + const run = vi.fn().mockResolvedValue({ + code: 0, + stdout: JSON.stringify({ + TCP: { "80": { HTTP: true }, "443": { HTTPS: true } }, + Web: { + "mac.tail.ts.net:80": { + Handlers: { "/": { Proxy: "http://127.0.0.1:18789" } }, + }, + "mac.tail.ts.net:443": { + Handlers: { "/openclaw": { Proxy: "http://127.0.0.1:18789" } }, + }, + "other.tail.ts.net:443": { + Handlers: { "/": { Proxy: "http://192.168.1.20:18789" } }, + }, + }, + }), + }); + + await expect(resolveTailscaleServeGatewayUrlsWithRunner(18789, run)).resolves.toEqual([]); + }); + + it("ignores load-balanced Tailscale Services and public Funnel routes", async () => { + const run = vi.fn().mockResolvedValue({ + code: 0, + stdout: JSON.stringify({ + TCP: { "443": { HTTPS: true } }, + Web: { + "mac.tail.ts.net:443": { + Handlers: { "/": { Proxy: "127.0.0.1:18789" } }, + }, + }, + AllowFunnel: { "mac.tail.ts.net:443": true }, + Services: { + "svc:openclaw": { + TCP: { "443": { HTTPS: true } }, + Web: { + "openclaw.tail.ts.net:443": { + Handlers: { "/": { Proxy: "127.0.0.1:18789" } }, + }, + }, + }, + }, + }), + }); + + await expect(resolveTailscaleServeGatewayUrlsWithRunner(18789, run)).resolves.toEqual([]); + }); }); diff --git a/src/shared/tailscale-status.ts b/src/shared/tailscale-status.ts index 49abea50767f..6e5ff46760e3 100644 --- a/src/shared/tailscale-status.ts +++ b/src/shared/tailscale-status.ts @@ -26,6 +26,28 @@ const TailscaleStatusSchema = z.object({ .optional(), }); +const TailscaleServeTcpHandlerSchema = z.object({ + HTTPS: z.boolean().optional(), +}); + +const TailscaleServeWebServerSchema = z.object({ + Handlers: z.record( + z.string(), + z.object({ + Proxy: z.string().optional(), + }), + ), +}); + +const TailscaleServeServiceSchema = z.object({ + TCP: z.record(z.string(), TailscaleServeTcpHandlerSchema).optional(), + Web: z.record(z.string(), TailscaleServeWebServerSchema).optional(), +}); + +const TailscaleServeConfigSchema = TailscaleServeServiceSchema.extend({ + AllowFunnel: z.record(z.string(), z.boolean()).optional(), +}); + function parsePossiblyNoisyStatus(raw: string): z.infer | null { const start = raw.indexOf("{"); const end = raw.lastIndexOf("}"); @@ -45,6 +67,71 @@ function extractTailnetHostFromStatusJson(raw: string): string | null { return ips.length > 0 ? (ips[0] ?? null) : null; } +function parseLoopbackProxyPort(proxy: string): number | null { + const trimmed = proxy.trim(); + if (/^\d+$/.test(trimmed)) { + return Number.parseInt(trimmed, 10); + } + const normalized = trimmed.includes("://") ? trimmed : `http://${trimmed}`; + try { + const parsed = new URL(normalized); + const host = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + if (!(host === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host))) { + return null; + } + const port = Number.parseInt(parsed.port, 10); + return Number.isInteger(port) && port >= 1 && port <= 65_535 ? port : null; + } catch { + return null; + } +} + +function collectServeGatewayUrls( + config: z.infer, + gatewayPort: number, + allowFunnel: Record, +): string[] { + const urls: string[] = []; + for (const [hostPort, webServer] of Object.entries(config.Web ?? {})) { + const handler = webServer.Handlers["/"]; + if ( + allowFunnel[hostPort] || + !handler?.Proxy || + parseLoopbackProxyPort(handler.Proxy) !== gatewayPort + ) { + continue; + } + try { + const endpoint = new URL(`https://${hostPort}`); + const port = endpoint.port || "443"; + if (config.TCP?.[port]?.HTTPS !== true) { + continue; + } + urls.push(`wss://${endpoint.host}`); + } catch { + continue; + } + } + return urls; +} + +function extractServeGatewayUrls(raw: string, gatewayPort: number): string[] { + const start = raw.indexOf("{"); + const end = raw.lastIndexOf("}"); + if (start === -1 || end <= start) { + return []; + } + const parsed = safeParseJsonWithSchema(TailscaleServeConfigSchema, raw.slice(start, end + 1)); + if (!parsed) { + return []; + } + // Service entries can load-balance to another node, while Funnel routes are public. + // Pairing fallbacks must stay pinned to this node and available only inside the tailnet. + return [ + ...new Set(collectServeGatewayUrls(parsed, gatewayPort, parsed.AllowFunnel ?? {})), + ].toSorted(); +} + /** Resolves the host published to clients for tailnet or Tailscale Serve gateway modes. */ export function resolveTailscalePublishedHost(params: { tailscaleMode: string; @@ -98,3 +185,30 @@ export async function resolveTailnetHostWithRunner( } return null; } + +/** Finds persistent HTTPS Serve routes whose root proxy targets this gateway port. */ +export async function resolveTailscaleServeGatewayUrlsWithRunner( + gatewayPort: number, + runCommandWithTimeout?: TailscaleStatusCommandRunner, +): Promise { + if (!runCommandWithTimeout) { + return []; + } + for (const candidate of TAILSCALE_STATUS_COMMAND_CANDIDATES) { + try { + const result = await runCommandWithTimeout([candidate, "serve", "status", "--json"], { + timeoutMs: 5000, + }); + if (result.code !== 0 || !result.stdout.trim()) { + continue; + } + const urls = extractServeGatewayUrls(result.stdout, gatewayPort); + if (urls.length > 0) { + return urls; + } + } catch { + continue; + } + } + return []; +} diff --git a/ui/src/pages/nodes/view-pairing.ts b/ui/src/pages/nodes/view-pairing.ts index dbf999f365bf..89cea520ffa2 100644 --- a/ui/src/pages/nodes/view-pairing.ts +++ b/ui/src/pages/nodes/view-pairing.ts @@ -28,6 +28,7 @@ export function renderDevicePairSetup(props: DevicePairSetupProps) { const description = t("nodes.pairing.subtitle"); const setup = props.setup; const pendingCount = props.pendingCount; + const gatewayUrls = setup?.gatewayUrls ?? (setup ? [setup.gatewayUrl] : []); return html` @@ -90,9 +91,15 @@ export function renderDevicePairSetup(props: DevicePairSetupProps) {
${setup.auth} - ${setup.gatewayUrl} +
+ ${gatewayUrls.map( + (gatewayUrl) => html` + ${gatewayUrl} + `, + )} +
diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index d64bdbec7f22..0ec14900bbb0 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -6415,6 +6415,13 @@ details[open] > .ov-expandable-toggle::after { white-space: nowrap; } +.device-pair-setup__gateways { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + .device-pair-setup__actions { display: flex; flex-wrap: wrap;