From 803e19b92dd792198edd5d905043d86be8b2fc3d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 10:27:20 +0200 Subject: [PATCH 1/2] iPhone: a consent link or QR code goes straight to review, no code to type A link from FTW Cloud's consent page, tapped or opened by the iPhone Camera from the page's QR code, now goes straight to the review screen. The owner types the code only when they have neither. Fredrik chose this on 17 October 2026 over keeping the code for links. The checks stay: the offer is opened against the pinned keys at once and refused when unsigned, expired, for another home or support from anyone but Sourceful. Approving checks the signed offer again, behind Face ID, and an unknown client still needs the owner's "I started this" first. The review shows the page's code for an owner who wants to compare. The pairing scanner reads a consent QR as its link: the saved home opens with the offer, or, with no home, it says to connect the box first. A debug build's -FTWOfferKeys now also pins in the demo, so a link signed elsewhere opens in the simulator. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Access/OfferFlowView.swift | 20 +++--- appleApp/FTW/FTWApp.swift | 12 +++- appleApp/FTW/Platform/QRScanner.swift | 5 +- .../Sources/FTWKit/Access/AccessWords.swift | 1 + .../Sources/FTWKit/Access/OfferFlow.swift | 57 ++++++++--------- .../Sources/FTWKit/State/NodeWords.swift | 1 + .../Sources/FTWKit/State/PairModel.swift | 11 ++++ .../Tests/FTWKitTests/AccessFlowTests.swift | 19 ++++++ .../Tests/FTWKitTests/AppModelTests.swift | 31 ++++++++++ .../Tests/FTWKitTests/LiveOfferTests.swift | 10 +-- .../FTWKit/Tests/FTWKitTests/OfferTests.swift | 62 ++++++++++++------- appleApp/FTWUITests/AccessE2E.swift | 19 +++--- 12 files changed, 168 insertions(+), 80 deletions(-) diff --git a/appleApp/FTW/Access/OfferFlowView.swift b/appleApp/FTW/Access/OfferFlowView.swift index 16915c9b..a155aed9 100644 --- a/appleApp/FTW/Access/OfferFlowView.swift +++ b/appleApp/FTW/Access/OfferFlowView.swift @@ -1,10 +1,10 @@ import FTWKit import SwiftUI -/// An assistant's offer, from its consent page to Face ID. The code the -/// page shows first, so a link someone else sent stops here; then who is -/// asking, the host before the name it gives itself, what it may read, -/// control as its own switch, off, and for how long. +/// An assistant's offer, from its consent page to Face ID. A link or the +/// page's QR code opens it at once; with neither, the owner types the code +/// first. Then who is asking, the host before the name it gives itself, +/// what it may read, control as its own switch, off, and for how long. /// The steps of one offer, pushed over the home for a link or from Access /// for a typed code. A page, never a sheet: FTW Cloud's first screen or /// Settings may be up when a link arrives, and a page waits under them. @@ -124,6 +124,12 @@ struct OfferReview: View { } } .padding(.vertical, 4) + if let code = flow.pageCode { + LabeledContent(AccessWords.text("Code on the page")) { + Text(code).font(Theme.mono(.body, weight: .semibold)).foregroundStyle(Theme.ink) + } + .accessibilityIdentifier("offer.pageCode") + } } footer: { if !offer.verified { Text(AccessWords.text("FTW Cloud doesn't know this client. Check that the address is the one you expect.")) @@ -191,9 +197,9 @@ struct OfferReview: View { } /// The home the grant is for. With several, the owner picks one: that -/// home opens and the link starts again there, from the code, so nothing -/// checked at one home is taken to another. An offer that names its home, -/// or one found by a code, which FTW Cloud gives once, stays where it is. +/// home opens and the link starts again there, so nothing checked at one +/// home is taken to another. An offer that names its home, or one found by +/// a code, which FTW Cloud gives once, stays where it is. struct HomePick: View { let app: AppModel let flow: OfferFlow diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index 3be87dbd..627e59d6 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -49,7 +49,17 @@ struct FTWApp: App { // the Secure Enclave, behind Face ID; the demo has keys of its own. model.approvalKeys = ApprovalKeychain(store: store) Biometry.name() - model.demoAccess = (SimulatedKeyring(), SimulatedAccess.offerKeys, SimulatedDesk(), SimulatedAccess.lookup) + var demoOffers = SimulatedAccess.offerKeys + #if DEBUG + // For screenshots: `-FTWOfferKeys :` pins those keys in the + // demo too, beside its own, so a consent link signed elsewhere opens + // in the simulator as a tapped one would. + if let extra = UserDefaults.standard.string(forKey: "FTWOfferKeys") { + let own = "\(OfferKeys.kid(SimulatedAccess.offerKey.publicKey)):\(Base64url.encode(SimulatedAccess.offerKey.publicKey))" + demoOffers = OfferKeys(pins: [own] + extra.split(separator: ",").map(String.init)) + } + #endif + model.demoAccess = (SimulatedKeyring(), demoOffers, SimulatedDesk(), SimulatedAccess.lookup) // A paired phone paints its cached home before anything else runs. model.launch() #if DEBUG diff --git a/appleApp/FTW/Platform/QRScanner.swift b/appleApp/FTW/Platform/QRScanner.swift index 8074d268..520878d0 100644 --- a/appleApp/FTW/Platform/QRScanner.swift +++ b/appleApp/FTW/Platform/QRScanner.swift @@ -1,8 +1,9 @@ import AVFoundation import SwiftUI -/// Reads a pairing QR through the camera. The first code that looks like an -/// FTW link is handed over once; the camera stops at that moment. +/// Reads a pairing QR, or an assistant's consent QR, through the camera. +/// The first code that looks like an FTW link is handed over once; the +/// camera stops at that moment. struct QRScanner: View { var onCode: (String) -> Void @State private var allowed: Bool? diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift index 7b5d7790..d8053b3c 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift @@ -521,6 +521,7 @@ public enum AccessWords { "Boxen kontrollerar varje anrop, och du kan ta bort åtkomsten när du vill.", "Done": "Klar", "Checking the offer": "Kontrollerar erbjudandet", + "Code on the page": "Koden på sidan", // One grant "Save with %@": "Spara med %@", "Remove access": "Ta bort åtkomst", diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift index 776fd8ea..272fc4b8 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift @@ -4,19 +4,21 @@ import Observation /// One offer from a consent page, from the link or the typed code to Face /// ID (docs/agents.md in srcfl/ftw, The grant offer). /// -/// A link someone else sent never reaches Face ID on its own: the owner -/// types the code the page in front of them shows, and it must be the -/// offer's. Typed without a link, the code fetches the offer from FTW Cloud, -/// once. Either way the offer is checked against the pinned offer keys -/// before anything is shown, and refused when it is unsigned, expired, for -/// a home this phone does not have, or support from anyone but Sourceful. -/// Approving checks it again, against the home open at that moment and the -/// clock then, from the signed bytes rather than what was shown. +/// A link, tapped or opened by the iPhone Camera from the page's QR code, +/// goes straight to review: the owner reads who asks, its host, whether FTW +/// Cloud knows it and what it asks for, and may compare the page's code. +/// Only with neither link nor QR code does the owner type the code, which +/// fetches the offer from FTW Cloud, once. Either way the offer is checked +/// against the pinned offer keys before anything is shown, and refused when +/// it is unsigned, expired, for a home this phone does not have, or support +/// from anyone but Sourceful. Approving checks it again, against the home +/// open at that moment and the clock then, from the signed bytes rather +/// than what was shown. @Observable @MainActor public final class OfferFlow { public enum Step: Equatable { - /// Type the code from the page. + /// No link: type the code from the page. case code case checking /// The offer, verified, for the owner to choose from. @@ -27,8 +29,8 @@ public final class OfferFlow { case refused(OfferError) } - public private(set) var step: Step = .code - /// The code as typed. + public private(set) var step: Step + /// The code as typed, when there is no link. public var typed = "" /// Control is a separate switch, off until ticked. public var control = false @@ -43,9 +45,6 @@ public final class OfferFlow { @ObservationIgnored let lookup: OfferLookup @ObservationIgnored let keys: OfferKeys @ObservationIgnored let wallMs: @MainActor () -> Double - /// Opened before the code: a link's offer is checked at once, so a bad - /// one is refused without asking for anything. - @ObservationIgnored private var opened: GrantOffer? /// The offer as signed, checked again when the owner approves. @ObservationIgnored private var signed: SignedOffer? /// Told of the row once the box granted it. @@ -56,6 +55,7 @@ public final class OfferFlow { self.lookup = lookup self.keys = keys self.wallMs = wallMs + step = link == nil ? .code : .checking } var nowMs: Int64 { Int64(wallMs()) } @@ -68,36 +68,31 @@ public final class OfferFlow { } } - /// For a link: check its offer before asking for the code. + /// For a link: check its offer and show it, with no code to type. public func start(home: String?) async { - guard let link, opened == nil, step == .code else { return } - guard !keys.isEmpty else { return refuse(.noPinnedKey) } + guard let link, step == .checking else { return } do { - let carried = try SignedOffer(base64url: link.offer) - opened = try check(carried, home: home) - signed = carried - } catch let e as OfferError { - refuse(e) + present(try SignedOffer(base64url: link.offer), home: home) } catch { refuse(.malformed) } } - /// The owner typed the code: compare it with the link's offer, or fetch - /// the offer it names. + /// The code on the page, to compare, for an offer that came by link. + /// A typed code needs no second look. + public var pageCode: String? { + guard link != nil, let offer, !offer.code.isEmpty else { return nil } + return OfferCode.show(offer.code) + } + + /// The owner typed the code: fetch the offer it names. public func submit(home: String?) async { + guard link == nil else { return } codeHelp = nil guard let code = OfferCode.normalize(typed) else { codeHelp = AccessWords.offer(.wrongCode) return } - if let opened { - guard OfferCode.matches(code, opened) else { - codeHelp = AccessWords.offer(.wrongCode) - return - } - return review(opened) - } guard !keys.isEmpty else { return refuse(.noPinnedKey) } step = .checking do { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 947545bf..b2abb9cb 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -308,6 +308,7 @@ public enum NodeWords { "That link is not an FTW pairing code.": "Länken är ingen FTW-kod för parkoppling.", "That didn't work. Try scanning again.": "Det gick inte. Försök skanna igen.", "There is no FTW pairing code in that picture. Try a closer screenshot of the QR.": "Bilden har ingen FTW-kod för parkoppling. Prova en närmare skärmbild av QR-koden.", + "That is an assistant's QR code. Connect your box first, then scan it again.": "Det är en assistents QR-kod. Anslut boxen först och skanna den sedan igen.", // The status line under Now's title: short words in mono caps. // LIVE and AS OF are the node view's, above. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift index 5fa4d340..ef5af4a1 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift @@ -39,7 +39,18 @@ public final class PairModel { public var canOpen: Bool { known != nil && app.connectHelp == nil } /// Scanned with the camera or pasted: a deliberate act, so it pairs. + /// An assistant's consent page goes where its link goes: the saved home + /// opens with the offer. public func pair(_ raw: String) async { + if OfferLink(raw) != nil { + guard canOpen else { + stage = .intro + message = NodeWords.text("That is an assistant's QR code. Connect your box first, then scan it again.") + return + } + app.offer(raw) + return openKnown() + } stage = .pairing message = nil do { diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift index b4ca5958..0e775fd3 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift @@ -258,6 +258,25 @@ import FTWDemo #expect(keyring.unlocks == 1) } + /// A consent page's link, tapped or opened from its QR code, reaches the + /// box with one Face ID and no code typed. Approving checks the offer + /// again before it signs. + @Test func aLinkIsApprovedWithNoCode() async throws { + let w = await world(seed: false) + let link = OfferLink(request: "r1", offer: SimulatedAccess.offer(nowMs: w.scheduler.nowMs).base64url) + let flow = OfferFlow(link: link, lookup: OfferLookup(base: URL(string: "https://cloud.test")!) { _ in + Issue.record("a link asked FTW Cloud") + return (Data(), 500) + }, keys: SimulatedAccess.offerKeys, wallMs: { [s = w.scheduler] in s.nowMs }) + await flow.start(home: nil) + guard case .review = flow.step else { Issue.record("no review: \(flow.step)"); return } + await w.during { await flow.approve(w.access, home: nil) } + guard case .done(let row) = flow.step else { Issue.record("not granted: \(flow.step) \(w.access.help ?? "")"); return } + #expect(row.principal.client == "Claude") + #expect(w.keyring.unlocks == 1) + #expect(w.box.access.rows.contains { $0.id == row.id }) + } + /// A cached list knows no keys: the box's own list decides, and with /// no answer nothing is signed. @Test func aCachedListNeverRegistersAKey() async throws { diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift index e61bcab9..c87045bb 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift @@ -184,6 +184,37 @@ import FTWDemo #expect(PairModel(app: app).offeredFingerprint != nil) } + /// The pairing scanner reads an assistant's consent QR as its link: the + /// saved home opens with the offer, and nothing pairs. With no home it + /// says to connect the box first. + @Test func aConsentQRInThePairingScannerOpensTheOffer() async throws { + let world = World() + let link = "https://app.ftw.energy/a#r1.b2ZmZXI" + let app = world.app() + app.launch() + let first = PairModel(app: app) + await first.pair(link) + #expect(first.stage == .intro) + #expect(first.message == NodeWords.text("That is an assistant's QR code. Connect your box first, then scan it again.")) + #expect(app.accessLink == nil) + #expect(world.passkeys.registrations == 0) + + let pairing = PairModel(app: app) + let t = Task { await pairing.pair(world.pairingURL) } + await world.run(500) + await t.value + let siteId = try #require(app.site?.siteId) + // Back on the scanner from the home, as "Scan a new pairing QR" is. + app.recovering = true + let again = PairModel(app: app) + #expect(again.canOpen) + await again.pair(link) + #expect(app.accessLink == OfferLink(link)) + #expect(!app.needsPairing) + #expect(app.site?.siteId == siteId) + #expect(world.passkeys.registrations == 1) + } + @Test func aConfigureWriteGoesThrough() async throws { let world = World() let app = world.app() diff --git a/appleApp/FTWKit/Tests/FTWKitTests/LiveOfferTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/LiveOfferTests.swift index 669c1d88..9285c524 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/LiveOfferTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/LiveOfferTests.swift @@ -3,7 +3,9 @@ import Testing @testable import FTWKit /// A real offer from a local ftwcloudd, read the way the app reads it. Off -/// unless FTW_LIVE_OFFER_LINK names the consent page's link: +/// unless FTW_LIVE_OFFER_LINK names the consent page's link. The link goes +/// straight to review; FTW_LIVE_OFFER_CODE, if set, must be the code the +/// review shows: /// /// FTW_LIVE_OFFER_LINK='https://app.ftw.energy/a#…' FTW_LIVE_OFFER_CODE=ABCD-EFGH \ /// FTW_LIVE_OFFER_KEYS=':' swift test --filter LiveOfferTests @@ -19,13 +21,13 @@ import Testing #expect(!keys.isEmpty) let flow = OfferFlow(link: link, lookup: OfferLookup(base: URL(string: "https://127.0.0.1:18943")!) { _ in (Data(), 500) }, keys: keys) await flow.start(home: nil) - #expect(flow.step == .code) - flow.typed = env["FTW_LIVE_OFFER_CODE"] ?? "" - await flow.submit(home: nil) guard case .review(let offer) = flow.step else { Issue.record("no review: \(flow.step)") return } + if let code = env["FTW_LIVE_OFFER_CODE"] { + #expect(flow.pageCode == OfferCode.normalize(code).map(OfferCode.show)) + } #expect(offer.role == Contract.roleAgent) #expect(offer.key.count == 32) #expect(offer.home.isEmpty) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift index d1d547e4..a9ae77c1 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift @@ -154,35 +154,51 @@ import Testing // MARK: The flow - /// A link's offer is checked at once, then waits for the code on the - /// page: a link someone else sent never reaches Face ID on its own. - @Test func aLinkWaitsForTheCodeOnThePage() async throws { + /// A link, tapped or opened by the iPhone Camera from the page's QR + /// code, is checked at once and goes straight to review: no code to + /// type. The page's code is there to compare. + @Test func aLinkGoesStraightToReview() async throws { let key = SoftwareSigner() let keys = OfferKeys(pins: ["\(OfferKeys.kid(key.publicKey)):\(Base64url.encode(key.publicKey))"]) let link = OfferLink(request: "r1", offer: try sign(offer(), key).base64url) - let flow = OfferFlow(link: link, lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { 1_000 }) + let flow = OfferFlow(link: link, lookup: lookup { _ in + Issue.record("a link asked FTW Cloud") + return (Data(), 500) + }, keys: keys, wallMs: { 1_000 }) + #expect(flow.step == .checking) await flow.start(home: nil) - #expect(flow.step == .code) - flow.typed = "AAAA-BBBB" - await flow.submit(home: nil) - #expect(flow.step == .code) - #expect(flow.codeHelp == AccessWords.offer(.wrongCode)) - flow.typed = "k7qf-2mxd" - await flow.submit(home: nil) guard case .review(let o) = flow.step else { Issue.record("no review: \(flow.step)"); return } #expect(o.client == "Claude") + #expect(flow.typed.isEmpty) + #expect(flow.pageCode == "K7QF-2MXD") // Control is off until ticked, and the length is the longest asked. #expect(!flow.control) #expect(flow.lengthMs == 30 * 86_400_000) + // A code typed into a link's flow changes nothing. + flow.typed = "AAAA-BBBB" + await flow.submit(home: nil) + #expect(flow.step == .review(o)) } - @Test func aLinkWithABadOfferStopsBeforeTheCode() async throws { - let stranger = SoftwareSigner() + /// The checks a link always had: unsigned, expired, broken, support from + /// anyone but Sourceful, or a build that pins no key. None reaches review. + @Test func aBadLinkIsStillRefused() async throws { let pinned = SoftwareSigner() let keys = OfferKeys(pins: ["\(OfferKeys.kid(pinned.publicKey)):\(Base64url.encode(pinned.publicKey))"]) - let flow = OfferFlow(link: OfferLink(request: "r1", offer: try sign(offer(), stranger).base64url), lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { 1_000 }) - await flow.start(home: nil) - #expect(flow.step == .refused(.unsigned)) + func flow(_ text: String, keys: OfferKeys = keys) async -> OfferFlow { + let f = OfferFlow(link: OfferLink(request: "r1", offer: text), lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { 1_000 }) + await f.start(home: nil) + return f + } + #expect(await flow(try sign(offer(), SoftwareSigner()).base64url).step == .refused(.unsigned)) + var old = offer() + old.expMs = 1_000 + #expect(await flow(try sign(old, pinned).base64url).step == .refused(.expired)) + #expect(await flow("bm90LWFuLW9mZmVy").step == .refused(.malformed)) + var support = offer() + support.role = Contract.roleSupport + #expect(await flow(try sign(support, pinned).base64url).step == .refused(.notFromSourceful)) + #expect(await flow(try sign(offer(), pinned).base64url, keys: OfferKeys(pins: [])).step == .refused(.noPinnedKey)) } /// Typed with no link, the code fetches the offer from FTW Cloud. @@ -194,9 +210,15 @@ import Testing #expect(req.url?.absoluteString == "https://cloud.test/oauth/requests?code=K7QF2MXD") return (try JSONSerialization.data(withJSONObject: ["offer": wire, "status": "pending"]), 200) }, keys: keys, wallMs: { 1_000 }) + // No link: the code first, and start does nothing. + #expect(flow.step == .code) + await flow.start(home: nil) + #expect(flow.step == .code) flow.typed = "k7qf2mxd" await flow.submit(home: nil) guard case .review = flow.step else { Issue.record("no review: \(flow.step)"); return } + // The owner just typed it: nothing to compare. + #expect(flow.pageCode == nil) let gone = OfferFlow(link: nil, lookup: lookup { _ in (Data(), 404) }, keys: keys, wallMs: { 1_000 }) gone.typed = "K7QF2MXD" @@ -216,8 +238,6 @@ import Testing var now = 1_000.0 let flow = OfferFlow(link: OfferLink(request: "r1", offer: try sign(o, key).base64url), lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { now }) await flow.start(home: "homeA") - flow.typed = "K7QF2MXD" - await flow.submit(home: "homeA") guard case .review = flow.step else { Issue.record("no review: \(flow.step)"); return } let site = SiteModel(siteId: "homeB", build: "t", ua: "t", scheduler: ManualScheduler(), files: nil) let access = AccessModel(site: site, signedFor: SignedFor(boxKey: [], deviceID: "x"), keyring: nil) @@ -226,8 +246,6 @@ import Testing let late = OfferFlow(link: OfferLink(request: "r1", offer: try sign(o, key).base64url), lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { now }) await late.start(home: "homeA") - late.typed = "K7QF2MXD" - await late.submit(home: "homeA") now += 10 * 60_000 await late.approve(access, home: "homeA") #expect(late.step == .refused(.expired)) @@ -241,8 +259,6 @@ import Testing o.verified = false let flow = OfferFlow(link: OfferLink(request: "r1", offer: try sign(o, key).base64url), lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { 1_000 }) await flow.start(home: nil) - flow.typed = "K7QF2MXD" - await flow.submit(home: nil) let site = SiteModel(siteId: "h", build: "t", ua: "t", scheduler: ManualScheduler(), files: nil) await flow.approve(AccessModel(site: site, signedFor: SignedFor(boxKey: [], deviceID: "x"), keyring: nil), home: nil) guard case .review = flow.step else { Issue.record("moved without the owner's word: \(flow.step)"); return } @@ -261,7 +277,7 @@ import Testing #expect(flow.step == .refused(.unknownHome)) let same = OfferFlow(link: OfferLink(request: "r1", offer: try sign(o, key).base64url), lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { 1_000 }) await same.start(home: "otherhome") - #expect(same.step == .code) + guard case .review = same.step else { Issue.record("no review: \(same.step)"); return } } // MARK: What the owner grants diff --git a/appleApp/FTWUITests/AccessE2E.swift b/appleApp/FTWUITests/AccessE2E.swift index 8dcb2156..42330402 100644 --- a/appleApp/FTWUITests/AccessE2E.swift +++ b/appleApp/FTWUITests/AccessE2E.swift @@ -5,7 +5,6 @@ import XCTest /// /// TEST_RUNNER_FTW_E2E_PAIR= /// TEST_RUNNER_FTW_E2E_LINK= -/// TEST_RUNNER_FTW_E2E_CODE= /// TEST_RUNNER_FTW_E2E_CLOUD=wss://127.0.0.1:18943/v1/app /// TEST_RUNNER_FTW_E2E_CLOUD_KEY= /// TEST_RUNNER_FTW_E2E_OFFER_KEYS=: @@ -20,7 +19,7 @@ final class AccessE2E: XCTestCase { @MainActor func testApproveThenRemove() throws { let env = ProcessInfo.processInfo.environment - guard let pair = env["FTW_E2E_PAIR"], let link = env["FTW_E2E_LINK"], let code = env["FTW_E2E_CODE"], + guard let pair = env["FTW_E2E_PAIR"], let link = env["FTW_E2E_LINK"], let cloud = env["FTW_E2E_CLOUD"], let key = env["FTW_E2E_CLOUD_KEY"], let offers = env["FTW_E2E_OFFER_KEYS"] else { throw XCTSkip("Set the TEST_RUNNER_FTW_E2E_* variables to run against the rig.") } @@ -49,20 +48,16 @@ final class AccessE2E: XCTestCase { } } // FTW Cloud's one screen at first start, over the consent link's - // page, which waits under it. + // page, which waits under it. The link opens the offer itself, with + // no code to type. let carryOn = app.buttons.matching(NSPredicate(format: "label == 'Continue' AND identifier != 'offer.continue'")).firstMatch - let field = app.textFields["offer.code"] - for _ in 0..<60 where !field.isHittable { + let host = app.staticTexts["offer.host"] + for _ in 0..<60 where !host.isHittable { if carryOn.exists, carryOn.isHittable { carryOn.tap() } Thread.sleep(forTimeInterval: 1) } - // The code from the page, then the offer. - XCTAssertTrue(field.waitForExistence(timeout: 5)) - field.tap() - shoot(app, "e2e-code") - field.typeText(code) - app.buttons["offer.continue"].tap() - XCTAssertTrue(app.staticTexts["offer.host"].waitForExistence(timeout: 15)) + XCTAssertTrue(host.waitForExistence(timeout: 15)) + XCTAssertFalse(app.textFields["offer.code"].exists) shoot(app, "e2e-offer") let control = app.switches["offer.control"] if control.waitForExistence(timeout: 3) { From 16e244d993c33e6c30011f9c2c95e2bddfd6730f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 11:03:08 +0200 Subject: [PATCH 2/2] iPhone: one fetch per code, the newest link wins, the scanner opens the open home Review fixes for the consent link flow: - submit asks FTW Cloud once: Return plus Continue no longer fetch twice and spend the code. present does nothing once an offer is shown. - A second link replaces an open offer page instead of stacking another. The page takes the link as its identity; without it SwiftUI kept the first flow's state at that place and showed the old offer. - The pairing scanner opens the home this phone opens (sites.current()), keeps the offer for a home that cannot open yet, and with no home says to scan with the iPhone Camera once the box is connected. - Tests: a wrong code is refused after one fetch, a double submit fetches once, start again changes nothing the owner chose. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/UI/RootView.swift | 7 ++- .../Sources/FTWKit/Access/OfferFlow.swift | 8 ++- .../Sources/FTWKit/State/NodeWords.swift | 3 +- .../Sources/FTWKit/State/PairModel.swift | 30 ++++++---- .../Tests/FTWKitTests/AppModelTests.swift | 12 ++-- .../FTWKit/Tests/FTWKitTests/OfferTests.swift | 59 +++++++++++++++++++ 6 files changed, 100 insertions(+), 19 deletions(-) diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift index 31fc316c..803f168f 100644 --- a/appleApp/FTW/UI/RootView.swift +++ b/appleApp/FTW/UI/RootView.swift @@ -38,7 +38,7 @@ enum HomeRoute: Hashable { case price /// What the home saved, from the home's node and the title's menu. case savings - /// An assistant's consent link: its offer, from the code to Face ID. + /// An assistant's consent link: its offer, to Face ID. case offer(OfferLink) /// The name in the bar, the same as the screen's own title. @@ -120,7 +120,10 @@ struct HomeView: View { case .savings: SavingsView(home: home) case .offer(let link): + // Its own identity, so a newer link in the same + // place gets a new flow, not the old one's state. OfferFlowView(app: app, flow: OfferFlow(link: link, lookup: app.offerLookup, keys: app.offerKeys)) + .id(link) } } .homeBar(app: app, home: home, title: route.title) @@ -136,10 +139,12 @@ struct HomeView: View { } // A consent page's link opens its offer as a page over the home, // in front of anything open, once this phone can grant from here. + // The newest link wins: an offer page already open goes. .onChange(of: app.accessLink, initial: true) { _, link in guard let link, home.access != nil else { return } app.accessLink = nil sheet = nil + path.removeAll { if case .offer = $0 { return true } else { return false } } path.append(.offer(link)) } // Now and the car read the chargers from one model, so a screen diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift index 272fc4b8..c69d74a9 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift @@ -85,9 +85,10 @@ public final class OfferFlow { return OfferCode.show(offer.code) } - /// The owner typed the code: fetch the offer it names. + /// The owner typed the code: fetch the offer it names, once. A second + /// press while the first is out asks nothing: the code works once. public func submit(home: String?) async { - guard link == nil else { return } + guard link == nil, step == .code else { return } codeHelp = nil guard let code = OfferCode.normalize(typed) else { codeHelp = AccessWords.offer(.wrongCode) @@ -112,7 +113,10 @@ public final class OfferFlow { } /// An offer that came another way, such as Get help: checked like any. + /// Only before one is shown, so nothing swaps the signed offer under a + /// review or an approval. public func present(_ given: SignedOffer, home: String?) { + guard step == .code || step == .checking else { return } do { let offer = try check(given, home: home) signed = given diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index b2abb9cb..d8491610 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -308,7 +308,8 @@ public enum NodeWords { "That link is not an FTW pairing code.": "Länken är ingen FTW-kod för parkoppling.", "That didn't work. Try scanning again.": "Det gick inte. Försök skanna igen.", "There is no FTW pairing code in that picture. Try a closer screenshot of the QR.": "Bilden har ingen FTW-kod för parkoppling. Prova en närmare skärmbild av QR-koden.", - "That is an assistant's QR code. Connect your box first, then scan it again.": "Det är en assistents QR-kod. Anslut boxen först och skanna den sedan igen.", + "That is an assistant's QR code. Connect your box first, then scan it with the iPhone Camera.": "Det är en assistents QR-kod. Anslut boxen först och skanna den sedan med iPhones kamera.", + "The assistant's request opens once your home does.": "Assistentens begäran öppnas när ditt hem öppnas.", // The status line under Now's title: short words in mono caps. // LIVE and AS OF are the node view's, above. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift index ef5af4a1..77d92cae 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift @@ -39,18 +39,9 @@ public final class PairModel { public var canOpen: Bool { known != nil && app.connectHelp == nil } /// Scanned with the camera or pasted: a deliberate act, so it pairs. - /// An assistant's consent page goes where its link goes: the saved home - /// opens with the offer. + /// An assistant's consent page goes where its link goes. public func pair(_ raw: String) async { - if OfferLink(raw) != nil { - guard canOpen else { - stage = .intro - message = NodeWords.text("That is an assistant's QR code. Connect your box first, then scan it again.") - return - } - app.offer(raw) - return openKnown() - } + if OfferLink(raw) != nil { return scannedOffer(raw) } stage = .pairing message = nil do { @@ -68,6 +59,23 @@ public final class PairModel { } } + /// An assistant's consent QR in the pairing scanner, taken as its link: + /// the home this phone opens shows the offer, or keeps it until it can + /// open. With no home there is nothing to grant from. + private func scannedOffer(_ raw: String) { + stage = .intro + guard app.vault.isEnrolled, let home = app.sites.current() else { + message = NodeWords.text("That is an assistant's QR code. Connect your box first, then scan it with the iPhone Camera.") + return + } + app.offer(raw) + if app.connectHelp == nil { + app.paired(home.siteId) + } else { + message = NodeWords.text("The assistant's request opens once your home does.") + } + } + /// Pair with the link that arrived, now that someone said so. public func acceptOffer() async { guard let link = app.offeredLink else { return } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift index c87045bb..d033660a 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift @@ -185,8 +185,8 @@ import FTWDemo } /// The pairing scanner reads an assistant's consent QR as its link: the - /// saved home opens with the offer, and nothing pairs. With no home it - /// says to connect the box first. + /// home this phone opens shows the offer, and nothing pairs. With no + /// home it says to connect the box first. @Test func aConsentQRInThePairingScannerOpensTheOffer() async throws { let world = World() let link = "https://app.ftw.energy/a#r1.b2ZmZXI" @@ -195,7 +195,7 @@ import FTWDemo let first = PairModel(app: app) await first.pair(link) #expect(first.stage == .intro) - #expect(first.message == NodeWords.text("That is an assistant's QR code. Connect your box first, then scan it again.")) + #expect(first.message == NodeWords.text("That is an assistant's QR code. Connect your box first, then scan it with the iPhone Camera.")) #expect(app.accessLink == nil) #expect(world.passkeys.registrations == 0) @@ -204,10 +204,14 @@ import FTWDemo await world.run(500) await t.value let siteId = try #require(app.site?.siteId) + // Another home listed first: the one this phone opens still wins. + let paired = try #require(app.sites.get(siteId)) + app.sites.put(StoredSite(siteId: "other", label: "Cabin", boxStaticKey: Data(repeating: 1, count: 32), rendezvousSecret: nil, pairingCode: nil, lanHint: nil, addedAtMs: 0, lastSeenAtMs: 0)) + app.sites.put(paired) + #expect(app.sites.all().first?.siteId == "other") // Back on the scanner from the home, as "Scan a new pairing QR" is. app.recovering = true let again = PairModel(app: app) - #expect(again.canOpen) await again.pair(link) #expect(app.accessLink == OfferLink(link)) #expect(!app.needsPairing) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift index a9ae77c1..b2c1cd27 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift @@ -227,6 +227,57 @@ import Testing #expect(gone.codeHelp == AccessWords.offer(.notFound)) } + /// A typed code must be the offer's own. FTW Cloud is asked once, even + /// when Return and Continue both fire: the code works once. + @Test func aTypedCodeIsAskedOnceAndMustMatch() async throws { + let key = SoftwareSigner() + let keys = OfferKeys(pins: ["\(OfferKeys.kid(key.publicKey)):\(Base64url.encode(key.publicKey))"]) + let wire = try sign(offer(), key).base64url + let asked = Asked() + let flow = OfferFlow(link: nil, lookup: lookup { _ in + asked.add() + return (try JSONSerialization.data(withJSONObject: ["offer": wire, "status": "pending"]), 200) + }, keys: keys, wallMs: { 1_000 }) + flow.typed = "AAAA-BBBB" + await flow.submit(home: nil) + #expect(flow.step == .refused(.wrongCode)) + #expect(asked.count == 1) + + let twice = OfferFlow(link: nil, lookup: lookup { _ in + asked.add() + return (try JSONSerialization.data(withJSONObject: ["offer": wire, "status": "pending"]), 200) + }, keys: keys, wallMs: { 1_000 }) + twice.typed = "K7QF2MXD" + async let first: Void = twice.submit(home: nil) + async let second: Void = twice.submit(home: nil) + _ = await (first, second) + guard case .review = twice.step else { Issue.record("no review: \(twice.step)"); return } + #expect(asked.count == 2) + } + + /// Coming back to the page runs `start` again; it changes nothing the + /// owner chose, and no other offer can be swapped in under the review. + @Test func startingAgainChangesNothing() async throws { + let key = SoftwareSigner() + let keys = OfferKeys(pins: ["\(OfferKeys.kid(key.publicKey)):\(Base64url.encode(key.publicKey))"]) + var o = offer() + o.verified = false + o.scopes = ["ftw.dispatch.hold", "ftw.live.read"] + let flow = OfferFlow(link: OfferLink(request: "r1", offer: try sign(o, key).base64url), lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { 1_000 }) + await flow.start(home: nil) + guard case .review(let shown) = flow.step else { Issue.record("no review: \(flow.step)"); return } + flow.control = true + flow.trustsUnknown = true + flow.lengthMs = 86_400_000 + await flow.start(home: nil) + var other = offer() + other.client = "Someone else" + flow.present(try sign(other, key), home: nil) + #expect(flow.step == .review(shown)) + #expect(flow.control && flow.trustsUnknown) + #expect(flow.lengthMs == 86_400_000) + } + /// Approving checks the signed offer again, against the home open then /// and the clock then: a home picked after the review, or an offer /// that ran out while the owner read, is refused. @@ -344,3 +395,11 @@ import Testing OfferLookup(base: URL(string: "https://cloud.test")!) { try answer($0) } } } + +/// How many times FTW Cloud's code lookup was asked. +final class Asked: @unchecked Sendable { + private let lock = NSLock() + private var n = 0 + var count: Int { lock.withLock { n } } + func add() { lock.withLock { n += 1 } } +}