From 9baf63ecb1144ef93208666759893e334c34c63c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 20:00:04 +0200 Subject: [PATCH] fix(ios): FTW Cloud hears that the phone looks, even when it changes during the handshake A launch passes through inactive, so message 1 often says hz 0. If the app came to the front before message 2, setLooking(true) found no open session and sent nothing, and nothing sent it later. The service never asked the box for 1 s updates, and the box stayed at 5 s for the whole session. In five cold starts of the 0.3.1 release build, three lost the watch. CloudCarrier now keeps what the service last heard and sends watch as soon as the session opens if that differs. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWKit/Carrier/CloudCarrier.swift | 21 +++++++++- .../Tests/FTWKitTests/CloudCarrierTests.swift | 40 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift index 6eb97db..85fb1c6 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift @@ -46,6 +46,9 @@ public final class CloudCarrier { private let scheduler: Scheduler private let makeSocket: WebSocketFactory private var looking: Bool + /// What the service last heard: message 1's `hz`, then each `watch`. + /// Nil until message 1 of this dial goes. + private var told: Bool? private var socket: WebSocketConnection? private var generation = 0 @@ -91,11 +94,22 @@ public final class CloudCarrier { /// Whether anyone can see the home. While someone can, the service keeps /// the box at one update a second. A closed session takes the change - /// with it into message 1 of the next. + /// with it into message 1 of the next. A session still opening says it + /// once message 2 is in: a launch passes through inactive, so message 1 + /// often says the phone looks away. public func setLooking(_ on: Bool) { guard on != looking else { return } looking = on - send(CloudWire.msgWatch, .map([("hz", .int(on ? CloudWire.hzLooking : CloudWire.hzAway))])) + tellLooking() + } + + /// Send `watch` when the service last heard otherwise and the session + /// is open. + private func tellLooking() { + guard let told, told != looking else { return } + if send(CloudWire.msgWatch, .map([("hz", .int(looking ? CloudWire.hzLooking : CloudWire.hzAway))])) { + self.told = looking + } } /// An owner suspends a device at FTW Cloud, or lifts it. It only narrows @@ -151,6 +165,7 @@ public final class CloudCarrier { let mine = generation handshake = nil transport = nil + told = nil socket = makeSocket(endpoint.url(home: home), WebSocketEvents( onOpen: { [weak self] in guard let self, mine == self.generation else { return } @@ -182,6 +197,7 @@ public final class CloudCarrier { guard let msg1 = try? hs.writeMessage(now) else { return } handshake = hs socket.send(msg1) + told = looking deadline?.cancel() deadline = scheduler.after(Self.handshakeTimeoutMs) { [weak self] in guard let self, self.transport == nil else { return } @@ -216,6 +232,7 @@ public final class CloudCarrier { attempt = 0 refusals = 0 setStatus(.open(sinceMs: arrival)) + tellLooking() onSnapshot(snapshot, arrival) } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift index b819b8d..5efabea 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift @@ -217,6 +217,46 @@ final class CloudRig { #expect(rig.service.watches == [1, 0]) } + /// A launch passes through inactive, so message 1 may say the phone + /// looks away. The app comes to the front before message 2: the service + /// hears it once the session opens, or the box stays at 5 s. + @Test func lookingChangedDuringTheHandshakeIsSaidOnceOpen() async throws { + let rig = try CloudRig(looking: false) + rig.carrier.start() + await rig.run(15) + #expect(rig.service.nows == []) + #expect(!rig.carrier.status.isOpen) + rig.carrier.setLooking(true) + await rig.run(50) + #expect(rig.service.nows == [CloudWire.hzAway]) + #expect(rig.carrier.status.isOpen) + #expect(rig.service.watches == [1]) + + // And the other way: the phone looked when it dialled and looked + // away before the session opened. + let away = try CloudRig(looking: true) + away.carrier.start() + await away.run(15) + away.carrier.setLooking(false) + await away.run(50) + #expect(away.service.nows == [CloudWire.hzLooking]) + #expect(away.service.watches == [0]) + } + + /// A change while the session is down rides in the next message 1, and + /// nothing more is said. + @Test func lookingChangedWhileClosedGoesInMessage1() async throws { + let rig = try CloudRig(looking: true) + rig.carrier.start() + await rig.run(50) + rig.service.close(code: 1006) + rig.carrier.setLooking(false) + await rig.run(600) + #expect(rig.carrier.status.isOpen) + #expect(rig.service.nows == [CloudWire.hzLooking, CloudWire.hzAway]) + #expect(rig.service.watches == []) + } + /// A refusal is asked again slowly, as after a service restart that has /// not heard from the box yet, and at once on the next foreground. @Test func aRefusalIsAskedAgainSlowly() async throws {