From 0e84efa02983d819e1370362fd8fc4fcf327c532 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 21:00:19 +0200 Subject: [PATCH 1/9] feat(protocol): copy the FTW Cloud names from the registry protocol/registry.yaml is the byte-for-byte copy of srcfl/ftw-webapp contract/registry.yaml, which now holds scope ftw.events.read, capability cloud.link, carrier state cloud and the cloud_scopes and cloud_purposes lists. Contract and CarrierKind follow so ContractTests still reads both directions. Nothing produces the cloud carrier yet; that is C3. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWKit/Protocol/Contract.swift | 4 ++- .../FTWKit/Sources/FTWShared/Readings.swift | 3 ++- .../Tests/FTWKitTests/ContractTests.swift | 2 +- protocol/registry.yaml | 27 ++++++++++++++++--- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift index 6774f90..9952757 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift @@ -14,6 +14,7 @@ public enum Contract { "ftw.plan.read", "ftw.health.read", "ftw.assets.read", + "ftw.events.read", "ftw.dispatch.write", "ftw.mode.write", "ftw.members.read", @@ -43,6 +44,7 @@ public enum Contract { "cmd.lease", "cmd.precondition", "cmd.readback", "der.battery", "der.ev", "der.v2x", "plan.dispatch", "net.webrtc", "price.spot", "api.passthrough", + "cloud.link", ] public static let capPlanDispatch = "plan.dispatch" @@ -119,5 +121,5 @@ public enum Contract { } public static let sourceStates = ["live", "lagging", "stale", "down", "never"] - public static let carrierStates = ["webrtc", "relay", "cache", "none"] + public static let carrierStates = ["webrtc", "relay", "cloud", "cache", "none"] } diff --git a/appleApp/FTWKit/Sources/FTWShared/Readings.swift b/appleApp/FTWKit/Sources/FTWShared/Readings.swift index 7099d3b..108799b 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Readings.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Readings.swift @@ -11,7 +11,8 @@ import Foundation /// with it: "connected, but the inverter went quiet 40 seconds ago" needs /// both. public enum CarrierKind: String, Sendable, Codable { - case webrtc, relay, cache, none + /// `cloud` is data read through FTW Cloud. Nothing produces it yet. + case webrtc, relay, cloud, cache, none } public enum SourceState: String, Sendable, Codable, Comparable { diff --git a/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift index f9b3f6e..e300bfe 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift @@ -59,7 +59,7 @@ import FTWDemo /// FTWShared spells the same states as enums, for widgets that never /// link FTWKit. @Test func sharedStatesAreTheRegistrys() { - #expect([CarrierKind.webrtc, .relay, .cache, .none].map(\.rawValue) == Contract.carrierStates) + #expect([CarrierKind.webrtc, .relay, .cloud, .cache, .none].map(\.rawValue) == Contract.carrierStates) #expect([SourceState.live, .lagging, .stale, .down, .never].map(\.rawValue) == Contract.sourceStates) #expect(Contract.sourceStates.allSatisfy { SourceState(rawValue: $0) != nil }) #expect(Contract.carrierStates.allSatisfy { CarrierKind(rawValue: $0) != nil }) diff --git a/protocol/registry.yaml b/protocol/registry.yaml index d407bcd..5d71d3a 100644 --- a/protocol/registry.yaml +++ b/protocol/registry.yaml @@ -3,8 +3,8 @@ # The single source for every name shared between this app and the box, and # the same file in both repositories — byte for byte. # -# In srcfl/ftw it generates Go constants: go/internal/appproto/contract_gen.go -# and go/internal/apiauth/contract_gen.go, from `go generate ./internal/...`. +# In srcfl/ftw it generates Go constants: go/protocol/contract_gen.go and +# go/internal/apiauth/contract_gen.go, from `go generate ./internal/...`. # In srcfl/ftw-webapp there is no generator — src/lib/protocol/contract.ts and # the error table in src/lib/protocol/messages.ts are written by hand and read # back against this file by tests/registry-contract.test.ts. @@ -76,6 +76,10 @@ capabilities: # history.5m. Present does not mean every path is reachable: reads and # configuration go through, anything that moves energy stays on cmd. - api.passthrough + # The box can run the FTW Cloud uplink. Absent means the app uses the relay + # only and offers no FTW Cloud. It says nothing about whether the owner + # turned FTW Cloud on; the box's grant decides that. + - cloud.link # --------------------------------------------------------------------------- # Scopes. One object axis, two verb axes: .. @@ -87,6 +91,7 @@ scopes: - { name: ftw.plan.read, desc: Dispatch plan and its reasoning } - { name: ftw.health.read, desc: Box and driver health } - { name: ftw.assets.read, desc: Devices and their capabilities } + - { name: ftw.events.read, desc: Events behind notifications } - { name: ftw.dispatch.write, desc: Control energy assets } - { name: ftw.mode.write, desc: Change site operating mode } - { name: ftw.members.read, desc: See who has access } @@ -100,6 +105,22 @@ roles: label: Viewer scopes: [ftw.live.read] +# --------------------------------------------------------------------------- +# FTW Cloud. What an owner can let the box share with FTW Cloud, and why. +# docs/protocol-cloud.md in srcfl/ftw is the wire format. +# +# cloud_scopes are the scopes a grant may name: the box sends a class only +# when the grant holds its scope, and a device reads it only when its own +# role carries the scope too. ftw.assets.read is left out on purpose: the +# box sends no device inventory, names or capabilities to FTW Cloud. +# +# cloud_purposes are what the data may be used for. serve is the owner's own +# phones and widgets. stats is household statistics, a separate consent that +# is off unless the owner turns it on. +# --------------------------------------------------------------------------- +cloud_scopes: [ftw.live.read, ftw.history.read, ftw.plan.read, ftw.health.read, ftw.events.read] +cloud_purposes: [serve, stats] + # --------------------------------------------------------------------------- # Command operations. What a `cmd` frame may name, and the scope the grant # must carry before the box acts on it. @@ -219,7 +240,7 @@ client_errors: # the two into one enum cannot express "connected, but the inverter is quiet". # --------------------------------------------------------------------------- source_states: [live, lagging, stale, down, never] -carrier_states: [webrtc, relay, cache, none] +carrier_states: [webrtc, relay, cloud, cache, none] # --------------------------------------------------------------------------- # History resolutions backed by what the box actually stores. From f69ffabd24b0457e29d7d0aed5ca61ebd4af77aa Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 07:48:48 +0200 Subject: [PATCH 2/9] feat(cloud): read and age a box-signed home from FTW Cloud CloudWire is the phone's half of the FTW Cloud wire: the unpadded lane 1 frame, the {t, b, sig} envelope, the signed input and its ES256 check with CryptoKit, the home id, the app session's prologue and snapshot, and the bodies of the classes a phone reads. Bodies decode as Go's decoder does: unknown keys ignored, a known key of the wrong type refused. CloudView ports Go's cloudwire.View: the highest seq per class and boot, boot replacement only by a later anchored message, rules N and U with the drift margin and 24-hour lifetime, and the state rules, which can only make the box's own grades worse. It is a plain Sendable value with no clock, so an extension can run the same rules. It also keeps the newest prices and hb for the screens, outside the grades. The tests replay srcfl/ftw's cloud-vectors.json and cloud-app-vectors.json, copied unchanged from cloud-c3-core at cc6b36cb, and all 16 freshness cases in docs/protocol-cloud.md, with the box signing by the vectors' key. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWKit/Carrier/CloudView.swift | 352 +++++++++ .../Sources/FTWKit/Carrier/CloudWire.swift | 695 ++++++++++++++++++ .../Tests/FTWKitTests/CloudViewTests.swift | 444 +++++++++++ .../Tests/FTWKitTests/CloudWireTests.swift | 401 ++++++++++ .../Resources/cloud-app-vectors.json | 53 ++ .../FTWKitTests/Resources/cloud-vectors.json | 166 +++++ 6 files changed, 2111 insertions(+) create mode 100644 appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/CloudViewTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/CloudWireTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-app-vectors.json create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-vectors.json diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift new file mode 100644 index 0000000..85b6309 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift @@ -0,0 +1,352 @@ +import Foundation +import FTWShared + +/// What a phone holds for one home read through FTW Cloud, and how fresh it +/// is. +/// +/// The service cannot sign, so it cannot make data look fresher than the box +/// graded it. It can only delay, drop or replay, and each of those must age +/// the data or mark it stale here. A port of Go's `cloudwire.View` +/// (srcfl/ftw `go/protocol/cloudwire/view.go`); `CloudViewTests` replays the +/// protocol's freshness cases against it. +/// +/// A plain value with no clock of its own: the caller passes phone time in, +/// so a widget or an extension can run the same rules on what the app +/// stored. Everything it holds belongs to the current boot. +/// +/// Assumes the phone's clock is close to true time. One that runs behind +/// lets a delaying service hide that much delay. +public struct CloudView: Sendable { + /// How far ahead of the phone clock a box's `wallMs` may be before the + /// phone stops trusting the box clock for that boot (rule N). + public static let ruleNLeadMs: Int64 = 2_000 + /// How long an uptime offset learned over Noise stays usable (rule U). + public static let ruleULifetimeMs: Int64 = 24 * 60 * 60 * 1000 + /// Rule U adds 0.01 % of the time since the offset was learned to every + /// age, because box clocks drift. + static let ruleUDriftDivisor: Int64 = 10_000 + + /// Why a message was dropped. The view is unchanged after each. + public enum AcceptError: Error, Equatable { + /// It does not verify with the box key for this home. + case signature + /// The phone holds this class at the same or a higher seq, or the + /// message is from another boot and not anchored later than what + /// the phone holds. + case notNewer + /// The box sends it, but never to a phone. + case notAClass(String) + /// Signed by the box, but the body is not what the class says. + case undecodable(String) + } + + /// How a phone would show what it holds at one moment. + public struct Grades: Equatable, Sendable { + /// False when no send time can be trusted: "cloud, age unverified", + /// at best stale and never live. + public var anchored = false + /// How old the anchor is now, drift margin included. Nil when + /// unanchored. + public var ageMs: Int64? + /// Each source's effective state, keyed by opaque source id. + public var sources: [String: SourceState] = [:] + /// Each live field's state, keyed by field id as text. + public var fields: [String: SourceState] = [:] + /// Each loadpoint's state, keyed by opaque loadpoint id. + public var loadpoints: [String: SourceState] = [:] + } + + public let home: String + /// The box's 65-byte public key, as the box gave it over Noise. Never + /// one from the service. + public let boxKey: Bytes + + /// Empty until the first message. Go compares boots with bytes.Equal, + /// where nil and empty are one value, and so does this. + private var currentBoot: Bytes = [] + private var seq: [String: UInt64] = [:] + private var noRuleN = false + /// The latest anchored send time held, phone clock. Survives a reboot, + /// so an older boot cannot come back. + private var newest: Int64 = 0 + private var m: Anchor? + private var offsets: [Bytes: UptimeOffset] = [:] + + /// The source table and its revision. + public private(set) var sources: [String: CloudWire.Source] = [:] + public private(set) var srcRev: UInt64 = 0 + /// The sources each field's freshness follows, sent with the table. + public private(set) var fieldSources: [String: [String]] = [:] + /// The newest of each class this phone shows. + public private(set) var live: CloudWire.Live? + public private(set) var lp: CloudWire.LP? + /// Kept for the screens and left out of `grade`, as in Go: a price + /// window ages by its own slots, not by the box's sources. + public private(set) var prices: CloudWire.Prices? + public private(set) var hb: CloudWire.HB? + + /// M: the newest anchored live or hb. + private struct Anchor: Sendable { + var seq: UInt64 + var hb: Bool + var boot: Bytes + /// Send time by rule N, or nil. + var wallSend: Int64? + var uptimeMs: Int64 + var srcRev: UInt64 + var liveSeq: UInt64 + var lpSeq: UInt64 + var ages: [String: Int64] + } + + private struct UptimeOffset: Sendable { + var offsetMs: Int64 + var learnedAtMs: Int64 + } + + public init(home: String, boxKey: Bytes) { + self.home = home + self.boxKey = boxKey + } + + /// The boot the view holds, nil before the first message. + public var boot: Bytes? { currentBoot.isEmpty ? nil : currentBoot } + + /// H: half the smallest `staleAfterMs`, between 2 and 15 s. + public var heartbeatMs: Int64 { Self.heartbeat(for: sources) } + + /// H for a source table: clamp(smallest staleAfterMs ÷ 2, 2 s, 15 s), + /// and 15 s when no source has a cadence. + public static func heartbeat(for table: [String: CloudWire.Source]) -> Int64 { + guard let smallest = table.values.map(\.staleAfterMs).filter({ $0 > 0 }).min() else { return 15_000 } + return min(max(smallest / 2, 2_000), 15_000) + } + + /// Rule U's anchor, from a Noise session with the box: phone time minus + /// box uptime, the smallest seen per boot. Every call renews its 24 + /// hours. + public mutating func learnOffset(boot: Bytes, phoneMs: Int64, uptimeMs: Int64) { + let offset = phoneMs &- uptimeMs + if let current = offsets[boot], current.offsetMs <= offset { + offsets[boot]?.learnedAtMs = phoneMs + return + } + offsets[boot] = UptimeOffset(offsetMs: offset, learnedAtMs: phoneMs) + } + + /// Takes one box envelope that arrived at `arrivalMs` on the phone + /// clock. Throws `AcceptError`, and changes nothing, for a message it + /// drops. A snapshot's envelopes go in one at a time, in rising seq. + public mutating func accept(_ envelope: CloudWire.Envelope, arrivalMs: Int64) throws { + guard CloudWire.verify(boxKey: boxKey, home: home, envelope: envelope) else { throw AcceptError.signature } + guard CloudWire.phoneClasses.contains(envelope.t) else { throw AcceptError.notAClass(envelope.t) } + // Decoded in full before anything changes, so a body that does not + // decode leaves the view as it was. + let message: Message + do { + message = try Message(t: envelope.t, b: envelope.b) + } catch { + throw AcceptError.undecodable("\(envelope.t): \(error)") + } + let h = message.header + let newBoot = h.boot != currentBoot + let lead = Self.leads(h, arrivalMs: arrivalMs) + let wall = Self.wallSend(h, arrivalMs: arrivalMs, off: lead || (!newBoot && noRuleN)) + let send = sendTime(boot: h.boot, wall: wall, uptimeMs: h.uptimeMs, nowMs: arrivalMs) + + // Another boot replaces this one only with an anchored message sent + // after the newest one held, so a replay of an older boot cannot + // wipe a newer one. + if newBoot, !currentBoot.isEmpty { + guard let send, send > newest else { throw AcceptError.notNewer } + } + guard h.seq > (newBoot ? 0 : seq[envelope.t, default: 0]) else { throw AcceptError.notNewer } + + if newBoot { reset(to: h.boot) } + if lead { noRuleN = true } + switch message { + case .live(let l): + live = l + if let table = l.sources { + sources = table + srcRev = l.srcRev + fieldSources = l.fieldSources ?? [:] + } + if send != nil { + setM(Anchor(seq: h.seq, hb: false, boot: h.boot, wallSend: wall, uptimeMs: h.uptimeMs, + srcRev: l.srcRev, liveSeq: h.seq, lpSeq: 0, ages: l.ages)) + } + case .lp(let p): + lp = p + case .prices(let p): + prices = p + case .hb(let b): + hb = b + if send != nil { + setM(Anchor(seq: h.seq, hb: true, boot: h.boot, wallSend: wall, uptimeMs: h.uptimeMs, + srcRev: b.srcRev, liveSeq: b.liveSeq, lpSeq: b.lpSeq, ages: b.ages)) + } + case .other: + break + } + seq[envelope.t] = h.seq + if let send, send > newest { newest = send } + } + + /// The send time the phone trusts for one box message arriving at + /// `arrivalMs`, or nil when neither rule anchors it. What an extension + /// asks of an alert before it shows the event's own text. + public func sentMs(_ header: CloudWire.Header, arrivalMs: Int64) -> Int64? { + let off = Self.leads(header, arrivalMs: arrivalMs) || (header.boot == currentBoot && noRuleN) + return sendTime(boot: header.boot, wall: Self.wallSend(header, arrivalMs: arrivalMs, off: off), uptimeMs: header.uptimeMs, nowMs: arrivalMs) + } + + /// When the box sent M, by the phone clock, or nil while nothing + /// anchored is held. The age on screen counts from here. + public func anchorSentMs(nowMs: Int64) -> Int64? { + guard let m else { return nil } + return sendTime(boot: m.boot, wall: noRuleN ? nil : m.wallSend, uptimeMs: m.uptimeMs, nowMs: nowMs) + } + + /// The protocol's state rules at `nowMs`. They can only make the box's + /// own grades worse. + public func grade(nowMs: Int64) -> Grades { + var g = Grades() + var t: Int64 = 0 + var anchor: Anchor? + if let m, let send = anchorSentMs(nowMs: nowMs) { + anchor = m + t = max(0, nowMs &- send) + if let u = ruleU(boot: m.boot, uptimeMs: m.uptimeMs, nowMs: nowMs), u == send, let off = offsets[m.boot] { + t = t &+ (nowMs &- off.learnedAtMs) / Self.ruleUDriftDivisor + } + g.anchored = true + g.ageMs = t + } + + // 1. A table older than M names leaves every source stale until the + // new one arrives. 3 and 4. Otherwise each age moves on by t, and + // the worse of the box's grade and that one wins. + let tableBehind = anchor.map { $0.srcRev > srcRev } ?? false + for (id, s) in sources { + var state = s.state + if let anchor, !tableBehind { + if let age = anchor.ages[id] { state = max(state, Self.ladder(ageMs: age &+ t, staleAfterMs: s.staleAfterMs)) } + } else { + state = max(state, .stale) + } + g.sources[id] = state + } + + // 2. An hb that names a newer live or lp than the phone holds makes + // those values stale. 5. A field follows the worst of its sources; + // one with none is live while t ≤ 2H. + if let live { + let behind = anchor.map { $0.hb && $0.liveSeq > seq[CloudWire.msgLive, default: 0] } ?? true + for f in live.fields.keys { + var state = SourceState.live + let srcs = fieldSources[f] ?? [] + if srcs.isEmpty, t > 2 * heartbeatMs { state = .stale } + for s in srcs { state = max(state, g.sources[s] ?? .stale) } + if behind { state = max(state, .stale) } + g.fields[f] = state + } + } + if let lp { + let behind = anchor.map { $0.hb && $0.lpSeq > seq[CloudWire.msgLP, default: 0] } ?? true + for l in lp.loadpoints { + var state = g.sources[l.src] ?? .stale + if behind { state = max(state, .stale) } + g.loadpoints[l.id] = state + } + } + return g + } + + /// Grades an age the way the box grades its own sources. + static func ladder(ageMs age: Int64, staleAfterMs s: Int64) -> SourceState { + if s <= 0 { return .never } + if age <= s { return .live } + if age <= 2 &* s { return .lagging } + if age <= 6 &* s { return .stale } + return .down + } + + // MARK: - Private + + private enum Message { + case live(CloudWire.Live) + case lp(CloudWire.LP) + case prices(CloudWire.Prices) + case hb(CloudWire.HB) + /// Classes the view only orders by seq. + case other(CloudWire.Header) + + init(t: String, b: Bytes) throws { + switch t { + case CloudWire.msgLive: self = .live(try CloudWire.Live(body: b)) + case CloudWire.msgLP: self = .lp(try CloudWire.LP(body: b)) + case CloudWire.msgPrices: self = .prices(try CloudWire.Prices(body: b)) + case CloudWire.msgHB: self = .hb(try CloudWire.HB(body: b)) + default: self = .other(try CloudWire.Header(body: b)) + } + } + + var header: CloudWire.Header { + switch self { + case .live(let l): return l.header + case .lp(let p): return p.header + case .prices(let p): return p.header + case .hb(let b): return b.header + case .other(let h): return h + } + } + } + + private mutating func setM(_ a: Anchor) { + if m.map({ a.seq > $0.seq }) ?? true { m = a } + } + + /// When the boot changes, the phone drops what it held and waits for + /// that boot's full live. + private mutating func reset(to boot: Bytes) { + currentBoot = boot + seq = [:] + noRuleN = false + sources = [:] + srcRev = 0 + fieldSources = [:] + live = nil + lp = nil + prices = nil + hb = nil + m = nil + } + + /// A `wallMs` this far ahead of the phone turns rule N off for its boot. + private static func leads(_ h: CloudWire.Header, arrivalMs: Int64) -> Bool { + h.wallMs.map { $0 > arrivalMs &+ ruleNLeadMs } ?? false + } + + /// Rule N: the earlier of `wallMs` and arrival, so a fast box clock + /// cannot make data look fresher. Nil without a wall time, with rule N + /// off, or before 1970, which Go's -1 sentinel also refuses. + private static func wallSend(_ h: CloudWire.Header, arrivalMs: Int64, off: Bool) -> Int64? { + guard let wallMs = h.wallMs, !off else { return nil } + let send = min(wallMs, arrivalMs) + return send >= 0 ? send : nil + } + + /// The earlier of rule N and rule U, or nil when neither applies at + /// `nowMs`. + private func sendTime(boot: Bytes, wall: Int64?, uptimeMs: Int64, nowMs: Int64) -> Int64? { + guard let u = ruleU(boot: boot, uptimeMs: uptimeMs, nowMs: nowMs) else { return wall } + guard let wall else { return u } + return min(wall, u) + } + + private func ruleU(boot: Bytes, uptimeMs: Int64, nowMs: Int64) -> Int64? { + guard let off = offsets[boot], nowMs &- off.learnedAtMs <= Self.ruleULifetimeMs else { return nil } + return off.offsetMs &+ uptimeMs + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift new file mode 100644 index 0000000..6e9664e --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift @@ -0,0 +1,695 @@ +import Foundation +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +import FTWShared + +/// The FTW Cloud wire, as a phone reads it. +/// +/// FTW Cloud keeps a box-signed copy of a home and serves it to that home's +/// phones. The service cannot sign for the box, so this file is what lets +/// the phone check the box's bytes itself: the unpadded frame, the envelope +/// and what its signature covers, the home id, and the bodies of the classes +/// a phone reads. srcfl/ftw `docs/protocol-cloud.md` is the normative text +/// and `go/protocol/cloudwire` the reference; its golden vectors replay in +/// `CloudWireTests` byte for byte. +/// +/// Bodies are read as Go's decoder reads them: unknown keys are ignored, +/// null reads as absent, and a known key of the wrong type refuses the body, +/// so the phone never keeps a message the reference would drop. +public enum CloudWire { + // MARK: - Constants + + /// Byte 0 of every frame. + public static let version: UInt8 = 1 + /// Byte 1 of every frame. Lane 1 without the app protocol's bulk + /// buckets: the service reads the content, so padding would hide little. + public static let lane: UInt8 = 1 + /// ver, lane, flags, rsvd and a u16 length. + public static let headerBytes = 6 + public static let maxPayload = 0xffff + + /// "ftw.cloud.v1" and a zero byte. Starts every signed input. + public static let signTag = Bytes("ftw.cloud.v1\0".utf8) + /// Starts the hash a home id is cut from. + public static let homeTag = Bytes("ftw.cloud.home.v1".utf8) + /// Starts the app session's Noise prologue; the home id follows. + public static let appPrologueTag = Bytes("ftw.cloud.v1:".utf8) + + /// ES256 as r ‖ s, each 32 bytes big-endian. + public static let sigBytes = 64 + /// An uncompressed SEC1 P-256 point: the box key as the phone holds it. + public static let publicKeyBytes = 65 + public static let bootBytes = 16 + + // MARK: - Message types + + // The classes a phone may read. hb goes to every device in the roster; + // the service forwards the rest by the device's scope. + public static let msgLive = "live" + public static let msgLP = "lp" + public static let msgPrices = "prices" + public static let msgPlan = "plan" + public static let msgHealth = "health" + public static let msgEvent = "event" + public static let msgTiles = "tiles" + public static let msgHB = "hb" + + /// Everything else the box sends (hello, roster, bye) is the service's + /// alone and never reaches a phone. + public static let phoneClasses: Set = [msgLive, msgLP, msgPrices, msgPlan, msgHealth, msgEvent, msgTiles, msgHB] + + // The app session's own types. All unsigned. + /// Handshake message 1, `{hz}`. + public static let msgNow = "now" + /// Handshake message 2: what the service holds. + public static let msgSnapshot = "snapshot" + /// Phone to service, `{hz}`: whether the phone can see the home. + public static let msgWatch = "watch" + /// An owner's, `{deviceId}`. They only narrow access. + public static let msgSuspend = "suspend" + public static let msgUnsuspend = "unsuspend" + + public static let hzLooking = 1 + public static let hzAway = 0 + + // Close codes on /v1/app. + /// The phone sent something the protocol does not allow. + public static let closeProtocol = 4400 + /// Every refusal, without saying which: no such home, not in the + /// roster, suspended, roster expired, grant off. + public static let closeDenied = 4403 + /// A newer session from the same device took over. + public static let closeReplaced = 4408 + /// The roster changed what this device may read. Reconnect at once. + public static let closeChanged = 4409 + /// The home has as many sessions as the service allows, as on the relay. + public static let closeBusy = 4429 + + /// A frame, envelope or body this protocol does not send. Local only. + public struct WireError: Error, Equatable { + public let code: String + public let message: String + } + + // MARK: - Frames + + /// One envelope in one frame: lane 1, flags 0 and no padding, so the + /// message is exactly 6 + len bytes. + public static func encodeFrame(_ envelope: Bytes) throws -> Bytes { + guard envelope.count <= maxPayload else { + throw WireError(code: "E_CLOUD_FRAME", message: "\(envelope.count) bytes do not fit the length field") + } + return [version, lane, 0, 0, UInt8(envelope.count >> 8), UInt8(envelope.count & 0xff)] + envelope + } + + /// The envelope bytes of one frame. Stricter than `Frame.decode`: lane 0, + /// a flag, a reserved bit or a byte past the declared length is not a + /// frame this protocol sends. + public static func decodeFrame(_ message: Bytes) throws -> Bytes { + func refuse(_ why: String) -> WireError { WireError(code: "E_CLOUD_FRAME", message: why) } + guard message.count >= headerBytes else { throw refuse("\(message.count) bytes") } + guard message[0] == version else { throw refuse("version \(message[0])") } + guard message[1] == lane else { throw refuse("lane \(message[1])") } + guard message[2] == 0, message[3] == 0 else { throw refuse("flags \(message[2]) \(message[3])") } + let len = Int(message[4]) << 8 | Int(message[5]) + guard len == message.count - headerBytes else { + throw refuse("declares \(len) bytes, carries \(message.count - headerBytes)") + } + return Array(message[headerBytes...]) + } + + // MARK: - Envelope + + /// One message, `{t, b, sig}`. `b` is the body's exact bytes, carried as + /// a byte string so the signature covers what was sent and the service + /// can forward it unchanged. `sig` is empty on whatever the box did not + /// sign. + public struct Envelope: Equatable, Sendable { + public var t: String + public var b: Bytes + public var sig: Bytes + + public init(t: String, b: Bytes, sig: Bytes = []) { + self.t = t + self.b = b + self.sig = sig + } + + /// Parses one envelope. `t` must be text and `b` and `sig` byte + /// strings, checked rather than converted: an envelope whose body is + /// text is not the envelope that was signed. Unknown keys are + /// ignored. + public static func decode(_ raw: Bytes) throws -> Envelope { + let value: CBOR + do { + value = try decodeCBOR(raw) + } catch { + throw envelopeError("\(error)") + } + return try Envelope(value) + } + + /// An envelope embedded as a CBOR map, as in a snapshot. + init(_ value: CBOR) throws { + guard case .map(let entries) = value else { throw envelopeError("not a map") } + var t: String? + var b: Bytes? + var sig: Bytes? + for e in entries { + guard case .text(let key) = e.key else { throw envelopeError("a key is not text") } + switch (key, e.value) { + case ("t", .text(let v)): t = v + case ("b", .bytes(let v)): b = v + case ("sig", .bytes(let v)): sig = v + case ("t", _), ("b", _), ("sig", _): throw envelopeError("\(key) has the wrong type") + default: continue + } + } + guard let t, let b, let sig else { throw envelopeError("t, b and sig are all required") } + guard !t.isEmpty else { throw envelopeError("empty type") } + self.init(t: t, b: b, sig: sig) + } + + /// A three-entry map in core deterministic key order, b, t, sig. An + /// empty signature is an empty byte string, never null. + public func encode() -> Bytes { + encodeCBOR(.map([("b", .bytes(b)), ("t", .text(t)), ("sig", .bytes(sig))])) + } + } + + /// An envelope the phone says itself: the body in core deterministic + /// encoding and an empty `sig`. The same bytes as Go's + /// `cloudwire.Unsigned` for the same body. + public static func encodeUnsigned(t: String, body: CBOR) -> Bytes { + Envelope(t: t, b: encodeCBOR(deterministic(body))).encode() + } + + /// RFC 8949 core deterministic order: each map's keys sorted by the bytes + /// of their own encoding, which puts shorter text keys first. The encoder + /// already writes every head in its shortest form. + static func deterministic(_ value: CBOR) -> CBOR { + switch value { + case .array(let items): + return .array(items.map(deterministic)) + case .map(let entries): + return .map(entries + .map { CBOR.Entry(deterministic($0.key), deterministic($0.value)) } + .sorted { encodeCBOR($0.key).lexicographicallyPrecedes(encodeCBOR($1.key)) }) + case .tagged(let tag, let inner): + return .tagged(tag, deterministic(inner)) + default: + return value + } + } + + // MARK: - Signatures + + /// What a signature covers: `signTag`, then the CBOR array + /// `[home, t, b]`. The home id is inside, so a message signed for one + /// home never verifies for another. + public static func signedInput(home: String, t: String, b: Bytes) -> Bytes { + signTag + encodeCBOR(.array([.text(home), .text(t), .bytes(b)])) + } + + /// Whether `envelope` carries an ES256 signature by `boxKey` for `home`, + /// over the bytes received, never a re-encoding. The key is the one the + /// box gave this phone over Noise, never one from the service. + public static func verify(boxKey: Bytes, home: String, envelope: Envelope) -> Bool { + guard boxKey.count == publicKeyBytes, envelope.sig.count == sigBytes, + let key = try? P256.Signing.PublicKey(x963Representation: boxKey), + let sig = try? P256.Signing.ECDSASignature(rawRepresentation: envelope.sig) + else { return false } + return key.isValidSignature(sig, for: SHA256.hash(data: signedInput(home: home, t: envelope.t, b: envelope.b))) + } + + // MARK: - Ids + + /// The home's name at the service: lowercase base32 without padding of + /// the first 16 bytes of SHA-256(`homeTag` ‖ pk), 26 characters. A hash + /// of the key, so no other box can claim the home. + public static func homeID(pk: Bytes) -> String { + base32(Array(Primitives.sha256(homeTag + pk).prefix(16))) + } + + /// A source's opaque id: the first 8 bytes of + /// HMAC-SHA256(grantKey, "src\0" ‖ driver), base64url without padding. + /// The phone maps these to names from the table the box sends over + /// Noise; computing one here is for the vectors. + static func sourceID(grantKey: Bytes, driver: String) -> String { + opaqueID(grantKey, "src\0", driver) + } + + /// A loadpoint's opaque id, cut the same way with "lp\0". + static func loadpointID(grantKey: Bytes, loadpoint: String) -> String { + opaqueID(grantKey, "lp\0", loadpoint) + } + + private static func opaqueID(_ key: Bytes, _ tag: String, _ name: String) -> String { + Base64url.encode(Array(Primitives.hmacSHA256(key: key, Bytes(tag.utf8) + Bytes(name.utf8)).prefix(8))) + } + + /// The Noise prologue of one home's app session. The phone learned the + /// home id from the box, so a service that answers for another home + /// fails the handshake. + public static func appPrologue(home: String) -> Bytes { + appPrologueTag + Bytes(home.utf8) + } + + private static func base32(_ bytes: Bytes) -> String { + let alphabet = Bytes("abcdefghijklmnopqrstuvwxyz234567".utf8) + var out = Bytes() + var buffer: UInt32 = 0 + var bits = 0 + for byte in bytes { + buffer = buffer << 8 | UInt32(byte) + bits += 8 + while bits >= 5 { + out.append(alphabet[Int(buffer >> UInt32(bits - 5) & 31)]) + bits -= 5 + } + } + if bits > 0 { out.append(alphabet[Int(buffer << UInt32(5 - bits) & 31)]) } + return String(decoding: out, as: UTF8.self) + } + + // MARK: - App session + + /// Handshake message 2: for each class the device may read, the newest + /// envelope the service holds, the newest live that carried sources and + /// the newest hb, in rising seq. `lastSeenMs` and `online` are the + /// service's own word, unsigned, and never shown as a reading. `more` + /// counts envelopes that did not fit and follow as ordinary frames. + public struct Snapshot: Equatable, Sendable { + public var envelopes: [Envelope] + public var lastSeenMs: Int64 + public var online: Bool + public var more: Int + } + + /// Reads message 2's payload. Each entry is a box envelope embedded as + /// a map; its `b` is the box's exact bytes, so it verifies as sent. + public static func parseSnapshot(payload: Bytes) throws -> Snapshot { + let env = try Envelope.decode(payload) + guard env.t == msgSnapshot else { throw envelopeError("message 2 is \(env.t), not a snapshot") } + let f = try Fields(body: env.b) + return Snapshot( + envelopes: try f.list("envelopes") { try Envelope($0) }, + lastSeenMs: try f.int("lastSeenMs"), + online: try f.bool("online"), + more: Int(try f.int("more")) + ) + } + + // MARK: - Bodies + + /// In every body the box sends. + public struct Header: Equatable, Sendable { + /// Starts at 1 and rises by 1 per message, per boot. + public var seq: UInt64 + /// Drawn at random at each Core start. + public var boot: Bytes + /// Box monotonic time since start, at send. + public var uptimeMs: Int64 + /// Box wall time at send, present only while NTP has synced it. + public var wallMs: Int64? + } + + /// The site's flows and the freshness of every source behind them. + public struct Live: Equatable, Sendable { + public var header: Header + /// Frozen field ids 1–6 and 10, keyed as text as the app protocol's + /// delta keys them. Field 1 is the mode index; the rest are watts. + public var fields: [String: Int64] + /// Present only when the source table changed and in every full + /// message, as is `sources`. + public var fieldSources: [String: [String]]? + public var sources: [String: Source]? + /// Rises by 1, per boot, each time the source table changes. + public var srcRev: UInt64 + /// For each live or lagging source, the age of its newest reading at + /// send, in ms of box uptime. + public var ages: [String: Int64] + public var dispatchBlockedBy: [String] + } + + /// One source's freshness as the box grades it, keyed by opaque id. + public struct Source: Equatable, Sendable { + public var kind: String + /// A state this build does not know ranks as never. + public var state: SourceState + /// Box uptime at the last good reading, when the table was sent. + public var lastOkMs: Int64 + public var staleAfterMs: Int64 + } + + /// Loadpoint and control feedback state. + public struct LP: Equatable, Sendable { + public var header: Header + public var controlRev: UInt64 + public var loadpoints: [Loadpoint] + public var feedback: [Feedback] + } + + /// One charger as the household sees it. Nil is the box's "not known". + public struct Loadpoint: Equatable, Sendable { + /// Opaque loadpoint id. + public var id: String + /// Opaque source id of the charger, whose freshness this follows. + public var src: String + public var plugged: Bool + public var socPermille: Int64? + /// vehicle, inferred or assumed; empty when unplugged. + public var socSource: String + /// Seconds since the level was confirmed, at send. + public var socAgeS: Int64? + public var targetPermille: Int64? + /// Box wall time. + public var readyByMs: Int64? + public var recurring: Bool + public var powerW: Int64 + public var sessionWh: Int64 + public var session: String + public var manual: Manual? + public var commandedW: Int64? + public var commandedReason: String + public var surplusOnly: Bool + public var gridDeferred: Bool + public var planPending: Bool + public var boost: Bool + public var nextStartMs: Int64? + public var nextEndMs: Int64? + public var nextWh: Int64? + } + + /// A manual charging hold. + public struct Manual: Equatable, Sendable { + public var state: String + public var sinceAgeMs: Int64 + public var requestedW: Int64? + public var commandedW: Int64? + public var reason: String + } + + /// Core's verdict on whether one device function follows. + public struct Feedback: Equatable, Sendable { + public var kind: String + public var src: String + public var status: String + public var reason: String + public var severity: String + public var evidence: String + public var fresh: Bool + public var actualW: Int64? + /// Age of that reading by box uptime, at send. + public var ageMs: Int64? + } + + /// The price window, in columns. Nil where a slot has no price. + public struct Prices: Equatable, Sendable { + public var header: Header + public var zone: String + public var currency: String + /// Box wall time the first slot starts. + public var t0Ms: Int64 + public var slotMs: Int64 + /// Minor units per kWh. + public var spotMinor: [Int64?] + /// What the household pays, tariff and VAT included. + public var totalMinor: [Int64?] + } + + /// The box is there, and this is how old its readings are. It names the + /// newest live and lp the box sent, so it never vouches for one the + /// phone has not seen. + public struct HB: Equatable, Sendable { + public var header: Header + public var clock: String + public var srcRev: UInt64 + public var liveSeq: UInt64 + /// 0 if the box has sent no lp. + public var lpSeq: UInt64 + public var ages: [String: Int64] + } + + static func envelopeError(_ why: String) -> WireError { WireError(code: "E_CLOUD_ENVELOPE", message: why) } + static func bodyError(_ key: String, _ want: String) -> WireError { WireError(code: "E_CLOUD_BODY", message: "\(key) is not \(want)") } +} + +// MARK: - Body decoding + +extension CloudWire.Header { + public init(body: Bytes) throws { try self.init(CloudWire.Fields(body: body)) } + + init(_ f: CloudWire.Fields) throws { + self.init(seq: try f.uint("seq"), boot: try f.bytes("boot"), uptimeMs: try f.int("uptimeMs"), wallMs: try f.optionalInt("wallMs")) + } +} + +extension CloudWire.Live { + public init(body: Bytes) throws { + let f = try CloudWire.Fields(body: body) + self.init( + header: try CloudWire.Header(f), + fields: try f.dictionary("fields") { try CloudWire.int($0, "fields") } ?? [:], + fieldSources: try f.dictionary("fieldSources") { try CloudWire.texts($0, "fieldSources") }, + sources: try f.dictionary("sources") { try CloudWire.Source(CloudWire.Fields($0, "sources")) }, + srcRev: try f.uint("srcRev"), + ages: try f.dictionary("ages") { try CloudWire.int($0, "ages") } ?? [:], + dispatchBlockedBy: try f.list("dispatchBlockedBy") { try CloudWire.text($0, "dispatchBlockedBy") } + ) + } +} + +extension CloudWire.Source { + init(_ f: CloudWire.Fields) throws { + self.init( + kind: try f.text("kind"), + state: SourceState(rawValue: try f.text("state")) ?? .never, + lastOkMs: try f.int("lastOkMs"), + staleAfterMs: try f.int("staleAfterMs") + ) + } +} + +extension CloudWire.LP { + public init(body: Bytes) throws { + let f = try CloudWire.Fields(body: body) + self.init( + header: try CloudWire.Header(f), + controlRev: try f.uint("controlRev"), + loadpoints: try f.list("loadpoints") { try CloudWire.Loadpoint(CloudWire.Fields($0, "loadpoints")) }, + feedback: try f.list("feedback") { try CloudWire.Feedback(CloudWire.Fields($0, "feedback")) } + ) + } +} + +extension CloudWire.Loadpoint { + init(_ f: CloudWire.Fields) throws { + self.init( + id: try f.text("id"), + src: try f.text("src"), + plugged: try f.bool("plugged"), + socPermille: try f.optionalInt("socPermille"), + socSource: try f.text("socSource"), + socAgeS: try f.optionalInt("socAgeS"), + targetPermille: try f.optionalInt("targetPermille"), + readyByMs: try f.optionalInt("readyByMs"), + recurring: try f.bool("recurring"), + powerW: try f.int("powerW"), + sessionWh: try f.int("sessionWh"), + session: try f.text("session"), + manual: try f.optional("manual").map(CloudWire.Manual.init), + commandedW: try f.optionalInt("commandedW"), + commandedReason: try f.text("commandedReason"), + surplusOnly: try f.bool("surplusOnly"), + gridDeferred: try f.bool("gridDeferred"), + planPending: try f.bool("planPending"), + boost: try f.bool("boost"), + nextStartMs: try f.optionalInt("nextStartMs"), + nextEndMs: try f.optionalInt("nextEndMs"), + nextWh: try f.optionalInt("nextWh") + ) + } +} + +extension CloudWire.Manual { + init(_ f: CloudWire.Fields) throws { + self.init( + state: try f.text("state"), + sinceAgeMs: try f.int("sinceAgeMs"), + requestedW: try f.optionalInt("requestedW"), + commandedW: try f.optionalInt("commandedW"), + reason: try f.text("reason") + ) + } +} + +extension CloudWire.Feedback { + init(_ f: CloudWire.Fields) throws { + self.init( + kind: try f.text("kind"), + src: try f.text("src"), + status: try f.text("status"), + reason: try f.text("reason"), + severity: try f.text("severity"), + evidence: try f.text("evidence"), + fresh: try f.bool("fresh"), + actualW: try f.optionalInt("actualW"), + ageMs: try f.optionalInt("ageMs") + ) + } +} + +extension CloudWire.Prices { + public init(body: Bytes) throws { + let f = try CloudWire.Fields(body: body) + self.init( + header: try CloudWire.Header(f), + zone: try f.text("zone"), + currency: try f.text("currency"), + t0Ms: try f.int("t0Ms"), + slotMs: try f.int("slotMs"), + spotMinor: try f.list("spotMinor") { $0.isNull ? nil : try CloudWire.int($0, "spotMinor") }, + totalMinor: try f.list("totalMinor") { $0.isNull ? nil : try CloudWire.int($0, "totalMinor") } + ) + } +} + +extension CloudWire.HB { + public init(body: Bytes) throws { + let f = try CloudWire.Fields(body: body) + self.init( + header: try CloudWire.Header(f), + clock: try f.text("clock"), + srcRev: try f.uint("srcRev"), + liveSeq: try f.uint("liveSeq"), + lpSeq: try f.uint("lpSeq"), + ages: try f.dictionary("ages") { try CloudWire.int($0, "ages") } ?? [:] + ) + } +} + +extension CloudWire { + /// A body's text keys. Absent and null both read as the zero value, as + /// Go leaves a field it was not given. + struct Fields { + private let map: [String: CBOR] + + init(body: Bytes) throws { + guard !body.isEmpty else { throw WireError(code: "E_CLOUD_BODY", message: "empty body") } + let value: CBOR + do { + value = try decodeCBOR(body) + } catch { + throw WireError(code: "E_CLOUD_BODY", message: "\(error)") + } + try self.init(value, "body") + } + + init(_ value: CBOR, _ key: String) throws { + var map = [String: CBOR]() + switch value { + case .map(let entries): + for e in entries { + if case .text(let k) = e.key { map[k] = e.value } + } + case .null, .undefined: + break + default: + throw bodyError(key, "a map") + } + self.map = map + } + + subscript(key: String) -> CBOR { map[key] ?? .null } + + func uint(_ key: String) throws -> UInt64 { try CloudWire.uint(self[key], key) } + func int(_ key: String) throws -> Int64 { try CloudWire.int(self[key], key) } + func text(_ key: String) throws -> String { try CloudWire.text(self[key], key) } + func bool(_ key: String) throws -> Bool { try CloudWire.bool(self[key], key) } + + func bytes(_ key: String) throws -> Bytes { + switch self[key] { + case .bytes(let b): return b + case .null, .undefined: return [] + default: throw bodyError(key, "a byte string") + } + } + + func optionalInt(_ key: String) throws -> Int64? { + self[key].isNull ? nil : try CloudWire.int(self[key], key) + } + + func optional(_ key: String) throws -> Fields? { + self[key].isNull ? nil : try Fields(self[key], key) + } + + func list(_ key: String, _ element: (CBOR) throws -> T) throws -> [T] { + switch self[key] { + case .array(let items): return try items.map(element) + case .null, .undefined: return [] + default: throw bodyError(key, "an array") + } + } + + /// Nil when absent, so "no table in this message" stays apart from + /// an empty one. + func dictionary(_ key: String, _ value: (CBOR) throws -> T) throws -> [String: T]? { + switch self[key] { + case .map(let entries): + var out = [String: T]() + for e in entries { + guard case .text(let k) = e.key else { throw bodyError(key, "keyed by text") } + out[k] = try value(e.value) + } + return out + case .null, .undefined: return nil + default: throw bodyError(key, "a map") + } + } + } + + static func uint(_ v: CBOR, _ key: String) throws -> UInt64 { + switch v { + case .unsigned(let u): return u + case .null, .undefined: return 0 + default: throw bodyError(key, "an unsigned integer") + } + } + + static func int(_ v: CBOR, _ key: String) throws -> Int64 { + switch v { + case .unsigned(let u) where u <= UInt64(Int64.max): return Int64(u) + case .negative(let n) where n <= UInt64(Int64.max): return -1 - Int64(n) + case .null, .undefined: return 0 + default: throw bodyError(key, "a 64-bit integer") + } + } + + static func text(_ v: CBOR, _ key: String) throws -> String { + switch v { + case .text(let s): return s + case .null, .undefined: return "" + default: throw bodyError(key, "text") + } + } + + static func bool(_ v: CBOR, _ key: String) throws -> Bool { + switch v { + case .bool(let b): return b + case .null, .undefined: return false + default: throw bodyError(key, "a bool") + } + } + + static func texts(_ v: CBOR, _ key: String) throws -> [String] { + switch v { + case .array(let items): return try items.map { try text($0, key) } + case .null, .undefined: return [] + default: throw bodyError(key, "an array of text") + } + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudViewTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudViewTests.swift new file mode 100644 index 0000000..94e2459 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudViewTests.swift @@ -0,0 +1,444 @@ +import CryptoKit +import Foundation +import Testing +@testable import FTWKit + +// The freshness cases from srcfl/ftw docs/protocol-cloud.md, as +// go/protocol/cloudwire/view_test.go runs them, against CloudView. Times are +// ms on one clock unless a case moves the box's. Rule N with 100 ms in +// transit unless stated. The box signs with the golden vectors' box key. + +private let transit: Int64 = 100 +private let hour: Int64 = 3_600_000 + +/// A box that signs what it sends. `start` is the phone time at which its +/// uptime was zero; `clockLead` moves its wall clock against the phone's. +struct FakeCloudBox { + let key: P256.Signing.PrivateKey + let home: String + let pk: Bytes + var boot: Bytes + var seq: UInt64 = 0 + var start: Int64 = 1_000_000 + var clockLead: Int64 = 0 + var noClock = false + + init(boot byte: UInt8) throws { + let v = try CloudVectors.load() + key = try v.boxSigner() + home = v.home + pk = Bytes(hex: v.boxKey.pk) + boot = Bytes(repeating: byte, count: CloudWire.bootBytes) + } + + mutating func header(_ at: Int64) -> [(String, CBOR)] { + seq += 1 + var h: [(String, CBOR)] = [("seq", .unsigned(seq)), ("boot", .bytes(boot)), ("uptimeMs", .int(at - start))] + if !noClock { h.append(("wallMs", .int(at + clockLead))) } + return h + } + + func seal(_ t: String, _ body: [(String, CBOR)]) throws -> CloudWire.Envelope { + let b = encodeCBOR(CloudWire.deterministic(.map(body))) + return CloudWire.Envelope(t: t, b: b, sig: try cloudSign(key, home: home, t: t, b: b)) + } + + /// A full live with one source "m", S = 10 s, read `age` ms before `at`. + mutating func fullLive(_ at: Int64, age: Int64, state: String = "live", srcRev: UInt64 = 1) throws -> CloudWire.Envelope { + let h = header(at) + let m = CBOR.map([("kind", .text("driver")), ("state", .text(state)), ("lastOkMs", .int(at - start - age)), ("staleAfterMs", .int(10_000))]) + return try seal(CloudWire.msgLive, h + [ + ("fields", .map(["2": .int(1000), "1": .int(0)])), + ("fieldSources", .map(["2": .array([.text("m")])])), + ("sources", .map(["m": m])), + ("srcRev", .unsigned(srcRev)), + ("ages", .map(["m": .int(age)])), + ("dispatchBlockedBy", .array([])), + ]) + } + + mutating func hb(_ at: Int64, age: Int64, srcRev: UInt64 = 1, liveSeq: UInt64, lpSeq: UInt64 = 0) throws -> CloudWire.Envelope { + let h = header(at) + return try seal(CloudWire.msgHB, h + [ + ("clock", .text("ntp")), ("srcRev", .unsigned(srcRev)), ("liveSeq", .unsigned(liveSeq)), + ("lpSeq", .unsigned(lpSeq)), ("ages", .map(["m": .int(age)])), + ]) + } + + /// A message the service drops: it only uses up a seq. + mutating func dropped(_ at: Int64) { _ = header(at) } + + func view() -> CloudView { CloudView(home: home, boxKey: pk) } +} + +private func source(_ v: CloudView, _ at: Int64) -> SourceState? { v.grade(nowMs: at).sources["m"] } + +/// The header of an hb the box signs at `at`, as the phone decodes it. +private func header(_ box: inout FakeCloudBox, at: Int64) throws -> CloudWire.Header { + let h = box.header(at) + return try CloudWire.Header(body: box.seal(CloudWire.msgHB, h).b) +} + +@Suite struct CloudViewTests { + // 1. A quiet live source stays live. + @Test func aQuietLiveSourceStaysLive() throws { + var box = try FakeCloudBox(boot: 1) + var v = box.view() + let t0: Int64 = 2_000_000 + try v.accept(box.fullLive(t0, age: 1_000), arrivalMs: t0 + transit) + for at in stride(from: t0 + 5_000, through: t0 + 120_000, by: 5_000) { + try v.accept(box.hb(at, age: 4_500, liveSeq: 1), arrivalMs: at + transit) + for probe in stride(from: at + transit, to: at + 5_000 + transit, by: 500) { + let g = v.grade(nowMs: probe) + #expect(g.sources["m"] == .live, "at \(probe)") + #expect(g.fields["2"] == .live, "at \(probe)") + #expect(g.fields["1"] == .live, "at \(probe)") + } + } + } + + // 2. A dead uplink ages on time. + @Test func aDeadUplinkAgesOnTime() throws { + var box = try FakeCloudBox(boot: 1) + var v = box.view() + let t0: Int64 = 2_000_000 + try v.accept(box.fullLive(t0, age: 1_000), arrivalMs: t0 + transit) + let last = t0 + 5_000 + try v.accept(box.hb(last, age: 2_000, liveSeq: 1), arrivalMs: last + transit) + // The send time is the box's wallMs, so t counts from `last`. + #expect(source(v, last + 8_000) == .live) + #expect(source(v, last + 8_001) == .lagging) + #expect(source(v, last + 18_000) == .lagging) + #expect(source(v, last + 18_001) == .stale) + #expect(source(v, last + 58_000) == .stale) + #expect(source(v, last + 58_001) == .down) + } + + // 3. A replay ages out, and a phone that holds newer drops it. + @Test func aReplayAgesOut() throws { + var box = try FakeCloudBox(boot: 1) + let t0: Int64 = 2_000_000 + let live = try box.fullLive(t0, age: 1_000) + let hb = try box.hb(t0 + 5_000, age: 2_000, liveSeq: 1) + + var fresh = box.view() + try fresh.accept(live, arrivalMs: t0 + hour) + try fresh.accept(hb, arrivalMs: t0 + hour + 10) + #expect(source(fresh, t0 + hour + 20) == .down) + #expect(fresh.grade(nowMs: t0 + hour + 20).fields["2"] == .down) + + var current = box.view() + try current.accept(live, arrivalMs: t0 + transit) + try current.accept(hb, arrivalMs: t0 + 5_000 + transit) + #expect(throws: CloudView.AcceptError.notNewer) { try current.accept(live, arrivalMs: t0 + hour) } + #expect(throws: CloudView.AcceptError.notNewer) { try current.accept(hb, arrivalMs: t0 + hour) } + } + + // 4. A withheld table goes stale until it arrives. + @Test func aWithheldTableGoesStale() throws { + var box = try FakeCloudBox(boot: 1) + var v = box.view() + let t0: Int64 = 2_000_000 + try v.accept(box.fullLive(t0, age: 1_000), arrivalMs: t0 + transit) + _ = try box.fullLive(t0 + 3_000, age: 12_000, state: "lagging", srcRev: 2) // dropped by the service + try v.accept(box.hb(t0 + 5_000, age: 1_000, srcRev: 2, liveSeq: 2), arrivalMs: t0 + 5_000 + transit) + #expect(source(v, t0 + 5_200) == .stale) + // The next full live brings revision 2, and the box's own grade. + try v.accept(box.fullLive(t0 + 6_000, age: 15_000, state: "lagging", srcRev: 2), arrivalMs: t0 + 6_000 + transit) + #expect(source(v, t0 + 6_200) == .lagging) + #expect(v.srcRev == 2) + } + + // 5. No anchor, never live. + @Test func noAnchorIsNeverLive() throws { + var box = try FakeCloudBox(boot: 1) + box.noClock = true + var v = box.view() + let t0: Int64 = 2_000_000 + try v.accept(box.fullLive(t0, age: 1_000), arrivalMs: t0 + transit) + try v.accept(box.hb(t0 + 5_000, age: 500, liveSeq: 1), arrivalMs: t0 + 5_000 + transit) + let g = v.grade(nowMs: t0 + 5_200) + #expect(!g.anchored) + #expect(g.ageMs == nil) + #expect(v.anchorSentMs(nowMs: t0 + 5_200) == nil) + #expect(g.sources["m"] == .stale) + #expect(g.fields == ["1": .stale, "2": .stale]) + } + + // 6. A fast box clock loses rule N, for the rest of the boot. + @Test func aFastBoxClockLosesRuleN() throws { + var box = try FakeCloudBox(boot: 1) + box.clockLead = 3_000 + var v = box.view() + let t0: Int64 = 2_000_000 + try v.accept(box.fullLive(t0, age: 1_000), arrivalMs: t0 + transit) + let g = v.grade(nowMs: t0 + 200) + #expect(!g.anchored) + #expect(g.sources["m"] != .live) + box.clockLead = 0 + try v.accept(box.hb(t0 + 5_000, age: 500, liveSeq: 1), arrivalMs: t0 + 5_000 + transit) + #expect(!v.grade(nowMs: t0 + 5_200).anchored, "rule N came back within the boot") + } + + // 7. A small clock lead gains nothing. + @Test func aSmallClockLeadGainsNothing() throws { + var box = try FakeCloudBox(boot: 1) + box.clockLead = 1_500 + var v = box.view() + let t0: Int64 = 2_000_000 + try v.accept(box.fullLive(t0, age: 2_000), arrivalMs: t0 + transit) + // The send time is the arrival, t0+100: age 2000 + t reaches S at + // t0+8100, not 1.5 s later. + #expect(v.anchorSentMs(nowMs: t0 + 200) == t0 + transit) + #expect(source(v, t0 + 8_100) == .live) + #expect(source(v, t0 + 8_101) == .lagging) + } + + // 8. A changed byte is dropped. + @Test func aChangedByteIsDropped() throws { + var box = try FakeCloudBox(boot: 1) + var v = box.view() + var env = try box.fullLive(2_000_000, age: 1_000) + env.b[env.b.count - 1] ^= 1 + #expect(throws: CloudView.AcceptError.signature) { try v.accept(env, arrivalMs: 2_000_100) } + #expect(v.live == nil && v.boot == nil) + } + + // 9 and 10. A dropped live or a held-back lp goes stale. + @Test func aDroppedLiveAndAHeldBackLPGoStale() throws { + var box = try FakeCloudBox(boot: 1) + var v = box.view() + let t0: Int64 = 2_000_000 + try v.accept(box.fullLive(t0, age: 1_000), arrivalMs: t0 + transit) + let h = box.header(t0 + 10) + let car = CBOR.map([("id", .text("lp1")), ("src", .text("m")), ("plugged", .bool(true)), ("socPermille", .int(430))]) + try v.accept(box.seal(CloudWire.msgLP, h + [("loadpoints", .array([car]))]), arrivalMs: t0 + 10 + transit) + #expect(v.grade(nowMs: t0 + 200).loadpoints["lp1"] == .live) + let (heldLive, heldLP) = (box.seq - 1, box.seq) + box.dropped(t0 + 3_000) // live + box.dropped(t0 + 3_100) // lp + try v.accept(box.hb(t0 + 5_000, age: 1_000, liveSeq: heldLive + 2, lpSeq: heldLP + 2), arrivalMs: t0 + 5_000 + transit) + let g = v.grade(nowMs: t0 + 5_200) + #expect(g.sources["m"] == .live) + #expect(g.fields == ["1": .stale, "2": .stale]) + #expect(g.loadpoints == ["lp1": .stale]) + } + + // 11. The byte budget runs out: the box stops sending live and hb, and + // the data ages as in case 2. + @Test func aSpentByteBudgetAgesLikeADeadUplink() throws { + var box = try FakeCloudBox(boot: 1) + var v = box.view() + let t0: Int64 = 2_000_000 + try v.accept(box.fullLive(t0, age: 1_000), arrivalMs: t0 + transit) + let last = t0 + 30_000 + for at in stride(from: t0 + 5_000, through: last, by: 5_000) { + try v.accept(box.hb(at, age: 4_500, liveSeq: 1), arrivalMs: at + transit) + } + #expect(source(v, last + 5_500) == .live) + #expect(source(v, last + 5_501) == .lagging) + #expect(source(v, last + 15_501) == .stale) + #expect(source(v, last + 55_501) == .down) + // The mode has no source and goes stale after 2H = 10 s. + #expect(v.grade(nowMs: last + 10_000).fields["1"] == .live) + #expect(v.grade(nowMs: last + 10_001).fields["1"] == .stale) + } + + // 12. A reboot clears the old table. + @Test func aRebootClearsTheOldTable() throws { + var x = try FakeCloudBox(boot: 1) + var v = x.view() + let t0: Int64 = 2_000_000 + try v.accept(x.fullLive(t0, age: 1_000, srcRev: 9), arrivalMs: t0 + transit) + + var y = try FakeCloudBox(boot: 2) + y.start = t0 + 30_000 + _ = try y.fullLive(t0 + 60_000, age: 1_000, srcRev: 3) // dropped + try v.accept(y.hb(t0 + 61_000, age: 1_000, srcRev: 3, liveSeq: 1), arrivalMs: t0 + 61_000 + transit) + let g = v.grade(nowMs: t0 + 61_200) + #expect(g.anchored) + #expect(g.sources.isEmpty && g.fields.isEmpty) + #expect(v.boot == y.boot && v.live == nil && v.srcRev == 0) + // An older message from boot X can no longer take the view back. + #expect(throws: CloudView.AcceptError.notNewer) { + try v.accept(x.hb(t0 + 1_000, age: 1_000, srcRev: 9, liveSeq: 1), arrivalMs: t0 + 61_300) + } + #expect(v.boot == y.boot) + } + + // 13. Rule U drift counts against freshness, and the anchor lapses. + @Test func ruleUDriftCountsAgainstFreshness() throws { + var box = try FakeCloudBox(boot: 1) + box.noClock = true + var v = box.view() + let learned: Int64 = 2_000_000 + v.learnOffset(boot: box.boot, phoneMs: learned, uptimeMs: learned - box.start) + + let at = learned + 10 * hour + try v.accept(box.fullLive(at, age: 0), arrivalMs: at + transit) + // The margin is 3.6 s: a reading sent with age 0 reaches S 3.6 s early. + #expect(source(v, at + 6_400) == .live) + #expect(source(v, at + 6_401) == .lagging) + #expect(v.grade(nowMs: at + 6_400).ageMs == 10_000) + #expect(!v.grade(nowMs: learned + CloudView.ruleULifetimeMs + 1).anchored, "rule U outlived its 24 hours") + } + + // 14. A snapshot with mixed seq. + @Test func aSnapshotWithMixedSeq() throws { + var box = try FakeCloudBox(boot: 1) + var v = box.view() + let t0: Int64 = 2_000_000 + box.seq = 4 + let h = box.header(t0) + let prices = try box.seal(CloudWire.msgPrices, h + [("zone", .text("SE3")), ("spotMinor", .array([.int(42), .null]))]) + box.seq = 2989 + try v.accept(box.fullLive(t0 + 10, age: 1_000), arrivalMs: t0 + 20) + box.seq = 2999 + try v.accept(box.fullLive(t0 + 20, age: 1_000), arrivalMs: t0 + 30) + try v.accept(prices, arrivalMs: t0 + 40) + #expect(v.live?.header.seq == 3000) + #expect(v.prices?.header.seq == 5) + #expect(v.prices?.spotMinor == [42, nil]) + } + + // 15. An event replayed two hours late is anchored two hours old, past + // the ten minutes an alert may be for its own text and buttons, and a + // second copy of it is dropped. + @Test func anOldAlertIsTwoHoursOld() throws { + var box = try FakeCloudBox(boot: 1) + var v = box.view() + let sent: Int64 = 2_000_000 + let h = box.header(sent) + let event = try box.seal(CloudWire.msgEvent, h + [("eventId", .text("e1")), ("kind", .text("driver.offline")), ("src", .text("m"))]) + let arrival = sent + 2 * hour + let trusted = try #require(v.sentMs(CloudWire.Header(body: event.b), arrivalMs: arrival)) + #expect(arrival - trusted == 2 * hour) + #expect(arrival - trusted > 10 * 60_000) + try v.accept(event, arrivalMs: arrival) + #expect(throws: CloudView.AcceptError.notNewer) { try v.accept(event, arrivalMs: arrival + 1) } + + box.noClock = true + let unanchored = box.header(sent) + #expect(v.sentMs(try CloudWire.Header(body: box.seal(CloudWire.msgEvent, unanchored).b), arrivalMs: arrival) == nil) + } + + // 16. The box's state wins. + @Test func theBoxStateWins() throws { + var box = try FakeCloudBox(boot: 1) + var v = box.view() + let t0: Int64 = 2_000_000 + try v.accept(box.fullLive(t0, age: 0, state: "down"), arrivalMs: t0 + transit) + #expect(source(v, t0 + 1_000) == .down) + } + + @Test func theHeartbeatIsClamped() { + func table(_ stale: [Int64]) -> [String: CloudWire.Source] { + Dictionary(uniqueKeysWithValues: stale.enumerated().map { + ("s\($0.offset)", CloudWire.Source(kind: "driver", state: .live, lastOkMs: 0, staleAfterMs: $0.element)) + }) + } + #expect(CloudView.heartbeat(for: [:]) == 15_000) + #expect(CloudView.heartbeat(for: table([10_000])) == 5_000) + #expect(CloudView.heartbeat(for: table([1_000])) == 2_000) + #expect(CloudView.heartbeat(for: table([120_000, 0])) == 15_000) + } + + @Test func theGoldenSnapshotGradesThroughTheView() throws { + let vectors = try CloudVectors.load() + let grantKey = Bytes(hex: vectors.grantKey) + let meter = CloudWire.sourceID(grantKey: grantKey, driver: "p1meter") + let inverter = CloudWire.sourceID(grantKey: grantKey, driver: "sungrow") + let charger = CloudWire.sourceID(grantKey: grantKey, driver: "easee") + let garage = CloudWire.loadpointID(grantKey: grantKey, loadpoint: "garage") + let wall: Int64 = 1_791_820_800_000 + + var v = CloudView(home: vectors.home, boxKey: Bytes(hex: vectors.boxKey.pk)) + for name in ["live full", "live delta", "lp", "prices", "hb"] { + let env = try vectors.envelope(name) + let wallMs = try #require(try CloudWire.Header(body: env.b).wallMs) + try v.accept(env, arrivalMs: wallMs + transit) + } + let hbSent = wall + 9_020 + #expect(v.anchorSentMs(nowMs: hbSent + transit) == hbSent) + let g = v.grade(nowMs: hbSent + transit) + #expect(g.anchored && g.ageMs == transit) + // The charger has no age in hb, so it keeps the box's lagging. + #expect(g.sources == [meter: .live, inverter: .live, charger: .lagging]) + #expect(g.fields == ["1": .live, "2": .live, "3": .live, "4": .live, "5": .live, "6": .lagging, "10": .lagging]) + #expect(g.loadpoints == [garage: .lagging]) + // The delta carried no table, so the full live's still holds. + #expect(v.live?.header.seq == 4 && v.live?.fields["2"] == 1795) + #expect(v.srcRev == 1 && v.fieldSources["6"] == [charger, inverter, meter]) + #expect(v.prices?.zone == "SE3" && v.hb?.lpSeq == 5 && v.lp?.controlRev == 812) + // The meter's 1.4 s reading reaches its 10 s at hb + 8.6 s. + #expect(v.grade(nowMs: hbSent + 8_600).sources[meter] == .live) + #expect(v.grade(nowMs: hbSent + 8_601).sources[meter] == .lagging) + } + + @Test func whatThePhoneNeverReadsIsRefused() throws { + let vectors = try CloudVectors.load() + var v = CloudView(home: vectors.home, boxKey: Bytes(hex: vectors.boxKey.pk)) + for name in ["hello", "roster", "bye"] { + let env = try vectors.envelope(name) + #expect(throws: CloudView.AcceptError.notAClass(env.t)) { try v.accept(env, arrivalMs: 0) } + } + // Signed for this home by another key, or for another home by this + // key: the signature fails either way. + var other = CloudView(home: "aaaaaaaaaaaaaaaaaaaaaaaaaa", boxKey: Bytes(hex: vectors.boxKey.pk)) + #expect(throws: CloudView.AcceptError.signature) { try other.accept(vectors.envelope("hb"), arrivalMs: 0) } + var service = CloudView(home: vectors.home, boxKey: Bytes(hex: vectors.serviceKey.pk)) + #expect(throws: CloudView.AcceptError.signature) { try service.accept(vectors.envelope("hb"), arrivalMs: 0) } + } + + @Test func aBodyThatDoesNotDecodeChangesNothing() throws { + var x = try FakeCloudBox(boot: 1) + var v = x.view() + let t0: Int64 = 2_000_000 + try v.accept(x.fullLive(t0, age: 1_000), arrivalMs: t0 + transit) + let before = v.grade(nowMs: t0 + 200) + + // Signed by the box, from a later boot, with a field that is text. + var y = try FakeCloudBox(boot: 2) + let h = y.header(t0 + 1_000) + let bad = try y.seal(CloudWire.msgLive, h + [("fields", .map(["2": .text("1000")]))]) + #expect(throws: CloudView.AcceptError.self) { try v.accept(bad, arrivalMs: t0 + 1_000 + transit) } + #expect(v.boot == x.boot) + #expect(v.grade(nowMs: t0 + 200) == before) + } + + @Test func aStateThisBuildDoesNotKnowRanksAsNever() throws { + var box = try FakeCloudBox(boot: 1) + var v = box.view() + let t0: Int64 = 2_000_000 + try v.accept(box.fullLive(t0, age: 0, state: "melting"), arrivalMs: t0 + transit) + let g = v.grade(nowMs: t0 + 200) + #expect(g.sources["m"] == .never) + #expect(g.fields["2"] == .never) + } + + @Test func ruleUKeepsTheSmallestOffsetAndTheEarlierRuleWins() throws { + var box = try FakeCloudBox(boot: 1) + let v0 = box.view() + var v = v0 + // Offsets of 1 000 000 and then 1 001 000: the smaller stays, and the + // second call renews its lifetime. + v.learnOffset(boot: box.boot, phoneMs: 2_000_000, uptimeMs: 1_000_000) + v.learnOffset(boot: box.boot, phoneMs: 2_500_000, uptimeMs: 1_499_000) + box.noClock = true + let h = try header(&box, at: 3_000_000) + #expect(v.sentMs(h, arrivalMs: 3_000_100) == 3_000_000) + #expect(v.sentMs(h, arrivalMs: 2_500_000 + CloudView.ruleULifetimeMs) == 3_000_000) + #expect(v.sentMs(h, arrivalMs: 2_500_001 + CloudView.ruleULifetimeMs) == nil) + #expect(v0.sentMs(h, arrivalMs: 3_000_100) == nil) + + // With both rules, the earlier send time: here the box's wall clock + // is 500 ms behind, so rule N gives the earlier time. + box.noClock = false + box.clockLead = -500 + let both = try header(&box, at: 3_000_000) + #expect(v.sentMs(both, arrivalMs: 3_000_100) == 2_999_500) + box.clockLead = 500 + let ahead = try header(&box, at: 3_000_000) + #expect(v.sentMs(ahead, arrivalMs: 3_000_100) == 3_000_000) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudWireTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudWireTests.swift new file mode 100644 index 0000000..7fb4893 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudWireTests.swift @@ -0,0 +1,401 @@ +import CryptoKit +import Foundation +import Testing +@testable import FTWKit + +/// srcfl/ftw `go/protocol/cloudwire/testdata/cloud-vectors.json`, copied +/// unchanged: fixed keys, every message type and the exact bytes Go makes. +/// ECDSA signatures are randomised, so the stored ones are checked by +/// verifying them. +struct CloudVectors: Decodable { + struct Key: Decodable { + let d: String + let pk: String + let home: String? + } + + struct OpaqueID: Decodable { + let kind: String + let name: String + let id: String + } + + struct Message: Decodable { + let name: String + /// "box", "service", or empty for unsigned. + let signer: String + let t: String + let body: String + let signedInputSha256: String + let sig: String + let envelope: String + let frame: String + } + + let boxKey: Key + let serviceKey: Key + let grantKey: String + let ids: [OpaqueID] + let messages: [Message] + + var home: String { boxKey.home ?? "" } + + static func load() throws -> CloudVectors { + guard let url = Bundle.module.url(forResource: "cloud-vectors", withExtension: "json", subdirectory: "Resources") else { + throw CocoaError(.fileNoSuchFile) + } + return try JSONDecoder().decode(CloudVectors.self, from: Data(contentsOf: url)) + } + + func message(_ name: String) throws -> Message { + try #require(messages.first { $0.name == name }, "no vector \(name)") + } + + func envelope(_ name: String) throws -> CloudWire.Envelope { + try CloudWire.Envelope.decode(Bytes(hex: message(name).envelope)) + } + + /// The box's signing key, for tests that need the box to say more. + func boxSigner() throws -> P256.Signing.PrivateKey { + try P256.Signing.PrivateKey(rawRepresentation: Bytes(hex: boxKey.d)) + } +} + +/// srcfl/ftw `go/protocol/cloudwire/testdata/cloud-app-vectors.json`: one app +/// session frozen byte for byte, which the phone replays as the initiator. +struct CloudAppVectors: Decodable { + struct Step: Decodable { + let from: String + let name: String + let frame: String + let message: String + } + + let serviceStatic: String + let servicePublic: String + let serviceEphemeral: String + let phoneStatic: String + let phonePublic: String + let phoneEphemeral: String + let home: String + let prologue: String + let msg1Payload: String + let msg1: String + let msg2Payload: String + let msg2: String + let handshakeHash: String + let snapshot: [String] + let transport: [Step] + + static func load() throws -> CloudAppVectors { + guard let url = Bundle.module.url(forResource: "cloud-app-vectors", withExtension: "json", subdirectory: "Resources") else { + throw CocoaError(.fileNoSuchFile) + } + return try JSONDecoder().decode(CloudAppVectors.self, from: Data(contentsOf: url)) + } +} + +/// ES256 over the signed input, as the box signs. The phone never signs; +/// only tests do. +func cloudSign(_ key: P256.Signing.PrivateKey, home: String, t: String, b: Bytes) throws -> Bytes { + Bytes(try key.signature(for: SHA256.hash(data: CloudWire.signedInput(home: home, t: t, b: b))).rawRepresentation) +} + +@Suite struct CloudWireTests { + @Test func everyGoldenMessageDecodesVerifiesAndReencodes() throws { + let v = try CloudVectors.load() + let boxPK = Bytes(hex: v.boxKey.pk) + let servicePK = Bytes(hex: v.serviceKey.pk) + #expect(v.messages.count == 13) + + for m in v.messages { + let frame = Bytes(hex: m.frame) + let payload = try CloudWire.decodeFrame(frame) + #expect(payload.hex == m.envelope, "\(m.name)") + #expect(try CloudWire.encodeFrame(payload) == frame, "\(m.name)") + + let env = try CloudWire.Envelope.decode(payload) + #expect(env.t == m.t, "\(m.name)") + #expect(env.b.hex == m.body, "\(m.name)") + #expect(env.sig.hex == m.sig, "\(m.name)") + #expect(env.encode() == payload, "\(m.name)") + #expect(Primitives.sha256(CloudWire.signedInput(home: v.home, t: m.t, b: env.b)).hex == m.signedInputSha256, "\(m.name)") + + if m.signer.isEmpty { + #expect(env.sig.isEmpty, "\(m.name)") + #expect(CloudWire.encodeUnsigned(t: m.t, body: try decodeCBOR(env.b)) == payload, "\(m.name)") + continue + } + let (key, other) = m.signer == "box" ? (boxPK, servicePK) : (servicePK, boxPK) + #expect(CloudWire.verify(boxKey: key, home: v.home, envelope: env), "\(m.name)") + #expect(!CloudWire.verify(boxKey: other, home: v.home, envelope: env), "\(m.name) verified with the wrong key") + var changed = env + changed.b[changed.b.count / 2] ^= 0x01 + #expect(!CloudWire.verify(boxKey: key, home: v.home, envelope: changed), "\(m.name) verified with a flipped byte") + } + } + + @Test func homeAndOpaqueIDsMatchGo() throws { + let v = try CloudVectors.load() + let boxPK = Bytes(hex: v.boxKey.pk) + #expect(CloudWire.homeID(pk: boxPK) == v.home) + #expect(Bytes(try v.boxSigner().publicKey.x963Representation) == boxPK) + let service = try P256.Signing.PrivateKey(rawRepresentation: Bytes(hex: v.serviceKey.d)) + #expect(Bytes(service.publicKey.x963Representation).hex == v.serviceKey.pk) + + let grantKey = Bytes(hex: v.grantKey) + #expect(v.ids.count == 4) + for id in v.ids { + let got = id.kind == "src" + ? CloudWire.sourceID(grantKey: grantKey, driver: id.name) + : CloudWire.loadpointID(grantKey: grantKey, loadpoint: id.name) + #expect(got == id.id, "\(id.kind) \(id.name)") + } + // A loadpoint and a source with one name do not share an id. + #expect(CloudWire.sourceID(grantKey: grantKey, driver: "garage") != CloudWire.loadpointID(grantKey: grantKey, loadpoint: "garage")) + } + + @Test func goldenBodiesDecodeToTheirFields() throws { + let v = try CloudVectors.load() + let grantKey = Bytes(hex: v.grantKey) + let meter = CloudWire.sourceID(grantKey: grantKey, driver: "p1meter") + let inverter = CloudWire.sourceID(grantKey: grantKey, driver: "sungrow") + let charger = CloudWire.sourceID(grantKey: grantKey, driver: "easee") + let garage = CloudWire.loadpointID(grantKey: grantKey, loadpoint: "garage") + let wall: Int64 = 1_791_820_800_000 + let boot = Bytes(repeating: 0xb0, count: CloudWire.bootBytes) + + let full = try CloudWire.Live(body: v.envelope("live full").b) + #expect(full.header == CloudWire.Header(seq: 3, boot: boot, uptimeMs: 60_020, wallMs: wall + 20)) + #expect(full.fields == ["1": 0, "2": 1840, "3": -2310, "4": 950, "5": 612, "6": 1100, "10": 2100]) + #expect(full.fieldSources?["6"] == [charger, inverter, meter]) + #expect(full.sources?[charger] == CloudWire.Source(kind: "driver", state: .lagging, lastOkMs: 1_000, staleAfterMs: 30_000)) + #expect(full.sources?.count == 3) + #expect(full.srcRev == 1) + #expect(full.ages == [meter: 920, inverter: 2620, charger: 59_020]) + #expect(full.dispatchBlockedBy.isEmpty) + + // A delta carries no table, which is not the same as an empty one. + let delta = try CloudWire.Live(body: v.envelope("live delta").b) + #expect(delta.sources == nil) + #expect(delta.fieldSources == nil) + #expect(delta.fields["2"] == 1795) + + let lp = try CloudWire.LP(body: v.envelope("lp").b) + #expect(lp.header.seq == 5) + #expect(lp.controlRev == 812) + let car = try #require(lp.loadpoints.first) + #expect(lp.loadpoints.count == 1) + #expect(car.id == garage && car.src == charger && car.plugged) + #expect(car.socPermille == 430 && car.socSource == "inferred" && car.socAgeS == 1800) + #expect(car.targetPermille == 800 && car.readyByMs == 1_791_867_600_000 && car.recurring) + #expect(car.powerW == 2100 && car.sessionWh == 3400 && car.session == "s1") + #expect(car.manual == nil) + #expect(car.commandedW == 2070 && car.commandedReason == "plan") + #expect(!car.surplusOnly && !car.gridDeferred && !car.planPending && !car.boost) + #expect(car.nextStartMs == 1_791_850_000_000 && car.nextEndMs == 1_791_857_200_000 && car.nextWh == 7400) + #expect(lp.feedback == [CloudWire.Feedback(kind: "ev", src: charger, status: "following", reason: "plan", severity: "ok", + evidence: "measured", fresh: true, actualW: 2100, ageMs: 900)]) + + let prices = try CloudWire.Prices(body: v.envelope("prices").b) + #expect(prices.zone == "SE3" && prices.currency == "SEK") + #expect(prices.t0Ms == 1_791_820_800_000 && prices.slotMs == 900_000) + #expect(prices.spotMinor == [42, 40, nil, -3]) + #expect(prices.totalMinor == [118, 115, nil, 71]) + + let hb = try CloudWire.HB(body: v.envelope("hb").b) + #expect(hb.header == CloudWire.Header(seq: 7, boot: boot, uptimeMs: 69_020, wallMs: wall + 9_020)) + #expect(hb.clock == "ntp" && hb.srcRev == 1 && hb.liveSeq == 4 && hb.lpSeq == 5) + #expect(hb.ages == [meter: 1_400, inverter: 3_100]) + } + + @Test func unsignedEnvelopesPutTheirBodyInDeterministicOrder() throws { + let v = try CloudVectors.load() + // Keys given out of order come out as Go's core deterministic + // encoder writes them: shorter first, then by bytes. + let challenge = CBOR.map([("nonce", .bytes(Bytes(repeating: 0x11, count: 32))), ("v", .int(1))]) + #expect(CloudWire.encodeUnsigned(t: "challenge", body: challenge).hex == (try v.message("challenge").envelope)) + #expect(CloudWire.encodeUnsigned(t: CloudWire.msgWatch, body: .map(["hz": .int(1)])).hex == (try v.message("watch").envelope)) + #expect(CloudWire.encodeUnsigned(t: "resync", body: .map(["what": .array([.text("full")])])).hex == (try v.message("resync").envelope)) + let nested = CBOR.map([("zz", .map([("bb", .int(1)), ("a", .int(2))])), ("y", .array([.map([("cc", .int(3)), ("d", .int(4))])]))]) + // {"y": [{"d": 4, "cc": 3}], "zz": {"a": 2, "bb": 1}} + #expect(encodeCBOR(CloudWire.deterministic(nested)).hex == "a2617981a261640462636303627a7aa261610262626201") + } + + @Test func appSessionReplaysAsTheInitiator() throws { + let v = try CloudAppVectors.load() + let signed = try CloudVectors.load() + let boxPK = Bytes(hex: signed.boxKey.pk) + + let phone = try Primitives.keyPair(fromSecret: Bytes(hex: v.phoneStatic)) + #expect(phone.publicKey.hex == v.phonePublic) + #expect(try Primitives.keyPair(fromSecret: Bytes(hex: v.serviceStatic)).publicKey.hex == v.servicePublic) + #expect(v.home == signed.home) + let prologue = CloudWire.appPrologue(home: v.home) + #expect(prologue.hex == v.prologue) + + let msg1Payload = CloudWire.encodeUnsigned(t: CloudWire.msgNow, body: .map(["hz": .int(CloudWire.hzLooking)])) + #expect(msg1Payload.hex == v.msg1Payload) + let handshake = try HandshakeState.initiator( + staticKey: phone, + remoteStatic: Bytes(hex: v.servicePublic), + prologue: prologue, + ephemeral: try Primitives.keyPair(fromSecret: Bytes(hex: v.phoneEphemeral)) + ) + #expect(try handshake.writeMessage(msg1Payload).hex == v.msg1) + let msg2Payload = try handshake.readMessage(Bytes(hex: v.msg2)) + #expect(msg2Payload.hex == v.msg2Payload) + + let snapshot = try CloudWire.parseSnapshot(payload: msg2Payload) + #expect(snapshot.online) + #expect(snapshot.more == 0) + #expect(snapshot.lastSeenMs == 1_791_820_869_000) + #expect(snapshot.envelopes.count == v.snapshot.count) + for (entry, name) in zip(snapshot.envelopes, v.snapshot) { + #expect(entry == (try signed.envelope(name)), "\(name)") + #expect(entry.encode().hex == (try signed.message(name).envelope), "\(name)") + #expect(CloudWire.verify(boxKey: boxPK, home: v.home, envelope: entry), "\(name)") + } + + let transport = NoiseTransport(try handshake.split()) + #expect(transport.handshakeHash.hex == v.handshakeHash) + #expect(v.transport.count == 4) + for step in v.transport { + let frame = Bytes(hex: step.frame) + if step.from == "service" { + #expect(try transport.decrypt(Bytes(hex: step.message)) == frame, "\(step.name)") + let env = try CloudWire.Envelope.decode(CloudWire.decodeFrame(frame)) + #expect(env == (try signed.envelope(step.name)), "\(step.name)") + #expect(CloudWire.verify(boxKey: boxPK, home: v.home, envelope: env), "\(step.name)") + } else { + #expect(try transport.encrypt(frame).hex == step.message, "\(step.name)") + } + } + // The phone's own frames are what encodeUnsigned writes. + let away = try CloudWire.encodeFrame(CloudWire.encodeUnsigned(t: CloudWire.msgWatch, body: .map(["hz": .int(CloudWire.hzAway)]))) + let suspend = try CloudWire.encodeFrame(CloudWire.encodeUnsigned(t: CloudWire.msgSuspend, body: .map(["deviceId": .text("Zf09xQ2a")]))) + #expect(v.transport.filter { $0.from == "phone" }.map(\.frame) == [away.hex, suspend.hex]) + } + + @Test func framesRefuseWhatThisProtocolNeverSends() throws { + let good: Bytes = [1, 1, 0, 0, 0, 2, 0xa0, 0xa0] + let cases: [String: Bytes] = [ + "short": [1, 1, 0], + "version": [2, 1, 0, 0, 0, 2, 0xa0, 0xa0], + "lane 0": [1, 0, 0, 0, 0, 2, 0xa0, 0xa0], + "flag": [1, 1, 2, 0, 0, 2, 0xa0, 0xa0], + "reserved": [1, 1, 0, 1, 0, 2, 0xa0, 0xa0], + "padding": good + [0], + "overrun": [1, 1, 0, 0, 0, 9, 0xa0], + "short length": [1, 1, 0, 0, 0, 1, 0xa0, 0xa0], + ] + for (name, msg) in cases { + #expect(throws: CloudWire.WireError.self, "\(name)") { try CloudWire.decodeFrame(msg) } + } + #expect(try CloudWire.decodeFrame(good) == [0xa0, 0xa0]) + #expect(try CloudWire.encodeFrame([0xa3, 1, 2, 3]) == [1, 1, 0, 0, 0, 4, 0xa3, 1, 2, 3]) + #expect(throws: CloudWire.WireError.self) { try CloudWire.encodeFrame(Bytes(repeating: 0, count: CloudWire.maxPayload + 1)) } + } + + @Test func anEmptySignatureIsAnEmptyByteString() throws { + let raw = CloudWire.Envelope(t: CloudWire.msgWatch, b: [0xa1, 0x62, 0x68, 0x7a, 1]).encode() + // {"b": h'a162687a01', "t": "watch", "sig": h''} + #expect(raw.hex == "a3616245a162687a0161746577617463686373696740") + let back = try CloudWire.Envelope.decode(raw) + #expect(back.t == CloudWire.msgWatch && back.sig.isEmpty && back.b == [0xa1, 0x62, 0x68, 0x7a, 1]) + } + + @Test func envelopesRefuseTheWrongShapes() { + func mk(_ pairs: [(String, CBOR)]) -> Bytes { encodeCBOR(.map(pairs)) } + let cases: [String: Bytes] = [ + "not a map": encodeCBOR(.array([.int(1)])), + "no type": mk([("b", .bytes([1])), ("sig", .bytes([]))]), + "no body": mk([("t", .text("hb")), ("sig", .bytes([]))]), + "no sig": mk([("t", .text("hb")), ("b", .bytes([1]))]), + "text body": mk([("t", .text("hb")), ("b", .text("a0")), ("sig", .bytes([]))]), + "bytes type": mk([("t", .bytes(Bytes("hb".utf8))), ("b", .bytes([1])), ("sig", .bytes([]))]), + "empty type": mk([("t", .text("")), ("b", .bytes([1])), ("sig", .bytes([]))]), + "text sig": mk([("t", .text("hb")), ("b", .bytes([1])), ("sig", .text(""))]), + "null sig": mk([("t", .text("hb")), ("b", .bytes([1])), ("sig", .null)]), + "integer body": mk([("t", .text("hb")), ("b", .int(5)), ("sig", .bytes([]))]), + "integer key": encodeCBOR(.map([CBOR.Entry(.int(1), .int(1)), CBOR.Entry(.text("t"), .text("hb")), + CBOR.Entry(.text("b"), .bytes([1])), CBOR.Entry(.text("sig"), .bytes([]))])), + "garbage": [0xff, 0x00], + "duplicate t": [0xa4, 0x61, 0x62, 0x41, 0, 0x61, 0x74, 0x61, 0x78, 0x61, 0x74, 0x61, 0x79, 0x63, 0x73, 0x69, 0x67, 0x40], + "truncated": [0xa3, 0x61, 0x62], + "trailing": mk([("t", .text("hb")), ("b", .bytes([1])), ("sig", .bytes([]))]) + [0], + ] + for (name, raw) in cases { + #expect(throws: CloudWire.WireError.self, "\(name)") { try CloudWire.Envelope.decode(raw) } + } + // Unknown keys are ignored. + let extra = mk([("b", .bytes([1])), ("t", .text("hb")), ("sig", .bytes([])), ("zz", .int(9))]) + #expect((try? CloudWire.Envelope.decode(extra)) == CloudWire.Envelope(t: "hb", b: [1])) + } + + @Test func theSignatureCoversHomeTypeAndEveryBodyByte() throws { + let v = try CloudVectors.load() + let boxPK = Bytes(hex: v.boxKey.pk) + let env = try v.envelope("hb") + #expect(CloudWire.verify(boxKey: boxPK, home: v.home, envelope: env)) + for i in env.b.indices { + var changed = env + changed.b[i] ^= 0x01 + #expect(!CloudWire.verify(boxKey: boxPK, home: v.home, envelope: changed), "byte \(i)") + } + var other = env + other.t = CloudWire.msgLive + #expect(!CloudWire.verify(boxKey: boxPK, home: v.home, envelope: other)) + #expect(!CloudWire.verify(boxKey: boxPK, home: "aaaaaaaaaaaaaaaaaaaaaaaaaa", envelope: env)) + var short = env + short.sig.removeLast() + #expect(!CloudWire.verify(boxKey: boxPK, home: v.home, envelope: short)) + #expect(!CloudWire.verify(boxKey: Array(boxPK.dropLast()), home: v.home, envelope: env)) + var offCurve = boxPK + offCurve[64] ^= 1 + #expect(!CloudWire.verify(boxKey: offCurve, home: v.home, envelope: env)) + // A signature this file makes verifies as Go's do. + let mine = CloudWire.Envelope(t: env.t, b: env.b, sig: try cloudSign(v.boxSigner(), home: v.home, t: env.t, b: env.b)) + #expect(CloudWire.verify(boxKey: boxPK, home: v.home, envelope: mine)) + } + + @Test func aHomeIDIsTwentySixLowercaseCharacters() throws { + let home = CloudWire.homeID(pk: Bytes(hex: try CloudVectors.load().boxKey.pk)) + #expect(home.count == 26) + #expect(home.utf8.allSatisfy { (0x61...0x7a).contains($0) || (0x32...0x37).contains($0) }) + #expect(String(decoding: CloudWire.appPrologue(home: "fjzyr67buzpyncqmjliwgezvde"), as: UTF8.self) == "ftw.cloud.v1:fjzyr67buzpyncqmjliwgezvde") + } + + @Test func aSnapshotIsTheOnlyMessageTwo() throws { + let now = CloudWire.encodeUnsigned(t: CloudWire.msgNow, body: .map(["hz": .int(1)])) + #expect(throws: CloudWire.WireError.self) { try CloudWire.parseSnapshot(payload: now) } + let notEnvelopes = CloudWire.encodeUnsigned(t: CloudWire.msgSnapshot, body: .map(["envelopes": .array([.emptyMap])])) + #expect(throws: CloudWire.WireError.self) { try CloudWire.parseSnapshot(payload: notEnvelopes) } + } + + @Test func bodiesIgnoreUnknownKeysAndRefuseTheWrongTypes() throws { + func body(_ pairs: [(String, CBOR)]) -> Bytes { encodeCBOR(.map(pairs)) } + let hb = try CloudWire.HB(body: body([("seq", .int(4)), ("future", .text("x")), ("wallMs", .null), ("liveSeq", .int(3))])) + #expect(hb.header.seq == 4 && hb.header.wallMs == nil && hb.liveSeq == 3 && hb.ages.isEmpty && hb.clock.isEmpty) + + let refused: [String: Bytes] = [ + "empty": [], + "not a map": encodeCBOR(.array([])), + "negative seq": body([("seq", .int(-1))]), + "text seq": body([("seq", .text("1"))]), + "float uptime": body([("seq", .int(1)), ("uptimeMs", .double(1.5))]), + "text boot": body([("seq", .int(1)), ("boot", .text("b"))]), + "too large": body([("seq", .int(1)), ("uptimeMs", .unsigned(UInt64(Int64.max) + 1))]), + ] + for (name, raw) in refused { + #expect(throws: CloudWire.WireError.self, "\(name)") { try CloudWire.Header(body: raw) } + } + #expect(throws: CloudWire.WireError.self) { try CloudWire.Live(body: body([("fields", .map(["2": .text("1")]))])) } + #expect(throws: CloudWire.WireError.self) { try CloudWire.Live(body: body([("sources", .array([]))])) } + #expect(throws: CloudWire.WireError.self) { try CloudWire.LP(body: body([("loadpoints", .array([.map(["plugged": .int(1)])]))])) } + + // A state this build does not know ranks as never. + let live = try CloudWire.Live(body: body([("sources", .map(["m": .map(["state": .text("melting")])]))])) + #expect(live.sources?["m"]?.state == .never) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-app-vectors.json b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-app-vectors.json new file mode 100644 index 0000000..b49c1b3 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-app-vectors.json @@ -0,0 +1,53 @@ +{ + "serviceStatic": "02d86370739892ce589fb0dac0416128cb716a667f6b3baf2979ed03a39be145", + "servicePublic": "4bcf8ca8663797d826934ad341de6e223114105a1f513b78da30ef3f32cd8557", + "serviceEphemeral": "f4e90c05a93a8b3e2e3741a3c3df44681e9829b95332b4a406e3325d3cce6bd3", + "phoneStatic": "ea39659c38ef7cf936ca64fc18edccb9889785bf85e202593bd192680b425f37", + "phonePublic": "f7d081a5e8884ade9cf55aeb6cf767afabf70d3c0b195a013651055908128f08", + "phoneEphemeral": "df36c6700bc874c62773997c6e95704050bac93a3b649e0883a7f5791a358ebc", + "home": "fjzyr67buzpyncqmjliwgezvde", + "prologue": "6674772e636c6f75642e76313a666a7a7972363762757a70796e63716d6a6c697767657a766465", + "msg1Payload": "a3616245a162687a016174636e6f776373696740", + "msg1": "38869cb4f47eb17ca6a251b657a79943bca4b3a22813caac1578296e837a0273dd35e67fe141738f375ec80128d8e3c18dae31dcc5fe42a0273b9cbe300164e9e903d823a7c1bab23d562d1bf2879379e0a974150000f0b93b6e0c0fe2d0f58b1d814d5427606b9ab58d548696d231eab9369a86", + "msg2Payload": "a36162590771a4646d6f726500666f6e6c696e65f569656e76656c6f70657385a361625901fdaa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b65644279806174646c69766563736967584002a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95a3616258a1a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b65644279806174646c6976656373696758400dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79a36162588caa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847617466707269636573637369675840e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd86a6c6173745365656e4d731b000001a130d8ad88617468736e617073686f746373696740", + "msg2": "a0719d669e99f8ba82522bf6f2e6614d99bccb5616ae3e2af7e4635267531c148d96093e3a658c9843d6ba1321e2fc10ea6f9dc9a4ff98256ab2edf4dd8f9d8d29fc50ed8ce63ab3d409e5740f425a84b8331523b6aebb7bc37080cf41080144beee9311a8089e341d3fb4886247e9114f2990333e5e29e7f3d91f338d0fdb89bb0ff5424c533b8a6cb800dd263eb08c5995540efdad9b729ff8d63e1c15df4cfe7079a8354fee4141d7ac43a0d44cc6b820444920c760983413caefbd1a31037003228381c291226f4cead6db97fe6023e8c0c1a503e8467c6726d1a045b456971a2ba19df457439481c218b95a5a94628ca3a813ee741501e475b6039f5614ab8a5e71ffe8377d3e06fc253f106a4133709fbfdccb6c6ca754ba375e12c924cb5c52351cf78ee6645f007a439ef55774f69b8e458d93cec2a1b369759ba67772b8c4f45737a82aef39f434c956b34a8a1978e261999904b80d39cd619cda27cbc983b45385b8c2569677f76d888439e7dae4df77a4e1d05ee11526f1a6276de5a53266d13fcc620e77594115f90d1b9e802ee942c5705aecb8bcd7ca2d410474dcd89d368f5411645a69bf364e67abc04e7638028117f577c016d008ba9eea84215a921b67dab3e16ec3c11b716574c332a5047e824b0232b97a1bdd210056b4a936cf70221aef0e40fd7ae1341abf7ab9b09a4424203c2f821ca2e9360b2f2130b28fa7d28cf7ac89d7013dd086f4f7aa88f3667cd25d279b0697224504f05bb5dd15098759a86cf046521fddf4010323fa2bfd88bd58b3e6babaf32d77a607b220aa613b4a0dacc1141556d8aae189d218f9f402e57b3ededb3f8b46d5d18267566fa0c309ef988333c416218579247abd47634d6631a4c6e81ecb0c5946370b1686b08509ff35aee5a6b4aed4ff56bb1c4fd7742377f8da760867a8c8e482abd062326085f62b2343957c6826e15d8a5025a584ca3b1eb31d2f48c5c0234ec9f63b68d589d4b7ad4727bb5141e2e2d3a3a7cfbe15eae129a6cdaaab7df28eb05d8ce4cf5d65761c4f5132dc717cae2c7045860224743780aca762c3e77fec18db5c7bb73bfd66f90df934afaf0b6f4f7d6102bae17b083e47a4aa63db614aa0441c0fd45193d7bc02194d9d93241f6ce83feda0f6f16d79cd13a3aadfb1fad0e8db8a40a1434f85634e923759363a71b37579f28c3795fba377a59c719937b56d44aed8bad1fd3a7be959b80ae0e12fcf1d8f2a016a64663a9f197eb35c1d1948dea05b88ff7cf25aa794a98e01383ab4dd5e4de1aceaa0dae29b90c776b4b8c97ef95201f1b466b5a19841a427a4b74123341f763770661c4fb8b01837a785c86f065d258801fac607d886d0775b32c8856e47f05be7c0f672c886c218371104c237c95c9e149bf2a5ab68882737919351429a0982da7304bb3d2643a11f12105ac393a5167fdf30a163b175c3bfe2aea7cb89cf13a60fac27fc447e98c530726d5ed4ec0d2355488a5c0ee8b265c95a3ec9381a2e1f156740bfe1346143e227d4ed6a78adbd1447f9e2690fb878f3c7bdbaefa366edbdb4c2b99c4735847beaaeada651140497d4dc82133797dd1f3333579b93aeef3a485dc3a658a20d22584b54bd5efac1d1542e37212465b51c0ccebb55d81f68729e77db8ea5eb001abd46fd4ea13d2e67114be82976a748368948bd9f3bb44784830eb8723d37e1bb12785fdcd2b02ce60d7ad655000d5abbdbbae9db3f215308e56f378b736cd101236221dbfb5c00a15c97161b53c144de4fdfac0f1e349536a37065259b86630b2f7351568878832c2a8c8c5669f70d8430b6a22c80c898da82ccd6e0b0cb902eda4b5294981a3d037cdf4da8ea31e511fdd142546e4c01e1c0154d1c098f5b36dee0d4732ad19032e9416eed93addea024bc422e5dad3783af218534b255d5dbde9202d0931230b7c6678c66165e05f0e98099b9ebe45ae7d83a1e0f4bc15845cbe4012a807632819d8d5762286c2868056b0670c26b7faa765047b42f89d7fe6e012a262c354a485419b0fb236a4332130707728e25b323f76dfcd6d89fcff4ffa274f988bcd09207f0e24f12f69a83f448b814c5eb0d7d87f993593f27ea3189a61f111d89c9d9e6668abd495253340741b31b34234e3d89106cda2a4dd06e3bc96ad3ed9dede19f1d387b1c7e094733b9e85571d9393fa23efdef0e64af8a53d4ab314b0c0f2a0f15d16bb2db9f8e57816b10d0e8a3d3406a08a68d24d0fdb1589ef951c7e98f77f9ad558da0a9a927ad9d85779bec3376f51188d12accf7fe20a019861a3c8bf0cf5135a093a7ab2cb7b2db0ca548b5e677a3bf8e0add303e3567d71826c4fd38a65a95aeeb8c166846090ee9a173fe0a753041f9df779f693ae686b75fe9854f76d0405bd2e4e5d98f3d0ee74747229c73f2964e72861a4b8df60fe638e8c450b80de929b1d612afc9c8fe537ffad8eed8f176c4d53cd1149e9566da2ff321905ea640938df4f972cb887c66c1a7828da36a014b09f111c1464ffe85f74d6e50a8428c4384579d0180ecd00209012545755e9b1c584817446d5a930e775460bb6edb1f19f6185e63685debd622b293657d3a560ef47c53aff422488f8cb09013290f08433e0f8284fa0e5105f84a2aa2882e75b7b846f19bf6dc89894a3fd773f1804f8cfa5e8a134ba15003472bacb2e5ccae391e8f08ae52a05ee1d4c2f850f94f096087bb4d26c5a6dacde4189ce9b76aeb738021092857b36c84037356d7f3431ac041c41072c57377ea", + "handshakeHash": "572f4062a19964d6417d27e070137f6e012ac705c073841669685158a36a0de7", + "snapshot": [ + "live full", + "live delta", + "lp", + "prices", + "hb" + ], + "transport": [ + { + "from": "service", + "name": "hb", + "frame": "0101000000d0a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8", + "message": "00000000000000009bc932e703b7e50790aabe40832bc82ce30862451e6dbe237412298bdaec184f5bce9309f5e4823f6dc191ab5ae312c7308284bda5b4c01cebc376c65fde035767f581515047a7d463eda5e9849ee2278db80301aa88813981e80f9f3f4988de7cf8869719a5160e41e2cca4c4b0deedcff9c5706ca531452abcee947966643389f57790b7c899cf6fa4945d70b455d3270a6484d978440b91d3e23ce3055f1ef260a148ba4570bb51cb58ef3190066f2b1443d6ecd11a015b96966e0caf32236a4df15140e6ae6e131ede59ff51403400bcd325f075f7d2a5000f847a6a7996480bbbc63d08" + }, + { + "from": "service", + "name": "lp", + "frame": "010100000250a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79", + "message": "000000000000000148cf8f560097201c885cbfb4654f3007e1146c3b6c39ce8a4b98af3c76347a1def28cbf0bf20db92705fcc9bff3ee7bd78220f315ebbfebc2b4371edd2b9823910351899a8b3ef8e2a26c61eaa56fd4d71fff10af0aef235390740ad8357756cbe3ccbbf667c02d3c36d056d8f2ea29dcf4dd3be678e9d19abc308d6f940c45053b6633a336e0e86ba1accd5ac2ac60a7b7471115b9c539ee973acc104a71b71c42724ed43335f9eb3eff4d447b52d989605ab2a0e3250fbf19ec53367f11f1fbaa633b1bbd7422bc0740f7d44ffc6e7fc1b1e6594fae2a3027eb58b5a63089a328878c377ef69d5b1e194606836b5db97e9da4cd5e6bac5214f17b7ef161d0a04605251de29bfab442788332d5dcfc716bc04f604544368a87cfb77d7d57b89dae49c8378b2cf2372b6fe1108076edd6adbec3fa25df23d210bb22281f91564367ec7bfb17d5b6c218eda569d65bf5a99a9a242ff72190419e2ebaa34919457c9163458d6eec3e42b67e395b6d96b771a82ca8e4cf5faf3db7493c02d1695e2b2a6e40d3156cb570f5d4880ca294fd8ac70d8deddd605cdc9fbf5cb4c0b3de38a66ff3e1ef0d35c01dfb576eda170a545682498c035afac02f29ce0e3b7c0c3d0ad1536d04481cbc90e18376c57348ff1f38b1b83000e343966b52cf82f0d243c0d225360c6c1034f0dfbc2ed08f09ff2844c578ac10f27e5fa85e77fe84b98e6fff0609e82409f9b32b5809f50355007f7284bc8d60d612d60b46627668de3d4e7966b6c1f40435bd4d0fbba103a13c29db8fb74aa1c6ca06ea20979e3093d3a88f8fbef1bba2e15cfd81822a573820f3ca87e9f9519fbb8c536f23831f69e7be75b869835" + }, + { + "from": "phone", + "name": "watch away", + "frame": "010100000016a3616245a162687a0061746577617463686373696740", + "message": "000000000000000018d9d2f74604f3ec3b405b61c46ab22f3f16d31530953cc94eec8cade0d64f32ba349e2f7835559709c008f3" + }, + { + "from": "phone", + "name": "suspend", + "frame": "010100000026a3616253a1686465766963654964685a6630397851326161746773757370656e646373696740", + "message": "00000000000000018ef98c7df1fa903675ab1bf0e8aff5cd342be0cdc21b8ead3d04a153cf0fe6ce0d28f5fdf88e409e68eadc1827bfc61039294263085fc27f2b9b980b" + } + ], + "notes": { + "keys": "Secrets are SHA-256 of the labels in app_vectors_test.go. X25519 clamps them.", + "snapshot": "Message 2 holds the cloud-vectors.json envelopes named here, in this order.", + "transport": "Each message is appwire's: u64 BE seq, then ChaCha20-Poly1305 over the frame with the seq as associated data. Each direction counts from 0." + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-vectors.json b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-vectors.json new file mode 100644 index 0000000..d578b86 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-vectors.json @@ -0,0 +1,166 @@ +{ + "boxKey": { + "d": "6c50811aa47e688ecbb01b2c1e91aca4a2ad7c879526d1c9679ef3fd25560074", + "pk": "04cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b8", + "home": "fjzyr67buzpyncqmjliwgezvde" + }, + "serviceKey": { + "d": "f8d258ed839f4602dcfc2c139237d740e960310da68140e23bf88bf4c3c6ffa5", + "pk": "043cb883f267f64e085082b9025d318d83a1b36bc9fa474091074498c6417100da767b737fc59344ddb1b3ae5ddd4b6446f330315d79a0fb7dc88e96a5ec827c9e" + }, + "grantKey": "7777777777777777777777777777777777777777777777777777777777777777", + "ids": [ + { + "kind": "src", + "name": "p1meter", + "id": "ACfHt4DWL7g" + }, + { + "kind": "src", + "name": "sungrow", + "id": "f-oHMDqV-mA" + }, + { + "kind": "src", + "name": "easee", + "id": "DUYWWQmn-Xg" + }, + { + "kind": "lp", + "name": "garage", + "id": "j3HZORNCWHs" + } + ], + "messages": [ + { + "name": "challenge", + "signer": "", + "t": "challenge", + "body": "a2617601656e6f6e636558201111111111111111111111111111111111111111111111111111111111111111", + "signedInputSha256": "b8baed8e00b3e02409d01f18a83294c626a1ee5d43a3e38efd071fb2a89e92f0", + "sig": "", + "envelope": "a36162582ca2617601656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116174696368616c6c656e67656373696740", + "frame": "010100000042a36162582ca2617601656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116174696368616c6c656e67656373696740" + }, + { + "name": "hello", + "signer": "box", + "t": "hello", + "body": "ae61760162706b584104cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b863656b6d58202222222222222222222222222222222222222222222222222222222222222222637365710164626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361707385646c697665626c706670726963657362686266726f73746572656275696c6467302e3134322e3065636c6f636b636e7470656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116677616c6c4d731b000001a130d7a000676772616e744964504444444444444444444444444444444468626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333368757074696d654d7319ea6069726f7374657253657104", + "signedInputSha256": "85ba027484b1e3ca0d10cca2602d07413d2dac2850e410e468274ee17ac43068", + "sig": "32b835d4f0c887e458a7af2f52f53368a7c188d85ec502bcec39fd43e6f0fd00a0205eb354a8ea6351acedecf5b3c8c5c663bd2c66d507e282f65794a87f540d", + "envelope": "a36162590155ae61760162706b584104cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b863656b6d58202222222222222222222222222222222222222222222222222222222222222222637365710164626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361707385646c697665626c706670726963657362686266726f73746572656275696c6467302e3134322e3065636c6f636b636e7470656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116677616c6c4d731b000001a130d7a000676772616e744964504444444444444444444444444444444468626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333368757074696d654d7319ea6069726f737465725365710461746568656c6c6f63736967584032b835d4f0c887e458a7af2f52f53368a7c188d85ec502bcec39fd43e6f0fd00a0205eb354a8ea6351acedecf5b3c8c5c663bd2c66d507e282f65794a87f540d", + "frame": "0101000001a9a36162590155ae61760162706b584104cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b863656b6d58202222222222222222222222222222222222222222222222222222222222222222637365710164626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361707385646c697665626c706670726963657362686266726f73746572656275696c6467302e3134322e3065636c6f636b636e7470656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116677616c6c4d731b000001a130d7a000676772616e744964504444444444444444444444444444444468626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333368757074696d654d7319ea6069726f737465725365710461746568656c6c6f63736967584032b835d4f0c887e458a7af2f52f53368a7c188d85ec502bcec39fd43e6f0fd00a0205eb354a8ea6351acedecf5b3c8c5c663bd2c66d507e282f65794a87f540d" + }, + { + "name": "hello_ok", + "signer": "service", + "t": "hello_ok", + "body": "a463656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f7374657253657103", + "signedInputSha256": "499bf81555f32a94ce6e39a3e1eb43880773b33005ae5efe459a8227aee4d855", + "sig": "230af93c21c9b3b977d9c89cfdd637ab53d0be86fee865f5b3bd0f931993476fd439a631ce64cd949df8047b48ce083610b71316cd20cd9ca12c49242c7d283d", + "envelope": "a361625863a463656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f737465725365710361746868656c6c6f5f6f6b637369675840230af93c21c9b3b977d9c89cfdd637ab53d0be86fee865f5b3bd0f931993476fd439a631ce64cd949df8047b48ce083610b71316cd20cd9ca12c49242c7d283d", + "frame": "0101000000b9a361625863a463656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f737465725365710361746868656c6c6f5f6f6b637369675840230af93c21c9b3b977d9c89cfdd637ab53d0be86fee865f5b3bd0f931993476fd439a631ce64cd949df8047b48ce083610b71316cd20cd9ca12c49242c7d283d" + }, + { + "name": "roster", + "signer": "box", + "t": "roster", + "body": "a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104", + "signedInputSha256": "f0926ee704ac9558f742f604315ef8ea260a9edef63a210172d9c299d5b171cf", + "sig": "cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124", + "envelope": "a361625901b2a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104617466726f73746572637369675840cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124", + "frame": "010100000207a361625901b2a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104617466726f73746572637369675840cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124" + }, + { + "name": "live full", + "signer": "box", + "t": "live", + "body": "aa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b6564427980", + "signedInputSha256": "e45fdca79363137b374011804fb5905c1452b533cb275c89de0aa61562886393", + "sig": "02a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95", + "envelope": "a361625901fdaa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b65644279806174646c69766563736967584002a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95", + "frame": "010100000250a361625901fdaa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b65644279806174646c69766563736967584002a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95" + }, + { + "name": "live delta", + "signer": "box", + "t": "live", + "body": "a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b6564427980", + "signedInputSha256": "6881cdc040c9d5e58e85e7bfb9e3b15ae13e6a9c235a06070c43d870df0976fd", + "sig": "0dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6", + "envelope": "a3616258a1a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b65644279806174646c6976656373696758400dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6", + "frame": "0101000000f3a3616258a1a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b65644279806174646c6976656373696758400dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6" + }, + { + "name": "lp", + "signer": "box", + "t": "lp", + "body": "a7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e", + "signedInputSha256": "4774dda3175a23d752f2b1aea4e1959d50edec99f4882caf007779daa829eb8f", + "sig": "b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79", + "envelope": "a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79", + "frame": "010100000250a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79" + }, + { + "name": "prices", + "signer": "box", + "t": "prices", + "body": "aa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847", + "signedInputSha256": "022b3d2ae277cd3caee80775e0a2fe1c8ecebcce3263f586c0ee5e4af96a00e7", + "sig": "e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3", + "envelope": "a36162588caa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847617466707269636573637369675840e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3", + "frame": "0101000000e0a36162588caa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847617466707269636573637369675840e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3" + }, + { + "name": "hb", + "signer": "box", + "t": "hb", + "body": "a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c", + "signedInputSha256": "6961171385681257aeb2a889a6c33b5854b95e85cef4d9a8fd3b6dfb7d709541", + "sig": "e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8", + "envelope": "a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8", + "frame": "0101000000d0a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8" + }, + { + "name": "watch", + "signer": "", + "t": "watch", + "body": "a162687a01", + "signedInputSha256": "64808965667acfc5db8cd769b2c18654ff94b593dd47363d30b0835f438bde81", + "sig": "", + "envelope": "a3616245a162687a0161746577617463686373696740", + "frame": "010100000016a3616245a162687a0161746577617463686373696740" + }, + { + "name": "resync", + "signer": "", + "t": "resync", + "body": "a16477686174816466756c6c", + "signedInputSha256": "778a30a84492300eb2b2b14555a35e0ac73d2f145d5ef5c0b9aa45e0be3f093f", + "sig": "", + "envelope": "a361624ca16477686174816466756c6c617466726573796e636373696740", + "frame": "01010000001ea361624ca16477686174816466756c6c617466726573796e636373696740" + }, + { + "name": "bye", + "signer": "box", + "t": "bye", + "body": "a6637365710863776879677265766f6b656464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0657075726765f56677616c6c4d731b000001a130d7c71068757074696d654d731a00011170", + "signedInputSha256": "0499c5251783c9826fdc39a6a09e8f0f35f20244dd100b07e9978dced9475389", + "sig": "19b5ea48e5c3b113712f5815840c2339d654a3438a516dff0b13c444c604d94f5f25b72689a8dd9e02de38740f3b420836d0e40e12e8d236ac21e8685e781b04", + "envelope": "a36162584da6637365710863776879677265766f6b656464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0657075726765f56677616c6c4d731b000001a130d7c71068757074696d654d731a0001117061746362796563736967584019b5ea48e5c3b113712f5815840c2339d654a3438a516dff0b13c444c604d94f5f25b72689a8dd9e02de38740f3b420836d0e40e12e8d236ac21e8685e781b04", + "frame": "01010000009ea36162584da6637365710863776879677265766f6b656464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0657075726765f56677616c6c4d731b000001a130d7c71068757074696d654d731a0001117061746362796563736967584019b5ea48e5c3b113712f5815840c2339d654a3438a516dff0b13c444c604d94f5f25b72689a8dd9e02de38740f3b420836d0e40e12e8d236ac21e8685e781b04" + }, + { + "name": "purged", + "signer": "service", + "t": "purged", + "body": "a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444", + "signedInputSha256": "8789b7655a3f732582f55de4e6e1dbb9d9a60dadc501b535560566e8c879c9b6", + "sig": "910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32", + "envelope": "a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32", + "frame": "010100000086a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32" + } + ] +} From 2f66504116a288a91c179b9df458efc64a508925 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 08:17:28 +0200 Subject: [PATCH 3/9] feat(cloud): the app reads its home from FTW Cloud first CloudCarrier opens one WebSocket to wss://cloud.ftw.energy/v1/app and runs Noise IK as initiator to the service's key with the home id in the prologue. Message 2 brings the copy the service holds in memory; every change the box sends follows on the same socket. Each envelope is checked with the box's P-256 key, which the box gives every role in hello_ok over its own Noise session, and graded by CloudView: never live without an anchor, a replayed copy ages, a forged one is dropped. SiteModel reads the cloud first while the home has a grant and the box session over the relay stays open beside it for commands. The relay's readings take over when the cloud fails, is out of reach or has nothing better, under the carrier the readings came by. Rule U is learned from the box session and kept with the box's FTW Cloud identity for the next cold start. Prices and cars from the cloud go to the widgets as carrier cloud. The first start shows one screen: the FTW app uses FTW Cloud, with a separate statistics box that is off. An owner's phone turns the grant on at the box over Noise, once per home; a viewer is told to ask the owner. About gains an FTW Cloud row to turn it off and show the receipt, and its privacy line says what FTW Cloud holds when it is on. The address is fixed and only a debug build may point elsewhere. Release builds carry no service key yet, so they read through the relay alone. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/About/AboutView.swift | 3 +- appleApp/FTW/Cloud/CloudConsentView.swift | 73 ++++ appleApp/FTW/Cloud/CloudGate.swift | 34 ++ appleApp/FTW/Cloud/CloudSettingsView.swift | 91 ++++ appleApp/FTW/UI/RootView.swift | 1 + .../Sources/FTWKit/Carrier/CloudCarrier.swift | 270 ++++++++++++ .../Sources/FTWKit/Carrier/CloudView.swift | 6 + .../Sources/FTWKit/Identity/CloudGrant.swift | 215 ++++++++++ .../Sources/FTWKit/Identity/CloudKeys.swift | 100 +++++ .../Sources/FTWKit/Protocol/Contract.swift | 9 + .../Sources/FTWKit/Protocol/Messages.swift | 3 + .../Sources/FTWKit/Protocol/Session.swift | 4 + .../Sources/FTWKit/State/AppModel.swift | 1 + .../Sources/FTWKit/State/SiteModel.swift | 396 ++++++++++++++++-- .../FTWKit/Sources/FTWShared/Freshness.swift | 13 +- .../FTWKit/Sources/FTWShared/Readings.swift | 2 +- .../Resources/sv.lproj/Localizable.strings | 5 + .../Tests/FTWKitTests/CloudCarrierTests.swift | 318 ++++++++++++++ .../Tests/FTWKitTests/CloudConsentTests.swift | 142 +++++++ .../FTWKitTests/SiteModelCloudTests.swift | 381 +++++++++++++++++ 20 files changed, 2039 insertions(+), 28 deletions(-) create mode 100644 appleApp/FTW/Cloud/CloudConsentView.swift create mode 100644 appleApp/FTW/Cloud/CloudGate.swift create mode 100644 appleApp/FTW/Cloud/CloudSettingsView.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift diff --git a/appleApp/FTW/About/AboutView.swift b/appleApp/FTW/About/AboutView.swift index 78906e1..1c7f2b2 100644 --- a/appleApp/FTW/About/AboutView.swift +++ b/appleApp/FTW/About/AboutView.swift @@ -81,6 +81,7 @@ struct AboutView: View { } Section { + if !app.isDemo { NavigationLink { CloudSettingsView(app: app) } label: { Label(CloudWords.text("FTW Cloud"), systemImage: "cloud") } } Link(destination: Origin.privacyURL()) { Label(AboutWords.text("Privacy policy"), systemImage: "hand.raised") } @@ -90,7 +91,7 @@ struct AboutView: View { } header: { Text(AboutWords.text("Privacy and support")) } footer: { - Text(AboutWords.text("FTW collects nothing about you. Readings and commands travel encrypted between this phone and your box, and Sourceful cannot read them.")) + Text(app.site?.cloudOn == true ? CloudWords.text("With FTW Cloud on, Sourceful keeps a copy of your home's readings, signed by your box, to serve this app. Commands travel encrypted between this phone and your box, and Sourceful cannot read them.") : AboutWords.text("FTW collects nothing about you. Readings and commands travel encrypted between this phone and your box, and Sourceful cannot read them.")) } Section { diff --git a/appleApp/FTW/Cloud/CloudConsentView.swift b/appleApp/FTW/Cloud/CloudConsentView.swift new file mode 100644 index 0000000..05475a1 --- /dev/null +++ b/appleApp/FTW/Cloud/CloudConsentView.swift @@ -0,0 +1,73 @@ +import FTWKit +import SwiftUI + +/// The one screen, at first start: the app uses FTW Cloud, and statistics +/// are a separate choice, off until ticked. An owner's phone turns FTW Cloud +/// on at the box when the person goes on; a viewer is told to ask the owner. +struct CloudConsentView: View { + let app: AppModel + @Bindable var consent: CloudConsent + + private var audience: CloudConsent.Audience { + app.site.map(CloudConsent.audience) ?? .unknown + } + + var body: some View { + List { + Section { + VStack(alignment: .leading, spacing: 12) { + Image(systemName: "cloud.fill") + .font(.largeTitle) + .foregroundStyle(Theme.accent) + .symbolEffect(.bounce, value: consent.answered) + .accessibilityHidden(true) + Text(CloudWords.text("The FTW app uses FTW Cloud")) + .font(.title2.weight(.bold)) + Text(CloudWords.text("FTW Cloud is part of the service. It keeps a copy of your home, signed by your box, so the app opens at once and stays up to date.")) + Text(CloudWords.text("Your box still decides. Commands go straight to it, never through FTW Cloud.")) + .foregroundStyle(Theme.fgDim) + } + .padding(.vertical, 4) + } + + switch audience { + case .viewer: + Section { + Label(CloudWords.text("Ask the owner of this home to turn on FTW Cloud."), systemImage: "person.crop.circle.badge.questionmark") + } + case .owner, .ownerLater, .unknown: + Section { + Toggle(CloudWords.text("Contribute to statistics"), isOn: $consent.contributeStats) + } footer: { + Text(CloudWords.text("Hourly energy totals, without your name or address, help make FTW better. Off unless you turn it on.")) + } + if audience == .ownerLater { + Section { + Label(CloudWords.text("Your box turns on FTW Cloud after its next update."), systemImage: "arrow.down.circle") + } + } + } + + Section { + Link(destination: Origin.privacyURL()) { + Label(CloudWords.text("Privacy policy"), systemImage: "hand.raised") + } + } footer: { + Text(CloudWords.text("You can turn FTW Cloud off in About.")) + } + } + .scrollContentBackground(.hidden) + .background(Theme.surface) + .safeAreaInset(edge: .bottom) { + Button(CloudWords.text("Continue")) { + Task { await consent.accept(app.site) } + } + .buttonStyle(FTWButtonStyle(kind: .primary)) + .padding(16) + .background(Theme.surface) + } + .animation(Theme.spring, value: audience) + .sensoryFeedback(.success, trigger: consent.answered) + .interactiveDismissDisabled() + } +} diff --git a/appleApp/FTW/Cloud/CloudGate.swift b/appleApp/FTW/Cloud/CloudGate.swift new file mode 100644 index 0000000..83672cb --- /dev/null +++ b/appleApp/FTW/Cloud/CloudGate.swift @@ -0,0 +1,34 @@ +import FTWKit +import SwiftUI + +extension View { + /// The FTW Cloud consent, once, over a real home, and the owner's grant + /// at the box once the box can take it. RootView's one line. + func cloudConsent(_ app: AppModel) -> some View { + modifier(CloudGate(app: app)) + } +} + +private struct CloudGate: ViewModifier { + let app: AppModel + @State private var consent = CloudConsent() + + func body(content: Content) -> some View { + content + .sheet(isPresented: Binding(get: { !app.needsPairing && consent.shouldAsk(app.site) }, set: { _ in })) { + CloudConsentView(app: app, consent: consent) + .presentationDragIndicator(.hidden) + } + // When the box answers, an owner who agreed turns FTW Cloud on + // there, once per home. + .task(id: ensureKey) { + guard let site = app.site else { return } + await consent.ensure(site) + } + } + + private var ensureKey: String { + guard let site = app.site else { return "" } + return "\(site.siteId ?? "")-\(site.heardFromBox)-\(site.cloudOn)-\(consent.answered)" + } +} diff --git a/appleApp/FTW/Cloud/CloudSettingsView.swift b/appleApp/FTW/Cloud/CloudSettingsView.swift new file mode 100644 index 0000000..2c6ef90 --- /dev/null +++ b/appleApp/FTW/Cloud/CloudSettingsView.swift @@ -0,0 +1,91 @@ +import FTWKit +import SwiftUI + +/// FTW Cloud in About: on or off, and the receipt once the copy is erased. +/// An owner changes it at the box over Noise; a viewer sees it. +struct CloudSettingsView: View { + let app: AppModel + @State private var consent = CloudConsent() + + var body: some View { + List { + if let site = app.site { + switch CloudConsent.audience(site) { + case .owner: + owner(site) + case .ownerLater: + Section { Text(CloudWords.text("Your box can't use FTW Cloud yet.")) } + case .viewer, .unknown: + Section { + Text(CloudWords.text(site.cloudOn ? "FTW Cloud is on for this home." : "FTW Cloud is off for this home.")) + } footer: { + Text(CloudWords.text("Only the owner of this home can change this.")) + } + } + } + Section { + Link(destination: Origin.privacyURL()) { + Label(CloudWords.text("Privacy policy"), systemImage: "hand.raised") + } + } + } + .scrollContentBackground(.hidden) + .background(Theme.surface) + .navigationTitle(CloudWords.text("FTW Cloud")) + .navigationBarTitleDisplayMode(.inline) + .task { + guard let site = app.site else { return } + await consent.refresh(site) + } + } + + @ViewBuilder + private func owner(_ site: SiteModel) -> some View { + let box = consent.box + let on = box?.on ?? site.cloudOn + Section { + Toggle(CloudWords.text("FTW Cloud"), isOn: Binding(get: { on }, set: { want in + Task { + if want { await consent.turnOn(site) } else { await followErasure(site) } + } + })) + .disabled(consent.working || box?.erasing == true) + if on { + Label(CloudWords.text(site.cloudStatus?.isOpen == true || box?.connected == true ? "Connected" : "Not connected"), systemImage: "dot.radiowaves.left.and.right") + .foregroundStyle(Theme.fgDim) + } + if box?.erasing == true { + Label(CloudWords.text("Erasing the copy"), systemImage: "hourglass") + } else if !on, let at = box?.erasedAtMs { + Label(CloudWords.format("Copy erased %@", AppWords.day(at, nowMs: Date().timeIntervalSince1970 * 1000, zone: .current)), systemImage: "checkmark.seal") + .foregroundStyle(Theme.fgDim) + } + } footer: { + VStack(alignment: .leading, spacing: 4) { + Text(CloudWords.text("Turning it off erases the copy in FTW Cloud. Your box shows when it is gone.")) + if let help = consent.help { Text(help).foregroundStyle(Theme.importing) } + } + } + if on { + Section { + Toggle(CloudWords.text("Contribute to statistics"), isOn: Binding(get: { box?.stats ?? false }, set: { want in + Task { await consent.setStats(want, site) } + })) + .disabled(consent.working) + } footer: { + Text(CloudWords.text("Hourly energy totals, without your name or address, help make FTW better. Off unless you turn it on.")) + } + } + } + + /// Off, then the receipt: the box has it once the service confirms the + /// copy is gone, usually within seconds. Asked a few times after the + /// tap, never on a timer of its own. + private func followErasure(_ site: SiteModel) async { + guard await consent.turnOff(site) else { return } + for _ in 0..<5 where consent.box?.erasing == true { + try? await Task.sleep(for: .seconds(2)) + await consent.refresh(site) + } + } +} diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift index 99158c1..d7c1034 100644 --- a/appleApp/FTW/UI/RootView.swift +++ b/appleApp/FTW/UI/RootView.swift @@ -19,6 +19,7 @@ struct RootView: View { .background(Theme.surface.ignoresSafeArea()) .tint(Theme.accent) .onAppear { LaunchClock.mark("first frame") } + .cloudConsent(app) } } diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift new file mode 100644 index 0000000..2b7068f --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift @@ -0,0 +1,270 @@ +import Foundation +import FTWShared + +/// The phone's session with FTW Cloud: one WebSocket, Noise IK as initiator +/// to the service's key, the home id in the prologue. +/// +/// Message 1 says whether the phone is looking, message 2 brings the copy the +/// service holds in memory, and every change the box sends after that comes +/// down the same socket. What arrives is the box's own signed envelopes; this +/// file moves them and checks nothing about them. `CloudView` checks every +/// signature with the key the box gave this phone over Noise. +/// +/// The service reads and never controls. Nothing sent here can change the +/// house: a command still goes to the box over the relay. +@MainActor +public final class CloudCarrier { + public enum Status: Equatable, Sendable { + case connecting + case open(sinceMs: Double) + /// `retrying` is false after a refusal: nothing changes by asking + /// again until the box's roster or grant does, so the carrier waits + /// for `wake`. + case closed(code: Int, retrying: Bool) + + public var isOpen: Bool { + if case .open = self { return true } + return false + } + } + + static let backoffBaseMs: Double = 500 + static let backoffCapMs: Double = 60_000 + /// Message 2 is one round trip after message 1; a service this slow is + /// not one to wait on. + static let handshakeTimeoutMs: Double = 10_000 + + public private(set) var status: Status = .connecting + + private let endpoint: CloudEndpoint + private let home: String + private let deviceKey: Primitives.KeyPair + private let scheduler: Scheduler + private let makeSocket: WebSocketFactory + private var looking: Bool + + private var socket: WebSocketConnection? + private var generation = 0 + private var handshake: HandshakeState? + private var transport: NoiseTransport? + /// Which of the app's service keys this dial uses. A refusal right after + /// message 1 may be a rotated key; the next one gets one try. + private var keyIndex = 0 + private var attempt = 0 + private var retry: Cancellable? + private var deadline: Cancellable? + private var shutdown = false + + private var onSnapshot: @MainActor (CloudWire.Snapshot, Double) -> Void = { _, _ in } + private var onEnvelope: @MainActor (CloudWire.Envelope, Double) -> Void = { _, _ in } + private var onStatus: @MainActor (Status) -> Void = { _ in } + + public init(endpoint: CloudEndpoint, home: String, deviceKey: Primitives.KeyPair, looking: Bool, scheduler: Scheduler, makeSocket: @escaping WebSocketFactory) { + self.endpoint = endpoint + self.home = home + self.deviceKey = deviceKey + self.looking = looking + self.scheduler = scheduler + self.makeSocket = makeSocket + } + + /// One owner: the site model. `start` dials once the handlers are set. + public func setHandlers( + onSnapshot: @escaping @MainActor (CloudWire.Snapshot, Double) -> Void, + onEnvelope: @escaping @MainActor (CloudWire.Envelope, Double) -> Void, + onStatus: @escaping @MainActor (Status) -> Void + ) { + self.onSnapshot = onSnapshot + self.onEnvelope = onEnvelope + self.onStatus = onStatus + } + + public func start() { + guard !shutdown, socket == nil, retry == nil else { return } + dial() + } + + /// Whether anyone can see the home. While someone can, the service keeps + /// the box at one update a second. + public func setLooking(_ on: Bool) { + guard on != looking else { return } + looking = on + send(CloudWire.msgWatch, .map([("hz", .int(on ? CloudWire.hzLooking : CloudWire.hzAway))])) + } + + /// An owner suspends a device at FTW Cloud, or lifts it. It only narrows + /// what the box's roster allows. + public func suspend(deviceID: String, _ on: Bool) { + send(on ? CloudWire.msgSuspend : CloudWire.msgUnsuspend, .map([("deviceId", .text(deviceID))])) + } + + /// The app came to the front or the network changed: dial now if the + /// session is not open, after a refusal too. + public func wake() { + guard !shutdown, !status.isOpen else { return } + retry?.cancel() + retry = nil + attempt = 0 + keyIndex = 0 + drop() + dial() + } + + public func close() { + guard !shutdown else { return } + shutdown = true + retry?.cancel() + deadline?.cancel() + drop() + onSnapshot = { _, _ in } + onEnvelope = { _, _ in } + onStatus = { _ in } + } + + // MARK: The wire + + private func dial() { + guard !shutdown else { return } + setStatus(.connecting) + generation += 1 + let mine = generation + handshake = nil + transport = nil + socket = makeSocket(endpoint.url(home: home), WebSocketEvents( + onOpen: { [weak self] in + guard let self, mine == self.generation else { return } + self.sendMessage1() + }, + onText: { _ in }, + onBinary: { [weak self] bytes in + guard let self, mine == self.generation else { return } + self.receive(bytes) + }, + onClose: { [weak self] code, _ in + guard let self, mine == self.generation else { return } + self.closed(code: code) + } + )) + } + + private func sendMessage1() { + guard handshake == nil, transport == nil, let socket else { return } + let keys = endpoint.serviceKeys + guard keyIndex < keys.count, + let hs = try? HandshakeState.initiator(staticKey: deviceKey, remoteStatic: keys[keyIndex], prologue: CloudWire.appPrologue(home: home)) else { + fail(code: CloudWire.closeDenied, retrying: false) + return + } + let now = CloudWire.encodeUnsigned(t: CloudWire.msgNow, body: .map([("hz", .int(looking ? CloudWire.hzLooking : CloudWire.hzAway))])) + guard let msg1 = try? hs.writeMessage(now) else { return } + handshake = hs + socket.send(msg1) + deadline?.cancel() + deadline = scheduler.after(Self.handshakeTimeoutMs) { [weak self] in + guard let self, self.transport == nil else { return } + self.fail(code: 1006, retrying: true) + } + } + + private func receive(_ bytes: Bytes) { + let arrival = scheduler.nowMs + if let transport { + guard let frame = try? transport.decrypt(bytes), + let payload = try? CloudWire.decodeFrame(frame), + let env = try? CloudWire.Envelope.decode(payload) else { + fail(code: CloudWire.closeProtocol, retrying: true) + return + } + // Only a delivered message proves the path works. + attempt = 0 + onEnvelope(env, arrival) + return + } + guard let hs = handshake, + let payload = try? hs.readMessage(bytes), + let snapshot = try? CloudWire.parseSnapshot(payload: payload), + let keys = try? hs.split() else { + fail(code: CloudWire.closeProtocol, retrying: true) + return + } + deadline?.cancel() + handshake = nil + transport = NoiseTransport(keys) + attempt = 0 + setStatus(.open(sinceMs: arrival)) + onSnapshot(snapshot, arrival) + } + + private func send(_ t: String, _ body: CBOR) { + guard let transport, let socket, status.isOpen else { return } + let env = CloudWire.encodeUnsigned(t: t, body: body) + guard let frame = try? CloudWire.encodeFrame(env), let msg = try? transport.encrypt(frame) else { return } + socket.send(msg) + } + + private func closed(code: Int) { + let beforeMessage2 = transport == nil + socket = nil + generation += 1 + deadline?.cancel() + switch code { + case CloudWire.closeDenied where beforeMessage2 && keyIndex + 1 < endpoint.serviceKeys.count: + // Perhaps the service already runs on the app's other key. + keyIndex += 1 + dial() + case CloudWire.closeDenied, CloudWire.closeReplaced: + setStatus(.closed(code: code, retrying: false)) + case CloudWire.closeChanged: + // The roster changed what this phone may read: ask again now, + // once, to learn what that is. + keyIndex = 0 + setStatus(.closed(code: code, retrying: true)) + if attempt == 0 { + attempt = 1 + schedule(0) + } else { + schedule(backoff()) + } + default: + keyIndex = 0 + setStatus(.closed(code: code, retrying: true)) + schedule(backoff()) + } + } + + private func fail(code: Int, retrying: Bool) { + drop() + setStatus(.closed(code: code, retrying: retrying)) + if retrying { schedule(backoff()) } + } + + /// Full jitter, as the relay carrier and the box's uplink do. + private func backoff() -> Double { + let ceiling = min(Self.backoffCapMs, Self.backoffBaseMs * pow(2, Double(attempt))) + attempt = min(attempt + 1, 16) + return scheduler.random() * ceiling + } + + private func schedule(_ delayMs: Double) { + retry?.cancel() + retry = scheduler.after(delayMs) { [weak self] in + guard let self else { return } + self.retry = nil + self.dial() + } + } + + private func drop() { + generation += 1 + socket?.close() + socket = nil + handshake = nil + transport = nil + } + + private func setStatus(_ next: Status) { + guard next != status else { return } + status = next + onStatus(next) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift index 85b6309..2d82d53 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift @@ -109,6 +109,12 @@ public struct CloudView: Sendable { self.boxKey = boxKey } + /// M's box uptime and the age of each source's newest reading at M, so a + /// screen can say how old a reading is. Nil before an anchored message. + public var anchorReadings: (uptimeMs: Int64, ages: [String: Int64])? { + m.map { ($0.uptimeMs, $0.ages) } + } + /// The boot the view holds, nil before the first message. public var boot: Bytes? { currentBoot.isEmpty ? nil : currentBoot } diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift new file mode 100644 index 0000000..4e35cca --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift @@ -0,0 +1,215 @@ +import Foundation +import Observation + +/// FTW Cloud as the person agrees to it, and as the box holds it. +/// +/// The app uses FTW Cloud as part of the service, so the answer is given +/// once, when the person starts using the app, on one screen. Statistics are +/// a separate box on that screen, off until ticked. The grant itself lives on +/// the box: an owner's phone turns it on there over its Noise session, the +/// first time, and an owner can turn it off again from About. A viewer cannot +/// change it and is told to ask the owner. +@Observable +@MainActor +public final class CloudConsent { + /// The consent texts the person read. The box records the names. + public static let policy = "ftw-cloud-2026-10" + public static let statsPolicy = "ftw-stats-2026-10" + + /// What the box says about FTW Cloud, for an owner. + public struct BoxState: Equatable, Sendable { + /// off, on or revoking. + public var state: String + public var stats: Bool + public var connected: Bool + /// When the service confirmed it erased the home's copy. + public var erasedAtMs: Double? + + public var on: Bool { state == "on" } + public var erasing: Bool { state == "revoking" } + + init(_ json: JSON) { + state = json["state"]?.string ?? "off" + stats = (json["purposes"]?.array ?? []).contains { $0.string == Contract.cloudPurposeStats } + connected = json["connected"]?.bool ?? false + erasedAtMs = json["purgedAtMs"]?.number.flatMap { $0 > 0 ? $0 : nil } + } + } + + /// Whom the screen speaks to. + public enum Audience: Equatable, Sendable { + /// An owner whose box can run FTW Cloud. + case owner + /// An owner whose box cannot yet: it turns FTW Cloud on after an + /// update. + case ownerLater + case viewer + /// The box has not answered yet. + case unknown + } + + public private(set) var answered: Bool + /// The statistics box. Off until the person ticks it. + public var contributeStats = false + public private(set) var box: BoxState? + public private(set) var working = false + public private(set) var help: String? + + @ObservationIgnored private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + answered = defaults.string(forKey: Keys.answered) == Self.policy + contributeStats = defaults.bool(forKey: Keys.stats) + } + + /// The one screen, once, on a real home. + public func shouldAsk(_ site: SiteModel?) -> Bool { + guard let site, !site.isDemo, site.siteId != nil else { return false } + return !answered + } + + public static func audience(_ site: SiteModel) -> Audience { + guard site.heardFromBox else { return .unknown } + guard site.canConfigure else { return .viewer } + return site.session.caps.contains(Contract.capCloudLink) ? .owner : .ownerLater + } + + /// The person read the screen and went on. An owner's phone turns FTW + /// Cloud on at the box now, or once the box can. + public func accept(_ site: SiteModel?) async { + defaults.set(Self.policy, forKey: Keys.answered) + defaults.set(contributeStats, forKey: Keys.stats) + answered = true + guard let site else { return } + await ensure(site) + } + + /// Turn FTW Cloud on at the box for an owner who agreed, once per home. + /// After that the box's word stands: if someone turned it off, on the box + /// or in About, it stays off until an owner turns it on again. + public func ensure(_ site: SiteModel) async { + guard answered, let siteId = site.siteId, !site.isDemo, Self.audience(site) == .owner, + !defaults.bool(forKey: Keys.asked(siteId)), !site.cloudOn else { return } + if await turnOn(site) { + defaults.set(true, forKey: Keys.asked(siteId)) + } + } + + /// What the box holds now. Owners only; a viewer's phone may not ask. + public func refresh(_ site: SiteModel) async { + guard site.canConfigure else { return } + do { + box = try await site.callBox(.get, "/api/cloud").map(BoxState.init) + help = nil + } catch let e as BoxAPIError { + help = e.help + } catch { + help = nil + } + } + + /// FTW Cloud on, with statistics if the person agreed to them. + @discardableResult + public func turnOn(_ site: SiteModel) async -> Bool { + await act { + let scopes = JSON.array(Contract.cloudScopes.map { .string($0) }) + let granted = try await site.callBox(.post, "/api/cloud/grant", body: .object([("scopes", scopes), ("policy", .string(Self.policy))])) + box = granted.map(BoxState.init) + if contributeStats { + box = try await site.callBox(.post, "/api/cloud/stats", body: .object([("policy", .string(Self.statsPolicy))])).map(BoxState.init) + } + if let siteId = site.siteId { defaults.set(true, forKey: Keys.asked(siteId)) } + } + } + + /// FTW Cloud off. The box asks the service to erase its copy and shows + /// the receipt once it has one. + @discardableResult + public func turnOff(_ site: SiteModel) async -> Bool { + await act { + box = try await site.callBox(.delete, "/api/cloud/grant").map(BoxState.init) + } + } + + @discardableResult + public func setStats(_ on: Bool, _ site: SiteModel) async -> Bool { + contributeStats = on + defaults.set(on, forKey: Keys.stats) + return await act { + box = on + ? try await site.callBox(.post, "/api/cloud/stats", body: .object([("policy", .string(Self.statsPolicy))])).map(BoxState.init) + : try await site.callBox(.delete, "/api/cloud/stats").map(BoxState.init) + } + } + + private func act(_ work: () async throws -> Void) async -> Bool { + working = true + defer { working = false } + do { + try await work() + help = nil + return true + } catch let e as BoxAPIError { + help = e.help + } catch { + help = CloudWords.text("That didn't work. Nothing on your box has changed.") + } + return false + } + + enum Keys { + static let answered = "FTWCloudConsent" + static let stats = "FTWCloudStats" + static func asked(_ siteId: String) -> String { "FTWCloudAsked-\(siteId)" } + } +} + +/// The words of the consent screen and the FTW Cloud row in About, in +/// English or Swedish, with the language `AppWords` uses. +public enum CloudWords { + public static func text(_ en: String) -> String { + AppWords.language == .sv ? (sv[en] ?? en) : en + } + + public static func format(_ en: String, _ args: String...) -> String { + var out = text(en) + for a in args { + guard let r = out.range(of: "%@") else { break } + out.replaceSubrange(r, with: a) + } + return out + } + + static let sv: [String: String] = [ + "The FTW app uses FTW Cloud": "FTW-appen använder FTW Cloud", + "FTW Cloud is part of the service. It keeps a copy of your home, signed by your box, so the app opens at once and stays up to date.": + "FTW Cloud ingår i tjänsten. Den håller en kopia av ditt hem, signerad av din box, så att appen öppnar direkt och håller sig uppdaterad.", + "Your box still decides. Commands go straight to it, never through FTW Cloud.": + "Boxen bestämmer fortfarande. Kommandon går direkt till den, aldrig via FTW Cloud.", + "Contribute to statistics": "Bidra till statistik", + "Hourly energy totals, without your name or address, help make FTW better. Off unless you turn it on.": + "Energisummor per timme, utan namn eller adress, hjälper oss att göra FTW bättre. Av tills du slår på det.", + "Ask the owner of this home to turn on FTW Cloud.": "Be ägaren av hemmet att slå på FTW Cloud.", + "Your box turns on FTW Cloud after its next update.": "Din box slår på FTW Cloud efter nästa uppdatering.", + "Continue": "Fortsätt", + "Privacy policy": "Integritetspolicy", + "You can turn FTW Cloud off in About.": "Du kan stänga av FTW Cloud under Om.", + "That didn't work. Nothing on your box has changed.": "Det gick inte. Inget på din box har ändrats.", + "FTW Cloud": "FTW Cloud", + "On": "På", + "Off": "Av", + "Erasing the copy": "Raderar kopian", + "Copy erased %@": "Kopian raderad %@", + "Connected": "Ansluten", + "Not connected": "Inte ansluten", + "Only the owner of this home can change this.": "Bara ägaren av hemmet kan ändra det här.", + "FTW Cloud is on for this home.": "FTW Cloud är på för det här hemmet.", + "FTW Cloud is off for this home.": "FTW Cloud är av för det här hemmet.", + "Your box can't use FTW Cloud yet.": "Din box kan inte använda FTW Cloud än.", + "Turning it off erases the copy in FTW Cloud. Your box shows when it is gone.": + "Stänger du av raderas kopian i FTW Cloud. Boxen visar när den är borta.", + "With FTW Cloud on, Sourceful keeps a copy of your home's readings, signed by your box, to serve this app. Commands travel encrypted between this phone and your box, and Sourceful cannot read them.": + "Med FTW Cloud på håller Sourceful en kopia av hemmets mätvärden, signerad av din box, för den här appen. Kommandon går krypterade mellan telefonen och boxen, och Sourceful kan inte läsa dem.", + ] +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift new file mode 100644 index 0000000..2b291f3 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift @@ -0,0 +1,100 @@ +import Foundation + +/// Where FTW Cloud is and the keys it answers with. +/// +/// The address is fixed: `wss://cloud.ftw.energy/v1/app`, as the relay's is. +/// The app carries up to two of the service's Noise keys so the service can +/// rotate across an app release. Only a debug build may point elsewhere, +/// for a local `ftwcloudd`: +/// +/// -FTWCloudURL wss://127.0.0.1:18443/v1/app -FTWCloudKey +public struct CloudEndpoint: Equatable, Sendable { + public var url: URL + /// X25519 public keys, 32 bytes each, tried in order. + public var serviceKeys: [Bytes] + + public init(url: URL, serviceKeys: [Bytes]) { + self.url = url + self.serviceKeys = serviceKeys + } + + public static let releaseURL = URL(string: "wss://cloud.ftw.energy/v1/app")! + + /// The service's Noise keys. Empty until the service runs with its key: + /// a release build then reads through the relay alone and offers no + /// FTW Cloud, as a box built without service keys never dials. + static let releaseKeys: [Bytes] = [] + + /// The endpoint this build uses, or nil when it has none. + public static var current: CloudEndpoint? { + #if DEBUG + resolve(defaults: .standard, debug: true) + #else + resolve(defaults: .standard, debug: false) + #endif + } + + /// Debug builds read the override; every other build ignores it. + static func resolve(defaults: UserDefaults, debug: Bool) -> CloudEndpoint? { + if debug, let raw = defaults.string(forKey: "FTWCloudURL"), let url = URL(string: raw), + url.scheme == "wss" || url.scheme == "ws", + let raw = defaults.string(forKey: "FTWCloudKey"), let key = try? Base64url.decode(raw), key.count == Noise.dhBytes { + return CloudEndpoint(url: url, serviceKeys: [key]) + } + let keys = releaseKeys.filter { $0.count == Noise.dhBytes }.prefix(2) + return keys.isEmpty ? nil : CloudEndpoint(url: releaseURL, serviceKeys: Array(keys)) + } + + /// The address for one home's session. + func url(home: String) -> URL { + var c = URLComponents(url: url, resolvingAgainstBaseURL: false)! + c.queryItems = [URLQueryItem(name: "home", value: home)] + return c.url! + } +} + +/// What the box tells its phones about FTW Cloud, over their own Noise +/// session: the key it signs with, its home id, its current boot and the +/// names behind its anonymous ids. It never comes from the service, which +/// could otherwise sign for the box. Present only while the grant is on. +public struct CloudIdentity: Codable, Equatable, Sendable { + /// The box's P-256 key, 65 bytes uncompressed. + public var pk: Bytes + /// Computed here from `pk`, never taken from the box's word for it. + public var home: String + public var boot: Bytes + /// Anonymous source id to the driver name the box session uses. + public var sources: [String: String] + /// Anonymous loadpoint id to the loadpoint id the box session uses. + public var loadpoints: [String: String] + + public init(pk: Bytes, boot: Bytes, sources: [String: String] = [:], loadpoints: [String: String] = [:]) { + self.pk = pk + home = CloudWire.homeID(pk: pk) + self.boot = boot + self.sources = sources + self.loadpoints = loadpoints + } + + /// hello_ok's `cloud`, or nil when it is absent or malformed. A home id + /// that is not the hash of the key is refused. + init?(_ c: CBOR?) { + guard let c, let pk = c["pk"]?.byteString, pk.count == CloudWire.publicKeyBytes, + let boot = c["boot"]?.byteString, boot.count == CloudWire.bootBytes else { return nil } + self.init(pk: pk, boot: boot, sources: Self.names(c["sources"]), loadpoints: Self.names(c["loadpoints"])) + if let said = c["home"]?.string, said != home { return nil } + } + + private static func names(_ c: CBOR?) -> [String: String] { + var out = [String: String]() + for e in c?.entries ?? [] { + if let k = e.key.string, let v = e.value.string { out[k] = v } + } + return out + } + + /// The same box and home, whatever its boot or names. + func sameHome(_ other: CloudIdentity?) -> Bool { + other?.pk == pk + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift index 9952757..bb40a69 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift @@ -51,6 +51,15 @@ public enum Contract { public static let capDerEV = "der.ev" public static let capPriceSpot = "price.spot" public static let capAPIPassthrough = "api.passthrough" + /// The box can run the FTW Cloud uplink. Absent, the app offers no FTW + /// Cloud and reads through the relay alone. + public static let capCloudLink = "cloud.link" + + /// The scopes an FTW Cloud grant may name, and what its data may be + /// used for: the registry's `cloud_scopes` and `cloud_purposes`. + public static let cloudScopes = ["ftw.live.read", "ftw.history.read", "ftw.plan.read", "ftw.health.read", "ftw.events.read"] + public static let cloudPurposes = ["serve", "stats"] + public static let cloudPurposeStats = "stats" public static let opSetMode = "site.mode.set" public static let opPlannerPrefsSet = "planner.prefs.set" diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift index 0636938..697ec92 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift @@ -100,9 +100,12 @@ public struct HelloOk: Equatable, Sendable { public var boot: BootProgress? public var hint: String? public var subscribed: Bool + /// The box's FTW Cloud key, home and names, while the grant is on. + public var cloud: CloudIdentity? init(_ c: CBOR) { proto = c["proto"]?.int ?? Proto.max + cloud = CloudIdentity(c["cloud"]) mode = c["mode"]?.string.flatMap(BoxMode.init(rawValue:)) ?? .full let b = c["box"] box = BoxInfo(id: b?["id"]?.string ?? "", build: b?["build"]?.string ?? "", tz: b?["tz"]?.string ?? "") diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift index 63b9edf..b6c5e99 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift @@ -26,6 +26,9 @@ public struct SessionState: Sendable { /// that states something about the box from them would be inventing it. public var heardFromBox = false public var box: BoxInfo? + /// FTW Cloud as the box described it in hello: its key, home and names. + /// Nil while the grant is off. + public var cloud: CloudIdentity? /// Box uptime at the last frame. All ages are deltas against this. public var uptimeMs: Double = 0 public var controlRev: UInt64 = 0 @@ -480,6 +483,7 @@ public final class Session { s.heardFromBox = true s.modes = b.modes s.box = b.box + s.cloud = b.cloud s.uptimeMs = b.uptimeMs s.boot = b.boot s.needsUpdate = b.hint == "app_update" || b.proto == Proto.floor diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift index 06f0bf9..7020a73 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -169,6 +169,7 @@ public final class AppModel { scheduler: scheduler ) if site.connect(noise) { connectHelp = nil } + site.connectCloud(deviceKey: device, makeSocket: makeSocket) } catch let e as HelpfulError { guard mine == connectGeneration else { return } connectHelp = e.help diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 12a6db6..816f56c 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -10,6 +10,13 @@ private let log = Logger(subsystem: "energy.ftw.app", category: "widgets") /// The session owns protocol state; this exposes it, restores the cached /// snapshot on start, keeps freshness honest and derives what the screens /// ask for. Nothing above this touches a frame. +/// +/// Readings come from one of two places. With FTW Cloud on, the cloud is the +/// first: its copy arrives from memory within a round trip and every change +/// follows on the same socket, each envelope signed by the box and checked +/// here. The box session over the relay stays open beside it for commands, +/// and its readings take over when the cloud fails, is out of reach, or has +/// nothing better. `session` is what the screens read either way. @Observable @MainActor public final class SiteModel { @@ -59,7 +66,29 @@ public final class SiteModel { @ObservationIgnored private var lastSnapshotMs: Double = 0 @ObservationIgnored private var snapshotTimer: Cancellable? @ObservationIgnored private var destroyed = false + /// The box session's own state, as the last frame left it. + @ObservationIgnored private var relay = SessionState() + /// When the box session last delivered a reading. Waking after sleep + /// waits for this one, whatever the cloud does meanwhile. + @ObservationIgnored private var relayFrameAtMs: Double? @ObservationIgnored private var listeners: [Int: @MainActor () -> Void] = [:] + + // FTW Cloud. All nil while the home has no grant or this build no + // endpoint. + @ObservationIgnored private var cloud: CloudCarrier? + @ObservationIgnored private(set) var cloudView: CloudView? + @ObservationIgnored private(set) var cloudIdentity: CloudIdentity? + @ObservationIgnored private var cloudDevice: Primitives.KeyPair? + @ObservationIgnored private var cloudSocket: WebSocketFactory? + @ObservationIgnored private var cloudEndpoint: CloudEndpoint? + /// Rule U's anchor as last learned, kept with the identity so a cold + /// start can age the cloud's copy of a box without a clock. + @ObservationIgnored private var ruleU: CloudCache.Offset? + @ObservationIgnored private var cloudDirty = false + /// Where the readings on screen came from at the last change. + @ObservationIgnored private(set) var source: ReadingSource = .relay + /// The cloud session, for the About screen. Nil while there is none. + public private(set) var cloudStatus: CloudCarrier.Status? @ObservationIgnored private var nextListener = 0 /// Runs the passkey ceremony a write needs. Injected by the shell. @ObservationIgnored public var stepUp: (@MainActor () async -> StepUpOutcome)? @@ -85,6 +114,9 @@ public final class SiteModel { core.restore(cached) cachedAtMs = cached.savedAtMs } + if let siteId, let kept = files?.readJSON(CloudCache.self, "cloud-\(siteId)") { + adoptCloud(kept.identity, offset: kept.offset) + } startTicker() } @@ -118,6 +150,8 @@ public final class SiteModel { snapshotTimer?.cancel() core.onChange = nil core.close() + cloud?.close() + cloud = nil listeners = [:] } @@ -129,6 +163,7 @@ public final class SiteModel { guard !destroyed else { return } persistNow() core.close() + closeCloud() // After the close, so no last change of rate goes out on the wire. setVisible(false) } @@ -138,6 +173,7 @@ public final class SiteModel { let was = documentVisible documentVisible = visible core.setTelemetryHz(visible ? 1 : 0.2) + cloud?.setLooking(visible) if !visible { cancelResume() persistNow() @@ -149,12 +185,14 @@ public final class SiteModel { /// A new network path should replace the old one without waiting. public func networkOnline() { guard documentVisible else { return } + cloud?.wake() beginResume(immediate: true) } /// Pull to refresh: ask for a fresh stream in place. public func refresh() { core.wake() + cloud?.wake() } // MARK: Observation for feature models @@ -178,23 +216,47 @@ public final class SiteModel { // MARK: Session changes private func onSession(_ s: SessionState) { - let previous = session + let previous = relay + let now = scheduler.nowMs if previous.phase == .streaming, s.phase != .streaming { - attemptStartedAtMs = scheduler.nowMs + attemptStartedAtMs = now } // Only a streaming session is a reading arriving. Restoring the cache // and answering hello both move the clock and carry no reading. - if s.phase == .streaming, previous.phase != .streaming || s.uptimeMs != previous.uptimeMs { - let now = scheduler.nowMs - lastFrameAtMs = now + let reading = s.phase == .streaming && (previous.phase != .streaming || s.uptimeMs != previous.uptimeMs) + if reading { + relayFrameAtMs = now finishResume() - recordRecent(s.fields, now) } - session = s + relay = s + if s.heardFromBox, !previous.heardFromBox || previous.cloud != s.cloud { + cloudHello(s.cloud) + } + // Every box message over Noise that names its uptime is a chance to + // learn rule U, once hello has named the boot it belongs to. + if s.heardFromBox, let boot = s.cloud?.boot, s.carrier == .relay || s.carrier == .webrtc, s.uptimeMs != previous.uptimeMs || !previous.heardFromBox { + learnRuleU(boot: boot, uptimeMs: s.uptimeMs, phoneMs: now) + } if s.phase == .streaming { cachedAtMs = nil scheduleSnapshot() } + publish() + if reading, source == .relay { + lastFrameAtMs = now + recordRecent(session.fields, now) + } + } + + /// What the screens read: the box session's state, with the cloud's + /// readings in it while the cloud is where readings come from. + private func publish() { + source = readingSource() + var s = relay + if source == .cloud, let view = cloudView, let identity = cloudIdentity { + compose(&s, view: view, identity: identity) + } + session = s notify() } @@ -210,8 +272,15 @@ public final class SiteModel { /// Write the last streaming state now, before the app can be suspended. public func persistNow() { - guard let siteId, let files, !destroyed, session.phase == .streaming || !session.fields.isEmpty, cachedAtMs == nil else { return } + guard let siteId, let files, !destroyed else { return } + if cloudDirty, let identity = cloudIdentity { + cloudDirty = false + files.writeJSON("cloud-\(siteId)", CloudCache(identity: identity, offset: ruleU)) + } + guard session.phase == .streaming || !session.fields.isEmpty, cachedAtMs == nil else { return } lastSnapshotMs = scheduler.nowMs + // The cloud's ages move with the clock; take them as of now. + if source == .cloud { publish() } files.writeJSON("snapshot-\(siteId)", CachedSnapshot(siteId: siteId, savedAtMs: scheduler.nowMs, state: session)) } @@ -221,12 +290,12 @@ public final class SiteModel { guard !destroyed, documentVisible else { return } resumeTimer?.cancel() resumeWaiting = true - if immediate || session.phase != .streaming || lastFrameAtMs == nil { + if immediate || relay.phase != .streaming || relayFrameAtMs == nil { core.wake() } - let frameAtStart = lastFrameAtMs + let frameAtStart = relayFrameAtMs resumeTimer = scheduler.after(Self.foregroundFrameDeadlineMs) { [weak self] in - guard let self, !self.destroyed, self.documentVisible, self.lastFrameAtMs == frameAtStart else { return } + guard let self, !self.destroyed, self.documentVisible, self.relayFrameAtMs == frameAtStart else { return } self.core.wake() } } @@ -257,7 +326,11 @@ public final class SiteModel { /// How the readings on screen reach us. A carrier is claimed only once it /// has delivered a reading: an open socket is evidence of nothing. public var carrier: CarrierKind { - if session.phase == .streaming, lastFrameAtMs != nil { return session.carrier } + if source == .cloud { + if cloud?.status.isOpen == true { return .cloud } + return cachedAtMs != nil ? .cache : .none + } + if relay.phase == .streaming, relayFrameAtMs != nil { return relay.carrier } return cachedAtMs != nil ? .cache : .none } @@ -283,24 +356,32 @@ public final class SiteModel { } /// The sources behind what the Now screen draws, named by the box. + /// The box session's sources behind what Now draws. The cloud's are + /// graded by `CloudView`, not by these. var nowSourceIDs: [String] { var ids = [String]() - for fid in Self.nowFids where session.fields[fid] != nil { - if let src = session.dict[fid]?.srcId, !ids.contains(src) { ids.append(src) } + for fid in Self.nowFids where relay.fields[fid] != nil { + if let src = relay.dict[fid]?.srcId, !ids.contains(src) { ids.append(src) } } return ids } public var srcState: SourceState { if session.fields.isEmpty { return .never } + if source == .cloud, let view = cloudView { return Self.cloudState(view, nowMs: nowMs) } + return relaySrcState + } + + private var relaySrcState: SourceState { + if relay.fields.isEmpty { return .never } // Every source state was read off the disk: true when written. - if lastFrameAtMs == nil { return .stale } + if relayFrameAtMs == nil { return .stale } let ids = nowSourceIDs let worst: SourceState if !ids.isEmpty { worst = core.worstSourceState(ids) - } else if !session.sources.isEmpty { - worst = core.worstSourceState(Array(session.sources.keys)) + } else if !relay.sources.isEmpty { + worst = core.worstSourceState(Array(relay.sources.keys)) } else { worst = .live } @@ -310,7 +391,7 @@ public final class SiteModel { } public var sinceLastFrameMs: Double { - guard let at = lastFrameAtMs else { return 0 } + guard let at = relayFrameAtMs else { return 0 } let since = nowMs - at return since < Self.streamQuietAfterMs ? 0 : since } @@ -318,6 +399,11 @@ public final class SiteModel { /// Age of the oldest reading on screen. Nil means unknown, which is the /// honest answer after a box restart. public var ageMs: Double? { + if source == .cloud, let view = cloudView { + guard let age = Self.cloudAge(view, nowMs: nowMs) else { return nil } + if carrier == .cache, let at = cachedAtMs { return max(age, nowMs - at) } + return age + } let ages = nowSourceIDs.compactMap { core.ageOf($0) } if carrier == .cache, let at = cachedAtMs { let shelf = nowMs - at @@ -330,7 +416,8 @@ public final class SiteModel { /// Live is three claims at once: streaming, not the cache, and every /// source answering. public var isLive: Bool { - session.phase == .streaming && carrier != .cache && srcState == .live + let streaming = source == .cloud ? cloud?.status.isOpen == true : session.phase == .streaming + return streaming && carrier != .cache && srcState == .live } public var explanation: Explanation.Result { @@ -381,14 +468,14 @@ public final class SiteModel { /// reload would only redraw it. Returns the write, for a caller that /// must not end before it lands. @discardableResult - func sharePrices(_ prices: Prices, fromMs: Double) -> Task? { + func sharePrices(_ prices: Prices, fromMs: Double, via: CarrierKind? = nil) -> Task? { guard let siteId, let shared else { return nil } let entry = PriceEntry(fromMs: fromMs, prices: prices) guard entry != lastPrices else { return nil } lastPrices = entry let record = CacheRecord( siteId: siteId, - carrier: session.carrier, + carrier: via ?? session.carrier, srcState: prices.stale ? .stale : .live, readAtMs: scheduler.nowMs, value: entry @@ -399,7 +486,6 @@ public final class SiteModel { /// Leave the chargers for the widgets, as the box just described them. /// `fresh` says whether the box's own reading of the charger is current. func shareCars(_ points: [Loadpoint], fresh: Bool) { - guard let siteId, let shared else { return } let now = scheduler.nowMs let entry = CarEntry(cars: points.map { lp in CarEntry.Car( @@ -412,10 +498,17 @@ public final class SiteModel { targetSocPct: lp.targetSocPct ) }) + shareCarEntry(entry, answering: fresh && points.allSatisfy { $0.charger?.available != false }) + } + + /// One car record for the widgets; the same car again is skipped for a + /// while. + private func shareCarEntry(_ entry: CarEntry, answering: Bool, via: CarrierKind? = nil) { + guard let siteId, let shared else { return } + let now = scheduler.nowMs if let last = lastCars, last.entry.sameReading(entry), now - last.atMs < Self.carRewriteMs { return } lastCars = (entry, now) - let answering = fresh && points.allSatisfy { $0.charger?.available != false } - write(CacheRecord(siteId: siteId, carrier: session.carrier, srcState: answering ? .live : .stale, readAtMs: now, value: entry), to: shared) + write(CacheRecord(siteId: siteId, carrier: via ?? session.carrier, srcState: answering ? .live : .stale, readAtMs: now, value: entry), to: shared) } /// Encoding and the file write run off the main actor. A sign-out @@ -524,3 +617,258 @@ private final class StreamWait { cont.resume(returning: ok) } } + +// MARK: - FTW Cloud + +/// Where the readings on screen come from. +enum ReadingSource: Sendable { + case relay, cloud +} + +/// What the app keeps of a home's FTW Cloud between launches: the box's word +/// on its key and names, and rule U's anchor. Both came from the box over +/// Noise; neither from the service. +struct CloudCache: Codable, Equatable, Sendable { + struct Offset: Codable, Equatable, Sendable { + var boot: Bytes + var offsetMs: Int64 + var learnedAtMs: Int64 + } + + var identity: CloudIdentity + var offset: Offset? +} + +extension SiteModel { + /// Read the home from FTW Cloud beside the box session, with the same + /// device key. It starts once the box has said, now or at an earlier + /// launch, that FTW Cloud is on and how to check what it forwards. + public func connectCloud(deviceKey: Primitives.KeyPair, makeSocket: @escaping WebSocketFactory, endpoint: CloudEndpoint? = CloudEndpoint.current) { + guard !destroyed, !isDemo else { return } + cloudDevice = deviceKey + cloudSocket = makeSocket + cloudEndpoint = endpoint + startCloud() + } + + /// An owner pauses FTW Cloud for one of the home's phones, or lifts the + /// pause. It only narrows what the box allows. + public func suspendAtCloud(deviceID: String, _ on: Bool) { + cloud?.suspend(deviceID: deviceID, on) + } + + /// Whether the box said FTW Cloud is on for this home. + public var cloudOn: Bool { cloudIdentity != nil } + + private func startCloud() { + guard cloud == nil, !destroyed, let endpoint = cloudEndpoint, let device = cloudDevice, + let socket = cloudSocket, let identity = cloudIdentity else { return } + let c = CloudCarrier(endpoint: endpoint, home: identity.home, deviceKey: device, looking: documentVisible, scheduler: scheduler, makeSocket: socket) + c.setHandlers( + onSnapshot: { [weak self] snap, at in self?.onCloudSnapshot(snap, at) }, + onEnvelope: { [weak self] env, at in self?.onCloudEnvelope(env, at) }, + onStatus: { [weak self] status in + self?.cloudStatus = status + self?.publish() + } + ) + cloud = c + cloudStatus = c.status + c.start() + } + + /// Close the session and keep what it delivered, which ages on screen. + private func closeCloud() { + cloud?.close() + cloud = nil + cloudStatus = nil + } + + private func adoptCloud(_ identity: CloudIdentity, offset: CloudCache.Offset?) { + if !identity.sameHome(cloudIdentity) { + closeCloud() + cloudView = CloudView(home: identity.home, boxKey: identity.pk) + ruleU = nil + } + cloudIdentity = identity + if let offset, var view = cloudView { + view.learnOffset(boot: offset.boot, phoneMs: offset.learnedAtMs, uptimeMs: offset.learnedAtMs - offset.offsetMs) + cloudView = view + ruleU = offset + } + } + + /// The box answered hello: FTW Cloud is on with this key, or off. + private func cloudHello(_ identity: CloudIdentity?) { + guard let identity else { + // Off, or never on. What the cloud delivered goes with it. + closeCloud() + cloudView = nil + cloudIdentity = nil + ruleU = nil + cloudDirty = false + if let siteId { files?.remove("cloud-\(siteId)") } + publish() + return + } + adoptCloud(identity, offset: nil) + cloudDirty = true + persistNow() + startCloud() + } + + /// Rule U: phone time minus box uptime, the smallest seen per boot. + private func learnRuleU(boot: Bytes, uptimeMs: Double, phoneMs: Double) { + guard var view = cloudView, uptimeMs > 0 else { return } + let up = Int64(uptimeMs), now = Int64(phoneMs) + view.learnOffset(boot: boot, phoneMs: now, uptimeMs: up) + cloudView = view + let offset = now - up + if let held = ruleU, held.boot == boot, held.offsetMs <= offset { + // Not smaller: only the time it was confirmed moves, which the + // drift margin counts from. Kept in memory; written with the + // next snapshot. + ruleU = CloudCache.Offset(boot: boot, offsetMs: held.offsetMs, learnedAtMs: now) + } else { + ruleU = CloudCache.Offset(boot: boot, offsetMs: offset, learnedAtMs: now) + } + cloudDirty = true + } + + private func onCloudSnapshot(_ snapshot: CloudWire.Snapshot, _ at: Double) { + var any = false + for env in snapshot.envelopes where acceptCloud(env, at) { any = true } + if any { cloudRead(at) } else { publish() } + } + + private func onCloudEnvelope(_ env: CloudWire.Envelope, _ at: Double) { + guard acceptCloud(env, at) else { return } + cloudRead(at) + } + + /// Check one envelope against the box's key and the freshness rules. + /// The service cannot sign, so whatever fails here never reaches a screen. + private func acceptCloud(_ env: CloudWire.Envelope, _ at: Double) -> Bool { + guard var view = cloudView, (try? view.accept(env, arrivalMs: Int64(at))) != nil else { return false } + cloudView = view + switch env.t { + case CloudWire.msgLP: shareCloudCars() + case CloudWire.msgPrices: shareCloudPrices() + default: break + } + return true + } + + private func cloudRead(_ at: Double) { + let before = session.fields + publish() + guard source == .cloud else { return } + lastFrameAtMs = at + cachedAtMs = nil + if session.fields != before { recordRecent(session.fields, at) } + scheduleSnapshot() + } + + /// The cloud is where readings come from while its session is open and it + /// holds a live, unless the box session streams live readings and the + /// cloud's are not live. With neither streaming, the newer copy stays. + private func readingSource() -> ReadingSource { + guard let view = cloudView, view.live != nil else { return .relay } + let relayStreaming = relay.phase == .streaming && relayFrameAtMs != nil + if relayStreaming, relaySrcState == .live, Self.cloudState(view, nowMs: scheduler.nowMs) != .live { + return .relay + } + if cloud?.status.isOpen == true { return .cloud } + return relayStreaming ? .relay : .cloud + } + + /// The cloud's readings in the shape the box session gives them, under + /// the names the box session uses, aged to now. + private func compose(_ s: inout SessionState, view: CloudView, identity: CloudIdentity) { + guard let live = view.live else { return } + let now = scheduler.nowMs + let g = view.grade(nowMs: Int64(now)) + let name = { (id: String) in identity.sources[id] ?? id } + var fields = [Int: Double]() + for (k, v) in live.fields { + if let fid = Int(k) { fields[fid] = Double(v) } + } + var dict = [Int: FieldDef]() + for fid in fields.keys { + let known = relay.dict[fid] + dict[fid] = FieldDef(name: known?.name ?? "", unit: known?.unit, srcId: view.fieldSources[String(fid)]?.first.map(name)) + } + let anchor = view.anchorReadings + let t = Double(g.ageMs ?? 0) + let upNow = Double(anchor?.uptimeMs ?? live.header.uptimeMs) + t + var sources = [String: Source]() + for (id, src) in view.sources { + let n = name(id) + let age = anchor?.ages[id].map { Double($0) + t } ?? (upNow - Double(src.lastOkMs)) + sources[n] = Source(kind: src.kind, name: relay.sources[n]?.name ?? n, lastOkMs: upNow - age, + staleAfterMs: Double(src.staleAfterMs), state: g.sources[id] ?? .never) + } + s.fields = fields + s.dict = dict + s.sources = sources + s.dispatchBlockedBy = live.dispatchBlockedBy.map(name) + s.uptimeMs = upNow + s.carrier = cloud?.status.isOpen == true ? .cloud : .none + } + + /// The worst grade among the fields Now draws. Never live without an + /// anchor: a copy whose age cannot be checked is at best stale. + static func cloudState(_ view: CloudView, nowMs: Double) -> SourceState { + let g = view.grade(nowMs: Int64(nowMs)) + let graded = nowFids.compactMap { g.fields[String($0)] } + var worst = graded.max() ?? g.sources.values.max() ?? .live + if !g.anchored { worst = max(worst, .stale) } + return worst + } + + /// The age of the oldest reading Now draws, by the anchor. Nil when the + /// copy cannot be anchored: unverified, shown as "—". + static func cloudAge(_ view: CloudView, nowMs: Double) -> Double? { + let g = view.grade(nowMs: Int64(nowMs)) + guard g.anchored, let t = g.ageMs, let anchor = view.anchorReadings else { return nil } + let ids = Set(nowFids.flatMap { view.fieldSources[String($0)] ?? [] }) + let ages = ids.compactMap { id -> Int64? in + if let a = anchor.ages[id] { return a } + return view.sources[id].map { anchor.uptimeMs - $0.lastOkMs } + } + guard let oldest = ages.max() else { return Double(t) } + return Double(max(0, oldest) + t) + } + + /// The cars as the cloud's lp has them, for the widgets. + private func shareCloudCars() { + guard let view = cloudView, let lp = view.lp, let identity = cloudIdentity else { return } + let now = scheduler.nowMs + let g = view.grade(nowMs: Int64(now)) + let entry = CarEntry(cars: lp.loadpoints.map { l in + CarEntry.Car( + loadpointID: identity.loadpoints[l.id] ?? l.id, + pluggedIn: l.plugged, + socPct: l.socPermille.map { (Double($0) / 10).rounded() }, + socSource: l.socSource, + socReadAtMs: l.socAgeS.map { now - Double($0) * 1000 }, + powerW: Double(l.powerW), + targetSocPct: l.targetPermille.map { (Double($0) / 10).rounded() } + ) + }) + shareCarEntry(entry, answering: lp.loadpoints.allSatisfy { g.loadpoints[$0.id] == .live }, via: .cloud) + } + + /// The cloud's price window, for the widgets, in the box session's shape. + private func shareCloudPrices() { + guard let p = cloudView?.prices, p.slotMs > 0 else { return } + var slots = [PriceSlot]() + for i in p.spotMinor.indices { + guard let spot = p.spotMinor[i], i < p.totalMinor.count, let total = p.totalMinor[i] else { continue } + slots.append(PriceSlot(startMs: Double(p.t0Ms + Int64(i) * p.slotMs), durationMs: Double(p.slotMs), + spotMinor: Double(spot), totalMinor: Double(total))) + } + guard !slots.isEmpty else { return } + sharePrices(Prices(zone: p.zone, currency: p.currency, slots: slots, stale: false), fromMs: Double(p.t0Ms), via: .cloud) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Freshness.swift b/appleApp/FTWKit/Sources/FTWShared/Freshness.swift index 521b286..c6aae1b 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Freshness.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Freshness.swift @@ -32,7 +32,7 @@ public enum Freshness { busy: Bool = false ) -> Band { let reaching = !noCarrier && [.idle, .handshaking, .subscribing, .failed, .booting].contains(phase) - let connected = carrier == .relay || carrier == .webrtc + let connected = carrier == .relay || carrier == .webrtc || carrier == .cloud // The relay turned this phone away because the home already has as // many phones watching as it serves. Not a fault, and not ours to fix. let turnedAway = busy && !connected && reaching @@ -40,7 +40,16 @@ public enum Freshness { let tone: Tone = connected ? (srcState == .live ? .live : .stale) : turnedAway ? .busy : (reaching ? .reaching : .lost) let message: String - if connected { + if connected, carrier == .cloud { + // The box signed every reading; the anchor says how old it is. + // Without one the copy is never live and its age is unverified. + switch srcState { + case .live: message = Words.text("Live via FTW Cloud") + case .never: message = Words.text("FTW Cloud connected · no reading yet") + case .down: message = Words.text("FTW Cloud connected · a device stopped responding") + default: message = ageMs == nil ? Words.text("FTW Cloud · age unverified") : Words.text("FTW Cloud connected · readings") + } + } else if connected { let webrtc = carrier == .webrtc switch srcState { case .live: message = webrtc ? Words.text("Live at home") : Words.text("Live via encrypted relay") diff --git a/appleApp/FTWKit/Sources/FTWShared/Readings.swift b/appleApp/FTWKit/Sources/FTWShared/Readings.swift index 108799b..e97af84 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Readings.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Readings.swift @@ -11,7 +11,7 @@ import Foundation /// with it: "connected, but the inverter went quiet 40 seconds ago" needs /// both. public enum CarrierKind: String, Sendable, Codable { - /// `cloud` is data read through FTW Cloud. Nothing produces it yet. + /// `cloud` is data read through FTW Cloud, signed by the box. case webrtc, relay, cloud, cache, none } diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings index 2fe7e81..1172908 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings @@ -9,6 +9,11 @@ "Encrypted relay connected · no reading yet" = "Krypterat relä anslutet · inget mätvärde än"; "Encrypted relay connected · a device stopped responding" = "Krypterat relä anslutet · en enhet svarar inte"; "Encrypted relay connected · readings" = "Krypterat relä anslutet · mätvärden"; +"Live via FTW Cloud" = "Live via FTW Cloud"; +"FTW Cloud connected · no reading yet" = "FTW Cloud anslutet · inget mätvärde än"; +"FTW Cloud connected · a device stopped responding" = "FTW Cloud anslutet · en enhet svarar inte"; +"FTW Cloud connected · readings" = "FTW Cloud anslutet · mätvärden"; +"FTW Cloud · age unverified" = "FTW Cloud · åldern obekräftad"; "Access ended" = "Åtkomsten är avslutad"; "FTW is busy" = "FTW är upptaget"; "Your box is starting" = "Din box startar"; diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift new file mode 100644 index 0000000..76b253b --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift @@ -0,0 +1,318 @@ +import Foundation +import Testing +@testable import FTWKit + +/// FTW Cloud's app endpoint, as `ftwcloudd` answers it, behind fake sockets: +/// Noise IK as responder, a phone admitted only by a key in its roster, the +/// snapshot in message 2, then pushes. +@MainActor +final class FakeCloudService { + let scheduler: ManualScheduler + let staticKey = Primitives.generateKeyPair() + var roster: Set = [] + var held: [CloudWire.Envelope] = [] + var online = true + var latencyMs: Double = 10 + /// Refuse every handshake made with this key, as a service that has + /// moved to another key would. + var refuseKey: Bytes? + private(set) var dialled: [URL] = [] + private(set) var nows: [Int] = [] + private(set) var received: [CloudWire.Envelope] = [] + private var sessions: [FakeCloudSocket] = [] + + init(scheduler: ManualScheduler) { + self.scheduler = scheduler + } + + var endpoint: CloudEndpoint { + CloudEndpoint(url: URL(string: "wss://cloud.test/v1/app")!, serviceKeys: [staticKey.publicKey]) + } + + var factory: WebSocketFactory { + { [unowned self] url, events in + self.dialled.append(url) + let socket = FakeCloudSocket(service: self, url: url, events: events) + self.sessions.append(socket) + _ = self.scheduler.after(self.latencyMs) { + guard !socket.closed else { return } + events.onOpen() + } + return socket + } + } + + var current: FakeCloudSocket? { sessions.last(where: { !$0.closed }) } + + func push(_ env: CloudWire.Envelope) { + held.append(env) + current?.push(env) + } + + func close(code: Int) { + guard let s = current else { return } + s.closed = true + s.events.onClose(code, "") + } + + fileprivate func receive(_ bytes: Bytes, on socket: FakeCloudSocket) { + if let transport = socket.transport { + guard let frame = try? transport.decrypt(bytes), let payload = try? CloudWire.decodeFrame(frame), + let env = try? CloudWire.Envelope.decode(payload) else { + socket.closeFromService(CloudWire.closeProtocol) + return + } + received.append(env) + return + } + let home = URLComponents(url: socket.url, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "home" }?.value ?? "" + let key = refuseKey == staticKey.publicKey ? Primitives.generateKeyPair() : staticKey + let hs = HandshakeState.responder(staticKey: key, prologue: CloudWire.appPrologue(home: home)) + guard let payload = try? hs.readMessage(bytes), + let now = try? CloudWire.Envelope.decode(payload), now.t == CloudWire.msgNow else { + socket.closeFromService(CloudWire.closeDenied) + return + } + nows.append((try? decodeCBOR(now.b))?["hz"]?.int ?? -1) + guard let msg2 = try? hs.writeMessage(snapshotPayload()), let keys = try? hs.split(), + roster.contains(keys.remoteStatic) else { + socket.closeFromService(CloudWire.closeDenied) + return + } + socket.transport = NoiseTransport(keys) + socket.deliver(msg2) + } + + func snapshotPayload(_ envelopes: [CloudWire.Envelope]? = nil) -> Bytes { + let envs = (envelopes ?? held).map { e -> CBOR in .map([("b", .bytes(e.b)), ("t", .text(e.t)), ("sig", .bytes(e.sig))]) } + return CloudWire.encodeUnsigned(t: CloudWire.msgSnapshot, body: .map([ + ("envelopes", .array(envs)), ("lastSeenMs", .int(Int64(scheduler.nowMs))), ("online", .bool(online)), ("more", .int(0)), + ])) + } + + var watches: [Int] { + received.filter { $0.t == CloudWire.msgWatch }.compactMap { (try? decodeCBOR($0.b))?["hz"]?.int } + } +} + +@MainActor +final class FakeCloudSocket: WebSocketConnection { + unowned let service: FakeCloudService + let url: URL + let events: WebSocketEvents + var closed = false + var transport: NoiseTransport? + + init(service: FakeCloudService, url: URL, events: WebSocketEvents) { + self.service = service + self.url = url + self.events = events + } + + func send(_ data: Bytes) { + guard !closed else { return } + _ = service.scheduler.after(service.latencyMs) { [weak self] in + guard let self, !self.closed else { return } + self.service.receive(data, on: self) + } + } + + func deliver(_ bytes: Bytes) { + _ = service.scheduler.after(service.latencyMs) { [weak self] in + guard let self, !self.closed else { return } + self.events.onBinary(bytes) + } + } + + func push(_ env: CloudWire.Envelope) { + guard let transport, let frame = try? CloudWire.encodeFrame(env.encode()), let msg = try? transport.encrypt(frame) else { return } + deliver(msg) + } + + func closeFromService(_ code: Int) { + _ = service.scheduler.after(service.latencyMs) { [weak self] in + guard let self, !self.closed else { return } + self.closed = true + self.events.onClose(code, "") + } + } + + func close() { + closed = true + } +} + +/// A phone's carrier and the events it delivered. +@MainActor +final class CloudRig { + let scheduler = ManualScheduler() + let service: FakeCloudService + let device = Primitives.generateKeyPair() + var box: FakeCloudBox + let carrier: CloudCarrier + var snapshots: [CloudWire.Snapshot] = [] + var envelopes: [CloudWire.Envelope] = [] + var statuses: [CloudCarrier.Status] = [] + + init(looking: Bool = true, endpoint: ((FakeCloudService) -> CloudEndpoint)? = nil) throws { + service = FakeCloudService(scheduler: scheduler) + box = try FakeCloudBox(boot: 1) + service.roster = [device.publicKey] + carrier = CloudCarrier(endpoint: endpoint?(service) ?? service.endpoint, home: box.home, deviceKey: device, looking: looking, scheduler: scheduler, makeSocket: service.factory) + carrier.setHandlers( + onSnapshot: { [unowned self] s, _ in self.snapshots.append(s) }, + onEnvelope: { [unowned self] e, _ in self.envelopes.append(e) }, + onStatus: { [unowned self] s in self.statuses.append(s) } + ) + } + + var at: Int64 { Int64(scheduler.nowMs) } + + func run(_ ms: Double) async { + var left = ms + while left > 0 { + for _ in 0..<5 { await Task.yield() } + scheduler.advance(min(10, left)) + left -= 10 + } + } +} + +@MainActor +@Suite struct CloudCarrierTests { + /// One round trip after the socket opens, the service's copy is here; + /// each later change follows on the same socket. + @Test func theSnapshotComesInMessage2AndChangesFollow() async throws { + let rig = try CloudRig() + rig.box.start = rig.at - 60_000 + rig.service.held = [try rig.box.fullLive(rig.at, age: 500)] + rig.carrier.start() + await rig.run(50) + #expect(rig.service.dialled.first?.absoluteString == "wss://cloud.test/v1/app?home=\(rig.box.home)") + #expect(rig.service.nows == [CloudWire.hzLooking]) + #expect(rig.carrier.status.isOpen) + #expect(rig.snapshots.count == 1) + #expect(rig.snapshots.first?.envelopes == rig.service.held) + + let hb = try rig.box.hb(rig.at, age: 900, liveSeq: 1) + rig.service.push(hb) + await rig.run(30) + #expect(rig.envelopes == [hb]) + } + + @Test func lookingAwayAndBackIsSaid() async throws { + let rig = try CloudRig(looking: false) + rig.carrier.start() + await rig.run(50) + #expect(rig.service.nows == [CloudWire.hzAway]) + rig.carrier.setLooking(true) + rig.carrier.setLooking(true) + rig.carrier.setLooking(false) + await rig.run(30) + #expect(rig.service.watches == [1, 0]) + } + + /// A refusal is not retried by itself: the roster or the grant has to + /// change first, and the next foreground asks again. + @Test func aRefusalWaitsForWake() async throws { + let rig = try CloudRig() + rig.service.roster = [] + rig.carrier.start() + await rig.run(100) + #expect(rig.carrier.status == .closed(code: CloudWire.closeDenied, retrying: false)) + await rig.run(120_000) + #expect(rig.service.dialled.count == 1) + rig.service.roster = [rig.device.publicKey] + rig.carrier.wake() + await rig.run(100) + #expect(rig.carrier.status.isOpen) + } + + /// The roster changed what this phone may read: it asks again at once. + @Test func aChangedRosterReconnectsAtOnce() async throws { + let rig = try CloudRig() + rig.carrier.start() + await rig.run(50) + rig.service.close(code: CloudWire.closeChanged) + await rig.run(60) + #expect(rig.service.dialled.count == 2) + #expect(rig.carrier.status.isOpen) + } + + /// Anything else heals by itself, with full jitter. + @Test func aLostSocketRedialsWithBackoff() async throws { + let rig = try CloudRig() + rig.carrier.start() + await rig.run(50) + rig.service.close(code: 1006) + #expect(rig.carrier.status == .closed(code: 1006, retrying: true)) + await rig.run(600) + #expect(rig.carrier.status.isOpen) + #expect(rig.service.dialled.count == 2) + } + + /// The app carries two service keys so the service can rotate: a refusal + /// right after message 1 tries the other key once. + @Test func aRotatedServiceKeyIsTriedNext() async throws { + let rig = try CloudRig(endpoint: { service in + CloudEndpoint(url: URL(string: "wss://cloud.test/v1/app")!, serviceKeys: [Primitives.generateKeyPair().publicKey, service.staticKey.publicKey]) + }) + rig.carrier.start() + await rig.run(100) + #expect(rig.carrier.status.isOpen) + #expect(rig.service.dialled.count == 2) + } + + @Test func aMessageThatDoesNotDecryptEndsTheSession() async throws { + let rig = try CloudRig() + rig.carrier.start() + await rig.run(50) + rig.service.current?.deliver(randomBytes(40)) + await rig.run(20) + #expect(!rig.carrier.status.isOpen) + #expect(rig.envelopes.isEmpty) + await rig.run(600) + #expect(rig.carrier.status.isOpen) + } + + @Test func closedMeansClosed() async throws { + let rig = try CloudRig() + rig.carrier.start() + await rig.run(50) + rig.carrier.close() + rig.carrier.wake() + await rig.run(120_000) + #expect(rig.service.dialled.count == 1) + } +} + +@Suite struct CloudEndpointTests { + /// Release builds read the fixed address and ignore any override. + @Test func onlyADebugBuildMayPointElsewhere() throws { + let defaults = try #require(UserDefaults(suiteName: "ftw-cloud-\(UUID().uuidString)")) + let key = Base64url.encode(Primitives.generateKeyPair().publicKey) + defaults.set("wss://127.0.0.1:18443/v1/app", forKey: "FTWCloudURL") + defaults.set(key, forKey: "FTWCloudKey") + let debug = try #require(CloudEndpoint.resolve(defaults: defaults, debug: true)) + #expect(debug.url.absoluteString == "wss://127.0.0.1:18443/v1/app") + #expect(CloudEndpoint.resolve(defaults: defaults, debug: false)?.url ?? CloudEndpoint.releaseURL == CloudEndpoint.releaseURL) + #expect(CloudEndpoint.releaseURL.absoluteString == "wss://cloud.ftw.energy/v1/app") + defaults.set("not a key", forKey: "FTWCloudKey") + #expect(CloudEndpoint.resolve(defaults: defaults, debug: true)?.url != debug.url) + } + + /// The identity comes from the box. A home that is not the hash of the + /// key is refused. + @Test func theHomeIsTheHashOfTheKey() throws { + let v = try CloudVectors.load() + let pk = Bytes(hex: v.boxKey.pk) + let boot = Bytes(repeating: 0xb0, count: CloudWire.bootBytes) + let good = CloudIdentity(.map([("pk", .bytes(pk)), ("home", .text(v.home)), ("boot", .bytes(boot)), + ("sources", .map(["ACfHt4DWL7g": .text("p1meter")])), ("loadpoints", .map([:]))])) + #expect(good?.home == v.home) + #expect(good?.sources["ACfHt4DWL7g"] == "p1meter") + #expect(CloudIdentity(.map([("pk", .bytes(pk)), ("home", .text("aaaaaaaaaaaaaaaaaaaaaaaaaa")), ("boot", .bytes(boot))])) == nil) + #expect(CloudIdentity(.map([("pk", .bytes(Bytes(pk.prefix(33)))), ("boot", .bytes(boot))])) == nil) + #expect(CloudIdentity(nil) == nil) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift new file mode 100644 index 0000000..dc087ce --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift @@ -0,0 +1,142 @@ +import Foundation +import Testing +@testable import FTWKit + +/// The first-start consent and the owner's grant at the box. +@MainActor +@Suite struct CloudConsentTests { + func defaults() throws -> UserDefaults { + try #require(UserDefaults(suiteName: "ftw-consent-\(UUID().uuidString)")) + } + + /// A home whose box answers hello and the cloud routes as Core does. + func home(role: String = Contract.roleOwner, caps: [String] = [Contract.capCloudLink, Contract.capAPIPassthrough], cloud: Bool = false) throws -> CloudSite { + let w = try CloudSite() + w.open() + w.site.connect(w.relay) + w.relay.api = { method, path, _ in + switch (method, path) { + case ("POST", "/api/cloud/grant"): return (200, #"{"state":"on","purposes":["serve"],"connected":false}"#) + case ("POST", "/api/cloud/stats"): return (200, #"{"state":"on","purposes":["serve","stats"]}"#) + case ("DELETE", "/api/cloud/grant"): return (200, #"{"state":"revoking","purposes":["serve"]}"#) + case ("GET", "/api/cloud"): return (200, #"{"state":"off","purposes":[],"purgedAtMs":1791821000000}"#) + default: return (404, #"{"code":"E_NOT_FOUND"}"#) + } + } + w.relay.open(w.scheduler.nowMs) + w.relay.helloOk(uptimeMs: w.uptime, cloud: cloud ? w.identity : nil, role: role, caps: caps) + return w + } + + @Test func askedOnceOnARealHome() throws { + let d = try defaults() + let consent = CloudConsent(defaults: d) + #expect(!consent.shouldAsk(nil)) + let demo = SiteModel(siteId: nil, build: "t", ua: "t", scheduler: ManualScheduler(), files: nil, isDemo: true) + #expect(!consent.shouldAsk(demo)) + let w = try CloudSite() + w.open() + #expect(consent.shouldAsk(w.site)) + #expect(!consent.contributeStats) + } + + @Test func anOwnerTurnsItOnAtTheBoxWithEveryCloudScope() async throws { + let w = try home() + let d = try defaults() + let consent = CloudConsent(defaults: d) + #expect(CloudConsent.audience(w.site) == .owner) + await consent.accept(w.site) + #expect(consent.answered) + #expect(!consent.shouldAsk(w.site)) + #expect(CloudConsent(defaults: d).answered) + let grant = try #require(w.relay.calls.first) + #expect(grant.method == "POST" && grant.path == "/api/cloud/grant") + let body = try JSON(parsing: Bytes(grant.body.utf8)) + #expect(body["scopes"]?.array?.compactMap(\.string) == Contract.cloudScopes) + #expect(body["policy"]?.string == CloudConsent.policy) + // No statistics unless ticked. + #expect(w.relay.calls.count == 1) + #expect(consent.box?.on == true) + } + + @Test func statisticsGoOnlyWhenTicked() async throws { + let w = try home() + let consent = CloudConsent(defaults: try defaults()) + consent.contributeStats = true + await consent.accept(w.site) + #expect(w.relay.calls.map(\.path) == ["/api/cloud/grant", "/api/cloud/stats"]) + #expect(consent.box?.stats == true) + } + + /// The grant is asked for once per home. After that the box's word + /// stands: turned off on the box or in About, it stays off. + @Test func theGrantIsAskedForOncePerHome() async throws { + let w = try home() + let d = try defaults() + let consent = CloudConsent(defaults: d) + await consent.accept(w.site) + await consent.ensure(w.site) + await CloudConsent(defaults: d).ensure(w.site) + #expect(w.relay.calls.count == 1) + } + + @Test func aViewerIsToldToAskTheOwner() async throws { + let w = try home(role: Contract.roleViewer) + let consent = CloudConsent(defaults: try defaults()) + #expect(CloudConsent.audience(w.site) == .viewer) + await consent.accept(w.site) + await consent.refresh(w.site) + #expect(w.relay.calls.isEmpty) + #expect(consent.answered) + } + + /// A box that cannot run the uplink yet gets the grant once it can. + @Test func aBoxWithoutTheUplinkIsAskedLater() async throws { + let w = try home(caps: [Contract.capAPIPassthrough]) + let d = try defaults() + let consent = CloudConsent(defaults: d) + #expect(CloudConsent.audience(w.site) == .ownerLater) + await consent.accept(w.site) + #expect(w.relay.calls.isEmpty) + w.relay.helloOk(uptimeMs: w.uptime, cloud: nil, caps: [Contract.capCloudLink, Contract.capAPIPassthrough]) + await consent.ensure(w.site) + #expect(w.relay.calls.map(\.path) == ["/api/cloud/grant"]) + } + + @Test func alreadyOnIsLeftAlone() async throws { + let w = try home(cloud: true) + let consent = CloudConsent(defaults: try defaults()) + await consent.accept(w.site) + #expect(w.relay.calls.isEmpty) + } + + /// About: off asks the box to erase the copy; the receipt is the time on + /// the service's confirmation. + @Test func offAndTheReceipt() async throws { + let w = try home(cloud: true) + let consent = CloudConsent(defaults: try defaults()) + #expect(await consent.turnOff(w.site)) + #expect(consent.box?.erasing == true) + await consent.refresh(w.site) + #expect(consent.box?.on == false) + #expect(consent.box?.erasedAtMs == 1_791_821_000_000) + } + + @Test func theScopesAreTheRegistrys() throws { + let yaml = try RepoFiles.read("protocol/registry.yaml") + #expect(yaml.contains("cloud_scopes: [\(Contract.cloudScopes.joined(separator: ", "))]")) + #expect(yaml.contains("cloud_purposes: [\(Contract.cloudPurposes.joined(separator: ", "))]")) + #expect(Contract.cloudPurposes.contains(Contract.cloudPurposeStats)) + } + + /// Every sentence of the screen has Swedish. + @Test func theWordsAreInBothLanguages() { + let keys = ["The FTW app uses FTW Cloud", "Contribute to statistics", "Ask the owner of this home to turn on FTW Cloud.", "Continue", + "Copy erased %@", "FTW Cloud is on for this home."] + for k in keys { #expect(CloudWords.sv[k] != nil, "\(k)") } + AppWords.$language.withValue(.sv) { + #expect(CloudWords.text("Continue") == "Fortsätt") + #expect(CloudWords.text("Contribute to statistics") == "Bidra till statistik") + } + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift new file mode 100644 index 0000000..2a942b3 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift @@ -0,0 +1,381 @@ +import Foundation +import Testing +@testable import FTWKit +import FTWShared + +/// A box session the test speaks for frame by frame: hello_ok with or +/// without FTW Cloud, then a snapshot of readings. +@MainActor +final class ScriptedBox: Carrier { + let kind: CarrierKind = .relay + private(set) var status: CarrierStatus = .connecting + private var onFrame: @MainActor (Bytes) -> Void = { _ in } + private var onStatus: @MainActor (CarrierStatus) -> Void = { _ in } + private(set) var sent: [String] = [] + /// The box's HTTP API through the passthrough: method, path and body in, + /// status and JSON out. + var api: ((String, String, String) -> (Int, String))? + private(set) var calls: [(method: String, path: String, body: String)] = [] + + func send(_ frame: Bytes) { + guard let f = try? Frame.decode(frame) else { return } + sent.append(f.envelope.t) + guard f.envelope.t == "api.req", let id = f.envelope.id, let b = f.envelope.b else { return } + let method = b["method"]?.string ?? "", path = b["path"]?.string ?? "" + let body = b["body"]?.byteString.map { String(decoding: $0, as: UTF8.self) } ?? "" + calls.append((method, path, body)) + let (status, json) = api?(method, path, body) ?? (404, "{}") + reply("api.head", id, .map([("status", .int(status)), ("headers", .map([("content-type", .text("application/json"))]))])) + reply("api.chunk", id, .map([("seq", .int(0)), ("data", .bytes(Bytes(json.utf8)))])) + reply("api.end", id, CBOR.emptyMap) + } + + private func reply(_ t: String, _ id: UInt32, _ body: CBOR) { + guard let frame = try? Frame.encodeBulk(envelope: Envelope(t: t, id: id, b: body)) else { return } + onFrame(frame) + } + + func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void) { + self.onFrame = onFrame + self.onStatus = onStatus + } + + func wake() {} + + func close(reason: String) { + status = .closed(reason: reason, retryable: false) + } + + func open(_ at: Double) { + status = .open(sinceMs: at) + onStatus(status) + } + + func say(_ t: String, _ body: CBOR) { + guard let frame = try? Frame.encodeBulk(envelope: Envelope(t: t, b: body)) else { return } + onFrame(frame) + } + + func helloOk(uptimeMs: Int64, cloud: CloudIdentity?, role: String = Contract.roleOwner, caps: [String] = [Contract.capCloudLink, Contract.capAPIPassthrough]) { + var body: [(String, CBOR)] = [ + ("proto", .int(Proto.max)), ("mode", .text("full")), + ("box", .map([("id", .text("box")), ("build", .text("t")), ("tz", .text("Europe/Stockholm"))])), + ("clock", .map([("source", .text("ntp")), ("syncedAtMs", .int(0)), ("uptimeMs", .int(uptimeMs))])), + ("caps", .array(caps.map { .text($0) })), ("role", .text(role)), ("modes", .array([])), + ("subscribed", .bool(true)), + ] + if let cloud { + body.append(("cloud", .map([ + ("pk", .bytes(cloud.pk)), ("home", .text(cloud.home)), ("boot", .bytes(cloud.boot)), + ("sources", .map(cloud.sources.sorted { $0.key < $1.key }.map { (k, v) -> (String, CBOR) in (k, .text(v)) })), + ("loadpoints", .map([(String, CBOR)]())), + ]))) + } + say("hello_ok", .map(body)) + } + + func snap(uptimeMs: Int64, gridW: Int) { + say("snap", .map([ + ("uptimeMs", .int(uptimeMs)), ("controlRev", .int(1)), + ("dict", .map(["2": .map([("name", .text("grid")), ("unit", .text("W")), ("srcId", .text("p1meter"))])])), + ("fields", .map(["2": .int(gridW)])), + ("sources", .map(["p1meter": .map([("kind", .text("driver")), ("name", .text("P1 meter")), ("lastOkMs", .int(uptimeMs - 200)), + ("staleAfterMs", .int(10_000)), ("state", .text("live"))])])), + ("dispatchBlockedBy", .array([])), + ])) + } +} + +/// A home with FTW Cloud on: a box that signs, the service, and a site model +/// that reads them. +@MainActor +final class CloudSite { + let scheduler = ManualScheduler() + let store = MemoryStore() + let files: SealedFiles + let shared = AppGroupFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-shared-\(UUID().uuidString)")) + let service: FakeCloudService + let device = Primitives.generateKeyPair() + var box: FakeCloudBox + let identity: CloudIdentity + var site: SiteModel! + let relay = ScriptedBox() + + init(noClock: Bool = false) throws { + files = SealedFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-test-\(UUID().uuidString)"), store: store) + service = FakeCloudService(scheduler: scheduler) + box = try FakeCloudBox(boot: 1) + box.start = Int64(scheduler.nowMs) - 60_000 + box.noClock = noClock + identity = CloudIdentity(pk: box.pk, boot: box.boot, sources: ["m": "p1meter"]) + service.roster = [device.publicKey] + } + + var at: Int64 { Int64(scheduler.nowMs) } + var uptime: Int64 { at - box.start } + + /// What an earlier launch left: the box's word on FTW Cloud, and a home + /// on screen a minute ago. + func keepFromEarlierLaunch(gridW: Int? = 111) { + files.writeJSON("cloud-s1", CloudCache(identity: identity, offset: nil)) + guard let gridW else { return } + var s = SessionState() + s.fields = [Contract.FID.gridW: Double(gridW)] + s.uptimeMs = 50_000 + s.sources = ["p1meter": Source(kind: "driver", name: "P1 meter", lastOkMs: 49_800, staleAfterMs: 10_000, state: .live)] + s.dict = [Contract.FID.gridW: FieldDef(name: "grid", unit: "W", srcId: "p1meter")] + files.writeJSON("snapshot-s1", CachedSnapshot(siteId: "s1", savedAtMs: scheduler.nowMs - 60_000, state: s)) + } + + func open() { + site = SiteModel(siteId: "s1", build: "test", ua: "test", scheduler: scheduler, files: files, shared: shared) + site.start() + } + + func connectCloud() { + site.connectCloud(deviceKey: device, makeSocket: service.factory, endpoint: service.endpoint) + } + + func live(gridW: Int, age: Int64 = 400) throws -> CloudWire.Envelope { + let h = box.header(at) + let m = CBOR.map([("kind", .text("driver")), ("state", .text("live")), ("lastOkMs", .int(uptime - age)), ("staleAfterMs", .int(10_000))]) + return try box.seal(CloudWire.msgLive, h + [ + ("fields", .map(["2": .int(Int64(gridW))])), ("fieldSources", .map(["2": .array([.text("m")])])), + ("sources", .map(["m": m])), ("srcRev", .unsigned(1)), ("ages", .map(["m": .int(age)])), ("dispatchBlockedBy", .array([])), + ]) + } + + func run(_ ms: Double) async { + var left = ms + while left > 0 { + for _ in 0..<5 { await Task.yield() } + scheduler.advance(min(10, left)) + left -= 10 + } + for _ in 0..<20 { await Task.yield() } + } +} + +@MainActor +@Suite struct SiteModelCloudTests { + /// The three layers: the phone's cache at once, the cloud's copy one + /// round trip later, then each change on the same socket. + @Test func cacheThenTheCloudsCopyThenEachChange() async throws { + let w = try CloudSite() + w.keepFromEarlierLaunch(gridW: 111) + w.service.held = [try w.live(gridW: 1000)] + w.open() + #expect(w.site.carrier == .cache) + #expect(w.site.session.fields[Contract.FID.gridW] == 111) + + w.connectCloud() + await w.run(40) + #expect(w.site.carrier == .cloud) + #expect(w.site.session.fields[Contract.FID.gridW] == 1000) + #expect(w.site.isLive) + #expect(w.site.srcState == .live) + #expect(w.site.freshness(noCarrier: false).message == "Live via FTW Cloud") + // Names come from the box's table, never from the service. + #expect(w.site.session.dict[Contract.FID.gridW]?.srcId == "p1meter") + #expect(w.site.session.sources["p1meter"]?.state == .live) + + let firstFrame = w.site.lastFrameAtMs + w.service.push(try w.live(gridW: 1200)) + await w.run(30) + #expect(w.site.session.fields[Contract.FID.gridW] == 1200) + #expect(w.site.lastFrameAtMs != firstFrame) + } + + /// The doc's replay case on the phone: a snapshot the service held back + /// an hour arrives, signed and intact, and shows its age, never live. + @Test func aReplayedSnapshotIsNotLive() async throws { + let w = try CloudSite() + w.keepFromEarlierLaunch(gridW: nil) + // The box signed it an hour before the phone opens. + let old = try w.live(gridW: 900) + w.scheduler.advance(3_600_000) + w.service.held = [old] + w.open() + w.connectCloud() + await w.run(40) + #expect(w.site.carrier == .cloud) + #expect(w.site.session.fields[Contract.FID.gridW] == 900) + #expect(w.site.srcState == .down) + #expect(!w.site.isLive) + #expect((w.site.ageMs ?? 0) > 3_500_000) + } + + /// No wall clock on the box and no rule U yet: shown, unverified, never + /// live. + @Test func noAnchorIsNeverLive() async throws { + let w = try CloudSite(noClock: true) + w.keepFromEarlierLaunch(gridW: nil) + w.service.held = [try w.live(gridW: 1000)] + w.open() + w.connectCloud() + await w.run(40) + #expect(w.site.carrier == .cloud) + #expect(w.site.srcState == .stale) + #expect(w.site.ageMs == nil) + #expect(w.site.freshness(noCarrier: false).message == "FTW Cloud · age unverified") + } + + /// Rule U from the box session gives the same box a trusted clock. + @Test func theBoxSessionAnchorsABoxWithoutAClock() async throws { + let w = try CloudSite(noClock: true) + w.open() + w.connectCloud() + w.site.connect(w.relay) + w.relay.open(w.scheduler.nowMs) + w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity) + w.service.held = [try w.live(gridW: 1000)] + await w.run(40) + #expect(w.site.carrier == .cloud) + #expect(w.site.srcState == .live) + #expect(w.site.ageMs != nil) + } + + /// The cloud fails or is out of reach: the box session's readings take + /// over under its own name, and the cloud takes back over when it heals. + @Test func theRelayTakesOverWhileTheCloudIsDown() async throws { + let w = try CloudSite() + w.open() + w.connectCloud() + w.site.connect(w.relay) + w.relay.open(w.scheduler.nowMs) + w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity) + w.relay.snap(uptimeMs: w.uptime, gridW: 700) + w.service.held = [try w.live(gridW: 1000)] + await w.run(40) + #expect(w.site.carrier == .cloud) + #expect(w.site.session.fields[Contract.FID.gridW] == 1000) + + w.service.close(code: 1006) + await w.run(1) + #expect(w.site.carrier == .relay) + #expect(w.site.session.fields[Contract.FID.gridW] == 700) + #expect(w.site.freshness(noCarrier: false).message == "Live via encrypted relay") + + await w.run(600) + #expect(w.site.carrier == .cloud) + } + + /// A box session that streams live wins over a cloud copy that is not + /// live: never show older data than the phone has. + @Test func aLiveBoxSessionBeatsAStaleCloud() async throws { + let w = try CloudSite() + w.open() + w.connectCloud() + w.site.connect(w.relay) + w.relay.open(w.scheduler.nowMs) + w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity) + let old = try w.live(gridW: 900, age: 120_000) + w.service.held = [old] + w.relay.snap(uptimeMs: w.uptime, gridW: 700) + await w.run(40) + #expect(w.site.carrier == .relay) + #expect(w.site.session.fields[Contract.FID.gridW] == 700) + } + + /// The box said FTW Cloud is off: the session closes and nothing of it + /// is kept. + @Test func aGrantTurnedOffForgetsTheCloud() async throws { + let w = try CloudSite() + w.keepFromEarlierLaunch(gridW: nil) + w.service.held = [try w.live(gridW: 1000)] + w.open() + w.connectCloud() + await w.run(40) + #expect(w.site.cloudOn) + w.site.connect(w.relay) + w.relay.open(w.scheduler.nowMs) + w.relay.helloOk(uptimeMs: w.uptime, cloud: nil) + await w.run(10) + #expect(!w.site.cloudOn) + #expect(w.site.carrier != .cloud) + #expect(w.service.current == nil) + #expect(w.files.readJSON(CloudCache.self, "cloud-s1") == nil) + } + + /// A first launch learns FTW Cloud from hello and keeps it for the next. + @Test func helloStartsTheCloudAndIsKept() async throws { + let w = try CloudSite() + w.service.held = [try w.live(gridW: 1000)] + w.open() + w.connectCloud() + await w.run(40) + #expect(w.service.dialled.isEmpty) + w.site.connect(w.relay) + w.relay.open(w.scheduler.nowMs) + w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity) + await w.run(40) + #expect(w.service.dialled.count == 1) + #expect(w.site.carrier == .cloud) + #expect(w.files.readJSON(CloudCache.self, "cloud-s1")?.identity == w.identity) + } + + /// An envelope that does not verify never reaches a screen. + @Test func anEnvelopeTheBoxDidNotSignIsDropped() async throws { + let w = try CloudSite() + w.keepFromEarlierLaunch(gridW: nil) + w.service.held = [try w.live(gridW: 1000)] + w.open() + w.connectCloud() + await w.run(40) + var forged = try w.live(gridW: 5) + forged.b[forged.b.count - 3] ^= 1 + w.service.push(forged) + await w.run(30) + #expect(w.site.session.fields[Contract.FID.gridW] == 1000) + } + + /// Prices and cars from the cloud go to the widgets, labelled as read + /// through FTW Cloud. + @Test func theWidgetsGetTheCloudsPricesAndCars() async throws { + let w = try CloudSite() + w.keepFromEarlierLaunch(gridW: nil) + let t0 = w.at - w.at % 86_400_000 + let prices = try w.box.seal(CloudWire.msgPrices, w.box.header(w.at) + [ + ("zone", .text("SE3")), ("currency", .text("SEK")), ("t0Ms", .int(t0)), ("slotMs", .int(900_000)), + ("spotMinor", .array([.int(42), .null, .int(40)])), ("totalMinor", .array([.int(118), .null, .int(115)])), + ]) + let lp = try w.box.seal(CloudWire.msgLP, w.box.header(w.at) + [ + ("controlRev", .int(3)), + ("loadpoints", .array([.map([("id", .text("j3HZ")), ("src", .text("m")), ("plugged", .bool(true)), + ("socPermille", .int(430)), ("socSource", .text("vehicle")), ("socAgeS", .int(60)), + ("targetPermille", .int(800)), ("powerW", .int(2100))])])), + ("feedback", .array([])), + ]) + w.service.held = [try w.live(gridW: 1000), prices, lp] + w.open() + w.connectCloud() + await w.run(60) + for _ in 0..<200 where w.shared.read(CarEntry.self, siteId: "s1") == nil { + try await Task.sleep(nanoseconds: 2_000_000) + } + let p = try #require(w.shared.read(PriceEntry.self, siteId: "s1")) + #expect(p.carrier == .cloud) + #expect(p.value.prices.slots.count == 2) + #expect(p.value.prices.slots.first?.totalMinor == 118) + let car = try #require(w.shared.read(CarEntry.self, siteId: "s1")) + #expect(car.carrier == .cloud) + #expect(car.value.cars.first?.socPct == 43) + #expect(car.value.cars.first?.pluggedIn == true) + } + + /// The background closes the session, as it closes the relay's. + @Test func theBackgroundClosesTheCloud() async throws { + let w = try CloudSite() + w.keepFromEarlierLaunch(gridW: nil) + w.service.held = [try w.live(gridW: 1000)] + w.open() + w.connectCloud() + await w.run(40) + w.site.suspend() + #expect(w.service.current == nil) + #expect(w.site.carrier != .cloud) + #expect(w.site.cloudStatus == nil) + // The readings stay, with their age. + #expect(w.site.session.fields[Contract.FID.gridW] == 1000) + } +} From fca88b0649be66aec14882d688a65f4a97f18183 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 08:55:05 +0200 Subject: [PATCH 4/9] feat(cloud): ask again after a refusal, log each layer, and a live test A refusal from FTW Cloud is asked again slowly, from 7.5 s up to five minutes, and at once on wake: a service that just restarted refuses a phone that reaches it before the box does. A session another session of the same phone replaced stays closed. SiteModel logs, once per home, how long after start the relay's first reading, the cloud's snapshot and its first live arrived (category reading), beside LaunchClock's first frame. LiveCloudTests drives a real box and a real ftwcloudd through the production relay, off unless the environment names them: it times the three layers against the relay five times each, replays a snapshot an hour late, revokes a phone with its stream open and closes the cloud under a running home. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWKit/Carrier/CloudCarrier.swift | 25 +- .../Sources/FTWKit/State/SiteModel.swift | 25 +- .../Tests/FTWKitTests/CloudCarrierTests.swift | 23 +- .../Tests/FTWKitTests/LiveCloudTests.swift | 337 ++++++++++++++++++ 4 files changed, 397 insertions(+), 13 deletions(-) create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/LiveCloudTests.swift diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift index 2b7068f..f68002b 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift @@ -17,9 +17,8 @@ public final class CloudCarrier { public enum Status: Equatable, Sendable { case connecting case open(sinceMs: Double) - /// `retrying` is false after a refusal: nothing changes by asking - /// again until the box's roster or grant does, so the carrier waits - /// for `wake`. + /// `retrying` is false once another session of this phone took + /// over: asking again would only take it back. case closed(code: Int, retrying: Bool) public var isOpen: Bool { @@ -30,6 +29,11 @@ public final class CloudCarrier { static let backoffBaseMs: Double = 500 static let backoffCapMs: Double = 60_000 + /// A refusal changes only when the box's roster or grant does, or when + /// a service that just restarted has heard from the box again. Asked + /// again slowly, and at once on `wake`. + static let refusedBaseMs: Double = 15_000 + static let refusedCapMs: Double = 300_000 /// Message 2 is one round trip after message 1; a service this slow is /// not one to wait on. static let handshakeTimeoutMs: Double = 10_000 @@ -51,6 +55,7 @@ public final class CloudCarrier { /// message 1 may be a rotated key; the next one gets one try. private var keyIndex = 0 private var attempt = 0 + private var refusals = 0 private var retry: Cancellable? private var deadline: Cancellable? private var shutdown = false @@ -105,6 +110,7 @@ public final class CloudCarrier { retry?.cancel() retry = nil attempt = 0 + refusals = 0 keyIndex = 0 drop() dial() @@ -152,7 +158,9 @@ public final class CloudCarrier { let keys = endpoint.serviceKeys guard keyIndex < keys.count, let hs = try? HandshakeState.initiator(staticKey: deviceKey, remoteStatic: keys[keyIndex], prologue: CloudWire.appPrologue(home: home)) else { - fail(code: CloudWire.closeDenied, retrying: false) + shutdown = true + drop() + setStatus(.closed(code: CloudWire.closeDenied, retrying: false)) return } let now = CloudWire.encodeUnsigned(t: CloudWire.msgNow, body: .map([("hz", .int(looking ? CloudWire.hzLooking : CloudWire.hzAway))])) @@ -191,6 +199,7 @@ public final class CloudCarrier { handshake = nil transport = NoiseTransport(keys) attempt = 0 + refusals = 0 setStatus(.open(sinceMs: arrival)) onSnapshot(snapshot, arrival) } @@ -212,8 +221,14 @@ public final class CloudCarrier { // Perhaps the service already runs on the app's other key. keyIndex += 1 dial() - case CloudWire.closeDenied, CloudWire.closeReplaced: + case CloudWire.closeReplaced: setStatus(.closed(code: code, retrying: false)) + case CloudWire.closeDenied: + keyIndex = 0 + setStatus(.closed(code: code, retrying: true)) + let ceiling = min(Self.refusedCapMs, Self.refusedBaseMs * pow(2, Double(refusals))) + refusals = min(refusals + 1, 16) + schedule(ceiling / 2 + scheduler.random() * ceiling / 2) case CloudWire.closeChanged: // The roster changed what this phone may read: ask again now, // once, to learn what that is. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 816f56c..1fa9cfd 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -4,6 +4,10 @@ import Observation import os private let log = Logger(subsystem: "energy.ftw.app", category: "widgets") +/// When each source first delivered, counted from `start`, so the three +/// layers of a cold start and the relay's path can be compared. Read with +/// `log show --predicate 'subsystem == "energy.ftw.app" AND category == "reading"'`. +private let readingLog = Logger(subsystem: "energy.ftw.app", category: "reading") /// The bridge between the session and the screens. /// @@ -87,6 +91,8 @@ public final class SiteModel { @ObservationIgnored private var cloudDirty = false /// Where the readings on screen came from at the last change. @ObservationIgnored private(set) var source: ReadingSource = .relay + @ObservationIgnored private var startedAtMs: Double = 0 + @ObservationIgnored private var firsts: Set = [] /// The cloud session, for the About screen. Nil while there is none. public private(set) var cloudStatus: CloudCarrier.Status? @ObservationIgnored private var nextListener = 0 @@ -110,6 +116,7 @@ public final class SiteModel { /// Paint from cache, then let the shell connect. The cache is a carrier, /// not a failure: it is how the first frame has something honest. public func start() { + startedAtMs = scheduler.nowMs if let siteId, let cached = files?.readJSON(CachedSnapshot.self, "snapshot-\(siteId)"), cached.siteId == siteId { core.restore(cached) cachedAtMs = cached.savedAtMs @@ -242,12 +249,22 @@ public final class SiteModel { scheduleSnapshot() } publish() - if reading, source == .relay { - lastFrameAtMs = now - recordRecent(session.fields, now) + if reading { + first("relay reading") + if source == .relay { + lastFrameAtMs = now + recordRecent(session.fields, now) + } } } + /// Logs once per home how long after `start` something first happened. + func first(_ what: String) { + guard startedAtMs > 0, firsts.insert(what).inserted else { return } + let ms = Int((scheduler.nowMs - startedAtMs).rounded()) + readingLog.notice("\(what, privacy: .public) \(ms, privacy: .public) ms after start") + } + /// What the screens read: the box session's state, with the cloud's /// readings in it while the cloud is where readings come from. private func publish() { @@ -738,11 +755,13 @@ extension SiteModel { private func onCloudSnapshot(_ snapshot: CloudWire.Snapshot, _ at: Double) { var any = false for env in snapshot.envelopes where acceptCloud(env, at) { any = true } + first("cloud snapshot") if any { cloudRead(at) } else { publish() } } private func onCloudEnvelope(_ env: CloudWire.Envelope, _ at: Double) { guard acceptCloud(env, at) else { return } + if env.t == CloudWire.msgLive { first("cloud live") } cloudRead(at) } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift index 76b253b..af20b49 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift @@ -212,22 +212,35 @@ final class CloudRig { #expect(rig.service.watches == [1, 0]) } - /// A refusal is not retried by itself: the roster or the grant has to - /// change first, and the next foreground asks again. - @Test func aRefusalWaitsForWake() async throws { + /// 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 { let rig = try CloudRig() rig.service.roster = [] rig.carrier.start() await rig.run(100) - #expect(rig.carrier.status == .closed(code: CloudWire.closeDenied, retrying: false)) - await rig.run(120_000) + #expect(rig.carrier.status == .closed(code: CloudWire.closeDenied, retrying: true)) + await rig.run(10_000) #expect(rig.service.dialled.count == 1) + await rig.run(10_000) + #expect(rig.service.dialled.count == 2) rig.service.roster = [rig.device.publicKey] rig.carrier.wake() await rig.run(100) #expect(rig.carrier.status.isOpen) } + /// Another session of this phone took over: this one stays closed. + @Test func aReplacedSessionStaysClosed() async throws { + let rig = try CloudRig() + rig.carrier.start() + await rig.run(50) + rig.service.close(code: CloudWire.closeReplaced) + await rig.run(120_000) + #expect(rig.carrier.status == .closed(code: CloudWire.closeReplaced, retrying: false)) + #expect(rig.service.dialled.count == 1) + } + /// The roster changed what this phone may read: it asks again at once. @Test func aChangedRosterReconnectsAtOnce() async throws { let rig = try CloudRig() diff --git a/appleApp/FTWKit/Tests/FTWKitTests/LiveCloudTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/LiveCloudTests.swift new file mode 100644 index 0000000..2ef0c77 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/LiveCloudTests.swift @@ -0,0 +1,337 @@ +import Foundation +import Security +import Testing +@testable import FTWKit + +/// Against a real FTW box and a real `ftwcloudd`, through the production +/// relay. Off unless the environment names them: +/// +/// FTW_LIVE_BOX=http://127.0.0.1:18080 the box's API, on loopback +/// FTW_LIVE_CLOUD_URL=wss://127.0.0.1:18443/v1/app +/// FTW_LIVE_CLOUD_KEY= +/// FTW_LIVE_CLOUD_CA=/path/to/ca.pem the local certificate's root +/// FTW_LIVE_CLOUD_STOP='kill …' FTW_LIVE_CLOUD_START='…' optional +/// +/// It pairs two phones, turns FTW Cloud on as the consent screen does, and +/// times the three layers of a cold start against the relay's path, five +/// times each. It then replays a snapshot an hour late, revokes a phone with +/// its stream open, and closes the cloud under a running home. +@MainActor +@Suite(.enabled(if: ProcessInfo.processInfo.environment["FTW_LIVE_CLOUD_URL"] != nil), .serialized) +struct LiveCloudTests { + let env = ProcessInfo.processInfo.environment + let scheduler = LiveScheduler() + + var box: URL { URL(string: env["FTW_LIVE_BOX"] ?? "http://127.0.0.1:18080")! } + + var endpoint: CloudEndpoint { + CloudEndpoint(url: URL(string: env["FTW_LIVE_CLOUD_URL"]!)!, serviceKeys: [try! Base64url.decode(env["FTW_LIVE_CLOUD_KEY"]!)]) + } + + var cloudSockets: WebSocketFactory { + let pem = try! String(contentsOfFile: env["FTW_LIVE_CLOUD_CA"]!, encoding: .utf8) + let der = Data(base64Encoded: pem.split(separator: "\n").filter { !$0.hasPrefix("-----") }.joined())! + let anchor = SecCertificateCreateWithData(nil, der as CFData)! + return { url, events in PinnedWebSocket(url: url, events: events, anchor: anchor) } + } + + /// One phone, as the app holds it after pairing. + struct Phone { + let enrollment: Enrollment + let key: Primitives.KeyPair + var id: String { String(Base64url.encode(key.publicKey).prefix(8)) } + } + + func pair(_ role: String) async throws -> Phone { + var req = URLRequest(url: box.appendingPathComponent("api/app-link/pairing")) + req.httpMethod = "POST" + req.httpBody = Data(#"{"role":"\#(role)"}"#.utf8) + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + let (data, _) = try await URLSession.shared.data(for: req) + let json = try JSON(parsing: Bytes(data)) + let url = try #require(json["url"]?.string ?? json["link"]?.string) + let vault = Vault(store: MemoryStore()) + return Phone(enrollment: try Enrollment.parse(scanned: url), key: try vault.deviceKey(vault.localWrappingKey())) + } + + func relay(_ phone: Phone) -> NoiseCarrier { + let e = phone.enrollment + return NoiseCarrier( + inner: RelayCarrier(secret: e.rendezvousSecret, scheduler: scheduler), + staticKey: phone.key, remoteStatic: e.boxStaticPublic, + prologue: NoiseCarrier.prologue(boxStaticKey: e.boxStaticPublic), + handshakePayload: e.pairingCode, scheduler: scheduler + ) + } + + func files() -> SealedFiles { + SealedFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-live-\(UUID().uuidString)"), store: MemoryStore()) + } + + func site(_ files: SealedFiles) -> SiteModel { + let s = SiteModel(siteId: "live", build: "live-cloud-test", ua: "test", scheduler: scheduler, files: files) + s.start() + return s + } + + /// Wait for `ok`, polling every 2 ms, and return how long it took. + func until(_ seconds: Double = 30, _ ok: () -> Bool) async throws -> Double { + let start = Date() + while !ok() { + if Date().timeIntervalSince(start) > seconds { throw Timeout() } + try await Task.sleep(nanoseconds: 2_000_000) + } + return Date().timeIntervalSince(start) * 1000 + } + + struct Timeout: Error {} + + func line(_ name: String, _ ms: [Double]) { + let sorted = ms.sorted() + let text = ms.map { String(Int($0.rounded())) }.joined(separator: ", ") + print("live-cloud: \(name): \(text) ms; median \(Int(sorted[sorted.count / 2].rounded())) ms") + } + + func shell(_ command: String?) throws { + guard let command else { return } + let p = Process() + p.executableURL = URL(fileURLWithPath: "/bin/sh") + p.arguments = ["-c", command] + try p.run() + p.waitUntilExit() + } + + @Test func threeLayersAgainstTheRelayAndTheProofs() async throws { + // An owner pairs and turns FTW Cloud on over the box session, as the + // consent screen does. + let owner = try await pair(Contract.roleOwner) + let setupFiles = files() + var home = site(setupFiles) + home.connect(relay(owner)) + _ = try await until { home.session.phase == .streaming } + #expect(home.session.caps.contains(Contract.capCloudLink)) + let consent = CloudConsent(defaults: UserDefaults(suiteName: "live-\(UUID().uuidString)")!) + await consent.accept(home) + print("live-cloud: grant \(consent.box?.state ?? (home.cloudOn ? "already on" : "?")) \(consent.help ?? "")") + #expect(consent.box?.on == true || home.cloudOn) + home.destroy() + + // The next hello names the box's key; the cache keeps it. + try await Task.sleep(nanoseconds: 2_000_000_000) + home = site(setupFiles) + home.connectCloud(deviceKey: owner.key, makeSocket: cloudSockets, endpoint: endpoint) + home.connect(relay(owner)) + _ = try await until { home.cloudOn && home.carrier == .cloud } + let identity = try #require(home.cloudIdentity) + print("live-cloud: home \(identity.home), sources \(identity.sources.values.sorted())") + home.destroy() + + // Five cold starts of each path, each on new sockets. + var relayFirst: [Double] = [], cloudSnapshot: [Double] = [], cloudLive: [Double] = [], cachePaint: [Double] = [] + for _ in 0..<5 { + try await Task.sleep(nanoseconds: 1_000_000_000) + let r = site(files()) + let t0 = Date() + r.connect(relay(owner)) + _ = try await until { r.session.phase == .streaming && r.lastFrameAtMs != nil } + relayFirst.append(Date().timeIntervalSince(t0) * 1000) + r.destroy() + } + for _ in 0..<5 { + try await Task.sleep(nanoseconds: 1_000_000_000) + let f = files() + f.writeJSON("cloud-live", CloudCache(identity: identity, offset: nil)) + f.writeJSON("snapshot-live", CachedSnapshot(siteId: "live", savedAtMs: Date().timeIntervalSince1970 * 1000 - 60_000, state: home.session)) + let t0 = Date() + let c = site(f) + cachePaint.append(Date().timeIntervalSince(t0) * 1000) + #expect(c.carrier == .cache && !c.session.fields.isEmpty) + c.connectCloud(deviceKey: owner.key, makeSocket: cloudSockets, endpoint: endpoint) + _ = try await until { c.carrier == .cloud } + cloudSnapshot.append(Date().timeIntervalSince(t0) * 1000) + let snapFrame = c.lastFrameAtMs + _ = try await until { c.lastFrameAtMs != snapFrame && c.cloudView?.live != nil } + cloudLive.append(Date().timeIntervalSince(t0) * 1000) + #expect(c.isLive) + c.destroy() + } + // How often the box sends while a phone looks: the cloud sends on + // change, at most once a second while watched. + var arrivals: [(String, Double)] = [] + let watcher = CloudCarrier(endpoint: endpoint, home: identity.home, deviceKey: owner.key, looking: true, scheduler: scheduler, makeSocket: cloudSockets) + watcher.setHandlers(onSnapshot: { _, _ in }, onEnvelope: { e, at in arrivals.append((e.t, at)) }, onStatus: { _ in }) + watcher.start() + try await Task.sleep(nanoseconds: 20_000_000_000) + watcher.close() + for t in ["live", "lp", "hb", "prices"] { + let at = arrivals.filter { $0.0 == t }.map(\.1) + let gaps = zip(at.dropFirst(), at).map { Int(($0 - $1).rounded()) } + print("live-cloud: watched 20 s: \(t) \(at.count)×, gaps \(gaps) ms") + } + + line("first frame from the phone's cache", cachePaint) + line("cloud snapshot (layer 2)", cloudSnapshot) + line("first cloud update after it (layer 3)", cloudLive) + line("relay, first reading (today's path)", relayFirst) + + // A replayed snapshot: the real bytes, an hour late, never live. + var snapshots: [CloudWire.Snapshot] = [] + let raw = CloudCarrier(endpoint: endpoint, home: identity.home, deviceKey: owner.key, looking: true, scheduler: scheduler, makeSocket: cloudSockets) + raw.setHandlers(onSnapshot: { s, _ in snapshots.append(s) }, onEnvelope: { _, _ in }, onStatus: { _ in }) + raw.start() + _ = try await until { !snapshots.isEmpty } + raw.close() + let now = Int64(Date().timeIntervalSince1970 * 1000) + var fresh = CloudView(home: identity.home, boxKey: identity.pk) + var late = CloudView(home: identity.home, boxKey: identity.pk) + for e in snapshots[0].envelopes { + try? fresh.accept(e, arrivalMs: now) + try? late.accept(e, arrivalMs: now + 3_600_000) + } + let g1 = fresh.grade(nowMs: now), g2 = late.grade(nowMs: now + 3_600_000) + print("live-cloud: replay: now sources \(g1.sources.values.map(\.rawValue).sorted()), an hour late \(g2.sources.values.map(\.rawValue).sorted()), fields \(Set(g2.fields.values.map(\.rawValue)).sorted())") + #expect(!g2.sources.values.contains(.live) && !g2.fields.values.contains(.live)) + + // Revoke a phone with its stream open: closed within a second. + let viewer = try await pair(Contract.roleViewer) + let v = site(files()) + v.connect(relay(viewer)) + _ = try await until { v.session.phase == .streaming } + v.destroy() + try await Task.sleep(nanoseconds: 1_500_000_000) + var status: CloudCarrier.Status = .connecting + let vc = CloudCarrier(endpoint: endpoint, home: identity.home, deviceKey: viewer.key, looking: true, scheduler: scheduler, makeSocket: cloudSockets) + vc.setHandlers(onSnapshot: { _, _ in }, onEnvelope: { _, _ in }, onStatus: { status = $0 }) + vc.start() + _ = try await until { status.isOpen } + var del = URLRequest(url: box.appendingPathComponent("api/app-link/devices/\(viewer.id)")) + del.httpMethod = "DELETE" + let t0 = Date() + let (_, resp) = try await URLSession.shared.data(for: del) + let cut = try await until(5) { !status.isOpen } + print("live-cloud: revoke: HTTP \((resp as? HTTPURLResponse)?.statusCode ?? 0), stream closed \(Int(Date().timeIntervalSince(t0) * 1000)) ms after the request (\(Int(cut)) ms after the answer), \(status)") + #expect(Date().timeIntervalSince(t0) < 1) + vc.close() + + // The cloud goes away under a running home: the relay takes over + // under its own name, and the cloud takes back over when it returns. + guard env["FTW_LIVE_CLOUD_STOP"] != nil else { return } + let f = files() + f.writeJSON("cloud-live", CloudCache(identity: identity, offset: nil)) + let both = site(f) + both.connectCloud(deviceKey: owner.key, makeSocket: cloudSockets, endpoint: endpoint) + both.connect(relay(owner)) + _ = try await until { both.carrier == .cloud && both.session.phase == .streaming } + try shell(env["FTW_LIVE_CLOUD_STOP"]) + let down = try await until { both.carrier == .relay } + print("live-cloud: cloud stopped: carrier \(both.carrier.rawValue) after \(Int(down)) ms, band \"\(both.freshness(noCarrier: false).message)\"") + try shell(env["FTW_LIVE_CLOUD_START"]) + both.networkOnline() + let up = try await until(70) { both.carrier == .cloud } + print("live-cloud: cloud back: carrier \(both.carrier.rawValue) after \(Int(up)) ms, band \"\(both.freshness(noCarrier: false).message)\"") + both.destroy() + } +} + +/// URLSession's WebSocket, trusting one local root and nothing else, so a +/// test reaches a local `ftwcloudd` without touching the Mac's keychain. +@MainActor +final class PinnedWebSocket: NSObject, WebSocketConnection { + private var task: URLSessionWebSocketTask? + private var session: URLSession? + private let events: WebSocketEvents + private var closed = false + private var opened = false + + init(url: URL, events: WebSocketEvents, anchor: SecCertificate) { + self.events = events + super.init() + let session = URLSession(configuration: .ephemeral, delegate: Delegate(owner: self, anchor: anchor), delegateQueue: nil) + self.session = session + let task = session.webSocketTask(with: url) + self.task = task + task.resume() + receive() + } + + func send(_ data: Bytes) { + guard !closed, let task else { return } + task.send(.data(Data(data))) { _ in } + } + + func close() { + guard !closed else { return } + closed = true + task?.cancel(with: .normalClosure, reason: nil) + session?.invalidateAndCancel() + } + + private func receive() { + task?.receive { [weak self] result in + Task { @MainActor [weak self] in + guard let self, !self.closed else { return } + switch result { + case .success(.data(let d)): + self.didOpen() + self.events.onBinary(d.byteArray) + self.receive() + case .success: + self.receive() + case .failure: + try? await Task.sleep(nanoseconds: 100_000_000) + let code = self.task?.closeCode.rawValue ?? 0 + self.didClose(code == 0 ? 1006 : code) + } + } + } + } + + fileprivate func didOpen() { + guard !opened, !closed else { return } + opened = true + events.onOpen() + } + + fileprivate func didClose(_ code: Int) { + guard !closed else { return } + closed = true + session?.invalidateAndCancel() + events.onClose(code, "") + } + + private final class Delegate: NSObject, URLSessionWebSocketDelegate, @unchecked Sendable { + weak var owner: PinnedWebSocket? + let anchor: SecCertificate + + init(owner: PinnedWebSocket, anchor: SecCertificate) { + self.owner = owner + self.anchor = anchor + } + + func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + guard let trust = challenge.protectionSpace.serverTrust else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + SecTrustSetAnchorCertificates(trust, [anchor] as CFArray) + SecTrustSetAnchorCertificatesOnly(trust, true) + if SecTrustEvaluateWithError(trust, nil) { + completionHandler(.useCredential, URLCredential(trust: trust)) + } else { + completionHandler(.cancelAuthenticationChallenge, nil) + } + } + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { + Task { @MainActor [weak owner] in owner?.didOpen() } + } + + func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + Task { @MainActor [weak owner] in owner?.didClose(closeCode.rawValue) } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + Task { @MainActor [weak owner] in owner?.didClose(1006) } + } + } +} From 6e97d19fc79ef3caac0f480240c4b465a3d661d7 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 09:12:34 +0200 Subject: [PATCH 5/9] fix(cloud): FTW Cloud turned off stays off after a restart Consent given while FTW Cloud was already on never noted that the home had been asked, so turning it off in About let the next start turn it on again. Found in the simulator. ensure now notes an existing grant, and turning off in About notes it too. The consent sheet takes the accent tint. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Cloud/CloudGate.swift | 1 + .../Sources/FTWKit/Identity/CloudGrant.swift | 12 +++++++-- .../Tests/FTWKitTests/CloudConsentTests.swift | 26 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/appleApp/FTW/Cloud/CloudGate.swift b/appleApp/FTW/Cloud/CloudGate.swift index 83672cb..0f70688 100644 --- a/appleApp/FTW/Cloud/CloudGate.swift +++ b/appleApp/FTW/Cloud/CloudGate.swift @@ -18,6 +18,7 @@ private struct CloudGate: ViewModifier { .sheet(isPresented: Binding(get: { !app.needsPairing && consent.shouldAsk(app.site) }, set: { _ in })) { CloudConsentView(app: app, consent: consent) .presentationDragIndicator(.hidden) + .tint(Theme.accent) } // When the box answers, an owner who agreed turns FTW Cloud on // there, once per home. diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift index 4e35cca..8db09e6 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift @@ -90,7 +90,12 @@ public final class CloudConsent { /// or in About, it stays off until an owner turns it on again. public func ensure(_ site: SiteModel) async { guard answered, let siteId = site.siteId, !site.isDemo, Self.audience(site) == .owner, - !defaults.bool(forKey: Keys.asked(siteId)), !site.cloudOn else { return } + !defaults.bool(forKey: Keys.asked(siteId)) else { return } + // Already on: nothing to ask for, now or later. + if site.cloudOn { + defaults.set(true, forKey: Keys.asked(siteId)) + return + } if await turnOn(site) { defaults.set(true, forKey: Keys.asked(siteId)) } @@ -127,7 +132,10 @@ public final class CloudConsent { /// the receipt once it has one. @discardableResult public func turnOff(_ site: SiteModel) async -> Bool { - await act { + // Off by hand is the person's word: the app never turns it on again + // by itself. + if let siteId = site.siteId { defaults.set(true, forKey: Keys.asked(siteId)) } + return await act { box = try await site.callBox(.delete, "/api/cloud/grant").map(BoxState.init) } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift index dc087ce..e9cba9f 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift @@ -110,6 +110,32 @@ import Testing #expect(w.relay.calls.isEmpty) } + /// Found in the simulator: consent given while FTW Cloud was already + /// on, then turned off in About. The next start must not turn it on + /// again. + @Test func offInAboutStaysOffAfterARestart() async throws { + let d = try defaults() + let w = try home(cloud: true) + let consent = CloudConsent(defaults: d) + await consent.accept(w.site) + await consent.turnOff(w.site) + // The next start: the box says FTW Cloud is off now. + let later = try home(cloud: false) + await CloudConsent(defaults: d).ensure(later.site) + #expect(later.relay.calls.isEmpty) + } + + /// Turned off on the box itself, not in the app: the app still leaves + /// it off, because it already asked once. + @Test func offOnTheBoxStaysOff() async throws { + let d = try defaults() + let w = try home(cloud: true) + await CloudConsent(defaults: d).accept(w.site) + let later = try home(cloud: false) + await CloudConsent(defaults: d).ensure(later.site) + #expect(later.relay.calls.isEmpty) + } + /// About: off asks the box to erase the copy; the receipt is the time on /// the service's confirmation. @Test func offAndTheReceipt() async throws { From f3c2ff695f59aee98984d1fd898ddeeb92681286 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 09:40:17 +0200 Subject: [PATCH 6/9] fix(app): honest band at start, prompt command failure, a calmer clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review points in SiteModel and the carriers: - A cold start no longer says "Reconnecting to your box". connect woke the carrier it had just made, which reported a reconnect that never happened; it now only arms the foreground deadline. - Carrier.send returns whether the frame went out, and a command on a carrier that is not open fails at once with E_NO_ACK instead of after the 5 s ack deadline. CloudCarrier's sends say so too. - The 1 Hz ticker is gone. The clock moves with each reading and, between readings, only when something on screen would change: the minute, a stream going quiet, a cloud grade lapsing (CloudView.nextGradeChange), or a counter the band shows in seconds. minuteMs is there for text that needs no finer clock. - After 20 s without a connection the band says "Can't reach your box · last reading HH:MM" and stops counting. - Snapshots are sealed and written off the main actor with the cache key kept in memory, are skipped when nothing changed, and the background writes once. - A keychain that cannot answer, as before the first unlock, never leads to a write: SecureStore.read tells absent from unavailable, and keys are made only when absent. - ProcessClock marks the first live reading beside LaunchClock's first frame, and signpost intervals time cold start to live and foreground to live. For flowing numbers: every value carries the time it was read (fieldAtMs, readingAtMs), a burst within one display frame reaches the screens as one change, and a source only takes over with a copy at least as new as the one on screen, so numbers never step back. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Platform/KeychainStore.swift | 18 +- appleApp/FTW/UI/LaunchClock.swift | 31 +- .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 6 +- .../Sources/FTWKit/Carrier/Carrier.swift | 6 +- .../Sources/FTWKit/Carrier/CloudCarrier.swift | 18 +- .../Sources/FTWKit/Carrier/CloudView.swift | 25 ++ .../Sources/FTWKit/Carrier/NoiseCarrier.swift | 8 +- .../Sources/FTWKit/Carrier/RelayCarrier.swift | 6 +- .../Sources/FTWKit/Identity/Vault.swift | 26 +- .../Sources/FTWKit/Protocol/Session.swift | 13 +- .../Sources/FTWKit/State/SiteModel.swift | 334 ++++++++++++++---- .../Sources/FTWKit/Store/SealedFiles.swift | 61 +++- .../Sources/FTWKit/Store/SecureStore.swift | 18 + .../FTWKit/Sources/FTWKit/Store/Sites.swift | 11 +- .../Sources/FTWKit/Support/ProcessClock.swift | 37 ++ .../FTWKit/Sources/FTWShared/Freshness.swift | 17 +- .../Resources/sv.lproj/Localizable.strings | 1 + .../Tests/FTWKitTests/CloudConsentTests.swift | 27 +- .../Tests/FTWKitTests/IdentityTests.swift | 2 +- .../Tests/FTWKitTests/ReviewFixTests.swift | 219 ++++++++++++ .../FTWKitTests/SiteModelCloudTests.swift | 39 +- 21 files changed, 761 insertions(+), 162 deletions(-) create mode 100644 appleApp/FTWKit/Sources/FTWKit/Support/ProcessClock.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/ReviewFixTests.swift diff --git a/appleApp/FTW/Platform/KeychainStore.swift b/appleApp/FTW/Platform/KeychainStore.swift index 16f5611..19d1133 100644 --- a/appleApp/FTW/Platform/KeychainStore.swift +++ b/appleApp/FTW/Platform/KeychainStore.swift @@ -14,13 +14,27 @@ final class KeychainStore: SecureStore { private let service = "energy.ftw.app" func get(_ key: String) -> Bytes? { + (try? read(key)) ?? nil + } + + /// Only errSecItemNotFound means absent. Before the first unlock the + /// keychain answers errSecInteractionNotAllowed, and that must never + /// lead to a new key written over the old one. + func read(_ key: String) throws -> Bytes? { var query = base(key) query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let data = result as? Data else { return nil } - return data.byteArray + switch status { + case errSecSuccess: + guard let data = result as? Data else { throw SecureStoreUnavailable(status: status) } + return data.byteArray + case errSecItemNotFound: + return nil + default: + throw SecureStoreUnavailable(status: status) + } } func put(_ key: String, _ value: Bytes) { diff --git a/appleApp/FTW/UI/LaunchClock.swift b/appleApp/FTW/UI/LaunchClock.swift index 27ef7a0..119aa55 100644 --- a/appleApp/FTW/UI/LaunchClock.swift +++ b/appleApp/FTW/UI/LaunchClock.swift @@ -1,36 +1,15 @@ -import Darwin -import os +import FTWKit /// How long from the process starting to each first thing on screen, -/// logged once each and marked for Instruments. Read them with: +/// logged once each and marked for Instruments: the first frame, the first +/// content, and (from SiteModel) the first live reading. Read them with: /// /// xcrun simctl spawn log show --last 2m \ /// --predicate 'subsystem == "energy.ftw.app" AND category == "launch"' @MainActor enum LaunchClock { - static let log = Logger(subsystem: "energy.ftw.app", category: "launch") - static let signposter = OSSignposter(logger: log) - private static var seen: Set = [] - - /// Once per event: the first frame, the first content, the stream. + /// Once per event: the first frame, the first content. static func mark(_ event: StaticString) { - let name = "\(event)" - guard seen.insert(name).inserted else { return } - signposter.emitEvent(event) - if let ms = sinceProcessStartMs() { - log.notice("\(name, privacy: .public) \(Int(ms.rounded()), privacy: .public) ms after process start") - } - } - - /// The kernel's record of when this process started. - static func sinceProcessStartMs() -> Double? { - var info = kinfo_proc() - var size = MemoryLayout.stride - var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] - guard sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) == 0 else { return nil } - let start = info.kp_proc.p_starttime - var now = timeval() - gettimeofday(&now, nil) - return Double(now.tv_sec - start.tv_sec) * 1000 + Double(now.tv_usec - start.tv_usec) / 1000 + ProcessClock.markOnce(event) } } diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index a3467df..7c8cafb 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -618,13 +618,15 @@ public final class LoopbackCarrier: Carrier { self.onStatus = onStatus } - public func send(_ frame: Bytes) { - guard status.isOpen, !closed else { return } + @discardableResult + public func send(_ frame: Bytes) -> Bool { + guard status.isOpen, !closed else { return false } toBox.append(frame) scheduler.after(latencyMs) { [weak self] in guard let self, !self.closed, !self.toBox.isEmpty else { return } self.box.receive(self.toBox.removeFirst()) } + return true } /// Pull to refresh in the demo: the house moves on a second. diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift index f3fdce7..c806929 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift @@ -35,7 +35,11 @@ public enum CarrierStatus: Equatable, Sendable { public protocol Carrier: AnyObject { var kind: CarrierKind { get } var status: CarrierStatus { get } - func send(_ frame: Bytes) + /// Whether the frame went out. A carrier that is not open drops it and + /// says so, so a command can fail at once instead of waiting out a + /// deadline for an answer that cannot come. + @discardableResult + func send(_ frame: Bytes) -> Bool /// Replaces the handlers. One owner at a time: the carrier stack is /// built as a chain, each layer owning the one below it. func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void) diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift index f68002b..1016d08 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift @@ -90,7 +90,8 @@ public final class CloudCarrier { } /// Whether anyone can see the home. While someone can, the service keeps - /// the box at one update a second. + /// the box at one update a second. A closed session takes the change + /// with it into message 1 of the next. public func setLooking(_ on: Bool) { guard on != looking else { return } looking = on @@ -98,8 +99,10 @@ public final class CloudCarrier { } /// An owner suspends a device at FTW Cloud, or lifts it. It only narrows - /// what the box's roster allows. - public func suspend(deviceID: String, _ on: Bool) { + /// what the box's roster allows. False when the session is not open and + /// nothing went out. + @discardableResult + public func suspend(deviceID: String, _ on: Bool) -> Bool { send(on ? CloudWire.msgSuspend : CloudWire.msgUnsuspend, .map([("deviceId", .text(deviceID))])) } @@ -204,11 +207,14 @@ public final class CloudCarrier { onSnapshot(snapshot, arrival) } - private func send(_ t: String, _ body: CBOR) { - guard let transport, let socket, status.isOpen else { return } + /// One message to the service, or false when the session is not open. + @discardableResult + private func send(_ t: String, _ body: CBOR) -> Bool { + guard let transport, let socket, status.isOpen else { return false } let env = CloudWire.encodeUnsigned(t: t, body: body) - guard let frame = try? CloudWire.encodeFrame(env), let msg = try? transport.encrypt(frame) else { return } + guard let frame = try? CloudWire.encodeFrame(env), let msg = try? transport.encrypt(frame) else { return false } socket.send(msg) + return true } private func closed(code: Int) { diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift index 2d82d53..bcb5245 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift @@ -269,6 +269,31 @@ public struct CloudView: Sendable { return g } + /// The next moment `grade` would change with no message arriving: a + /// source's age passing S, 2S or 6S, a field with no source passing 2H, + /// or rule U's anchor lapsing. Nil while nothing anchored is held. It may + /// come a little early, never late, so a clock can wait for exactly this. + public func nextGradeChange(nowMs: Int64) -> Int64? { + guard let m, let send = anchorSentMs(nowMs: nowMs) else { return nil } + var next: Int64? + func consider(_ at: Int64) { + if at > nowMs, next.map({ at < $0 }) ?? true { next = at } + } + // Rule U's margin makes ages run a little fast; starting the wait + // that much sooner keeps the clock from passing a change. + var drift: Int64 = 0 + if let u = ruleU(boot: m.boot, uptimeMs: m.uptimeMs, nowMs: nowMs), u == send, let off = offsets[m.boot] { + drift = (nowMs &- off.learnedAtMs) / Self.ruleUDriftDivisor + consider(off.learnedAtMs &+ Self.ruleULifetimeMs &+ 1) + } + for (id, s) in sources where s.staleAfterMs > 0 { + guard let age = m.ages[id] else { continue } + for k: Int64 in [1, 2, 6] { consider(send &+ k &* s.staleAfterMs &- age &+ 1 &- drift) } + } + consider(send &+ 2 &* heartbeatMs &+ 1 &- drift) + return next + } + /// Grades an age the way the box grades its own sources. static func ladder(ageMs age: Int64, staleAfterMs s: Int64) -> SourceState { if s <= 0 { return .never } diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift index 72f725e..b2c068f 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift @@ -75,16 +75,18 @@ public final class NoiseCarrier: Carrier { self.onStatus = onStatus } - public func send(_ frame: Bytes) { + @discardableResult + public func send(_ frame: Bytes) -> Bool { // Before the handshake there is no key, and sending in the clear to // keep a caller happy would be worse than dropping. - guard let transport, status.isOpen else { return } + guard let transport, status.isOpen else { return false } do { - inner.send(try transport.encrypt(frame)) + return inner.send(try transport.encrypt(frame)) } catch { // Nonce exhaustion or a destroyed cipher: continuing would risk // reusing a (key, nonce) pair. fail("encryption failed", retryable: true) + return false } } diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift index aaac9f8..75d46eb 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift @@ -68,9 +68,11 @@ public final class RelayCarrier: Carrier { self.onStatus = onStatus } - public func send(_ frame: Bytes) { - guard status.isOpen, let socket else { return } + @discardableResult + public func send(_ frame: Bytes) -> Bool { + guard status.isOpen, let socket else { return false } socket.send(frame) + return true } /// Drop an apparently live socket and dial now, after a foreground wake. diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Vault.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Vault.swift index 1f9a91d..3c5cfd2 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/Vault.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Vault.swift @@ -152,8 +152,10 @@ public final class Vault { // MARK: Wrapping keys /// The local key: random, in the Keychain, no ceremony in front of it. - public func localWrappingKey() -> WrappingKey { - if let existing = store.get(Self.localWrapKey), existing.count == 32 { + /// Made only when the Keychain says there is none; one it cannot read, + /// as before the first unlock, throws rather than being replaced. + public func localWrappingKey() throws -> WrappingKey { + if let existing = try store.read(Self.localWrapKey), existing.count == 32 { return WrappingKey(credentialID: Self.localCredentialID, source: .local, key: existing) } let key = randomBytes(32) @@ -165,39 +167,39 @@ public final class Vault { /// copies exist. Reading your own house is not a privilege. public func silentWrappingKey() throws -> WrappingKey? { guard let record else { throw emptyVault() } - return record.copies.contains { $0.credentialId == Self.localCredentialID } ? localWrappingKey() : nil + return record.copies.contains { $0.credentialId == Self.localCredentialID } ? try localWrappingKey() : nil } /// Add the local copy, so the next start never prompts. public func ensureLocalCopy(_ current: WrappingKey) throws { guard let record else { throw emptyVault() } if record.copies.contains(where: { $0.credentialId == Self.localCredentialID }) { return } - try addCredential(current: current, next: localWrappingKey()) + try addCredential(current: current, next: try localWrappingKey()) } /// A key for a device enrolling now: one prompt, no questions. Where PRF /// is missing this still returns a key, the local one, and `source` says /// so. A decline is rethrown; any other platform failure falls back. public func enrollWrappingKey(_ passkeys: PasskeyAuthenticator?, label: String = Origin.rpName) async throws -> WrappingKey { - guard let passkeys, passkeys.isAvailable else { return localWrappingKey() } + guard let passkeys, passkeys.isAvailable else { return try localWrappingKey() } do { - let outcome = try await passkeys.register(label: label, userHandle: userHandle(), excludeCredentialIDs: credentialIDs) + let outcome = try await passkeys.register(label: label, userHandle: try userHandle(), excludeCredentialIDs: credentialIDs) if let prf = outcome.prfOutput { return PRF.wrappingKey(credentialID: outcome.credentialID, prfOutput: prf) } // Some platforms register the passkey and only evaluate PRF on an // assertion. One more prompt now, while the person is looking, // beats a vault nothing can open later. - guard outcome.prfEnabled else { return localWrappingKey() } + guard outcome.prfEnabled else { return try localWrappingKey() } let asserted = try await passkeys.assert(credentialIDs: [outcome.credentialID]) if let prf = asserted.prfOutput { return PRF.wrappingKey(credentialID: asserted.credentialID, prfOutput: prf) } - return localWrappingKey() + return try localWrappingKey() } catch is PasskeyCancelled { throw PasskeyCancelled() } catch { - return localWrappingKey() + return try localWrappingKey() } } @@ -219,7 +221,7 @@ public final class Vault { } catch {} } if record.copies.contains(where: { $0.credentialId == Self.localCredentialID }) { - return localWrappingKey() + return try localWrappingKey() } throw locked() } @@ -234,8 +236,8 @@ public final class Vault { store.putJSON(Self.vaultKey, record) } - func userHandle() -> Bytes { - if let existing = store.get(Self.userHandleKey) { return existing } + func userHandle() throws -> Bytes { + if let existing = try store.read(Self.userHandleKey) { return existing } // Stable per install, so a second passkey replaces the first in the // platform's list instead of stacking up identical entries. let handle = randomBytes(16) diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift index b6c5e99..d475832 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift @@ -363,6 +363,9 @@ public final class Session { ])), ]) let frame = try Frame.encode(lane: Frame.laneControl, envelope: Envelope(t: "cmd", b: body), bucket: subscription.bucket) + // A carrier that is reconnecting inside itself would drop the frame + // in silence; saying so now beats waiting out the ack deadline. + guard carrier.status.isOpen else { throw Self.notSent } return try await withCheckedThrowingContinuation { cont in let ackTimer = scheduler.after(Self.cmdAckTimeoutMs) { [weak self] in guard let self, let p = self.pendingCommands[cmdId], !p.acked else { return } @@ -378,10 +381,18 @@ public final class Session { p.continuation.resume(returning: CmdResult(cmdId: cmdId, state: .unconfirmed)) } pendingCommands[cmdId] = PendingCommand(continuation: cont, ackTimer: ackTimer, confirmTimer: confirmTimer, onAck: onAck) - carrier.send(frame) + if !carrier.send(frame), let p = pendingCommands.removeValue(forKey: cmdId) { + p.ackTimer.cancel() + p.confirmTimer.cancel() + p.continuation.resume(throwing: Self.notSent) + } } } + /// A command that never left this phone. The registry's no-ack code, + /// because that is what it is: the box never had it. + static let notSent = CommandError(code: "E_NO_ACK", help: "That didn't reach your box. Try again.") + /// UUIDv7: time-ordered, so the box can expire idempotency keys by prefix. static func uuidv7(nowMs: Double) -> String { var bytes = randomBytes(16) diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 1fa9cfd..22c07f1 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -8,6 +8,7 @@ private let log = Logger(subsystem: "energy.ftw.app", category: "widgets") /// layers of a cold start and the relay's path can be compared. Read with /// `log show --predicate 'subsystem == "energy.ftw.app" AND category == "reading"'`. private let readingLog = Logger(subsystem: "energy.ftw.app", category: "reading") +private let signposter = OSSignposter(subsystem: "energy.ftw.app", category: "reading") /// The bridge between the session and the screens. /// @@ -39,8 +40,19 @@ public final class SiteModel { public private(set) var cachedAtMs: Double? public private(set) var lastFrameAtMs: Double? public private(set) var documentVisible = true - /// The one clock screens depend on, so derived ages move on screen. + /// The clock screens read for ages. It moves with every reading, and + /// between readings only when something on screen would change: the + /// minute, a stream going quiet, a grade lapsing, or a counter the band + /// shows in seconds. public private(set) var nowMs: Double + /// The current minute. Text that needs no finer clock reads this and + /// redraws once a minute. + public private(set) var minuteMs: Double + /// When each value on screen was read, by the phone clock. A view can + /// glide from one value to the next over the time between them. + public private(set) var fieldAtMs: [Int: Double] = [:] + /// When the newest reading on screen was read, by the phone clock. + public private(set) var readingAtMs: Double? /// Import ceiling the optimiser defends, when known. public var ceilingW: Double? @@ -63,12 +75,15 @@ public final class SiteModel { /// Called after every write to `shared`, so the app can wake its widgets. @ObservationIgnored public var sharedWritten: (@MainActor () -> Void)? @ObservationIgnored private var attemptStartedAtMs: Double - @ObservationIgnored private var ticker: Cancellable? + @ObservationIgnored private var clock: Cancellable? @ObservationIgnored private var resumeTimer: Cancellable? @ObservationIgnored private var resumeWaiting = false @ObservationIgnored private var recent: [(t: Double, v: [Int: Double])] = [] @ObservationIgnored private var lastSnapshotMs: Double = 0 @ObservationIgnored private var snapshotTimer: Cancellable? + /// The last snapshot written, without its time, so the same home is not + /// written twice. + @ObservationIgnored private var lastSnapshot: CachedSnapshot? @ObservationIgnored private var destroyed = false /// The box session's own state, as the last frame left it. @ObservationIgnored private var relay = SessionState() @@ -76,6 +91,16 @@ public final class SiteModel { /// waits for this one, whatever the cloud does meanwhile. @ObservationIgnored private var relayFrameAtMs: Double? @ObservationIgnored private var listeners: [Int: @MainActor () -> Void] = [:] + /// A burst of messages within one display frame reaches the screens as + /// one change: the first goes at once, the rest together after it. + @ObservationIgnored private var publishTimer: Cancellable? + @ObservationIgnored private var lastPublishMs: Double = -.infinity + /// Whether a source delivered a reading since the last publish. + @ObservationIgnored private var relayDelivered = false + @ObservationIgnored private var cloudDelivered = false + /// From a start or a return to the front until the screen is live, as a + /// signpost interval. + @ObservationIgnored private var liveSpan: (name: StaticString, state: OSSignpostIntervalState, fromMs: Double)? // FTW Cloud. All nil while the home has no grant or this build no // endpoint. @@ -89,6 +114,9 @@ public final class SiteModel { /// start can age the cloud's copy of a box without a clock. @ObservationIgnored private var ruleU: CloudCache.Offset? @ObservationIgnored private var cloudDirty = false + /// When the newest cloud envelope arrived, for the time of a reading the + /// anchor cannot place. + @ObservationIgnored private var cloudArrivalMs: Double? /// Where the readings on screen came from at the last change. @ObservationIgnored private(set) var source: ReadingSource = .relay @ObservationIgnored private var startedAtMs: Double = 0 @@ -99,6 +127,9 @@ public final class SiteModel { /// Runs the passkey ceremony a write needs. Injected by the shell. @ObservationIgnored public var stepUp: (@MainActor () async -> StepUpOutcome)? + /// One display frame: what arrives within it is drawn together. + static let coalesceMs: Double = 16 + public init(siteId: String?, build: String, ua: String, scheduler: Scheduler, files: SealedFiles?, shared: AppGroupFiles? = nil, isDemo: Bool = false) { self.siteId = siteId self.scheduler = scheduler @@ -106,6 +137,7 @@ public final class SiteModel { self.shared = isDemo ? nil : shared self.isDemo = isDemo nowMs = scheduler.nowMs + minuteMs = (scheduler.nowMs / 60_000).rounded(.down) * 60_000 attemptStartedAtMs = scheduler.nowMs core = Session(build: build, ua: ua, scheduler: scheduler) core.onChange = { [weak self] s in self?.onSession(s) } @@ -117,28 +149,24 @@ public final class SiteModel { /// not a failure: it is how the first frame has something honest. public func start() { startedAtMs = scheduler.nowMs + beginLiveSpan("cold start to live") if let siteId, let cached = files?.readJSON(CachedSnapshot.self, "snapshot-\(siteId)"), cached.siteId == siteId { core.restore(cached) cachedAtMs = cached.savedAtMs + lastSnapshot = cached.timeless } if let siteId, let kept = files?.readJSON(CloudCache.self, "cloud-\(siteId)") { adoptCloud(kept.identity, offset: kept.offset) } - startTicker() - } - - private func startTicker() { - ticker?.cancel() - ticker = scheduler.after(1_000) { [weak self] in - guard let self, !self.destroyed else { return } - self.nowMs = self.scheduler.nowMs - self.notify() - self.startTicker() - } + scheduleClock() } /// Attach a carrier. Refused once the model is gone, so a slow connect /// can never hand an old home's stream to a new one. + /// + /// A carrier made for this call is already dialling. Waking it would + /// only report a reconnect that never happened, so it gets the deadline + /// and nothing more. @discardableResult public func connect(_ carrier: Carrier) -> Bool { if destroyed { @@ -146,15 +174,16 @@ public final class SiteModel { return false } core.connect(carrier) - if documentVisible { beginResume(immediate: true) } + if documentVisible { beginResume(wake: false) } return true } public func destroy() { destroyed = true - ticker?.cancel() + clock?.cancel() resumeTimer?.cancel() snapshotTimer?.cancel() + publishTimer?.cancel() core.onChange = nil core.close() cloud?.close() @@ -162,38 +191,51 @@ public final class SiteModel { listeners = [:] } - /// The app went to the background. Write what is on screen and close the - /// session: iOS freezes the app within seconds anyway, and an open socket - /// holds one of the four streams the relay allows a home. The readings - /// stay, and the band dates them until a new carrier streams again. + /// The app went to the background. Close the session and write what is + /// on screen, once: iOS freezes the app within seconds anyway, and an + /// open socket holds one of the four streams the relay allows a home. The + /// readings stay, and the band dates them until a new carrier streams + /// again. public func suspend() { guard !destroyed else { return } - persistNow() core.close() closeCloud() // After the close, so no last change of rate goes out on the wire. - setVisible(false) + hide() + persistNow(wait: true) } /// Match the stream's cadence to whether anyone can see it. public func setVisible(_ visible: Bool) { - let was = documentVisible - documentVisible = visible - core.setTelemetryHz(visible ? 1 : 0.2) - cloud?.setLooking(visible) - if !visible { - cancelResume() + guard visible else { + hide() persistNow() return } - if !was { beginResume(immediate: false) } + let was = documentVisible + documentVisible = true + core.setTelemetryHz(1) + cloud?.setLooking(true) + if !was { + beginLiveSpan("foreground to live") + beginResume(wake: relay.phase != .streaming || relayFrameAtMs == nil) + } + scheduleClock() + } + + private func hide() { + documentVisible = false + core.setTelemetryHz(0.2) + cloud?.setLooking(false) + cancelResume() + scheduleClock() } /// A new network path should replace the old one without waiting. public func networkOnline() { guard documentVisible else { return } cloud?.wake() - beginResume(immediate: true) + beginResume(wake: true) } /// Pull to refresh: ask for a fresh stream in place. @@ -204,7 +246,7 @@ public final class SiteModel { // MARK: Observation for feature models - /// Called after every session change and every clock tick. + /// Called after every change the screens see, and when the clock moves. @discardableResult public func observe(_ f: @escaping @MainActor () -> Void) -> Int { nextListener += 1 @@ -233,6 +275,7 @@ public final class SiteModel { let reading = s.phase == .streaming && (previous.phase != .streaming || s.uptimeMs != previous.uptimeMs) if reading { relayFrameAtMs = now + relayDelivered = true finishResume() } relay = s @@ -248,14 +291,9 @@ public final class SiteModel { cachedAtMs = nil scheduleSnapshot() } + if reading { first("relay reading") } publish() - if reading { - first("relay reading") - if source == .relay { - lastFrameAtMs = now - recordRecent(session.fields, now) - } - } + scheduleClock() } /// Logs once per home how long after `start` something first happened. @@ -265,18 +303,125 @@ public final class SiteModel { readingLog.notice("\(what, privacy: .public) \(ms, privacy: .public) ms after start") } - /// What the screens read: the box session's state, with the cloud's - /// readings in it while the cloud is where readings come from. + /// Picks where readings come from now, and draws the change: at once + /// when the screens have been still for a frame, else together with + /// whatever else arrives within it. private func publish() { source = readingSource() + guard publishTimer == nil else { return } + let since = scheduler.nowMs - lastPublishMs + if since >= Self.coalesceMs { + publishNow() + return + } + publishTimer = scheduler.after(Self.coalesceMs - since) { [weak self] in + guard let self, !self.destroyed else { return } + self.publishTimer = nil + self.source = self.readingSource() + self.publishNow() + } + } + + /// What the screens read: the box session's state, with the cloud's + /// readings in it while the cloud is where readings come from, each value + /// with the time it was read. The clock moves in the same change, so a + /// reading redraws a screen once. + private func publishNow() { + let now = scheduler.nowMs + lastPublishMs = now + let s = composed() + let delivered = source == .cloud ? cloudDelivered : relayDelivered + relayDelivered = false + cloudDelivered = false + let at = readingTime(source) ?? now + var times = fieldAtMs + for (fid, v) in s.fields where session.fields[fid] != v || times[fid] == nil { + times[fid] = at + } + for fid in times.keys where s.fields[fid] == nil { times[fid] = nil } + if delivered { + lastFrameAtMs = now + recordRecent(s.fields, now) + } + if times != fieldAtMs { fieldAtMs = times } + if delivered || readingAtMs == nil || s.fields.isEmpty != session.fields.isEmpty { readingAtMs = s.fields.isEmpty ? nil : at } + advanceClock() + session = s + endLiveSpanIfLive() + notify() + } + + /// The readings the source in use gives, aged to now. + private func composed() -> SessionState { var s = relay if source == .cloud, let view = cloudView, let identity = cloudIdentity { compose(&s, view: view, identity: identity) } - session = s - notify() + return s } + /// When the newest reading from a source was read, by the phone clock. + /// For the cloud that is the anchor's send time; nil when it cannot be + /// placed. + private func readingTime(_ from: ReadingSource) -> Double? { + switch from { + case .relay: + return relayFrameAtMs ?? cachedAtMs + case .cloud: + guard let view = cloudView else { return nil } + return view.anchorSentMs(nowMs: Int64(scheduler.nowMs)).map(Double.init) ?? cloudArrivalMs + } + } + + // MARK: The clock + + /// Arms the one timer the clock needs: the next moment something on + /// screen would change with no reading arriving. While live that is the + /// minute, the moment the stream would count as quiet, or a cloud grade + /// lapsing. A counter in seconds runs only while the band shows one. + private func scheduleClock() { + clock?.cancel() + clock = nil + guard !destroyed else { return } + let now = scheduler.nowMs + var next = ((now / 60_000).rounded(.down) + 1) * 60_000 + if documentVisible { + if relay.phase == .streaming, let at = relayFrameAtMs, at + Self.streamQuietAfterMs > now { + next = min(next, at + Self.streamQuietAfterMs) + } + if source == .cloud, let at = cloudView?.nextGradeChange(nowMs: Int64(now)) { + next = min(next, Double(at)) + } + if bandCountsSeconds { + next = min(next, now + 1_000) + } + } + clock = scheduler.after(max(1, next - now)) { [weak self] in + guard let self, !self.destroyed else { return } + self.clock = nil + self.advanceClock() + self.notify() + self.scheduleClock() + } + } + + /// The band shows seconds while it counts a wait or an age under a + /// minute; the clock ticks each second only then. + private var bandCountsSeconds: Bool { + let band = freshness(noCarrier: false) + if band.wait != nil { return true } + if band.age != nil, let age = ageMs, age < 60_000 { return true } + return false + } + + private func advanceClock() { + nowMs = scheduler.nowMs + let minute = (nowMs / 60_000).rounded(.down) * 60_000 + if minute != minuteMs { minuteMs = minute } + } + + // MARK: Snapshots + private func scheduleSnapshot() { guard siteId != nil, files != nil, snapshotTimer == nil else { return } let due = max(0, lastSnapshotMs + Self.snapshotIntervalMs - scheduler.nowMs) @@ -287,29 +432,53 @@ public final class SiteModel { } } - /// Write the last streaming state now, before the app can be suspended. - public func persistNow() { + /// Write what is on screen, unless it is what was written last. The seal + /// and the file write run off the main actor; `wait` keeps them on it, + /// for the moment before iOS freezes the app. + public func persistNow(wait: Bool = false) { guard let siteId, let files, !destroyed else { return } if cloudDirty, let identity = cloudIdentity { cloudDirty = false - files.writeJSON("cloud-\(siteId)", CloudCache(identity: identity, offset: ruleU)) + files.writeJSON("cloud-\(siteId)", CloudCache(identity: identity, offset: ruleU), wait: wait) } guard session.phase == .streaming || !session.fields.isEmpty, cachedAtMs == nil else { return } - lastSnapshotMs = scheduler.nowMs // The cloud's ages move with the clock; take them as of now. - if source == .cloud { publish() } - files.writeJSON("snapshot-\(siteId)", CachedSnapshot(siteId: siteId, savedAtMs: scheduler.nowMs, state: session)) + let state = source == .cloud ? composed() : session + let snapshot = CachedSnapshot(siteId: siteId, savedAtMs: scheduler.nowMs, state: state) + guard snapshot.timeless != lastSnapshot else { return } + lastSnapshot = snapshot.timeless + lastSnapshotMs = scheduler.nowMs + files.writeJSON("snapshot-\(siteId)", snapshot, wait: wait) + } + + // MARK: Time to live + + private func beginLiveSpan(_ name: StaticString) { + if let span = liveSpan { signposter.endInterval(span.name, span.state) } + liveSpan = (name, signposter.beginInterval(name), scheduler.nowMs) + } + + /// Ends the interval the first time the screen is live after a start or + /// a return, and logs the process's first live beside LaunchClock's + /// first frame. + private func endLiveSpanIfLive() { + guard let span = liveSpan, isLive else { return } + liveSpan = nil + signposter.endInterval(span.name, span.state) + let ms = Int((scheduler.nowMs - span.fromMs).rounded()) + readingLog.notice("\(span.name, privacy: .public) \(ms, privacy: .public) ms via \(self.carrier.rawValue, privacy: .public)") + ProcessClock.markOnce("live") } // MARK: Waking after sleep - private func beginResume(immediate: Bool) { + /// Give a foreground stream a deadline to prove itself, and with `wake` + /// ask the carrier to replace a path that may have slept stale. + private func beginResume(wake: Bool) { guard !destroyed, documentVisible else { return } resumeTimer?.cancel() resumeWaiting = true - if immediate || relay.phase != .streaming || relayFrameAtMs == nil { - core.wake() - } + if wake { core.wake() } let frameAtStart = relayFrameAtMs resumeTimer = scheduler.after(Self.foregroundFrameDeadlineMs) { [weak self] in guard let self, !self.destroyed, self.documentVisible, self.relayFrameAtMs == frameAtStart else { return } @@ -368,10 +537,16 @@ public final class SiteModel { waitMs: connectionWaitMs, bootPct: session.boot?.pct, noCarrier: noCarrier, - busy: busy + busy: busy, + lastReading: (readingAtMs ?? cachedAtMs).map { AppWords.clock($0, zone: householdZone) } ) } + /// The household's time zone, as the box names it, else the phone's. + var householdZone: TimeZone { + session.box.flatMap { TimeZone(identifier: $0.tz) } ?? .current + } + /// The sources behind what the Now screen draws, named by the box. /// The box session's sources behind what Now draws. The cloud's are /// graded by `CloudView`, not by these. @@ -670,8 +845,9 @@ extension SiteModel { /// An owner pauses FTW Cloud for one of the home's phones, or lifts the /// pause. It only narrows what the box allows. - public func suspendAtCloud(deviceID: String, _ on: Bool) { - cloud?.suspend(deviceID: deviceID, on) + @discardableResult + public func suspendAtCloud(deviceID: String, _ on: Bool) -> Bool { + cloud?.suspend(deviceID: deviceID, on) ?? false } /// Whether the box said FTW Cloud is on for this home. @@ -685,8 +861,10 @@ extension SiteModel { onSnapshot: { [weak self] snap, at in self?.onCloudSnapshot(snap, at) }, onEnvelope: { [weak self] env, at in self?.onCloudEnvelope(env, at) }, onStatus: { [weak self] status in - self?.cloudStatus = status - self?.publish() + guard let self else { return } + self.cloudStatus = status + self.publish() + self.scheduleClock() } ) cloud = c @@ -757,6 +935,7 @@ extension SiteModel { for env in snapshot.envelopes where acceptCloud(env, at) { any = true } first("cloud snapshot") if any { cloudRead(at) } else { publish() } + scheduleClock() } private func onCloudEnvelope(_ env: CloudWire.Envelope, _ at: Double) { @@ -779,26 +958,39 @@ extension SiteModel { } private func cloudRead(_ at: Double) { - let before = session.fields + cloudArrivalMs = at + if readingSource() == .cloud { + cloudDelivered = true + cachedAtMs = nil + scheduleSnapshot() + } publish() - guard source == .cloud else { return } - lastFrameAtMs = at - cachedAtMs = nil - if session.fields != before { recordRecent(session.fields, at) } - scheduleSnapshot() + scheduleClock() } /// The cloud is where readings come from while its session is open and it /// holds a live, unless the box session streams live readings and the - /// cloud's are not live. With neither streaming, the newer copy stays. + /// cloud's are not live. Numbers never step back in time: a copy that is + /// not live takes over only when it is at least as new as what is on + /// screen. With neither streaming, the newer copy stays. private func readingSource() -> ReadingSource { guard let view = cloudView, view.live != nil else { return .relay } + let now = scheduler.nowMs let relayStreaming = relay.phase == .streaming && relayFrameAtMs != nil - if relayStreaming, relaySrcState == .live, Self.cloudState(view, nowMs: scheduler.nowMs) != .live { + let cloudLive = Self.cloudState(view, nowMs: now) == .live + if relayStreaming, relaySrcState == .live, !cloudLive { return .relay } - if cloud?.status.isOpen == true { return .cloud } - return relayStreaming ? .relay : .cloud + let cloudAt = view.anchorSentMs(nowMs: Int64(now)).map(Double.init) + let relayAt = relayFrameAtMs ?? cachedAtMs + if cloud?.status.isOpen == true { + if cloudLive || source == .cloud { return .cloud } + guard let relayAt else { return .cloud } + return (cloudAt ?? -.infinity) >= relayAt ? .cloud : .relay + } + if relayStreaming { return .relay } + guard let relayAt else { return .cloud } + return (cloudAt ?? -.infinity) >= relayAt ? .cloud : .relay } /// The cloud's readings in the shape the box session gives them, under @@ -891,3 +1083,13 @@ extension SiteModel { sharePrices(Prices(zone: p.zone, currency: p.currency, slots: slots, stale: false), fromMs: Double(p.t0Ms), via: .cloud) } } + +extension CachedSnapshot { + /// The same snapshot without the time it was written, to tell whether a + /// new one says anything new. + var timeless: CachedSnapshot { + var c = self + c.savedAtMs = 0 + return c + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift b/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift index 95a0929..7fc55b9 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift @@ -15,6 +15,10 @@ public final class SealedFiles { static let keyName = "cache-key" private let directory: URL private let store: SecureStore + /// The key, once read, so a write does not ask the keychain again. + private var cachedKey: Bytes? + /// The latest write off the main actor; each waits for the one before. + private var writing: Task? public init(directory: URL, store: SecureStore) { self.directory = directory @@ -28,10 +32,23 @@ public final class SealedFiles { return base.appendingPathComponent("FTW", isDirectory: true) } - private var key: Bytes { - if let k = store.get(Self.keyName), k.count == 32 { return k } + /// The cache key, made only when the store says it has none. A store + /// that cannot answer gives no key, and nothing is written or read. + private var key: Bytes? { + if let cachedKey { return cachedKey } + let held: Bytes? + do { + held = try store.read(Self.keyName) + } catch { + return nil + } + if let held, held.count == 32 { + cachedKey = held + return held + } let k = randomBytes(32) store.put(Self.keyName, k) + cachedKey = k return k } @@ -40,6 +57,31 @@ public final class SealedFiles { } public func write(_ name: String, _ plain: Bytes) { + guard let key else { return } + Self.seal(key: key, name: name, plain: plain, to: url(name), in: directory) + } + + /// Seal and write off the main actor, in the order asked for. Returns + /// the write, for a caller that must not end before it lands. + @discardableResult + public func writeLater(_ name: String, _ plain: Bytes) -> Task? { + guard let key else { return nil } + let previous = writing + let target = url(name), directory = directory + let task = Task { + await previous?.value + await Self.sealOffMain(key: key, name: name, plain: plain, to: target, in: directory) + } + writing = task + return task + } + + @concurrent + private nonisolated static func sealOffMain(key: Bytes, name: String, plain: Bytes, to target: URL, in directory: URL) async { + seal(key: key, name: name, plain: plain, to: target, in: directory) + } + + private nonisolated static func seal(key: Bytes, name: String, plain: Bytes, to target: URL, in directory: URL) { do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let nonce = randomBytes(12) @@ -48,14 +90,14 @@ public final class SealedFiles { #if os(iOS) options.insert(.completeFileProtectionUntilFirstUserAuthentication) #endif - try Data(sealed).write(to: url(name), options: options) + try Data(sealed).write(to: target, options: options) } catch { // A cache that could not be written costs a slower next start. } } public func read(_ name: String) -> Bytes? { - guard let data = try? Data(contentsOf: url(name)), data.count > 28 else { return nil } + guard let key, let data = try? Data(contentsOf: url(name)), data.count > 28 else { return nil } let bytes = data.byteArray return try? Primitives.aesGCMOpen(key: key, nonce: Array(bytes[0..<12]), ad: Array(name.utf8), ciphertext: Array(bytes[12...])) } @@ -69,11 +111,18 @@ public final class SealedFiles { public func clear() { try? FileManager.default.removeItem(at: directory) store.remove(Self.keyName) + cachedKey = nil } - public func writeJSON(_ name: String, _ value: T) { + /// Encode here; with `wait` false the seal and the write happen off the + /// main actor. + public func writeJSON(_ name: String, _ value: T, wait: Bool = true) { guard let data = try? JSONEncoder().encode(value) else { return } - write(name, data.byteArray) + if wait { + write(name, data.byteArray) + } else { + writeLater(name, data.byteArray) + } } public func readJSON(_ type: T.Type, _ name: String) -> T? { diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift b/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift index c43f86a..a65ee79 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift @@ -9,11 +9,26 @@ import Foundation /// which is this phone's identity. Clearing the store is signing out. @MainActor public protocol SecureStore: AnyObject { + /// The value, or nil when it is absent or could not be read. func get(_ key: String) -> Bytes? + /// The value, nil only when it is absent, and an error when the store + /// could not answer, as before the first unlock. Anything that would + /// create a value on nil must use this: a store that cannot answer does + /// not mean the value is gone. + func read(_ key: String) throws -> Bytes? func put(_ key: String, _ value: Bytes) func remove(_ key: String) } +/// The store could not answer, so nothing may be written on its word. +public struct SecureStoreUnavailable: Error, Equatable { + public let status: Int32 + + public init(status: Int32) { + self.status = status + } +} + @MainActor public final class MemoryStore: SecureStore { private var map: [String: Bytes] = [:] @@ -28,6 +43,9 @@ public final class MemoryStore: SecureStore { } extension SecureStore { + /// A store that cannot fail to answer, such as `MemoryStore`. + public func read(_ key: String) throws -> Bytes? { get(key) } + func getJSON(_ type: T.Type, _ key: String) -> T? { guard let raw = get(key) else { return nil } return try? JSONDecoder().decode(T.self, from: Data(raw)) diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift b/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift index 58b2fe7..3a615ec 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift @@ -51,8 +51,17 @@ public final class SiteList { all().first { $0.siteId == siteId } } + /// Adds or replaces one home. When the store cannot say which homes it + /// holds, nothing is written: a list written then would drop the others. public func put(_ site: StoredSite) { - var rows = all().filter { $0.siteId != site.siteId } + let held: Bytes? + do { + held = try store.read(Self.sitesKey) + } catch { + return + } + let existing = held.flatMap { try? JSONDecoder().decode([StoredSite].self, from: Data($0)) } ?? [] + var rows = existing.filter { $0.siteId != site.siteId } rows.append(site) store.putJSON(Self.sitesKey, rows) } diff --git a/appleApp/FTWKit/Sources/FTWKit/Support/ProcessClock.swift b/appleApp/FTWKit/Sources/FTWKit/Support/ProcessClock.swift new file mode 100644 index 0000000..cf27ee7 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Support/ProcessClock.swift @@ -0,0 +1,37 @@ +import Darwin +import os + +/// How long since this process started, by the kernel's record, and a mark +/// logged once per process. The app's LaunchClock marks the first frame and +/// the first content with it; SiteModel marks the first live reading, so all +/// three count from the same moment. Read them with: +/// +/// xcrun simctl spawn log show --last 2m \ +/// --predicate 'subsystem == "energy.ftw.app" AND category == "launch"' +@MainActor +public enum ProcessClock { + static let log = Logger(subsystem: "energy.ftw.app", category: "launch") + private static var seen: Set = [] + + /// Once per name: a signpost event and a line with the time since start. + public static func markOnce(_ event: StaticString) { + let name = "\(event)" + guard seen.insert(name).inserted else { return } + OSSignposter(logger: log).emitEvent(event) + if let ms = sinceStartMs() { + log.notice("\(name, privacy: .public) \(Int(ms.rounded()), privacy: .public) ms after process start") + } + } + + /// The kernel's record of when this process started. + public static func sinceStartMs() -> Double? { + var info = kinfo_proc() + var size = MemoryLayout.stride + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] + guard sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) == 0 else { return nil } + let start = info.kp_proc.p_starttime + var now = timeval() + gettimeofday(&now, nil) + return Double(now.tv_sec - start.tv_sec) * 1000 + Double(now.tv_usec - start.tv_usec) / 1000 + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Freshness.swift b/appleApp/FTWKit/Sources/FTWShared/Freshness.swift index c6aae1b..2d97aeb 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Freshness.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Freshness.swift @@ -10,6 +10,11 @@ import Foundation public enum Freshness { public enum Tone: Sendable { case live, stale, reaching, lost, busy } + /// How long the band counts a wait in seconds. Past this, a growing + /// counter only looks broken; the band says it cannot reach the box and + /// when the last reading was. + public static let countForMs: Double = 20_000 + public struct Band: Equatable, Sendable { public let tone: Tone public let message: String @@ -29,7 +34,8 @@ public enum Freshness { waitMs: Double, bootPct: Int?, noCarrier: Bool, - busy: Bool = false + busy: Bool = false, + lastReading: String? = nil ) -> Band { let reaching = !noCarrier && [.idle, .handshaking, .subscribing, .failed, .booting].contains(phase) let connected = carrier == .relay || carrier == .webrtc || carrier == .cloud @@ -37,7 +43,10 @@ public enum Freshness { // many phones watching as it serves. Not a fault, and not ours to fix. let turnedAway = busy && !connected && reaching - let tone: Tone = connected ? (srcState == .live ? .live : .stale) : turnedAway ? .busy : (reaching ? .reaching : .lost) + // A box that is starting says so for as long as it takes. Anything + // else that has not connected for a while is said as such. + let longWait = reaching && !turnedAway && phase != .booting && waitMs >= countForMs + let tone: Tone = connected ? (srcState == .live ? .live : .stale) : turnedAway ? .busy : (reaching && !longWait ? .reaching : .lost) let message: String if connected, carrier == .cloud { @@ -64,6 +73,8 @@ public enum Freshness { message = Words.text("FTW is busy") } else if phase == .booting { message = Words.text("Your box is starting") + } else if longWait { + message = lastReading.map { Words.format("Can't reach your box · last reading %@", $0) } ?? Words.text("Can't reach your box") } else if reaching { if phase == .failed { message = Words.text("Reconnecting to your box") @@ -81,7 +92,7 @@ public enum Freshness { } var wait: String? - if reaching, !turnedAway { + if reaching, !turnedAway, !longWait { if phase == .booting, let bootPct { wait = "\(bootPct)%" } else { diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings index 1172908..ab9434b 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings @@ -23,6 +23,7 @@ "Securing home link" = "Krypterar hemlänken"; "Connecting to your box" = "Ansluter till din box"; "Can't reach your box" = "Når inte din box"; +"Can't reach your box · last reading %@" = "Når inte din box · senaste mätvärde %@"; /* Age of a reading */ "unknown" = "okänt"; diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift index e9cba9f..e7c830d 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift @@ -10,7 +10,7 @@ import Testing } /// A home whose box answers hello and the cloud routes as Core does. - func home(role: String = Contract.roleOwner, caps: [String] = [Contract.capCloudLink, Contract.capAPIPassthrough], cloud: Bool = false) throws -> CloudSite { + func home(role: String = Contract.roleOwner, caps: [String] = [Contract.capCloudLink, Contract.capAPIPassthrough], cloud: Bool = false) async throws -> CloudSite { let w = try CloudSite() w.open() w.site.connect(w.relay) @@ -25,6 +25,8 @@ import Testing } w.relay.open(w.scheduler.nowMs) w.relay.helloOk(uptimeMs: w.uptime, cloud: cloud ? w.identity : nil, role: role, caps: caps) + // The screens see a burst one display frame later. + await w.run(20) return w } @@ -41,7 +43,7 @@ import Testing } @Test func anOwnerTurnsItOnAtTheBoxWithEveryCloudScope() async throws { - let w = try home() + let w = try await home() let d = try defaults() let consent = CloudConsent(defaults: d) #expect(CloudConsent.audience(w.site) == .owner) @@ -60,7 +62,7 @@ import Testing } @Test func statisticsGoOnlyWhenTicked() async throws { - let w = try home() + let w = try await home() let consent = CloudConsent(defaults: try defaults()) consent.contributeStats = true await consent.accept(w.site) @@ -71,7 +73,7 @@ import Testing /// The grant is asked for once per home. After that the box's word /// stands: turned off on the box or in About, it stays off. @Test func theGrantIsAskedForOncePerHome() async throws { - let w = try home() + let w = try await home() let d = try defaults() let consent = CloudConsent(defaults: d) await consent.accept(w.site) @@ -81,7 +83,7 @@ import Testing } @Test func aViewerIsToldToAskTheOwner() async throws { - let w = try home(role: Contract.roleViewer) + let w = try await home(role: Contract.roleViewer) let consent = CloudConsent(defaults: try defaults()) #expect(CloudConsent.audience(w.site) == .viewer) await consent.accept(w.site) @@ -92,19 +94,20 @@ import Testing /// A box that cannot run the uplink yet gets the grant once it can. @Test func aBoxWithoutTheUplinkIsAskedLater() async throws { - let w = try home(caps: [Contract.capAPIPassthrough]) + let w = try await home(caps: [Contract.capAPIPassthrough]) let d = try defaults() let consent = CloudConsent(defaults: d) #expect(CloudConsent.audience(w.site) == .ownerLater) await consent.accept(w.site) #expect(w.relay.calls.isEmpty) w.relay.helloOk(uptimeMs: w.uptime, cloud: nil, caps: [Contract.capCloudLink, Contract.capAPIPassthrough]) + await w.run(20) await consent.ensure(w.site) #expect(w.relay.calls.map(\.path) == ["/api/cloud/grant"]) } @Test func alreadyOnIsLeftAlone() async throws { - let w = try home(cloud: true) + let w = try await home(cloud: true) let consent = CloudConsent(defaults: try defaults()) await consent.accept(w.site) #expect(w.relay.calls.isEmpty) @@ -115,12 +118,12 @@ import Testing /// again. @Test func offInAboutStaysOffAfterARestart() async throws { let d = try defaults() - let w = try home(cloud: true) + let w = try await home(cloud: true) let consent = CloudConsent(defaults: d) await consent.accept(w.site) await consent.turnOff(w.site) // The next start: the box says FTW Cloud is off now. - let later = try home(cloud: false) + let later = try await home(cloud: false) await CloudConsent(defaults: d).ensure(later.site) #expect(later.relay.calls.isEmpty) } @@ -129,9 +132,9 @@ import Testing /// it off, because it already asked once. @Test func offOnTheBoxStaysOff() async throws { let d = try defaults() - let w = try home(cloud: true) + let w = try await home(cloud: true) await CloudConsent(defaults: d).accept(w.site) - let later = try home(cloud: false) + let later = try await home(cloud: false) await CloudConsent(defaults: d).ensure(later.site) #expect(later.relay.calls.isEmpty) } @@ -139,7 +142,7 @@ import Testing /// About: off asks the box to erase the copy; the receipt is the time on /// the service's confirmation. @Test func offAndTheReceipt() async throws { - let w = try home(cloud: true) + let w = try await home(cloud: true) let consent = CloudConsent(defaults: try defaults()) #expect(await consent.turnOff(w.site)) #expect(consent.box?.erasing == true) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift index e4ae48f..bb39c64 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift @@ -183,7 +183,7 @@ struct IdentityVectors: Decodable { @Test func restoreRefusesToOverwriteAnotherIdentity() async throws { let vault = Vault(store: MemoryStore()) - let local = vault.localWrappingKey() + let local = try vault.localWrappingKey() _ = try vault.deviceKey(local) #expect(throws: Vault.VaultError.self) { try vault.restoreDeviceKey(local, scalar: randomBytes(32)) } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/ReviewFixTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/ReviewFixTests.swift new file mode 100644 index 0000000..a54d64a --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/ReviewFixTests.swift @@ -0,0 +1,219 @@ +import Foundation +import Testing +@testable import FTWKit +import FTWShared + +/// A store that cannot answer, as the keychain before the first unlock: every +/// read throws errSecInteractionNotAllowed, and every write is counted. +@MainActor +final class LockedStore: SecureStore { + private(set) var writes: [String] = [] + + func get(_ key: String) -> Bytes? { nil } + func read(_ key: String) throws -> Bytes? { throw SecureStoreUnavailable(status: -25308) } + func put(_ key: String, _ value: Bytes) { writes.append(key) } + func remove(_ key: String) {} +} + +@MainActor +@Suite struct ReviewFixTests { + /// Every open used to say "Reconnecting to your box", because a carrier + /// made a moment earlier was woken at once. A cold start says it is + /// connecting, and says reconnecting only once the carrier really drops. + @Test func aColdStartSaysConnectingNeverReconnecting() async throws { + let world = ShellTests.World() + let first = world.app() + let pair = PairModel(app: first) + let task = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await task.value + first.suspend() + + let app = world.app() + app.launch() + let site = try #require(app.site) + var said: [String] = [] + for _ in 0..<40 { + await world.run(20, step: 10) + let message = site.freshness(noCarrier: false).message + if said.last != message { said.append(message) } + } + #expect(site.session.phase == .streaming) + #expect(said.contains("Connecting to your box")) + #expect(!said.contains("Reconnecting to your box"), "\(said)") + + world.relay.closeCurrent(code: 1006, reason: "") + await world.run(40, step: 10) + #expect(site.freshness(noCarrier: false).message == "Reconnecting to your box") + } + + /// A command on a carrier that is not open fails at once, not after the + /// five seconds an ack would get. + @Test func aCommandOnAClosedCarrierFailsAtOnce() async throws { + let scheduler = ManualScheduler() + let site = SiteModel(siteId: "s1", build: "t", ua: "t", scheduler: scheduler, files: nil) + site.start() + let relay = ScriptedBox() + site.connect(relay) + let started = scheduler.nowMs + do { + _ = try await site.command(Contract.opSetMode, args: [("mode", .text("self_consumption"))]) + Issue.record("a command went out on a carrier that was not open") + } catch let e as CommandError { + #expect(e.code == "E_NO_ACK") + } + #expect(scheduler.nowMs - started < 100) + #expect(relay.sent.isEmpty) + } + + /// Before the first unlock the keychain cannot answer. Nothing is then + /// written: no new wrap key over the old one, no cache key, no list of + /// homes with the others dropped, and no pairing. + @Test func aLockedKeychainLeadsToNoWrite() async throws { + let store = LockedStore() + let vault = Vault(store: store) + #expect(throws: SecureStoreUnavailable.self) { try vault.localWrappingKey() } + let files = SealedFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-locked-\(UUID().uuidString)"), store: store) + files.write("snapshot-s1", [1, 2, 3]) + #expect(files.read("snapshot-s1") == nil) + SiteList(store: store).put(StoredSite(siteId: "s1", label: "Home", boxStaticKey: Data(repeating: 1, count: 32), rendezvousSecret: nil, pairingCode: nil, lanHint: nil, addedAtMs: 0, lastSeenAtMs: 0)) + + let app = AppModel(store: store, files: files, passkeys: nil, scheduler: ManualScheduler(), build: "t", ua: "t") + app.launch() + await app.connect() + #expect(store.writes.isEmpty, "\(store.writes)") + } + + /// After 20 s without a connection the band stops counting and says when + /// the last reading was. + @Test func aLongWaitSaysCantReachWithTheLastReading() async throws { + let scheduler = ManualScheduler() + let site = SiteModel(siteId: "s1", build: "t", ua: "t", scheduler: scheduler, files: nil) + site.start() + site.connect(ScriptedBox()) + var band = site.freshness(noCarrier: false) + #expect(band.wait == "0s") + for _ in 0..<19 { scheduler.advance(1_000) } + band = site.freshness(noCarrier: false) + #expect(band.wait == "19s") + for _ in 0..<2 { scheduler.advance(1_000) } + band = site.freshness(noCarrier: false) + #expect(band.message == "Can't reach your box") + #expect(band.wait == nil) + #expect(band.tone == .lost) + + let withReading = Freshness.band(carrier: .cache, transport: .none, srcState: .stale, ageMs: 120_000, phase: .failed, + waitMs: 25_000, bootPct: nil, noCarrier: false, lastReading: "14:02") + #expect(withReading.message == "Can't reach your box · last reading 14:02") + #expect(withReading.wait == nil) + let starting = Freshness.band(carrier: .cache, transport: .none, srcState: .stale, ageMs: 120_000, phase: .booting, + waitMs: 25_000, bootPct: 40, noCarrier: false, lastReading: "14:02") + #expect(starting.message == "Your box is starting") + } + + /// While live, the clock moves with the frames: one change a second, not + /// a frame and a tick. + @Test func theClockMovesWithTheFrames() async throws { + let scheduler = ManualScheduler() + let site = SiteModel(siteId: "s1", build: "t", ua: "t", scheduler: scheduler, files: nil) + site.start() + let relay = ScriptedBox() + site.connect(relay) + relay.open(scheduler.nowMs) + relay.helloOk(uptimeMs: 60_000, cloud: nil) + relay.snap(uptimeMs: 60_000, gridW: 500) + scheduler.advance(20) + var changes = 0 + site.observe { changes += 1 } + for i in 1...10 { + scheduler.advance(1_000) + relay.say("delta", .map([("seq", .int(i)), ("uptimeMs", .int(60_000 + i * 1_000)), ("fields", .map(["2": .int(500 + i)]))])) + } + scheduler.advance(20) + #expect(site.isLive) + #expect(changes <= 12, "\(changes) changes in 10 s of 1 Hz frames") + #expect(site.nowMs == scheduler.nowMs - 20) + + // Silence: the clock wakes once to say the stream went quiet. + scheduler.advance(SiteModel.streamQuietAfterMs + 10) + #expect(site.srcState == .lagging) + } + + /// The same home is not written twice, and the background writes once. + @Test func theSameSnapshotIsNotWrittenTwice() async throws { + let w = try CloudSite() + w.open() + w.site.connect(w.relay) + w.relay.open(w.scheduler.nowMs) + w.relay.helloOk(uptimeMs: w.uptime, cloud: nil) + w.relay.snap(uptimeMs: w.uptime, gridW: 700) + await w.run(40) + w.site.persistNow(wait: true) + // Each write draws a new nonce, so equal bytes prove no second write. + let first = w.files.read("snapshot-s1") + w.site.persistNow(wait: true) + w.site.suspend() + #expect(first != nil) + #expect(w.files.read("snapshot-s1") == first) + } + + /// Numbers never step back: a cloud copy older than the cache on screen + /// waits until the cloud has something newer. + @Test func anOlderCopyNeverReplacesANewerOne() async throws { + let w = try CloudSite() + w.keepFromEarlierLaunch(gridW: 111) + // The cache was written a minute ago; the cloud's copy is from five + // minutes ago and has gone stale. + let old = try w.live(gridW: 900) + w.scheduler.advance(300_000) + w.files.writeJSON("snapshot-s1", CachedSnapshot(siteId: "s1", savedAtMs: w.scheduler.nowMs - 60_000, state: { + var s = SessionState() + s.fields = [Contract.FID.gridW: 111] + return s + }())) + w.service.held = [old] + w.open() + w.connectCloud() + await w.run(60) + #expect(w.site.session.fields[Contract.FID.gridW] == 111) + #expect(w.site.carrier == .cache) + + // A fresh live from the box is newer than the cache, and takes over. + w.service.push(try w.live(gridW: 1200)) + await w.run(60) + #expect(w.site.session.fields[Contract.FID.gridW] == 1200) + #expect(w.site.carrier == .cloud) + let at = try #require(w.site.fieldAtMs[Contract.FID.gridW]) + #expect(abs(at - w.scheduler.nowMs) < 200) + } + + /// Envelopes that land in one burst reach the screens as one change. + @Test func aBurstIsOneChange() async throws { + let w = try CloudSite() + w.keepFromEarlierLaunch(gridW: nil) + w.service.held = [try w.live(gridW: 1000)] + w.open() + w.connectCloud() + await w.run(60) + var changes = 0 + w.site.observe { changes += 1 } + w.service.push(try w.live(gridW: 1100)) + w.service.push(try w.live(gridW: 1150)) + w.service.push(try w.live(gridW: 1200)) + await w.run(60) + #expect(w.site.session.fields[Contract.FID.gridW] == 1200) + #expect(changes <= 2, "\(changes) changes for one burst") + } + + /// The cloud's next grade change is when a reading's age passes S. + @Test func theCloudSaysWhenItsGradeNextChanges() throws { + var box = try FakeCloudBox(boot: 1) + let at: Int64 = 2_000_000 + var view = box.view() + try view.accept(box.fullLive(at, age: 1_000), arrivalMs: at + 100) + // Rule N: sent at `at`. S = 10 s, age 1 s: live until at + 9 s. + #expect(view.nextGradeChange(nowMs: at + 100) == at + 9_001) + #expect(view.grade(nowMs: at + 9_001).sources["m"] == .lagging) + #expect(view.grade(nowMs: at + 9_000).sources["m"] == .live) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift index 2a942b3..7240da6 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift @@ -17,10 +17,12 @@ final class ScriptedBox: Carrier { var api: ((String, String, String) -> (Int, String))? private(set) var calls: [(method: String, path: String, body: String)] = [] - func send(_ frame: Bytes) { - guard let f = try? Frame.decode(frame) else { return } + @discardableResult + func send(_ frame: Bytes) -> Bool { + guard status.isOpen else { return false } + guard let f = try? Frame.decode(frame) else { return true } sent.append(f.envelope.t) - guard f.envelope.t == "api.req", let id = f.envelope.id, let b = f.envelope.b else { return } + guard f.envelope.t == "api.req", let id = f.envelope.id, let b = f.envelope.b else { return true } let method = b["method"]?.string ?? "", path = b["path"]?.string ?? "" let body = b["body"]?.byteString.map { String(decoding: $0, as: UTF8.self) } ?? "" calls.append((method, path, body)) @@ -28,6 +30,7 @@ final class ScriptedBox: Carrier { reply("api.head", id, .map([("status", .int(status)), ("headers", .map([("content-type", .text("application/json"))]))])) reply("api.chunk", id, .map([("seq", .int(0)), ("data", .bytes(Bytes(json.utf8)))])) reply("api.end", id, CBOR.emptyMap) + return true } private func reply(_ t: String, _ id: UInt32, _ body: CBOR) { @@ -169,7 +172,7 @@ final class CloudSite { #expect(w.site.session.fields[Contract.FID.gridW] == 111) w.connectCloud() - await w.run(40) + await w.run(60) #expect(w.site.carrier == .cloud) #expect(w.site.session.fields[Contract.FID.gridW] == 1000) #expect(w.site.isLive) @@ -181,7 +184,7 @@ final class CloudSite { let firstFrame = w.site.lastFrameAtMs w.service.push(try w.live(gridW: 1200)) - await w.run(30) + await w.run(50) #expect(w.site.session.fields[Contract.FID.gridW] == 1200) #expect(w.site.lastFrameAtMs != firstFrame) } @@ -197,7 +200,7 @@ final class CloudSite { w.service.held = [old] w.open() w.connectCloud() - await w.run(40) + await w.run(60) #expect(w.site.carrier == .cloud) #expect(w.site.session.fields[Contract.FID.gridW] == 900) #expect(w.site.srcState == .down) @@ -213,7 +216,7 @@ final class CloudSite { w.service.held = [try w.live(gridW: 1000)] w.open() w.connectCloud() - await w.run(40) + await w.run(60) #expect(w.site.carrier == .cloud) #expect(w.site.srcState == .stale) #expect(w.site.ageMs == nil) @@ -229,7 +232,7 @@ final class CloudSite { w.relay.open(w.scheduler.nowMs) w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity) w.service.held = [try w.live(gridW: 1000)] - await w.run(40) + await w.run(60) #expect(w.site.carrier == .cloud) #expect(w.site.srcState == .live) #expect(w.site.ageMs != nil) @@ -246,12 +249,12 @@ final class CloudSite { w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity) w.relay.snap(uptimeMs: w.uptime, gridW: 700) w.service.held = [try w.live(gridW: 1000)] - await w.run(40) + await w.run(60) #expect(w.site.carrier == .cloud) #expect(w.site.session.fields[Contract.FID.gridW] == 1000) w.service.close(code: 1006) - await w.run(1) + await w.run(20) #expect(w.site.carrier == .relay) #expect(w.site.session.fields[Contract.FID.gridW] == 700) #expect(w.site.freshness(noCarrier: false).message == "Live via encrypted relay") @@ -272,7 +275,7 @@ final class CloudSite { let old = try w.live(gridW: 900, age: 120_000) w.service.held = [old] w.relay.snap(uptimeMs: w.uptime, gridW: 700) - await w.run(40) + await w.run(60) #expect(w.site.carrier == .relay) #expect(w.site.session.fields[Contract.FID.gridW] == 700) } @@ -285,12 +288,12 @@ final class CloudSite { w.service.held = [try w.live(gridW: 1000)] w.open() w.connectCloud() - await w.run(40) + await w.run(60) #expect(w.site.cloudOn) w.site.connect(w.relay) w.relay.open(w.scheduler.nowMs) w.relay.helloOk(uptimeMs: w.uptime, cloud: nil) - await w.run(10) + await w.run(30) #expect(!w.site.cloudOn) #expect(w.site.carrier != .cloud) #expect(w.service.current == nil) @@ -303,12 +306,12 @@ final class CloudSite { w.service.held = [try w.live(gridW: 1000)] w.open() w.connectCloud() - await w.run(40) + await w.run(60) #expect(w.service.dialled.isEmpty) w.site.connect(w.relay) w.relay.open(w.scheduler.nowMs) w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity) - await w.run(40) + await w.run(60) #expect(w.service.dialled.count == 1) #expect(w.site.carrier == .cloud) #expect(w.files.readJSON(CloudCache.self, "cloud-s1")?.identity == w.identity) @@ -321,11 +324,11 @@ final class CloudSite { w.service.held = [try w.live(gridW: 1000)] w.open() w.connectCloud() - await w.run(40) + await w.run(60) var forged = try w.live(gridW: 5) forged.b[forged.b.count - 3] ^= 1 w.service.push(forged) - await w.run(30) + await w.run(50) #expect(w.site.session.fields[Contract.FID.gridW] == 1000) } @@ -370,7 +373,7 @@ final class CloudSite { w.service.held = [try w.live(gridW: 1000)] w.open() w.connectCloud() - await w.run(40) + await w.run(60) w.site.suspend() #expect(w.service.current == nil) #expect(w.site.carrier != .cloud) From 329dfcec56cd420c5c0f014b75fea048faf048dd Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 09:56:58 +0200 Subject: [PATCH 7/9] fix(app): the band and the cards read one snapshot; time to live per start Found in the simulator: while a burst waited for its display frame, the band read the newest relay state and said "Live via encrypted relay" while the card still said "Not live right now". carrier, srcState, ageMs and isLive now derive from a snapshot taken in the same change as session, and the cache's claim is retired in that change too. Closing the cloud publishes at once, and the background settles what the screens say before it writes. A cold start passes through inactive on its way to the front; it keeps its own cold-start-to-live interval instead of starting a foreground one. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWKit/State/SiteModel.swift | 88 ++++++++++++------- .../Tests/FTWKitTests/ReviewFixTests.swift | 19 ++++ 2 files changed, 76 insertions(+), 31 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 22c07f1..8ea4707 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -101,6 +101,17 @@ public final class SiteModel { /// From a start or a return to the front until the screen is live, as a /// signpost interval. @ObservationIgnored private var liveSpan: (name: StaticString, state: OSSignpostIntervalState, fromMs: Double)? + /// What the band and the cards derive from, taken in the same change as + /// `session`, so they never disagree while a burst waits for its frame. + private var shown = Shown() + + private struct Shown { + var source: ReadingSource = .relay + var relay = SessionState() + var relayFrameAtMs: Double? + var cloudOpen = false + var cloudView: CloudView? + } // FTW Cloud. All nil while the home has no grant or this build no // endpoint. @@ -202,6 +213,11 @@ public final class SiteModel { closeCloud() // After the close, so no last change of rate goes out on the wire. hide() + // Settle what the screens say before iOS freezes them. + publishTimer?.cancel() + publishTimer = nil + source = readingSource() + publishNow() persistNow(wait: true) } @@ -217,7 +233,9 @@ public final class SiteModel { core.setTelemetryHz(1) cloud?.setLooking(true) if !was { - beginLiveSpan("foreground to live") + // A start passes through inactive on its way to the front; its + // own interval keeps running. + if liveSpan == nil { beginLiveSpan("foreground to live") } beginResume(wake: relay.phase != .streaming || relayFrameAtMs == nil) } scheduleClock() @@ -287,10 +305,7 @@ public final class SiteModel { if s.heardFromBox, let boot = s.cloud?.boot, s.carrier == .relay || s.carrier == .webrtc, s.uptimeMs != previous.uptimeMs || !previous.heardFromBox { learnRuleU(boot: boot, uptimeMs: s.uptimeMs, phoneMs: now) } - if s.phase == .streaming { - cachedAtMs = nil - scheduleSnapshot() - } + if s.phase == .streaming { scheduleSnapshot() } if reading { first("relay reading") } publish() scheduleClock() @@ -345,7 +360,10 @@ public final class SiteModel { } if times != fieldAtMs { fieldAtMs = times } if delivered || readingAtMs == nil || s.fields.isEmpty != session.fields.isEmpty { readingAtMs = s.fields.isEmpty ? nil : at } + // A stream, or a reading from the cloud, retires the cache's claim. + if relay.phase == .streaming || (source == .cloud && delivered), cachedAtMs != nil { cachedAtMs = nil } advanceClock() + shown = Shown(source: source, relay: relay, relayFrameAtMs: relayFrameAtMs, cloudOpen: cloud?.status.isOpen == true, cloudView: cloudView) session = s endLiveSpanIfLive() notify() @@ -512,11 +530,11 @@ public final class SiteModel { /// How the readings on screen reach us. A carrier is claimed only once it /// has delivered a reading: an open socket is evidence of nothing. public var carrier: CarrierKind { - if source == .cloud { - if cloud?.status.isOpen == true { return .cloud } + if shown.source == .cloud { + if shown.cloudOpen { return .cloud } return cachedAtMs != nil ? .cache : .none } - if relay.phase == .streaming, relayFrameAtMs != nil { return relay.carrier } + if shown.relay.phase == .streaming, shown.relayFrameAtMs != nil { return shown.relay.carrier } return cachedAtMs != nil ? .cache : .none } @@ -550,40 +568,43 @@ public final class SiteModel { /// The sources behind what the Now screen draws, named by the box. /// The box session's sources behind what Now draws. The cloud's are /// graded by `CloudView`, not by these. - var nowSourceIDs: [String] { + var nowSourceIDs: [String] { Self.nowSourceIDs(shown.relay) } + + static func nowSourceIDs(_ r: SessionState) -> [String] { var ids = [String]() - for fid in Self.nowFids where relay.fields[fid] != nil { - if let src = relay.dict[fid]?.srcId, !ids.contains(src) { ids.append(src) } + for fid in nowFids where r.fields[fid] != nil { + if let src = r.dict[fid]?.srcId, !ids.contains(src) { ids.append(src) } } return ids } public var srcState: SourceState { if session.fields.isEmpty { return .never } - if source == .cloud, let view = cloudView { return Self.cloudState(view, nowMs: nowMs) } - return relaySrcState + if shown.source == .cloud, let view = shown.cloudView { return Self.cloudState(view, nowMs: nowMs) } + return Self.relayState(shown.relay, frameAtMs: shown.relayFrameAtMs, nowMs: nowMs) } - private var relaySrcState: SourceState { - if relay.fields.isEmpty { return .never } + /// The box session's grade for what Now draws, from the box's own + /// source states and how long the stream has been quiet. + static func relayState(_ r: SessionState, frameAtMs: Double?, nowMs: Double) -> SourceState { + if r.fields.isEmpty { return .never } // Every source state was read off the disk: true when written. - if relayFrameAtMs == nil { return .stale } - let ids = nowSourceIDs - let worst: SourceState - if !ids.isEmpty { - worst = core.worstSourceState(ids) - } else if !relay.sources.isEmpty { - worst = core.worstSourceState(Array(relay.sources.keys)) - } else { - worst = .live - } + guard let frameAtMs else { return .stale } + let ids = nowSourceIDs(r) + let keys = ids.isEmpty ? Array(r.sources.keys) : ids + let worst = keys.isEmpty ? .live : keys.map { r.sources[$0]?.state ?? .never }.max() ?? .live // A stream gone quiet is not live however healthy it looked. - if worst == .live, sinceLastFrameMs > 0 { return .lagging } + if worst == .live, nowMs - frameAtMs >= streamQuietAfterMs { return .lagging } return worst } + /// The same grade from the newest state, for choosing a source. + private var relaySrcState: SourceState { + Self.relayState(relay, frameAtMs: relayFrameAtMs, nowMs: scheduler.nowMs) + } + public var sinceLastFrameMs: Double { - guard let at = relayFrameAtMs else { return 0 } + guard let at = shown.relayFrameAtMs else { return 0 } let since = nowMs - at return since < Self.streamQuietAfterMs ? 0 : since } @@ -591,12 +612,17 @@ public final class SiteModel { /// Age of the oldest reading on screen. Nil means unknown, which is the /// honest answer after a box restart. public var ageMs: Double? { - if source == .cloud, let view = cloudView { + if shown.source == .cloud, let view = shown.cloudView { guard let age = Self.cloudAge(view, nowMs: nowMs) else { return nil } if carrier == .cache, let at = cachedAtMs { return max(age, nowMs - at) } return age } - let ages = nowSourceIDs.compactMap { core.ageOf($0) } + let r = shown.relay + let ages = nowSourceIDs.compactMap { id -> Double? in + guard let src = r.sources[id] else { return nil } + let age = r.uptimeMs - src.lastOkMs + return age < 0 ? nil : age + } if carrier == .cache, let at = cachedAtMs { let shelf = nowMs - at return shelf + (ages.max() ?? 0) @@ -608,7 +634,7 @@ public final class SiteModel { /// Live is three claims at once: streaming, not the cache, and every /// source answering. public var isLive: Bool { - let streaming = source == .cloud ? cloud?.status.isOpen == true : session.phase == .streaming + let streaming = shown.source == .cloud ? shown.cloudOpen : session.phase == .streaming return streaming && carrier != .cache && srcState == .live } @@ -877,6 +903,7 @@ extension SiteModel { cloud?.close() cloud = nil cloudStatus = nil + publish() } private func adoptCloud(_ identity: CloudIdentity, offset: CloudCache.Offset?) { @@ -961,7 +988,6 @@ extension SiteModel { cloudArrivalMs = at if readingSource() == .cloud { cloudDelivered = true - cachedAtMs = nil scheduleSnapshot() } publish() diff --git a/appleApp/FTWKit/Tests/FTWKitTests/ReviewFixTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/ReviewFixTests.swift index a54d64a..d818608 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/ReviewFixTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/ReviewFixTests.swift @@ -139,6 +139,25 @@ final class LockedStore: SecureStore { #expect(site.srcState == .lagging) } + /// Found in the simulator: while a burst waited for its frame, the band + /// read the newest relay state and said "Live via encrypted relay" as + /// the card still said "Not live right now". Both read one snapshot now. + @Test func theBandAndTheCardsNeverDisagree() async throws { + let scheduler = ManualScheduler() + let site = SiteModel(siteId: "s1", build: "t", ua: "t", scheduler: scheduler, files: nil) + site.start() + let relay = ScriptedBox() + site.connect(relay) + relay.open(scheduler.nowMs) + relay.helloOk(uptimeMs: 60_000, cloud: nil) + relay.snap(uptimeMs: 60_000, gridW: 500) + // Within the frame: whatever is shown agrees with itself. + #expect((site.carrier == .relay) == (site.session.phase == .streaming)) + #expect(!site.isLive || site.carrier == .relay) + scheduler.advance(20) + #expect(site.carrier == .relay && site.session.phase == .streaming && site.isLive) + } + /// The same home is not written twice, and the background writes once. @Test func theSameSnapshotIsNotWrittenTwice() async throws { let w = try CloudSite() From 57afd800566574885c9e014b69137ef5a093f30a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 10:50:53 +0200 Subject: [PATCH 8/9] feat(cloud): release builds dial FTW Cloud with its Noise key CloudEndpoint.releaseKeys carries the public half of cloud.ftw.energy's Noise key, /ftw-cloud/noise-key in Parameter Store. A release build now reads from FTW Cloud once the box says FTW Cloud is on; a debug build does the same unless launch arguments point it at a local service. Two tests: the release endpoint is cloud.ftw.energy with exactly that key, and a service that answers with any other key refuses message 1, so the phone opens no session and holds no snapshot. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWKit/Identity/CloudKeys.swift | 12 +++++--- .../Tests/FTWKitTests/CloudCarrierTests.swift | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift index 2b291f3..6bae79e 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift @@ -20,10 +20,14 @@ public struct CloudEndpoint: Equatable, Sendable { public static let releaseURL = URL(string: "wss://cloud.ftw.energy/v1/app")! - /// The service's Noise keys. Empty until the service runs with its key: - /// a release build then reads through the relay alone and offers no - /// FTW Cloud, as a box built without service keys never dials. - static let releaseKeys: [Bytes] = [] + /// The service's Noise keys, base64url of the X25519 public half. Up to + /// two, so the key can rotate across an app release: ship the new key + /// beside the old one, switch the service, then drop the old one. The + /// one key is cloud.ftw.energy's, kept as `/ftw-cloud/noise-key` in + /// Parameter Store (srcfl/ftw-cloud `deploy/noise.md`). Empty would mean + /// a release build reads through the relay alone and offers no FTW Cloud. + static let releaseKeyText = ["6-1WbbDzKvbwODSwhUN24MPemfLNRhcbMP2tpU_QAEk"] + static let releaseKeys: [Bytes] = releaseKeyText.compactMap { try? Base64url.decode($0) } /// The endpoint this build uses, or nil when it has none. public static var current: CloudEndpoint? { diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift index af20b49..ea41e31 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift @@ -314,6 +314,34 @@ final class CloudRig { #expect(CloudEndpoint.resolve(defaults: defaults, debug: true)?.url != debug.url) } + /// A release build dials cloud.ftw.energy with its one Noise key, as a + /// debug build does when nothing points it elsewhere. + @Test func aReleaseBuildCarriesTheServicesKey() throws { + #expect(CloudEndpoint.releaseKeys.count == 1) + #expect(CloudEndpoint.releaseKeys.map(Base64url.encode) == CloudEndpoint.releaseKeyText) + #expect(CloudEndpoint.releaseKeys.allSatisfy { $0.count == Noise.dhBytes }) + let defaults = try #require(UserDefaults(suiteName: "ftw-cloud-\(UUID().uuidString)")) + for debug in [false, true] { + let e = try #require(CloudEndpoint.resolve(defaults: defaults, debug: debug)) + #expect(e.url == CloudEndpoint.releaseURL) + #expect(e.serviceKeys == CloudEndpoint.releaseKeys) + } + } + + /// A service that answers with any other key refuses message 1, so the + /// phone never opens a session and holds no snapshot. + @MainActor + @Test func aServiceWithAnotherKeyIsNotTheCloud() async throws { + let rig = try CloudRig { svc in CloudEndpoint(url: svc.endpoint.url, serviceKeys: CloudEndpoint.releaseKeys) } + rig.box.start = rig.at - 60_000 + rig.service.held = [try rig.box.fullLive(rig.at, age: 500)] + rig.carrier.start() + await rig.run(100) + #expect(!rig.carrier.status.isOpen) + #expect(rig.snapshots.isEmpty) + #expect(rig.statuses.contains(.closed(code: CloudWire.closeDenied, retrying: true))) + } + /// The identity comes from the box. A home that is not the hash of the /// key is refused. @Test func theHomeIsTheHashOfTheKey() throws { From 77dba308ffbfdf6fb8bdd0ee0c28c38b224fa1a3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 16:14:19 +0200 Subject: [PATCH 9/9] fix(app): Swedish help when a connect fails; prices keep the home's zone - AppModel.connect catches PasskeyCancelled. A closed sheet now says the unlock was cancelled and offers Try again, instead of saying the key is lost. Every help text of ConnectError and VaultError, and the one for a failure connect cannot name, goes through AppWords with Swedish. A test reads them from the source, so a new one needs Swedish too. - SiteModel.sharePrices passes the box's time zone to PriceEntry, so the price widget reads the clock the way the home does. Co-Authored-By: Claude Opus 5.5 --- .../FTWKit/Sources/FTWKit/Format/EV.swift | 10 +++++ .../Sources/FTWKit/Identity/Vault.swift | 14 ++++--- .../Sources/FTWKit/State/AppModel.swift | 13 +++++-- .../Sources/FTWKit/State/SiteModel.swift | 2 +- .../Tests/FTWKitTests/AppModelTests.swift | 38 +++++++++++++++++++ .../Tests/FTWKitTests/WidgetPricesTests.swift | 3 ++ 6 files changed, 70 insertions(+), 10 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift index 2c91992..9b723e1 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -326,6 +326,16 @@ public enum AppWords { static let monthLabels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] static let sv: [String: String] = [ + // Connecting, and the device key (AppModel.connect, Vault) + "This phone has no record of that home.": "Telefonen har inga uppgifter om det hemmet.", + "This device has no key for that home.": "Den här enheten har ingen nyckel till det hemmet.", + "This home was paired before this app could reach it privately.": "Hemmet parkopplades innan appen kunde nå det privat.", + "Unlocking was cancelled. Try again to open your home.": "Upplåsningen avbröts. Försök igen för att öppna ditt hem.", + "This device can no longer unlock its key. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR.": "Enheten kan inte längre låsa upp sin nyckel. Öppna boxens lokala dashboard, välj Settings → FTW app → Show pairing code och skanna en ny QR-kod.", + "This phone is already set up for a home. Sign out of it first.": "Telefonen är redan kopplad till ett hem. Logga ut från det först.", + "That is the only passkey that can unlock this device. Add another one first.": "Det är den enda passkey som kan låsa upp enheten. Lägg till en till först.", + "This passkey has not been given access yet. Unlock with the one you set up first.": "Den här passkeyn har inte fått åtkomst än. Lås upp med den du skapade först.", + "Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan the QR.": "Öppna boxens lokala dashboard, välj Settings → FTW app → Show pairing code och skanna QR-koden.", // Clock and calendar "today %@": "i dag %@", "tomorrow %@": "i morgon %@", diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Vault.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Vault.swift index 3c5cfd2..b41fc1e 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/Vault.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Vault.swift @@ -106,7 +106,7 @@ public final class Vault { let pair = try Primitives.keyPair(fromSecret: scalar) let existing = record if let existing, existing.publicKey.byteArray != pair.publicKey { - throw VaultError(code: "E_VAULT_OTHER_KEY", message: "a different device key is already stored", help: "This phone is already set up for a home. Sign out of it first.") + throw VaultError(code: "E_VAULT_OTHER_KEY", message: "a different device key is already stored", help: AppWords.text("This phone is already set up for a home. Sign out of it first.")) } var pkcs8 = Self.pkcs8Prefix + scalar defer { wipe(&pkcs8) } @@ -136,7 +136,7 @@ public final class Vault { let copies = record.copies.filter { $0.credentialId != credentialID } if copies.count == record.copies.count { return } if copies.isEmpty { - throw VaultError(code: "E_VAULT_LAST_COPY", message: "refusing to remove the only wrapped copy", help: "That is the only passkey that can unlock this device. Add another one first.") + throw VaultError(code: "E_VAULT_LAST_COPY", message: "refusing to remove the only wrapped copy", help: AppWords.text("That is the only passkey that can unlock this device. Add another one first.")) } write(Record(publicKey: record.publicKey, copies: copies)) } @@ -267,16 +267,20 @@ public final class Vault { } } + /// What to do when no key opens the stored copy. The shell says the + /// same when a connect fails for a reason it cannot name. + static let lockedHelp = "This device can no longer unlock its key. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR." + private func locked() -> VaultError { - VaultError(code: "E_VAULT_LOCKED", message: "no wrapping key opens the stored copy", help: "This device can no longer unlock its key. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR.") + VaultError(code: "E_VAULT_LOCKED", message: "no wrapping key opens the stored copy", help: AppWords.text(Self.lockedHelp)) } private func noCopy(_ credentialID: String) -> VaultError { - VaultError(code: "E_VAULT_NO_COPY", message: "no wrapped copy for credential \(credentialID)", help: "This passkey has not been given access yet. Unlock with the one you set up first.") + VaultError(code: "E_VAULT_NO_COPY", message: "no wrapped copy for credential \(credentialID)", help: AppWords.text("This passkey has not been given access yet. Unlock with the one you set up first.")) } private func emptyVault() -> VaultError { - VaultError(code: "E_VAULT_EMPTY", message: "no device key has been created", help: "Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan the QR.") + VaultError(code: "E_VAULT_EMPTY", message: "no device key has been created", help: AppWords.text("Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan the QR.")) } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift index 8f489d3..3c45a5a 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -142,15 +142,15 @@ public final class AppModel { let mine = connectGeneration do { guard let stored = sites.get(siteId) else { - throw ConnectError(kind: .notPaired, help: "This phone has no record of that home.") + throw ConnectError(kind: .notPaired, help: AppWords.text("This phone has no record of that home.")) } guard vault.isEnrolled else { - throw ConnectError(kind: .notEnrolled, help: "This device has no key for that home.") + throw ConnectError(kind: .notEnrolled, help: AppWords.text("This device has no key for that home.")) } // Read from the QR, never derived: a handle derived from the box // key would be one household identifier good for years. guard let secret = stored.rendezvousSecret?.byteArray else { - throw ConnectError(kind: .stalePairing, help: "This home was paired before this app could reach it privately.") + throw ConnectError(kind: .stalePairing, help: AppWords.text("This home was paired before this app could reach it privately.")) } var wrapping = try vault.silentWrappingKey() if wrapping == nil { @@ -173,12 +173,17 @@ public final class AppModel { ) if site.connect(noise) { connectHelp = nil } site.connectCloud(deviceKey: device, makeSocket: makeSocket) + } catch is PasskeyCancelled { + // A closed sheet is not a lost key: say what happened and let + // the person try again. + guard mine == connectGeneration else { return } + connectHelp = AppWords.text("Unlocking was cancelled. Try again to open your home.") } catch let e as HelpfulError { guard mine == connectGeneration else { return } connectHelp = e.help } catch { guard mine == connectGeneration else { return } - connectHelp = "This device can no longer unlock its key. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR." + connectHelp = AppWords.text(Vault.lockedHelp) } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index f6d5877..d6cbcdb 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -688,7 +688,7 @@ public final class SiteModel { @discardableResult func sharePrices(_ prices: Prices, fromMs: Double, via: CarrierKind? = nil) -> Task? { guard let siteId, let shared else { return nil } - let entry = PriceEntry(fromMs: fromMs, prices: prices) + let entry = PriceEntry(fromMs: fromMs, prices: prices, timeZone: session.box?.tz) guard entry != lastPrices else { return nil } lastPrices = entry let record = CacheRecord( diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift index 2ed611c..e87b163 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift @@ -185,6 +185,44 @@ import FTWDemo #expect(world.box.house.schedule?.socFraction == 0.7) } + /// Closing the passkey sheet at start is not a lost key: the screen says + /// so and offers to try again, instead of sending the person to pair. + @Test func aCancelledUnlockIsNotALostKey() async throws { + let world = World() + let app = world.app() + let pair = PairModel(app: app) + let t = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await t.value + // A device enrolled before the local copy existed asks once more. + try? app.vault.removeCredential(Vault.localCredentialID) + #expect(try app.vault.silentWrappingKey() == nil) + world.passkeys.cancel = true + let again = world.app() + again.launch() + await world.run(100) + #expect(again.connectHelp == "Unlocking was cancelled. Try again to open your home.") + } + + /// Every help text a connect can show has Swedish. Read from the + /// source, so a new one cannot slip through. + @Test func everyConnectHelpHasSwedish() throws { + let pattern = #/AppWords\.text\("((?:[^"\\]|\\.)*)"\)/# + var seen = 0 + for file in ["appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift", "appleApp/FTWKit/Sources/FTWKit/Identity/Vault.swift"] { + for match in try RepoFiles.read(file).matches(of: pattern) { + let english = String(match.1).replacingOccurrences(of: "\\\"", with: "\"") + seen += 1 + #expect(AppWords.sv[english] != nil, "no Swedish for \"\(english)\" in \(file)") + } + } + #expect(seen >= 7) + #expect(AppWords.sv[Vault.lockedHelp] != nil) + AppWords.$language.withValue(.sv) { + #expect(AppWords.text(Vault.lockedHelp).hasPrefix("Enheten kan inte längre")) + } + } + @Test func noKeyIsSaidPlainlyAndNothingRetries() async throws { let world = World() let app = world.app() diff --git a/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift index b0c4667..0c93240 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift @@ -39,6 +39,9 @@ import Testing let read = try #require(CacheReader(sources: [shared]).read(PriceEntry.self, siteId: "home")) #expect(read.value.prices == written) + // The widget reads the clock the way the home does. + #expect(read.value.timeZone != nil) + #expect(read.value.timeZone == site.session.box?.tz) #expect(read.carrier == .cache) #expect(read.record.readAtMs >= asked && read.record.readAtMs <= scheduler.nowMs) #expect(wakes.count == 1)