mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-12 18:56:02 +00:00
fix(pairing): advertise reachable Tailnet routes (#100317)
* fix(pairing): advertise reachable tailnet routes * fix(pairing): satisfy native and SDK checks * fix(ios): cancel stale pairing route probes * test(pairing): document LAN-only Serve fallback * fix(ios): preserve pairing result initializer
This commit is contained in:
committed by
GitHub
parent
17c0ad86cb
commit
862de9f1a1
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String>()
|
||||
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?
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,7 +36,7 @@ openclaw devices approve <requestId>
|
||||
- `--password <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`)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
resolveGatewayBindUrl,
|
||||
resolveGatewayPort,
|
||||
resolveTailnetHostWithRunner,
|
||||
resolveTailscaleServeGatewayUrlsWithRunner,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export { resolveAdvertisedLanHost } from "openclaw/plugin-sdk/gateway-runtime";
|
||||
export {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<ResolveUrlResu
|
||||
pickTailnetHost: pickTailnetIPv4,
|
||||
pickLanHost: () => 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<R
|
||||
if (mobilePairingUrlError) {
|
||||
return { error: mobilePairingUrlError };
|
||||
}
|
||||
return result;
|
||||
const urls = result.urls?.filter(
|
||||
(url) => !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<SetupPayload> {
|
||||
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<SetupPayload> {
|
||||
const { issueDeviceBootstrapToken, PAIRING_SETUP_BOOTSTRAP_PROFILE } =
|
||||
await loadDevicePairApiModule();
|
||||
const issuedBootstrap = await issueDeviceBootstrapToken({
|
||||
@@ -586,6 +612,7 @@ async function issueSetupPayload(url: string): Promise<SetupPayload> {
|
||||
});
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]],
|
||||
];
|
||||
|
||||
@@ -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}`,
|
||||
"",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof TailscaleStatusSchema> | 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<typeof TailscaleServeServiceSchema>,
|
||||
gatewayPort: number,
|
||||
allowFunnel: Record<string, boolean>,
|
||||
): 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<string[]> {
|
||||
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 [];
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
<openclaw-modal-dialog label=${title} description=${description} @modal-cancel=${props.onClose}>
|
||||
@@ -90,9 +91,15 @@ export function renderDevicePairSetup(props: DevicePairSetupProps) {
|
||||
|
||||
<div class="device-pair-setup__meta">
|
||||
<span class="pill">${setup.auth}</span>
|
||||
<span class="device-pair-setup__gateway" title=${setup.gatewayUrl}
|
||||
>${setup.gatewayUrl}</span
|
||||
>
|
||||
<div class="device-pair-setup__gateways">
|
||||
${gatewayUrls.map(
|
||||
(gatewayUrl) => html`
|
||||
<span class="device-pair-setup__gateway" title=${gatewayUrl}
|
||||
>${gatewayUrl}</span
|
||||
>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="device-pair-setup__actions">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user