From 62c2276d9397c0456414354f9fabf706373c6819 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 08:18:12 +0200 Subject: [PATCH] fix(ios): pair from one iPhone with a code, link or QR picture Signed-off-by: Fredrik Ahlgren --- README.md | 7 +- appleApp/FTW-Info.plist | 7 ++ .../FTW/Intents/en.lproj/InfoPlist.strings | 1 + .../FTW/Intents/sv.lproj/InfoPlist.strings | 1 + appleApp/FTW/UI/PairView.swift | 59 +++++++-- .../Sources/FTWKit/Identity/Enrollment.swift | 3 +- .../FTWKit/Identity/LocalPairing.swift | 115 ++++++++++++++++++ .../FTWKit/Identity/PairingImage.swift | 5 +- .../Sources/FTWKit/State/NodeWords.swift | 16 +++ .../Sources/FTWKit/State/PairModel.swift | 48 +++++++- .../Tests/FTWKitTests/LocalPairingTests.swift | 83 +++++++++++++ appleApp/FTWUITests/PairingTests.swift | 49 ++++++++ 12 files changed, 375 insertions(+), 19 deletions(-) create mode 100644 appleApp/FTW/Intents/en.lproj/InfoPlist.strings create mode 100644 appleApp/FTW/Intents/sv.lproj/InfoPlist.strings create mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/LocalPairing.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/LocalPairingTests.swift create mode 100644 appleApp/FTWUITests/PairingTests.swift diff --git a/README.md b/README.md index a8bebf01..84c78f14 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,11 @@ iOS 26 is the minimum. iPhone only. - Pair: camera QR, a link from outside shown before it is trusted (also a pairing QR scanned with the iOS camera, as a universal link), and the live - demo against a simulated box. In the simulator, which has no camera, a - picture of the QR from Photos takes the same path. + demo against a simulated box. A QR picture from Photos works on an iPhone + too. Enter pairing code accepts a full pairing link, or a short code and + the box's local address. The short-code path needs + [Core #1721](https://github.com/srcfl/ftw/pull/1721) and the home Wi-Fi; + it asks for the house password when the box requires it. - Now: one sentence, the energy flow, the price card with the cheapest two hours, today's totals and the fuse. The freshness band above it keeps carrier and source state apart. diff --git a/appleApp/FTW-Info.plist b/appleApp/FTW-Info.plist index e13f671f..7b320149 100644 --- a/appleApp/FTW-Info.plist +++ b/appleApp/FTW-Info.plist @@ -2,6 +2,13 @@ + NSLocalNetworkUsageDescription + FTW connects to your box on your home network to exchange the pairing code. + NSAppTransportSecurity + + NSAllowsLocalNetworking + + FTWAppGroup $(FTW_APP_GROUP) BGTaskSchedulerPermittedIdentifiers diff --git a/appleApp/FTW/Intents/en.lproj/InfoPlist.strings b/appleApp/FTW/Intents/en.lproj/InfoPlist.strings new file mode 100644 index 00000000..7748cb15 --- /dev/null +++ b/appleApp/FTW/Intents/en.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"NSLocalNetworkUsageDescription" = "FTW connects to your box on your home network to exchange the pairing code."; diff --git a/appleApp/FTW/Intents/sv.lproj/InfoPlist.strings b/appleApp/FTW/Intents/sv.lproj/InfoPlist.strings new file mode 100644 index 00000000..3bbaa999 --- /dev/null +++ b/appleApp/FTW/Intents/sv.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"NSLocalNetworkUsageDescription" = "FTW ansluter till din box på hemnätet för att byta in parkoden."; diff --git a/appleApp/FTW/UI/PairView.swift b/appleApp/FTW/UI/PairView.swift index 560497ce..ecf45a10 100644 --- a/appleApp/FTW/UI/PairView.swift +++ b/appleApp/FTW/UI/PairView.swift @@ -1,8 +1,6 @@ import FTWKit import SwiftUI -#if targetEnvironment(simulator) import PhotosUI -#endif /// Pairing, the first thing anyone sees. Scan, then Face ID. Everything that /// can fail does so before the passkey prompt. @@ -13,9 +11,7 @@ import PhotosUI struct PairView: View { let app: AppModel @State private var model: PairModel - #if targetEnvironment(simulator) @State private var picture: PhotosPickerItem? - #endif init(app: AppModel) { self.app = app @@ -55,6 +51,7 @@ struct PairView: View { private var title: String { switch model.stage { case .scanning: return L("Scan the pairing QR") + case .enteringCode: return L("Enter pairing code") case .pairing: return L("Securing this phone") case .demoing: return L("Starting the demo") case .intro: @@ -74,6 +71,41 @@ struct PairView: View { .overlay(RoundedRectangle(cornerRadius: 24).strokeBorder(Theme.accentFill, lineWidth: 3).padding(60)) Hint(L("In your box's local dashboard, open Settings → FTW app. Hold the pairing QR inside the frame.")) + case .enteringCode: + Text(L("Stay on your home Wi-Fi.")).font(.callout) + Text(L("Box address")).font(.caption).foregroundStyle(Theme.ink2) + TextField(L("Box address"), text: $model.boxAddress) + .textFieldStyle(.roundedBorder) + .textContentType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .accessibilityIdentifier("pair.address") + Text(L("Pairing code")).font(.caption).foregroundStyle(Theme.ink2) + TextField(L("Pairing code"), text: $model.code) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .accessibilityIdentifier("pair.code") + PasteButton(payloadType: String.self) { values in + if let value = values.first { model.code = value } + } + if model.needsPassword { + SecureField(L("House password"), text: $model.password) + .textContentType(.password) + .accessibilityIdentifier("pair.password") + } + if let message = model.message { Problem(message) } + if model.codeBusy { ProgressView(L("Connecting to your box")) } + DisclosureGroup(L("How?")) { + VStack(alignment: .leading, spacing: 8) { + Text(L("On this iPhone, open your box in Safari. In Settings → FTW app, choose Use a pairing code. Return here and enter it while on your home Wi-Fi.")) + Hint(L("Copying the code in Safari includes the box address. You can also paste a full pairing link here.")) + } + .font(.callout) + .padding(.top, 6) + } + case .pairing: Text(L("Confirm with Face ID or Touch ID if your phone asks.")) ProgressView() @@ -95,6 +127,10 @@ struct PairView: View { /// quiet way out. @ViewBuilder private var actions: some View { switch model.stage { + case .enteringCode: + prominent(L("Connect")) { Task { await model.connectCode() } } + .disabled(model.codeBusy || model.code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Button(L("Cancel")) { model.cancel() }.buttonStyle(.quiet) case .scanning, .pairing: Button(L("Cancel")) { model.cancel() } .buttonStyle(.glass) @@ -110,8 +146,12 @@ struct PairView: View { } else if model.canOpen, let known = model.known { prominent(NodeWords.format("Open %@", known.label)) { model.openKnown() } Button(L("Scan a new pairing QR")) { model.stage = .scanning }.buttonStyle(.quiet) + Button(L("Enter pairing code")) { model.enterCode() }.buttonStyle(.quiet) } else { prominent(L("Scan the pairing QR")) { model.stage = .scanning } + Button(L("Enter pairing code")) { model.enterCode() } + .buttonStyle(.quiet) + .accessibilityIdentifier("pair.enterCode") if canDismiss { Button(L("Not now")) { model.dismiss() }.buttonStyle(.quiet) } @@ -189,7 +229,7 @@ struct PairView: View { private var setup: some View { Card { Text(L("Connect your own box")).font(.title3.weight(.semibold)) - Text(L("At home, open your box's FTW dashboard, then Settings → FTW app → Show pairing code.")) + Text(L("Have only this iPhone? Open the box in Safari, then Settings → FTW app. Open the pairing link in FTW or use a pairing code.")) .font(.callout) DisclosureGroup(L("How?")) { VStack(alignment: .leading, spacing: 6) { @@ -198,25 +238,21 @@ struct PairView: View { Text(L("3. Choose Show pairing code, then scan the QR here.")) Hint(L("The QR code is inside FTW Settings. It is not printed on the Raspberry Pi or its case.")) Hint(L("Next, your phone asks for Face ID or Touch ID to protect your FTW key. There is no FTW account or password. If this phone loses its key, a new Settings QR is the way back.")) - Hint(L("Can't see Show pairing code? Turn on Let the FTW app connect to this box, save, and restart the box first.")) + Hint(L("Can't see Show pairing code? Turn on Let the FTW app connect to this box, then choose Save and restart FTW in the dashboard.")) } .font(.callout) .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 6) } .font(.footnote.weight(.medium)) - #if targetEnvironment(simulator) - // The simulator has no camera. A screenshot of the QR in Photos - // takes the same path as a scan. PhotosPicker(L("Choose a picture of the QR"), selection: $picture, matching: .images) .buttonStyle(.quiet) + .accessibilityIdentifier("pair.picture") .onChange(of: picture) { _, item in read(item) } - #endif } } } -#if targetEnvironment(simulator) extension PairView { private func read(_ item: PhotosPickerItem?) { guard let item else { return } @@ -231,7 +267,6 @@ extension PairView { } } } -#endif struct Title: View { let text: String diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Enrollment.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Enrollment.swift index 73427f96..9d8c01a7 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/Enrollment.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Enrollment.swift @@ -11,7 +11,8 @@ import Foundation /// service"). Both are read. /// /// Everything after `#` is a fragment and never sent in a request, so the -/// trust anchor reaches the phone optically and reaches no server on the way. +/// trust anchor reaches the phone from the box: a QR, a link, a paste, or a +/// local code exchange. It reaches no cloud server on the way. public struct Enrollment: Equatable, Sendable { /// The box's Noise static public key. The trust anchor. public var boxStaticPublic: Bytes diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/LocalPairing.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/LocalPairing.swift new file mode 100644 index 00000000..dd0bf9dc --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/LocalPairing.swift @@ -0,0 +1,115 @@ +import Foundation +import Network + +/// A short code is exchanged directly with the box on the home network. +/// Its full enrollment link then follows the same path as a camera scan. +public struct LocalPairing: Sendable { + public typealias Send = @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse) + private let send: Send + + public init(send: @escaping Send = LocalPairing.request) { self.send = send } + + public func redeem(address: String, code: String, password: String = "") async throws -> String { + let input = try Self.input(address: address, code: code) + var request = URLRequest(url: input.url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if !password.isEmpty { request.setValue("Bearer \(password)", forHTTPHeaderField: "Authorization") } + request.httpBody = try JSONEncoder().encode(Claim(kind: "claim", code: input.code)) + let (data, response): (Data, HTTPURLResponse) + do { (data, response) = try await send(request) } + catch is CancellationError { throw CancellationError() } + catch { throw LocalPairingError.unreachable } + if response.statusCode == 401 { throw LocalPairingError.password } + guard response.statusCode == 200 else { + throw response.statusCode == 400 ? LocalPairingError.code : LocalPairingError.unavailable + } + guard data.count < 8_192, let reply = try? JSONDecoder().decode(Reply.self, from: data), + (try? Enrollment.parse(scanned: reply.url)) != nil else { throw LocalPairingError.unavailable } + return reply.url + } + + /// Copying the code on the box includes its address as a fragment. The + /// code never becomes a URL path or query, including while it is pasted. + static func input(address: String, code: String) throws -> (url: URL, code: String) { + var host = address.trimmingCharacters(in: .whitespacesAndNewlines) + var typed = code.trimmingCharacters(in: .whitespacesAndNewlines) + if let pasted = URLComponents(string: typed), pasted.scheme != nil, let fragment = pasted.fragment { + var box = pasted + box.fragment = nil + host = box.string ?? "" + typed = fragment + } + let canonical = typed.uppercased().filter { !$0.isWhitespace && $0 != "-" } + .replacingOccurrences(of: "O", with: "0") + .replacingOccurrences(of: "I", with: "1") + .replacingOccurrences(of: "L", with: "1") + guard canonical.count == 8, canonical.allSatisfy({ "0123456789ABCDEFGHJKMNPQRSTVWXYZ".contains($0) }) else { + throw LocalPairingError.code + } + if !host.contains("://") { host = "http://" + host } + guard var target = URLComponents(string: host), + ["http", "https"].contains(target.scheme?.lowercased() ?? ""), + let hostname = target.host, Self.localHost(hostname), + target.user == nil, target.password == nil, target.query == nil, target.fragment == nil, + target.path.isEmpty || target.path == "/", + target.port == nil || (1...65535).contains(target.port!) else { throw LocalPairingError.address } + target.path = "/api/app-link/pairing" + guard let url = target.url else { throw LocalPairingError.address } + return (url, canonical) + } + + private static func localHost(_ host: String) -> Bool { + let name = host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + if name == "localhost" || name.hasSuffix(".local") || name.hasSuffix(".localhost") || name.hasSuffix(".home.arpa") { return true } + if let address = IPv4Address(name) { + let b = Array(address.rawValue) + return b[0] == 10 || b[0] == 127 || (b[0] == 172 && (16...31).contains(b[1])) || + (b[0] == 192 && b[1] == 168) || (b[0] == 169 && b[1] == 254) + } + if let address = IPv6Address(name) { + let b = Array(address.rawValue) + return address == .loopback || b[0] & 0xfe == 0xfc || (b[0] == 0xfe && b[1] & 0xc0 == 0x80) + } + return false + } + + public static func request(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let configuration = URLSessionConfiguration.ephemeral + configuration.urlCache = nil + configuration.httpCookieStorage = nil + configuration.urlCredentialStorage = nil + configuration.waitsForConnectivity = true + configuration.timeoutIntervalForResource = 30 + let session = URLSession(configuration: configuration, delegate: NoPairingRedirect(), delegateQueue: nil) + defer { session.invalidateAndCancel() } + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw LocalPairingError.unavailable } + return (data, http) + } + + private struct Claim: Encodable { let kind: String; let code: String } + private struct Reply: Decodable { let url: String } +} + +private final class NoPairingRedirect: NSObject, URLSessionTaskDelegate { + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) { + completionHandler(nil) + } +} + +public enum LocalPairingError: Error, HelpfulError, Equatable { + case address, code, password, unreachable, unavailable + + public var help: String { + let english = switch self { + case .address: "Enter the box address shown beside the pairing code, for example ftw.local:8080." + case .code: "That code has expired, was used or is incorrect. Make a new pairing code in Settings → FTW app." + case .password: "Enter the house password you use in the box's dashboard." + case .unreachable: "Could not reach the box. Stay on your home Wi-Fi, check its address and allow Local Network access for FTW in iPhone Settings." + case .unavailable: "Could not pair with a code. Update the box, make a new code and try again, or use a picture of its QR." + } + return NodeWords.text(english) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/PairingImage.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/PairingImage.swift index 997df3d9..875dce72 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/PairingImage.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/PairingImage.swift @@ -1,9 +1,8 @@ import CoreImage import Foundation -/// A pairing link read off a picture of the QR. The simulator has no camera, -/// so this is how pairing is tried there. The link then takes the same path -/// as one the camera read. +/// A pairing link read from a picture or screenshot of the QR. It takes the +/// same path as a camera scan, including on a phone with no second screen. public enum PairingImage { /// The first QR in the picture that `Enrollment` accepts, or nil. public static func link(in imageData: Data) -> String? { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index d8491610..1c2a0394 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -277,6 +277,22 @@ public enum NodeWords { "Get this phone back in": "Få in telefonen igen", "Welcome back": "Välkommen tillbaka", "Connect FTW": "Anslut FTW", + "Can't see Show pairing code? Turn on Let the FTW app connect to this box, then choose Save and restart FTW in the dashboard.": "Ser du inte Show pairing code? Slå på Let the FTW app connect to this box och välj sedan Save and restart FTW i webbgränssnittet.", + "Enter pairing code": "Ange parkod", + "Connect": "Anslut", + "Stay on your home Wi-Fi.": "Anslut till hemmets wifi.", + "Box address": "Boxens adress", + "Pairing code": "Parkod", + "House password": "Huslösenord", + "Connecting to your box": "Ansluter till boxen", + "On this iPhone, open your box in Safari. In Settings → FTW app, choose Use a pairing code. Return here and enter it while on your home Wi-Fi.": "Öppna boxen i Safari på denna iPhone. Välj Use a pairing code under Settings → FTW app. Gå tillbaka hit och ange koden medan du är på hemmets wifi.", + "Copying the code in Safari includes the box address. You can also paste a full pairing link here.": "När du kopierar koden i Safari följer boxens adress med. Du kan också klistra in en hel parkopplingslänk här.", + "Have only this iPhone? Open the box in Safari, then Settings → FTW app. Open the pairing link in FTW or use a pairing code.": "Har du bara denna iPhone? Öppna boxen i Safari och välj Settings → FTW app. Öppna parkopplingslänken i FTW eller använd en parkod.", + "Enter the box address shown beside the pairing code, for example ftw.local:8080.": "Ange boxens adress som står vid parkoden, till exempel ftw.local:8080.", + "That code has expired, was used or is incorrect. Make a new pairing code in Settings → FTW app.": "Koden har gått ut, har använts eller stämmer inte. Skapa en ny parkod under Settings → FTW app.", + "Enter the house password you use in the box's dashboard.": "Ange det huslösenord du använder i boxens webbgränssnitt.", + "Could not reach the box. Stay on your home Wi-Fi, check its address and allow Local Network access for FTW in iPhone Settings.": "Kunde inte nå boxen. Anslut till hemmets wifi, kontrollera adressen och tillåt Lokalt nätverk för FTW i iPhones inställningar.", + "Could not pair with a code. Update the box, make a new code and try again, or use a picture of its QR.": "Kunde inte parkoppla med kod. Uppdatera boxen, skapa en ny kod och försök igen, eller använd en bild av QR-koden.", "In your box's local dashboard, open Settings → FTW app. Hold the pairing QR inside the frame.": "Öppna Settings → FTW app i boxens lokala dashboard. Håll QR-koden inom ramen.", "Confirm with Face ID or Touch ID if your phone asks.": "Bekräfta med Face ID eller Touch ID om telefonen frågar.", "Loading a simulated home.": "Laddar ett simulerat hem.", diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift index 77d92cae..6f08197f 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift @@ -7,7 +7,7 @@ import Observation @MainActor public final class PairModel { public enum Stage: Equatable, Sendable { - case intro, scanning, pairing, demoing + case intro, scanning, enteringCode, pairing, demoing } public var stage: Stage = .intro @@ -19,8 +19,14 @@ public final class PairModel { /// This phone already holds a key and a home: something local went /// missing, not a stranger arriving. public private(set) var known: StoredSite? + public var boxAddress = "ftw.local:8080" + public var code = "" + public var password = "" + public private(set) var needsPassword = false + public private(set) var codeBusy = false @ObservationIgnored private unowned let app: AppModel + @ObservationIgnored private var codeAttempt = 0 public init(app: AppModel) { self.app = app @@ -82,6 +88,43 @@ public final class PairModel { await pair(link) } + public func enterCode() { + message = nil + stage = .enteringCode + } + + public func connectCode(using local: LocalPairing = LocalPairing()) async { + guard !codeBusy else { return } + codeAttempt += 1 + let attempt = codeAttempt + codeBusy = true + defer { + if attempt == codeAttempt { + codeBusy = false + password = "" + } + } + message = nil + do { + let link: String + if (try? Enrollment.parse(scanned: code)) != nil { + link = code + } else { + link = try await local.redeem(address: boxAddress, code: code, password: password) + } + guard attempt == codeAttempt, !Task.isCancelled else { return } + codeBusy = false + password = "" + await pair(link) + } catch { + guard attempt == codeAttempt, !Task.isCancelled else { return } + codeBusy = false + password = "" + needsPassword = (error as? LocalPairingError) == .password + message = (error as? HelpfulError)?.help ?? LocalPairingError.unreachable.help + } + } + public func declineOffer() { app.offeredLink = nil offeredFingerprint = nil @@ -104,6 +147,9 @@ public final class PairModel { } public func cancel() { + codeAttempt += 1 + codeBusy = false + password = "" stage = .intro message = nil } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/LocalPairingTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/LocalPairingTests.swift new file mode 100644 index 00000000..3f5eda74 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/LocalPairingTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing +@testable import FTWKit + +@Suite struct LocalPairingTests { + @Test func acceptsAPastedCodeWithTheBoxAddress() throws { + let input = try LocalPairing.input(address: "wrong.local", code: "http://192.168.1.42:8080#k7m2-9qrt") + #expect(input.url.absoluteString == "http://192.168.1.42:8080/api/app-link/pairing") + #expect(input.code == "K7M29QRT") + #expect(try LocalPairing.input(address: "ftw.local:8080", code: "0000-OOIL").code == "00000011") + #expect(try LocalPairing.input(address: "http://[fd00::1]:8080", code: "K7M2-9QRT").url.absoluteString == "http://[fd00::1]:8080/api/app-link/pairing") + } + + @Test func refusesPublicTargetsAndCredentialsInAddresses() { + for address in ["https://example.com", "http://8.8.8.8", "http://[2606:4700::1111]", "http://user:pass@ftw.local", "http://ftw.local/path", "http://ftw.local?code=secret", "ftp://ftw.local", "http://ftw.local:0"] { + #expect(throws: LocalPairingError.address) { try LocalPairing.input(address: address, code: "K7M2-9QRT") } + } + #expect(throws: LocalPairingError.code) { try LocalPairing.input(address: "ftw.local", code: "1234") } + } + + @Test func postsTheCodeLocallyThenReadsTheUnchangedV3Payload() async throws { + // The Go-generated v3 vector also used by EnrollmentTests. + let link = EnrollmentTests.v3 + let client = LocalPairing { request in + #expect(request.url?.absoluteString == "http://ftw.local:8080/api/app-link/pairing") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer house-secret") + let body = try JSONSerialization.jsonObject(with: #require(request.httpBody)) as? [String: String] + #expect(body == ["kind": "claim", "code": "K7M29QRT"]) + let data = try JSONSerialization.data(withJSONObject: ["url": link]) + return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + #expect(try await client.redeem(address: "ftw.local:8080", code: "K7M2-9QRT", password: "house-secret") == link) + } + + @Test func explainsARequiredPasswordAndARejectedOrExpiredCode() async { + for (status, error) in [(401, LocalPairingError.password), (400, .code), (403, .unavailable), (302, .unavailable)] { + let client = LocalPairing { request in + (Data(), HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!) + } + await #expect(throws: error) { try await client.redeem(address: "ftw.local", code: "K7M2-9QRT") } + } + } +} + +@MainActor +@Suite struct LocalPairingFlowTests { + @Test func aNewPhoneOpensItsHomeFromATypedCode() async throws { + let world = ShellTests.World() + let app = world.app() + let model = PairModel(app: app) + let link = world.pairingURL + let client = LocalPairing { request in + let data = try JSONSerialization.data(withJSONObject: ["url": link]) + return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + model.enterCode() + model.code = "K7M2-9QRT" + let task = Task { await model.connectCode(using: client) } + await world.run(1_000) + await task.value + #expect(app.site?.session.phase == .streaming) + #expect(app.sites.current()?.boxStaticKey == Data(world.endpoint.staticKey.publicKey)) + #expect(model.message == nil) + } + + @Test func aFailedCodeKeepsTheFormAndNeverStoresAHome() async { + let world = ShellTests.World() + let app = world.app() + let model = PairModel(app: app) + model.enterCode() + model.code = "K7M2-9QRT" + let client = LocalPairing { request in + (Data(), HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)!) + } + await model.connectCode(using: client) + #expect(model.stage == .enteringCode && model.needsPassword) + #expect(!model.codeBusy && app.sites.all().isEmpty) + model.password = "house-secret" + model.cancel() + #expect(model.password.isEmpty && model.stage == .intro) + } +} diff --git a/appleApp/FTWUITests/PairingTests.swift b/appleApp/FTWUITests/PairingTests.swift new file mode 100644 index 00000000..39e2d1cf --- /dev/null +++ b/appleApp/FTWUITests/PairingTests.swift @@ -0,0 +1,49 @@ +import XCTest + +final class PairingTests: XCTestCase { + @MainActor + func testOneIPhoneHasACodeAndAPictureWithoutTheCamera() throws { + try checkPairing(appearance: "Light", text: "UICTContentSizeCategoryL") + } + + @MainActor + func testPairingCodeInDarkMode() throws { + try checkPairing(appearance: "Dark", text: "UICTContentSizeCategoryL") + } + + @MainActor + func testPairingCodeAtTheLargestTextSize() throws { + try checkPairing(appearance: "Light", text: "UICTContentSizeCategoryAccessibilityXXXL") + } + + @MainActor + private func checkPairing(appearance: String, text: String) throws { + let originalAppearance = XCUIDevice.shared.appearance + XCUIDevice.shared.appearance = appearance == "Dark" ? .dark : .light + defer { XCUIDevice.shared.appearance = originalAppearance } + let app = XCUIApplication() + app.launchArguments = ["-AppleLanguages", "(sv)", "-AppleLocale", "sv_SE", + "-UIPreferredContentSizeCategoryName", text] + app.launch() + let enter = app.buttons["pair.enterCode"] + guard enter.waitForExistence(timeout: 10) else { throw XCTSkip("This simulator already holds a paired home.") } + XCTAssertTrue(enter.isHittable) + XCTAssertTrue(app.buttons["pair.picture"].exists) + enter.tap() + XCTAssertTrue(app.navigationBars["Ange parkod"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.textFields["pair.address"].exists) + let code = app.textFields["pair.code"] + XCTAssertTrue(code.exists) + XCTAssertFalse(app.buttons["pair.primary"].isEnabled) + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "pair-code-swedish-\(appearance)-\(text)" + screenshot.lifetime = .keepAlways + add(screenshot) + code.tap() + code.typeText("1234") + app.buttons["pair.primary"].tap() + XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS 'Koden har gått ut'")).firstMatch.waitForExistence(timeout: 3)) + app.buttons["Avbryt"].tap() + XCTAssertTrue(app.navigationBars["Anslut FTW"].exists) + } +}