Skip to content

Commit a0646a6

Browse files
committed
feat(ios): redeem gateway setup short codes
1 parent 640258d commit a0646a6

7 files changed

Lines changed: 342 additions & 8 deletions

File tree

apps/ios/Sources/Design/SettingsProTabActions.swift

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,10 @@ extension SettingsProTab {
186186

187187
func applySetupCodeAndConnect() async {
188188
self.setupStatusText = nil
189-
guard self.applySetupCode() else { return }
189+
guard let link = await self.resolveSetupInputForConnect() else { return }
190+
self.stagedGatewaySetupLink = nil
191+
self.setupCode = ""
192+
self.applyGatewayLink(link)
190193
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
191194
guard let port = self.resolvedManualPort(host: host) else {
192195
self.setupStatusText = "Failed: invalid port"
@@ -197,6 +200,44 @@ extension SettingsProTab {
197200
await self.connectManual()
198201
}
199202

203+
func resolveSetupInputForConnect() async -> GatewayConnectDeepLink? {
204+
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
205+
if let stagedGatewaySetupLink, raw.isEmpty {
206+
return stagedGatewaySetupLink
207+
}
208+
guard !raw.isEmpty else {
209+
self.setupStatusText = "Paste a setup code to continue."
210+
return nil
211+
}
212+
if AppleReviewDemoMode.isSetupCode(raw) {
213+
self.stagedGatewaySetupLink = nil
214+
self.setupCode = ""
215+
self.setupStatusText = "Apple Review demo mode enabled."
216+
self.appModel.enterAppleReviewDemoMode()
217+
return nil
218+
}
219+
if let link = GatewayConnectDeepLink.fromSetupInput(raw) {
220+
return link
221+
}
222+
guard GatewaySetupShortCode.looksLikeShortCode(raw) else {
223+
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
224+
return nil
225+
}
226+
guard let gateway = self.shortCodeRedemptionGateway() else {
227+
self.setupStatusText = "Choose or enter a Gateway host before using a short code."
228+
return nil
229+
}
230+
self.connectingGatewayID = "manual"
231+
self.setupStatusText = "Redeeming setup short code..."
232+
defer { self.connectingGatewayID = nil }
233+
do {
234+
return try await GatewaySetupShortCodeRedeemer().redeem(raw, through: gateway)
235+
} catch {
236+
self.setupStatusText = Self.shortCodeRedeemMessage(error)
237+
return nil
238+
}
239+
}
240+
200241
func applyPendingGatewaySetupLinkIfNeeded() {
201242
guard let link = self.appModel.consumePendingGatewaySetupLink() else { return }
202243
self.setupCode = ""
@@ -260,6 +301,24 @@ extension SettingsProTab {
260301
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
261302
}
262303

304+
func shortCodeRedemptionGateway() -> GatewayConnectDeepLink? {
305+
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
306+
if !host.isEmpty, let port = self.resolvedManualPort(host: host) {
307+
let scheme = self.manualGatewayTLS ? "wss" : "ws"
308+
if let link = GatewayConnectDeepLink.fromGatewayURL("\(scheme)://\(host):\(port)") {
309+
return link
310+
}
311+
}
312+
return nil
313+
}
314+
315+
static func shortCodeRedeemMessage(_ error: Error) -> String {
316+
if let error = error as? LocalizedError, let message = error.errorDescription {
317+
return message
318+
}
319+
return "Could not redeem setup short code."
320+
}
321+
263322
func openGatewayQRScanner() {
264323
self.appModel.disconnectGateway()
265324
self.connectingGatewayID = nil

apps/ios/Sources/Design/SettingsProTabSections.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -717,7 +717,7 @@ extension SettingsProTab {
717717
VStack(alignment: .leading, spacing: 12) {
718718
Text("Setup Code")
719719
.font(.subheadline.weight(.semibold))
720-
TextField("Paste setup code", text: self.$setupCode)
720+
TextField("Paste setup or short code", text: self.$setupCode)
721721
.textInputAutocapitalization(.never)
722722
.autocorrectionDisabled()
723723
.textFieldStyle(.roundedBorder)

apps/ios/Sources/Onboarding/OnboardingWizardView.swift

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,7 @@ struct OnboardingWizardView: View {
620620
extension OnboardingWizardView {
621621
private var setupCodeSection: some View {
622622
Section {
623-
TextField("Paste setup code", text: self.$setupCode)
623+
TextField("Paste setup or short code", text: self.$setupCode)
624624
.textInputAutocapitalization(.never)
625625
.autocorrectionDisabled()
626626
.onSubmit {
@@ -652,7 +652,7 @@ extension OnboardingWizardView {
652652
} header: {
653653
Text("Setup Code")
654654
} footer: {
655-
Text("Use this if you received a setup code instead of a QR code.")
655+
Text("Use this if you received a setup code or short code instead of a QR code.")
656656
}
657657
}
658658

@@ -709,10 +709,7 @@ extension OnboardingWizardView {
709709
return
710710
}
711711

712-
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
713-
self.setupCodeStatus = "Setup code not recognized or uses an insecure ws:// gateway URL."
714-
return
715-
}
712+
guard let link = await self.resolveSetupInput(raw) else { return }
716713

717714
self.connectingGatewayID = "setup-code"
718715
self.applyGatewayLink(link)
@@ -724,6 +721,47 @@ extension OnboardingWizardView {
724721
await self.connectManual()
725722
}
726723

724+
private func resolveSetupInput(_ raw: String) async -> GatewayConnectDeepLink? {
725+
if let link = GatewayConnectDeepLink.fromSetupInput(raw) {
726+
return link
727+
}
728+
guard GatewaySetupShortCode.looksLikeShortCode(raw) else {
729+
self.setupCodeStatus = "Setup code not recognized or uses an insecure ws:// gateway URL."
730+
return nil
731+
}
732+
guard let gateway = self.shortCodeRedemptionGateway() else {
733+
self.setupCodeStatus = "Choose or enter a Gateway host before using a short code."
734+
return nil
735+
}
736+
self.connectingGatewayID = "setup-code"
737+
self.setupCodeStatus = "Redeeming setup short code..."
738+
do {
739+
return try await GatewaySetupShortCodeRedeemer().redeem(raw, through: gateway)
740+
} catch {
741+
self.connectingGatewayID = nil
742+
self.setupCodeStatus = Self.shortCodeRedeemMessage(error)
743+
return nil
744+
}
745+
}
746+
747+
private func shortCodeRedemptionGateway() -> GatewayConnectDeepLink? {
748+
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
749+
if !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 {
750+
let scheme = self.manualTLS ? "wss" : "ws"
751+
if let link = GatewayConnectDeepLink.fromGatewayURL("\(scheme)://\(host):\(self.manualPort)") {
752+
return link
753+
}
754+
}
755+
return nil
756+
}
757+
758+
private static func shortCodeRedeemMessage(_ error: Error) -> String {
759+
if let error = error as? LocalizedError, let message = error.errorDescription {
760+
return message
761+
}
762+
return "Could not redeem setup short code."
763+
}
764+
727765
private func handleScannedLink(_ link: GatewayConnectDeepLink) {
728766
self.applyGatewayLink(link)
729767
self.setupCodeStatus = nil

apps/ios/SwiftSources.input.xcfilelist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ WatchApp/Sources/WatchInboxView.swift
142142
../shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift
143143
../shared/OpenClawKit/Sources/OpenClawKit/OpenClawKitResources.swift
144144
../shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift
145+
../shared/OpenClawKit/Sources/OpenClawKit/GatewaySetupShortCodeRedeemer.swift
145146
../shared/OpenClawKit/Sources/OpenClawKit/JPEGTranscoder.swift
146147
../shared/OpenClawKit/Sources/OpenClawKit/NodeError.swift
147148
../shared/OpenClawKit/Sources/OpenClawKit/ScreenCommands.swift

apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,20 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
6565
password: nil)
6666
}
6767

68+
/// Parse a gateway WebSocket/HTTP URL with optional auth fields from a setup source.
69+
public static func fromGatewayURL(
70+
_ urlString: String,
71+
bootstrapToken: String? = nil,
72+
token: String? = nil,
73+
password: String? = nil) -> GatewayConnectDeepLink?
74+
{
75+
self.fromGatewayURLString(
76+
urlString,
77+
bootstrapToken: bootstrapToken,
78+
token: token,
79+
password: password)
80+
}
81+
6882
/// Parse a gateway setup payload from a device-pair setup code or copied setup text.
6983
///
7084
/// Accepted inputs are:
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import Foundation
2+
3+
public protocol GatewaySetupShortCodeHTTPClient: Sendable {
4+
func data(for request: URLRequest) async throws -> (Data, URLResponse)
5+
}
6+
7+
extension URLSession: GatewaySetupShortCodeHTTPClient {}
8+
9+
public enum GatewaySetupShortCode {
10+
private static let alphabet = Set("ABCDEFGHJKLMNPQRSTUVWXYZ23456789")
11+
private static let codeLength = 8
12+
13+
public static func normalize(_ value: String) -> String? {
14+
let normalized = value
15+
.uppercased()
16+
.replacingOccurrences(of: #"\s+"#, with: "", options: .regularExpression)
17+
.replacingOccurrences(of: "-", with: "")
18+
guard normalized.count == self.codeLength else { return nil }
19+
guard normalized.allSatisfy({ self.alphabet.contains($0) }) else { return nil }
20+
return normalized
21+
}
22+
23+
public static func looksLikeShortCode(_ value: String) -> Bool {
24+
self.normalize(value) != nil
25+
}
26+
}
27+
28+
public struct GatewaySetupShortCodeRedeemer: Sendable {
29+
public enum RedeemError: Error, Equatable, LocalizedError {
30+
case invalidCode
31+
case invalidGateway
32+
case invalidResponse
33+
case invalidOrExpired
34+
case rejected(statusCode: Int)
35+
36+
public var errorDescription: String? {
37+
switch self {
38+
case .invalidCode:
39+
"Setup short code is not valid."
40+
case .invalidGateway:
41+
"Choose a reachable Gateway before using a setup short code."
42+
case .invalidResponse:
43+
"Gateway returned an invalid setup response."
44+
case .invalidOrExpired:
45+
"Setup short code is invalid or expired."
46+
case let .rejected(statusCode):
47+
"Gateway rejected the setup short code (HTTP \(statusCode))."
48+
}
49+
}
50+
}
51+
52+
private struct RedeemRequest: Encodable {
53+
let code: String
54+
}
55+
56+
private struct RedeemResponse: Decodable {
57+
let ok: Bool
58+
let payload: SetupPayload?
59+
}
60+
61+
private struct SetupPayload: Decodable {
62+
let url: String
63+
let bootstrapToken: String
64+
let token: String?
65+
let password: String?
66+
}
67+
68+
public let client: GatewaySetupShortCodeHTTPClient
69+
70+
public init(client: GatewaySetupShortCodeHTTPClient = URLSession.shared) {
71+
self.client = client
72+
}
73+
74+
public func redeem(
75+
_ rawCode: String,
76+
through gateway: GatewayConnectDeepLink) async throws -> GatewayConnectDeepLink
77+
{
78+
guard let code = GatewaySetupShortCode.normalize(rawCode) else {
79+
throw RedeemError.invalidCode
80+
}
81+
guard let url = Self.redemptionURL(for: gateway) else {
82+
throw RedeemError.invalidGateway
83+
}
84+
var request = URLRequest(url: url)
85+
request.httpMethod = "POST"
86+
request.setValue("application/json", forHTTPHeaderField: "content-type")
87+
request.httpBody = try JSONEncoder().encode(RedeemRequest(code: code))
88+
89+
let (data, response) = try await self.client.data(for: request)
90+
guard let http = response as? HTTPURLResponse else {
91+
throw RedeemError.invalidResponse
92+
}
93+
if http.statusCode == 404 || http.statusCode == 400 {
94+
throw RedeemError.invalidOrExpired
95+
}
96+
guard (200..<300).contains(http.statusCode) else {
97+
throw RedeemError.rejected(statusCode: http.statusCode)
98+
}
99+
let decoded = try JSONDecoder().decode(RedeemResponse.self, from: data)
100+
guard decoded.ok, let payload = decoded.payload else {
101+
throw RedeemError.invalidResponse
102+
}
103+
guard let link = GatewayConnectDeepLink.fromGatewayURL(
104+
payload.url,
105+
bootstrapToken: payload.bootstrapToken,
106+
token: payload.token,
107+
password: payload.password)
108+
else {
109+
throw RedeemError.invalidResponse
110+
}
111+
return link
112+
}
113+
114+
public static func redemptionURL(for gateway: GatewayConnectDeepLink) -> URL? {
115+
guard let websocketURL = gateway.websocketURL,
116+
GatewayConnectDeepLink.fromGatewayURL(websocketURL.absoluteString) != nil,
117+
var components = URLComponents(url: websocketURL, resolvingAgainstBaseURL: false)
118+
else {
119+
return nil
120+
}
121+
switch components.scheme?.lowercased() {
122+
case "ws":
123+
components.scheme = "http"
124+
case "wss":
125+
components.scheme = "https"
126+
default:
127+
return nil
128+
}
129+
components.path = "/api/v1/pairing/setup-code/redeem"
130+
components.query = nil
131+
components.fragment = nil
132+
return components.url
133+
}
134+
}

0 commit comments

Comments
 (0)