From fc2b752e7d2285192adcf9ee0c0850d0162b2dcd Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 20:22:33 +0200 Subject: [PATCH 01/21] iPhone M2: Charge now as a Control, Siri and App Shortcuts, local Live Activity Charge now is a Live Activity intent in FTWShared, so the Control in the widget extension and the Live Activity's Back to the plan run in the app, which holds the key. supportedModes is background; iOS 27 also names the app as the only execution target. Every command needs the unlocked phone. "Is the car ready?" answers a locked phone with the gist only, and its card offers Charge now or Back to the plan. LoadpointsModel starts the Live Activity on the box's cmd.result for Charge now and ends it on Back to the plan, Pause or a read without the hold. Static words and a timer, no power, stale after an hour. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW-Info.plist | 2 + appleApp/FTW/FTWApp.swift | 3 + appleApp/FTW/Intents/CarIntents.swift | 132 +++++++ .../FTW/Intents/ChargeNowActivities.swift | 61 ++++ appleApp/FTW/Intents/IntentRunner.swift | 110 ++++++ .../FTW/Intents/en.lproj/AppShortcuts.strings | 8 + .../FTW/Intents/sv.lproj/AppShortcuts.strings | 8 + .../FTW/Intents/sv.lproj/Localizable.strings | 16 + .../FTWKit/State/LoadpointsModel.swift | 261 ++++++++++++++ .../FTWKit/Sources/FTWShared/ChargeNow.swift | 150 ++++++++ .../Sources/FTWShared/CommandIntents.swift | 201 +++++++++++ .../Resources/sv.lproj/Localizable.strings | 11 + .../Tests/FTWKitTests/RemoteTests.swift | 332 ++++++++++++++++++ .../Tests/FTWSharedTests/ChargeNowTests.swift | 135 +++++++ .../FTWWidgets/Control/ChargeNowControl.swift | 32 ++ .../LiveActivity/ChargeNowLiveActivity.swift | 162 +++++++++ appleApp/FTWWidgets/PriceWidget.swift | 2 + 17 files changed, 1626 insertions(+) create mode 100644 appleApp/FTW/Intents/CarIntents.swift create mode 100644 appleApp/FTW/Intents/ChargeNowActivities.swift create mode 100644 appleApp/FTW/Intents/IntentRunner.swift create mode 100644 appleApp/FTW/Intents/en.lproj/AppShortcuts.strings create mode 100644 appleApp/FTW/Intents/sv.lproj/AppShortcuts.strings create mode 100644 appleApp/FTW/Intents/sv.lproj/Localizable.strings create mode 100644 appleApp/FTWKit/Sources/FTWShared/ChargeNow.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/CommandIntents.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/ChargeNowTests.swift create mode 100644 appleApp/FTWWidgets/Control/ChargeNowControl.swift create mode 100644 appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift diff --git a/appleApp/FTW-Info.plist b/appleApp/FTW-Info.plist index 2aff52e..0682a4c 100644 --- a/appleApp/FTW-Info.plist +++ b/appleApp/FTW-Info.plist @@ -8,6 +8,8 @@ energy.ftw.app.prices + NSSupportsLiveActivities + UIBackgroundModes fetch diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index e0591e1..e599fe9 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -27,6 +27,9 @@ struct FTWApp: App { // App Review and anyone without a box can try a simulated home. demo: SimulatedBox.loopback ) + // Siri, the Control and the Live Activity reach the box through the + // app, and Charge now starts its Live Activity wherever it is pressed. + IntentRunner.install(model) // A paired phone paints its cached home before anything else runs. model.launch() #if DEBUG diff --git a/appleApp/FTW/Intents/CarIntents.swift b/appleApp/FTW/Intents/CarIntents.swift new file mode 100644 index 0000000..76f3a53 --- /dev/null +++ b/appleApp/FTW/Intents/CarIntents.swift @@ -0,0 +1,132 @@ +import AppIntents +import FTWKit +import FTWShared +import SwiftUI + +/// "Is the car ready?" Reading is not a privilege, so a locked phone +/// answers too, but with the gist alone: no level, no time, no power. The +/// card under the answer offers the one command that fits, and that button +/// asks for the unlocked phone like every other command. +struct CarStatusIntent: AppIntent { + static let title: LocalizedStringResource = "Is the car ready?" + static let description = IntentDescription("Says whether the car will be ready in time, from what your box says now. A locked iPhone gives the gist only.") + static let authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed + static let supportedModes: IntentModes = .background + + @available(iOS 27.0, *) + static var allowedExecutionTargets: IntentExecutionTargets { .main } + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetIntent { + guard let runner = IntentRunner.shared, let answer = await runner.answer() else { + throw IntentFailure(ChargeNowActivity.openAndRetry) + } + let nowMs = Date.now.timeIntervalSince1970 * 1000 + return .result(dialog: IntentDialog(stringLiteral: answer.spoken(nowMs: nowMs)), snippetIntent: CarCardIntent()) + } +} + +/// The card under the answer. The system runs it again after a button on +/// it, so it shows the box's newest account. +struct CarCardIntent: SnippetIntent { + static let title: LocalizedStringResource = "The car" + static let isDiscoverable = false + + @MainActor + func perform() async throws -> some IntentResult & ShowsSnippetView { + .result(view: CarCard(answer: IntentRunner.shared?.currentAnswer(), nowMs: Date.now.timeIntervalSince1970 * 1000)) + } +} + +struct CarCard: View { + let answer: CarAnswer? + let nowMs: Double + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + if let answer { + Label { + Text(answer.headline).font(.headline) + } icon: { + Image(systemName: Self.symbol(answer.gist)).foregroundStyle(.tint) + } + if !answer.details.isEmpty { + VStack(alignment: .leading, spacing: 2) { + ForEach(answer.details, id: \.self) { Text($0) } + } + .font(.subheadline) + .foregroundStyle(.secondary) + .privacySensitive() + } + if let read = answer.readText(nowMs: nowMs) { + Text(read).font(.caption).foregroundStyle(.secondary) + } + if answer.holding { + Button(intent: BackToPlanIntent(loadpointID: answer.loadpointID)) { + Label(ChargeNowActivity.backToPlan, systemImage: "calendar") + } + .buttonStyle(.borderedProminent) + } else if answer.canChargeNow { + Button(intent: ChargeNowIntent()) { + Label(ChargeNowActivity.title, systemImage: "bolt.fill") + } + .buttonStyle(.bordered) + } + } else { + Text(ChargeNowActivity.openAndRetry).font(.headline) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + + static func symbol(_ gist: CarAnswer.Gist) -> String { + switch gist { + case .noCharger: return "ev.charger" + case .unplugged: return "powerplug" + case .chargingNow: return "bolt.car.fill" + case .paused: return "pause.circle" + case .ready: return "checkmark.circle.fill" + case .onTrack: return "clock" + case .short: return "exclamationmark.triangle" + case .pluggedIn: return "car.fill" + } + } +} + +/// What Siri, Spotlight and the Shortcuts app offer without setup. Fixed +/// phrases: no App Schema covers a car's charging, so Siri matches these +/// and not free speech. +struct FTWShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: ChargeNowIntent(), + phrases: [ + "Charge now with \(.applicationName)", + "Start charging the car with \(.applicationName)", + ], + shortTitle: "Charge now", + systemImageName: "bolt.car.fill" + ) + AppShortcut( + intent: CarStatusIntent(), + phrases: [ + "Is the car ready in \(.applicationName)", + "Ask \(.applicationName) if the car is ready", + ], + shortTitle: "Is the car ready?", + systemImageName: "car.fill" + ) + AppShortcut( + intent: SetLevelIntent(), + phrases: [ + "Tell \(.applicationName) the car is at \(\.$level)", + "Set the car's level in \(.applicationName)", + ], + shortTitle: "Car's level", + systemImageName: "battery.75percent" + ) + } + + static let shortcutTileColor: ShortcutTileColor = .orange +} diff --git a/appleApp/FTW/Intents/ChargeNowActivities.swift b/appleApp/FTW/Intents/ChargeNowActivities.swift new file mode 100644 index 0000000..eee41fc --- /dev/null +++ b/appleApp/FTW/Intents/ChargeNowActivities.swift @@ -0,0 +1,61 @@ +import ActivityKit +import Foundation +import FTWKit +import FTWShared +import os + +/// The Live Activity "Charging now", on ActivityKit. `LoadpointsModel` +/// decides when it starts, moves and ends, from the box's answers; this +/// only carries that out. Local: nothing pushes to it until M7, so its +/// stale date says how long one confirmation is good for. +@MainActor +final class ChargeNowActivities: ChargeNowHost { + private let log = Logger(subsystem: "energy.ftw.app", category: "live-activity") + + func current(home: String, loadpointID: String) -> ChargeNowActivity.State? { + Self.live(home, loadpointID).first?.content.state + } + + func apply(_ action: ChargeNowActivity.Action, home: String, loadpointID: String, zone: TimeZone) { + switch action { + case .start(let state): + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + log.notice("Live Activities are off for FTW") + return + } + do { + let attributes = ChargeNowAttributes(home: home, loadpointID: loadpointID, zone: zone) + _ = try Activity.request(attributes: attributes, content: ActivityContent(state: state, staleDate: state.staleDate), pushType: nil) + log.info("charging now: started") + } catch { + log.error("charging now: could not start: \(error.localizedDescription, privacy: .public)") + } + case .update(let state): + Task { await Self.update(home, loadpointID, to: state) } + case .end: + Task { await Self.end(home, loadpointID) } + log.info("charging now: ended") + case .none: + break + } + } + + private nonisolated static func update(_ home: String, _ loadpointID: String, to state: ChargeNowActivity.State) async { + for activity in live(home, loadpointID) { + await activity.update(ActivityContent(state: state, staleDate: state.staleDate)) + } + } + + /// Back to the plan: gone at once, not left on the Lock Screen. + private nonisolated static func end(_ home: String, _ loadpointID: String) async { + for activity in live(home, loadpointID) { + await activity.end(nil, dismissalPolicy: .immediate) + } + } + + private nonisolated static func live(_ home: String, _ loadpointID: String) -> [Activity] { + Activity.activities.filter { + $0.attributes.home == home && $0.attributes.loadpointID == loadpointID && ($0.activityState == .active || $0.activityState == .stale) + } + } +} diff --git a/appleApp/FTW/Intents/IntentRunner.swift b/appleApp/FTW/Intents/IntentRunner.swift new file mode 100644 index 0000000..ca2fdf6 --- /dev/null +++ b/appleApp/FTW/Intents/IntentRunner.swift @@ -0,0 +1,110 @@ +import AppIntents +import FTWKit +import FTWShared +import os + +/// Runs what a Control, Siri or the Live Activity asks for, inside the app. +/// +/// An intent can wake the app in the background. `FTWApp.init` has then +/// already started connecting; this waits for that stream, sends the +/// command exactly as the car view does, and closes the session again if +/// nobody is looking, because an open socket holds one of the four streams +/// the relay allows a home. +@MainActor +final class IntentRunner: CommandRunner { + static let log = Logger(subsystem: "energy.ftw.app", category: "intents") + + /// The runner `FTWApp` installed. The intents that only the app has + /// (`CarStatusIntent`, its card) reach it here. + private(set) static var shared: IntentRunner? + + let app: AppModel + /// Intents running now. The last one to finish closes the session. + private var running = 0 + + init(app: AppModel) { + self.app = app + } + + /// Before the first home opens: the commands' runner, and the Live + /// Activity that Charge now starts wherever it is pressed. + static func install(_ app: AppModel) { + let runner = IntentRunner(app: app) + shared = runner + CommandHost.install(runner) + LoadpointsModel.sharedChargeNowHost = ChargeNowActivities() + } + + nonisolated func run(_ command: RemoteCommand) async -> RemoteOutcome { + await perform(command) + } + + private func perform(_ command: RemoteCommand) async -> RemoteOutcome { + guard let home = await begin() else { return RemoteOutcome(.failed, ChargeNowActivity.openAndRetry) } + defer { end() } + let started = ContinuousClock.now + let out = await home.loadpoints.perform(command) + Self.log.info("\(String(describing: command), privacy: .public) answered in \((ContinuousClock.now - started) / .milliseconds(1), format: .fixed(precision: 0), privacy: .public) ms") + return out + } + + /// "Is the car ready?": read the chargers and the plan, then answer. If + /// the box is out of reach, the answer is the last one this phone had, + /// dated. + func answer() async -> CarAnswer? { + let locked = DeviceLock.isLocked + guard let home = await begin() else { return nil } + defer { end() } + if !(await home.loadpoints.refreshForAnswer()) { + Self.log.notice("is the car ready: answered from what the phone had") + } + return home.loadpoints.answer(locked: locked) + } + + /// The card's answer as it stands, without asking the box again: a + /// button on the card has just read it. + func currentAnswer() -> CarAnswer? { + app.home?.loadpoints.answer(locked: DeviceLock.isLocked) + } + + /// The home, with a session on its way. A launch for an intent has + /// already started connecting; an app whose session was closed in the + /// background opens a new one. + private func begin() async -> HomeModels? { + if app.home == nil { app.launch() } + guard let home = app.home else { return nil } + running += 1 + if !app.isDemo, home.site.session.phase != .streaming { + await app.refresh() + } + return home + } + + private func end() { + running -= 1 + guard running == 0, PricesInBackground.inBackground else { return } + app.suspend() + } +} + +/// Whether the phone is locked, from the file system: a file protected +/// until unlock cannot be read or written while the phone is locked. No +/// UIKit. When in doubt it says locked, which only hides details. +enum DeviceLock { + static var isLocked: Bool { + let url = URL.applicationSupportDirectory.appending(path: "unlock-probe") + if (try? Data(contentsOf: url)) != nil { return false } + do { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data([1]).write(to: url, options: [.atomic, .completeFileProtection]) + return false + } catch { + return true + } + } +} + +/// The shared intents, listed for this app's App Intents metadata. +struct FTWAppIntents: AppIntentsPackage { + static var includedPackages: [any AppIntentsPackage.Type] { [FTWSharedIntents.self] } +} diff --git a/appleApp/FTW/Intents/en.lproj/AppShortcuts.strings b/appleApp/FTW/Intents/en.lproj/AppShortcuts.strings new file mode 100644 index 0000000..9fdd62c --- /dev/null +++ b/appleApp/FTW/Intents/en.lproj/AppShortcuts.strings @@ -0,0 +1,8 @@ +/* Siri phrases in English, as written in FTWShortcuts. */ + +"Charge now with ${applicationName}" = "Charge now with ${applicationName}"; +"Start charging the car with ${applicationName}" = "Start charging the car with ${applicationName}"; +"Is the car ready in ${applicationName}" = "Is the car ready in ${applicationName}"; +"Ask ${applicationName} if the car is ready" = "Ask ${applicationName} if the car is ready"; +"Tell ${applicationName} the car is at ${level}" = "Tell ${applicationName} the car is at ${level}"; +"Set the car's level in ${applicationName}" = "Set the car's level in ${applicationName}"; diff --git a/appleApp/FTW/Intents/sv.lproj/AppShortcuts.strings b/appleApp/FTW/Intents/sv.lproj/AppShortcuts.strings new file mode 100644 index 0000000..8a69e9b --- /dev/null +++ b/appleApp/FTW/Intents/sv.lproj/AppShortcuts.strings @@ -0,0 +1,8 @@ +/* Siri phrases in Swedish. ${applicationName} is FTW. */ + +"Charge now with ${applicationName}" = "Ladda nu med ${applicationName}"; +"Start charging the car with ${applicationName}" = "Börja ladda bilen med ${applicationName}"; +"Is the car ready in ${applicationName}" = "Är bilen klar i ${applicationName}"; +"Ask ${applicationName} if the car is ready" = "Fråga ${applicationName} om bilen är klar"; +"Tell ${applicationName} the car is at ${level}" = "Säg till ${applicationName} att bilen är på ${level}"; +"Set the car's level in ${applicationName}" = "Ange bilens nivå i ${applicationName}"; diff --git a/appleApp/FTW/Intents/sv.lproj/Localizable.strings b/appleApp/FTW/Intents/sv.lproj/Localizable.strings new file mode 100644 index 0000000..257d205 --- /dev/null +++ b/appleApp/FTW/Intents/sv.lproj/Localizable.strings @@ -0,0 +1,16 @@ +/* Swedish for the App Intents: their titles, descriptions and parameters, + as Siri, Spotlight and the Shortcuts app show them. The key is the English. */ + +"Charge now" = "Ladda nu"; +"Starts charging the car at once, until you go back to the plan." = "Börjar ladda bilen direkt, tills du går tillbaka till planen."; +"Back to the plan" = "Tillbaka till planen"; +"Ends Charge now or Pause. FTW decides when to charge again." = "Avslutar Ladda nu eller Pausa. FTW bestämmer igen när bilen laddar."; +"Charger" = "Laddare"; +"Set the car's level" = "Ange bilens nivå"; +"Tells FTW how full the car's battery is, when FTW cannot read it from the car." = "Säger till FTW hur fullt bilens batteri är, när FTW inte kan läsa av det från bilen."; +"Level" = "Nivå"; +"The car is at ${level}" = "Bilen är på ${level}"; +"Is the car ready?" = "Är bilen klar?"; +"Says whether the car will be ready in time, from what your box says now. A locked iPhone gives the gist only." = "Säger om bilen blir klar i tid, utifrån vad din box säger nu. En låst iPhone svarar utan detaljer."; +"The car" = "Bilen"; +"Car's level" = "Bilens nivå"; diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index 8633717..e319570 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -2,6 +2,15 @@ import Foundation import FTWShared import Observation +/// Where the Live Activity for Charge now lives. The app supplies one built +/// on ActivityKit; a test supplies a fake. `home` keeps one home's activity +/// apart from another's, and from the demo's. +@MainActor +public protocol ChargeNowHost: AnyObject { + func current(home: String, loadpointID: String) -> ChargeNowActivity.State? + func apply(_ action: ChargeNowActivity.Action, home: String, loadpointID: String, zone: TimeZone) +} + /// The car view. Everything on it is a fact the box served; the controls /// express intent and the box decides. Each command is followed from the /// tap to the box's answer and stays on screen, and after any outcome the @@ -62,6 +71,12 @@ public final class LoadpointsModel: Activatable { /// Bumped by each appearance and by `reread`: a new reason to ask. @ObservationIgnored private var visits = 0 + /// The Live Activity for Charge now. A test sets its own; otherwise the + /// app's, which it installs at launch. + @ObservationIgnored public var chargeNowHost: (any ChargeNowHost)? + public static var sharedChargeNowHost: (any ChargeNowHost)? + private var activityHost: (any ChargeNowHost)? { chargeNowHost ?? Self.sharedChargeNowHost } + /// `cache` is what the app left for its widgets; a cold start paints the /// car from it before the box has answered. Nil in the demo and tests. public init(site: SiteModel, charging: ChargingWatch?, cache: CacheReader? = CacheReader.shared()) { @@ -191,6 +206,7 @@ public final class LoadpointsModel: Activatable { readAtMs = site.scheduler.nowMs error = nil site.shareCars(points, fresh: true) + for lp in points { showChargeNow(lp.id, .seen(holding: Self.holding(lp), atMs: readAtMs ?? site.scheduler.nowMs)) } planAsk?.evaluate() } catch { guard mine == token else { return } @@ -332,10 +348,38 @@ public final class LoadpointsModel: Activatable { private func hold(_ lp: Loadpoint, _ ask: EVText.Step.Ask, _ args: [(String, CBOR)]) async { if let step = holdStep[lp.id], !step.settled { return } await follow(lp.id, ask, op: Contract.opLoadpointHold, args: args, into: \.holdStep, tag: "hold") + // Only the box's confirmation moves the Live Activity, and at once: + // the read below can take a while. + let result = holdStep[lp.id]?.ask == ask ? holdStep[lp.id]?.result : nil + switch (ask, result) { + case (.chargeNow, .applied?): showChargeNow(lp.id, .started(atMs: site.scheduler.nowMs)) + case (.resume, .applied?), (.pause, .applied?): showChargeNow(lp.id, .stopped) + default: break + } try? await loadChargers() + // Taken and never confirmed: the charger's own account decides. + if ask == .chargeNow, case .unconfirmed? = result, let now = points.first(where: { $0.id == lp.id }), Self.holding(now) { + showChargeNow(lp.id, .started(atMs: readAtMs ?? site.scheduler.nowMs)) + } charging?.refresh() } + /// Whether Charge now is on for this charger, in the box's account. The + /// box ends the hold when the car is unplugged; its flag says so. + static func holding(_ lp: Loadpoint) -> Bool { + lp.manualActive && !EVText.isPaused(lp) + } + + /// The Live Activity's home key: the site, or the demo's own. + var activityHome: String { site.siteId ?? "demo" } + + private func showChargeNow(_ id: String, _ event: ChargeNowActivity.Event) { + guard let host = activityHost else { return } + let action = ChargeNowActivity.next(host.current(home: activityHome, loadpointID: id), event) + guard action != .none else { return } + host.apply(action, home: activityHome, loadpointID: id, zone: zone) + } + /// One command, written into `into` at every stage: sent, received, /// and what the box did or why not. private func follow(_ id: String, _ ask: EVText.Step.Ask, op: String, args: [(String, CBOR)], into path: ReferenceWritableKeyPath, tag: String) async { @@ -455,4 +499,221 @@ public final class LoadpointsModel: Activatable { public func holdSending(_ lp: Loadpoint) -> Bool { holdStep[lp.id].map { !$0.settled } ?? false } + + // MARK: From outside the app + + /// How long a command from outside the app waits for the stream. An + /// intent gets about 30 s in all, and the box's receipt and result can + /// take 20 of them. + public static let remoteStreamWaitMs: Double = 10_000 + + /// A command from a Control, Siri or the Live Activity. The caller has + /// opened the session; this waits for the stream, reads the chargers, + /// sends the command exactly as the car view would, and says how it + /// ended. The Live Activity moves as it does for a tap in the car view. + public func perform(_ command: RemoteCommand, waitMs: Double = LoadpointsModel.remoteStreamWaitMs) async -> RemoteOutcome { + guard await streaming(within: waitMs) else { + return RemoteOutcome(.failed, AppWords.text("Not connected to FTW right now. Try again when the connection is back.")) + } + guard site.hasPassthrough else { + return RemoteOutcome(.failed, AppWords.text("Your box cannot do this yet. It may need an update.")) + } + do { + try await loadChargers() + } catch { + return RemoteOutcome(.failed, self.error ?? EVText.failure(error)) + } + guard let lp = Self.target(command, in: points) else { + return RemoteOutcome(.failed, Self.say("FTW has no charger to control.")) + } + switch command { + case .chargeNow: + await chargeNow(lp) + return remoteOutcome(holdStep[lp.id], lp.id) + case .backToPlan: + await backToPlan(lp) + return remoteOutcome(holdStep[lp.id], lp.id) + case .setLevel(let pct): + await setLevel(lp, pct: pct) + return remoteOutcome(levelStep[lp.id], lp.id) + } + } + + /// The charger a command means when it names none: the one with a car, + /// else the only one. Back to the plan means the one that holds. + static func target(_ command: RemoteCommand, in points: [Loadpoint]) -> Loadpoint? { + switch command { + case .backToPlan(let id?): + return points.first { $0.id == id } + case .backToPlan(nil): + return points.first(where: \.manualActive) ?? points.first(where: \.pluggedIn) ?? points.first + case .chargeNow, .setLevel: + return points.first(where: \.pluggedIn) ?? points.first + } + } + + /// The receipt's sentence, and whether the box did it, has it, or not. + private func remoteOutcome(_ step: EVText.Step?, _ id: String) -> RemoteOutcome { + guard let step else { return RemoteOutcome(.failed, AppWords.text("That did not go through. Try again.")) } + let text = EVText.outcome(step, points.first { $0.id == id }).text + switch step.result { + case .applied?: return RemoteOutcome(.done, text) + case .unconfirmed?, nil: return RemoteOutcome(.pending, text) + case .superseded?, .refused?: return RemoteOutcome(.failed, text) + } + } + + /// Read what "Is the car ready?" needs: the chargers, and the plan's + /// shortfall when a car has a goal. False when the stream did not come + /// in time; the answer is then the last one this phone had. + public func refreshForAnswer(waitMs: Double = LoadpointsModel.remoteStreamWaitMs) async -> Bool { + guard await streaming(within: waitMs), site.hasPassthrough else { return false } + do { + try await loadChargers() + if points.contains(where: { $0.pluggedIn && $0.schedule != nil && !$0.manualActive }) { + try await readShortfall() + } + return true + } catch { + return false + } + } + + /// "Is the car ready?" from what the box last said. Locked, the answer + /// is the gist alone: no level, no time and no power. + public func answer(locked: Bool) -> CarAnswer { + guard let lp = points.first(where: \.pluggedIn) ?? points.first else { + return CarAnswer(gist: .noCharger, headline: Self.say("FTW has no charger to control."), details: [], readAtMs: readAtMs, loadpointID: nil, holding: false, canChargeNow: false) + } + let r = readiness(lp) + let holds = Self.holding(lp) + let gist: CarAnswer.Gist + if !lp.pluggedIn { + gist = .unplugged + } else if holds { + gist = .chargingNow + } else if EVText.isPaused(lp) { + gist = .paused + } else if let r { + switch r { + case .reached: gist = .ready + case .onTrack: gist = .onTrack + case .short: gist = .short + } + } else { + gist = .pluggedIn + } + var answer = CarAnswer(gist: gist, headline: Self.say(CarAnswer.gistWords[gist] ?? ""), details: [], readAtMs: readAtMs, loadpointID: lp.id, holding: holds, canChargeNow: lp.pluggedIn && !holds) + guard !locked else { return answer } + let level = level(lp) + switch gist { + case .ready, .onTrack, .short: + answer.headline = EVText.readiness(r!, level: level, nowMs: site.nowMs, zone: zone) + case .chargingNow, .paused: + answer.headline = EVText.manualStatus(lp) + default: + answer.headline = EVText.status(lp, zone: zone) + } + if let level { answer.details.append("\(AppWords.percent(level.pct)) · \(EVText.originShort(level, zone: zone))") } + if !holds, let plan = EVText.plan(lp, windows: windows[lp.id] ?? lp.planWindows, nowMs: site.nowMs, zone: zone) { + answer.details.append(plan) + } else if let goal = EVText.nextGoal(lp, nowMs: site.nowMs, zone: zone), gist == .pluggedIn { + answer.details.append(goal) + } + return answer + } + + /// True once the session streams, false if it has not within `ms`. + private func streaming(within ms: Double) async -> Bool { + if site.session.phase == .streaming { return true } + let wait = RemoteWait() + let ok = await withCheckedContinuation { cont in + wait.continuation = cont + wait.token = site.observe { [weak self, wait] in + if self?.site.session.phase == .streaming { wait.settle(true) } + } + wait.timer = site.scheduler.after(ms) { [wait] in wait.settle(false) } + } + if let token = wait.token { site.unobserve(token) } + return ok + } + + /// FTWKit's Swedish lives in `AppWords`; these few sentences are said + /// only outside the app. + nonisolated static func say(_ en: String) -> String { + AppWords.language == .sv ? (remoteSv[en] ?? en) : en + } + + nonisolated static let remoteSv: [String: String] = [ + "FTW has no charger to control.": "FTW har ingen laddare att styra.", + "The car is not plugged in.": "Bilen är inte inkopplad.", + "Charge now is on.": "Ladda nu är på.", + "Charging is paused.": "Laddningen är pausad.", + "The car is ready.": "Bilen är klar.", + "The car will be ready in time.": "Bilen blir klar i tid.", + "The car will not be ready in time.": "Bilen blir inte klar i tid.", + "The car is plugged in.": "Bilen är inkopplad.", + "Read %@.": "Läst %@.", + "Read %@": "Läst %@", + ] +} + +/// "Is the car ready?", answered for Siri and its card. +public struct CarAnswer: Equatable, Sendable { + public enum Gist: Equatable, Sendable { + case noCharger, unplugged, chargingNow, paused, ready, onTrack, short, pluggedIn + } + + public var gist: Gist + /// The answer in one sentence. + public var headline: String + /// The level and the plan, as the car view says them. Empty when locked. + public var details: [String] + /// When the box said it, so the card can date it. + public var readAtMs: Double? + public var loadpointID: String? + /// Charge now is on: the card offers Back to the plan. + public var holding: Bool + /// A car is plugged in and not held: the card offers Charge now. + public var canChargeNow: Bool + + /// Spoken: the answer, and its age once it is minutes old. + public func spoken(nowMs: Double) -> String { + guard let at = readAtMs, nowMs - at >= 5 * 60_000 else { return headline } + return headline + " " + LoadpointsModel.say("Read %@.").replacingOccurrences(of: "%@", with: PowerFormat.age(nowMs - at)) + } + + /// On the card: "Read just now", "Read 12 min ago". + public func readText(nowMs: Double) -> String? { + guard let at = readAtMs else { return nil } + return LoadpointsModel.say("Read %@").replacingOccurrences(of: "%@", with: PowerFormat.age(max(0, nowMs - at))) + } + + /// What a locked phone says. + static let gistWords: [Gist: String] = [ + .noCharger: "FTW has no charger to control.", + .unplugged: "The car is not plugged in.", + .chargingNow: "Charge now is on.", + .paused: "Charging is paused.", + .ready: "The car is ready.", + .onTrack: "The car will be ready in time.", + .short: "The car will not be ready in time.", + .pluggedIn: "The car is plugged in.", + ] +} + +/// One wait for the stream, settled once. The timer settles it even if the +/// model is gone, so an intent always ends. +@MainActor +private final class RemoteWait { + var continuation: CheckedContinuation? + var token: Int? + var timer: Cancellable? + + func settle(_ ok: Bool) { + guard let cont = continuation else { return } + continuation = nil + timer?.cancel() + cont.resume(returning: ok) + } } diff --git a/appleApp/FTWKit/Sources/FTWShared/ChargeNow.swift b/appleApp/FTWKit/Sources/FTWShared/ChargeNow.swift new file mode 100644 index 0000000..5c59ef5 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/ChargeNow.swift @@ -0,0 +1,150 @@ +import Foundation + +// "Charging now" outside the app: the Live Activity that reminds the owner +// that Charge now is on and has no time limit. The phone starts it when the +// box confirms Charge now, and ends it when the box confirms that charging +// went back to the plan. It shows the time the box confirmed it, a timer +// from then, and one button. It never shows power: a phone with its app +// asleep has no reading to show, and a figure it cannot vouch for would be +// invented. + +/// What the Live Activity holds, and how it moves on each answer from the +/// box. Plain values, so the app, the widget extension and a test all read +/// the same rules. +public enum ChargeNowActivity { + /// The Live Activity's dynamic state. Two times, both on the phone's + /// clock: when the box first confirmed Charge now, and when the phone + /// last saw it still on. + public struct State: Codable, Hashable, Sendable { + public var startedAtMs: Double + public var confirmedAtMs: Double + + public init(startedAtMs: Double, confirmedAtMs: Double) { + self.startedAtMs = startedAtMs + self.confirmedAtMs = confirmedAtMs + } + + public var startedAt: Date { Date(timeIntervalSince1970: startedAtMs / 1000) } + public var confirmedAt: Date { Date(timeIntervalSince1970: confirmedAtMs / 1000) } + + /// After this the phone can no longer vouch that charging is still + /// on: the car may have been unplugged, or someone went back to the + /// plan from another phone. The view then says when it was last + /// confirmed instead. + public var staleDate: Date { Date(timeIntervalSince1970: (confirmedAtMs + ChargeNowActivity.vouchMs) / 1000) } + } + + /// How long one confirmation is shown as current. Until the box can + /// push to the Live Activity (M7), nothing confirms it while the app + /// sleeps. + public static let vouchMs: Double = 60 * 60_000 + + /// What the box just said about one charger. + public enum Event: Equatable, Sendable { + /// The box confirmed Charge now: its `cmd.result`, or, when the + /// result never came, a read of the charger after this phone asked. + case started(atMs: Double) + /// The box confirmed Back to the plan or Pause. + case stopped + /// A read of the charger. `holding` is whether Charge now is on. + case seen(holding: Bool, atMs: Double) + } + + public enum Action: Equatable, Sendable { + case start(State) + case update(State) + case end + case none + } + + /// The next step for one charger's Live Activity. Only a confirmation + /// starts one: a read that finds Charge now on without this phone having + /// asked does not know when it started. + public static func next(_ current: State?, _ event: Event) -> Action { + switch (current, event) { + case (nil, .started(let at)): + return .start(State(startedAtMs: at, confirmedAtMs: at)) + case (nil, _): + return .none + case (let s?, .started(let at)), (let s?, .seen(true, let at)): + return .update(State(startedAtMs: s.startedAtMs, confirmedAtMs: max(s.confirmedAtMs, at))) + case (_?, .stopped), (_?, .seen(false, _)): + return .end + } + } + + // MARK: Words + + public static var title: String { ChargeNowWord.chargeNow.text } + public static var summary: String { ChargeNowWord.summary.text } + public static var backToPlan: String { ChargeNowWord.backToPlan.text } + public static var noTimeLimit: String { ChargeNowWord.noTimeLimit.text } + /// For a command the app cannot run yet: not paired, or no home open. + public static var openAndRetry: String { ChargeNowWord.openAndRetry.text } + + /// "Charging now since 14:02", on the household's clock. + public static func since(_ state: State, zone: TimeZone) -> String { + ChargeNowWord.since.format(clock(state.startedAtMs, zone: zone)) + } + + /// "Since 14:02", for the smallest places. + public static func sinceShort(_ state: State, zone: TimeZone) -> String { + ChargeNowWord.sinceShort.format(clock(state.startedAtMs, zone: zone)) + } + + /// After `staleDate`: when the phone last saw it on. + public static func confirmed(_ state: State, zone: TimeZone) -> String { + ChargeNowWord.confirmed.format(clock(state.confirmedAtMs, zone: zone)) + } + + static func clock(_ ms: Double, zone: TimeZone) -> String { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = zone + let c = cal.dateComponents([.hour, .minute], from: Date(timeIntervalSince1970: ms / 1000)) + return String(format: "%02d:%02d", c.hour ?? 0, c.minute ?? 0) + } +} + +/// Every sentence the Control and the Live Activity show. The English is +/// the key; the Swedish lives in `Resources/sv.lproj`, and a test checks +/// each one has it. +enum ChargeNowWord: String, CaseIterable { + case chargeNow = "Charge now" + case summary = "Starts charging the car at once, until you go back to the plan." + case backToPlan = "Back to the plan" + case noTimeLimit = "No time limit" + case since = "Charging now since %@" + case sinceShort = "Since %@" + case confirmed = "Last confirmed %@ · open FTW" + case openToCharge = "Open FTW to charge now." + case openAndRetry = "Open FTW, then try again." + + var text: String { Words.text(rawValue) } + + func format(_ arg: String) -> String { + String(format: text, arg) + } +} + +#if os(iOS) +import ActivityKit + +/// The Live Activity's attributes. One per charger and home; the zone is +/// the household's, so "since 14:02" reads as the box's own page would. +public struct ChargeNowAttributes: ActivityAttributes { + public typealias ContentState = ChargeNowActivity.State + + /// The site, or "demo" for the simulated home. + public var home: String + public var loadpointID: String + public var zoneID: String + + public init(home: String, loadpointID: String, zone: TimeZone) { + self.home = home + self.loadpointID = loadpointID + zoneID = zone.identifier + } + + public var zone: TimeZone { TimeZone(identifier: zoneID) ?? .current } +} +#endif diff --git a/appleApp/FTWKit/Sources/FTWShared/CommandIntents.swift b/appleApp/FTWKit/Sources/FTWShared/CommandIntents.swift new file mode 100644 index 0000000..f6b0f46 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/CommandIntents.swift @@ -0,0 +1,201 @@ +import AppIntents +import Foundation +import os +import Synchronization + +// The commands a person can give FTW without opening the app: Charge now +// from a Control, the Action button or Siri; Back to the plan from the Live +// Activity; the car's level from Siri. They live here because the widget +// extension draws the Control and the Live Activity and so must know the +// types. Neither runs here: the app installs a runner at launch, and only +// the app holds the key that reaches the box. +// +// Every one of them needs the phone unlocked (Fredrik, 12 Oct 2026). A +// watch unlocked on the wrist counts; the system decides that, not FTW. + +private let log = Logger(subsystem: "energy.ftw.app", category: "intents") + +/// What a person asked for from outside the app. +public enum RemoteCommand: Sendable, Equatable { + /// Charge now on the charger with a car, at its full current and with no + /// time limit. + case chargeNow + /// Release Charge now or Pause. Nil means the charger that holds one. + case backToPlan(loadpointID: String?) + /// Tell FTW how full the car is, in whole percent. + case setLevel(pct: Double) +} + +/// How a command ended, in a sentence ready to show or speak. +public struct RemoteOutcome: Sendable, Equatable { + public enum Kind: Sendable, Equatable { + /// The box did it. + case done + /// The box has it and has not confirmed it yet. Not a failure. + case pending + /// It did not happen, and `text` says what to do. + case failed + } + + public var kind: Kind + public var text: String + + public init(_ kind: Kind, _ text: String) { + self.kind = kind + self.text = text + } +} + +/// Runs a command over the app's session with the box. Only the app has +/// one. +public protocol CommandRunner: Sendable { + func run(_ command: RemoteCommand) async -> RemoteOutcome +} + +/// Where an intent finds the runner. Empty in the widget extension. +public enum CommandHost { + private static let installed = Mutex<(any CommandRunner)?>(nil) + + /// The app calls this once, at launch, before any intent can run. + public static func install(_ runner: any CommandRunner) { + installed.withLock { $0 = runner } + } + + public static var runner: (any CommandRunner)? { + installed.withLock { $0 } + } + + /// Run `command` and turn its outcome into a dialog, or into an error + /// the system shows. Without a runner this process is not the app; the + /// person is told to open it, and nothing is sent. + public static func answer(_ command: RemoteCommand, runner: (any CommandRunner)? = CommandHost.runner) async throws -> String { + guard let runner else { + log.fault("\(String(describing: command), privacy: .public) ran in \(Bundle.main.bundleIdentifier ?? "?", privacy: .public), which cannot reach the box") + throw IntentFailure(command == .chargeNow ? ChargeNowWord.openToCharge.text : ChargeNowWord.openAndRetry.text) + } + let out = await runner.run(command) + log.info("\(String(describing: command), privacy: .public): \(String(describing: out.kind), privacy: .public)") + guard out.kind != .failed else { throw IntentFailure(out.text) } + return out.text + } +} + +/// A command that did not happen, said as what to do now. +public struct IntentFailure: Error, Equatable, CustomLocalizedStringResourceConvertible { + public let text: String + + public init(_ text: String) { + self.text = text + } + + public var localizedStringResource: LocalizedStringResource { LocalizedStringResource(stringLiteral: text) } +} + +// MARK: - The intents + +/// Charge now: the charger's full current until Back to the plan or an +/// unplug. No time limit and no level gate, because the level is often a +/// guess (Fredrik, 4 Sep 2026). +/// +/// A Live Activity intent, because it starts one, and because the system +/// then runs it in the app's process even when the Control lives in the +/// widget extension. `supportedModes` keeps it in the background; iOS 27 +/// can say outright that it runs in the app. +public struct ChargeNowIntent: AppIntent { + public static let title: LocalizedStringResource = "Charge now" + public static let description = IntentDescription("Starts charging the car at once, until you go back to the plan.") + public static let authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication + public static let supportedModes: IntentModes = .background + + @available(iOS 27.0, macOS 27.0, watchOS 27.0, tvOS 27.0, visionOS 27.0, *) + public static var allowedExecutionTargets: IntentExecutionTargets { .main } + + public init() {} + + public var command: RemoteCommand { .chargeNow } + + public func perform() async throws -> some IntentResult & ProvidesDialog { + .result(dialog: IntentDialog(stringLiteral: try await CommandHost.answer(command))) + } +} + +/// Back to the plan: release Charge now or Pause, and let FTW decide again. +/// The Live Activity's button. +public struct BackToPlanIntent: AppIntent { + public static let title: LocalizedStringResource = "Back to the plan" + public static let description = IntentDescription("Ends Charge now or Pause. FTW decides when to charge again.") + public static let authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication + public static let supportedModes: IntentModes = .background + /// The Live Activity names its charger; Siri and Shortcuts do not. + public static let isDiscoverable = false + + @available(iOS 27.0, macOS 27.0, watchOS 27.0, tvOS 27.0, visionOS 27.0, *) + public static var allowedExecutionTargets: IntentExecutionTargets { .main } + + @Parameter(title: "Charger") + public var loadpointID: String? + + public init() {} + + public init(loadpointID: String?) { + self.loadpointID = loadpointID + } + + public var command: RemoteCommand { .backToPlan(loadpointID: loadpointID) } + + public func perform() async throws -> some IntentResult & ProvidesDialog { + .result(dialog: IntentDialog(stringLiteral: try await CommandHost.answer(command))) + } +} + +/// The car's level, for a car FTW cannot read: `loadpoint.soc.set`. The box +/// takes it only while the car is plugged in, and says so otherwise. +public struct SetLevelIntent: AppIntent { + public static let title: LocalizedStringResource = "Set the car's level" + public static let description = IntentDescription("Tells FTW how full the car's battery is, when FTW cannot read it from the car.") + public static let authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication + public static let supportedModes: IntentModes = .background + + @available(iOS 27.0, macOS 27.0, watchOS 27.0, tvOS 27.0, visionOS 27.0, *) + public static var allowedExecutionTargets: IntentExecutionTargets { .main } + + @Parameter(title: "Level") + public var level: CarLevel + + public static var parameterSummary: some ParameterSummary { + Summary("The car is at \(\.$level)") + } + + public init() {} + + public init(level: CarLevel) { + self.level = level + } + + public var command: RemoteCommand { .setLevel(pct: Double(level.rawValue)) } + + public func perform() async throws -> some IntentResult & ProvidesDialog { + .result(dialog: IntentDialog(stringLiteral: try await CommandHost.answer(command))) + } +} + +/// The levels Siri understands. Ten steps: a level said aloud is a rough +/// one, and the car view has ±5 for the rest. +public enum CarLevel: Int, AppEnum, CaseIterable, Sendable { + case p10 = 10, p20 = 20, p30 = 30, p40 = 40, p50 = 50, p60 = 60, p70 = 70, p80 = 80, p90 = 90, p100 = 100 + + public static let typeDisplayRepresentation: TypeDisplayRepresentation = "Level" + public static let caseDisplayRepresentations: [CarLevel: DisplayRepresentation] = [ + .p10: "10 %", .p20: "20 %", .p30: "30 %", .p40: "40 %", .p50: "50 %", + .p60: "60 %", .p70: "70 %", .p80: "80 %", .p90: "90 %", .p100: "100 %", + ] +} + +#if os(iOS) +extension ChargeNowIntent: LiveActivityIntent {} +extension BackToPlanIntent: LiveActivityIntent {} +#endif + +/// The intents above, for the app and the widget extension to list in +/// their own `AppIntentsPackage`. +public struct FTWSharedIntents: AppIntentsPackage {} diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings index 2fe7e81..4fb73ef 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings @@ -46,3 +46,14 @@ "incl. grid fee" = "inkl. nätavgift"; "spot price only" = "endast spotpris"; "incl. surcharges" = "inkl. påslag"; + +/* Charge now: the Control and the Live Activity */ +"Charge now" = "Ladda nu"; +"Starts charging the car at once, until you go back to the plan." = "Börjar ladda bilen direkt, tills du går tillbaka till planen."; +"Back to the plan" = "Tillbaka till planen"; +"No time limit" = "Ingen tidsgräns"; +"Charging now since %@" = "Laddar nu sedan %@"; +"Since %@" = "Sedan %@"; +"Last confirmed %@ · open FTW" = "Senast bekräftat %@ · öppna FTW"; +"Open FTW to charge now." = "Öppna FTW för att ladda nu."; +"Open FTW, then try again." = "Öppna FTW och försök igen."; diff --git a/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift new file mode 100644 index 0000000..f3a5246 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift @@ -0,0 +1,332 @@ +import Foundation +import Testing +@testable import FTWKit +import FTWDemo +import FTWShared + +/// Commands from outside the app (Control, Siri, the Live Activity) against +/// the simulated box: what goes on the wire, how each ends, and how the +/// Live Activity follows the box's `cmd.result`. In English unless a test +/// says otherwise. +@MainActor +@Suite struct RemoteTests { + @MainActor + final class Rig { + let scheduler: ManualScheduler + var box: SimulatedBox? + var wire: RecordingCarrier? + var app: AppModel! + let activities = FakeActivities() + + init(nowMs: Double = CarTextTests.t) { + scheduler = ManualScheduler(nowMs: nowMs) + app = AppModel(store: MemoryStore(), files: nil, passkeys: nil, scheduler: scheduler, build: "test", ua: "test", demo: { [unowned self] s in + let box = SimulatedBox(scheduler: s, house: SimulatedHouse(timeZone: CarTextTests.stockholm)) + box.resultDelayMs = 900 + box.house.chargerRampMs = 4_000 + self.box = box + let wire = RecordingCarrier(inner: LoopbackCarrier(box: box, scheduler: s)) + self.wire = wire + return wire + }) + } + + /// The demo home, with the fake Live Activity in place. + func start() throws -> LoadpointsModel { + app.startDemo() + let loadpoints = try #require(app.home?.loadpoints) + loadpoints.chargeNowHost = activities + return loadpoints + } + + func run(_ ms: Double, step: Double = 50) async { + var left = ms + while left > 0 { + for _ in 0..<10 { await Task.yield() } + scheduler.advance(min(step, left)) + left -= step + } + for _ in 0..<10 { await Task.yield() } + } + + /// Run a command the way an intent does, from a cold app: nothing + /// read yet, the stream not yet up. + func perform(_ command: RemoteCommand, _ loadpoints: LoadpointsModel, for ms: Double = 5_000) async -> RemoteOutcome { + let task = Task { await loadpoints.perform(command) } + await run(ms) + return await task.value + } + } + + // MARK: The intent's body on the wire + + /// Charge now from a Control: the charger's full current, held with no + /// time limit, valid for two minutes of box uptime. The box's result + /// starts the Live Activity at the moment it confirmed. + @Test func chargeNowFromOutsideSendsTheHoldAndStartsTheLiveActivity() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + let loadpoints = try rig.start() + let out = await rig.perform(.chargeNow, loadpoints) + #expect(out.kind == .done) + #expect(out.text == "FTW started charging. Waiting for the car to draw power.") + + let wire = try #require(rig.wire) + let cmd = try #require(wire.commands.last) + #expect(cmd.op == Contract.opLoadpointHold) + #expect(cmd.args["id"]?.string == "garage") + #expect(cmd.args["power_w"]?.double == 11_000) + #expect(cmd.args["hold_s"]?.double == 0) + #expect(cmd.args["phase_mode"]?.string == "3p") + #expect(cmd.args["clear"] == nil) + let valid = try #require(cmd.notValidAfterMs) + #expect(abs(valid - (cmd.uptimeMs + Session.cmdValidForMs)) < 1) + #expect(cmd.uptimeMs > 0) + #expect(rig.box?.house.manualHoldW == 11_000) + + // Started once, at the box's confirmation; the read after it + // found the hold and moved nothing back. + guard case .start(let s)? = rig.activities.log.first else { + Issue.record("no Live Activity: \(rig.activities.log)") + return + } + #expect(s.startedAtMs == s.confirmedAtMs) + #expect(s.startedAtMs > CarTextTests.t) + #expect(rig.activities.log.dropFirst().allSatisfy { if case .update = $0 { return true } else { return false } }) + #expect(rig.activities.states["demo/garage"]?.startedAtMs == s.startedAtMs) + } + } + + /// Back to the plan from the Live Activity's button: a clear, and the + /// Live Activity ends on the box's result. + @Test func backToThePlanClearsTheHoldAndEndsTheLiveActivity() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + let loadpoints = try rig.start() + _ = await rig.perform(.chargeNow, loadpoints) + #expect(rig.activities.states["demo/garage"] != nil) + + let out = await rig.perform(.backToPlan(loadpointID: "garage"), loadpoints, for: 3_000) + #expect(out.kind == .done) + #expect(out.text == "Back to the plan. FTW decides when to charge.") + let cmd = try #require(rig.wire?.commands.last) + #expect(cmd.op == Contract.opLoadpointHold) + #expect(cmd.args["clear"]?.bool == true) + #expect(cmd.args["id"]?.string == "garage") + #expect(rig.box?.house.manualHoldW == nil) + #expect(rig.activities.log.last == .end) + #expect(rig.activities.states["demo/garage"] == nil) + } + } + + /// A read that no longer finds the hold ends the Live Activity: someone + /// went back to the plan elsewhere. + @Test func aReadWithoutTheHoldEndsTheLiveActivity() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + let loadpoints = try rig.start() + _ = await rig.perform(.chargeNow, loadpoints) + #expect(rig.activities.states["demo/garage"] != nil) + rig.box?.house.manualHoldW = nil + let read = Task { try? await loadpoints.loadChargers() } + await rig.run(1_000) + await read.value + #expect(rig.activities.log.last == .end) + } + } + + /// Charge now in the car view moves the same Live Activity. + @Test func chargeNowInTheAppStartsItToo() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + let loadpoints = try rig.start() + loadpoints.activate() + await rig.run(1_500) + let car = try #require(loadpoints.points.first) + let tap = Task { await loadpoints.chargeNow(car) } + await rig.run(3_000) + await tap.value + guard case .start? = rig.activities.log.first else { + Issue.record("no Live Activity: \(rig.activities.log)") + return + } + let pause = Task { await loadpoints.pause(car) } + await rig.run(3_000) + await pause.value + #expect(rig.activities.log.last == .end) + } + } + + /// Received and never confirmed, and the charger does not hold it: no + /// Live Activity, and the answer says the box has not confirmed. + @Test func anUnconfirmedChargeNowStartsNothing() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + let loadpoints = try rig.start() + rig.box?.neverConfirm = true + let out = await rig.perform(.chargeNow, loadpoints, for: 22_000) + #expect(out.kind == .pending) + #expect(out.text == "FTW received it but has not confirmed the result yet.") + #expect(rig.activities.log.isEmpty) + } + } + + /// The level from Siri: `loadpoint.soc.set` as a fraction. + @Test func theLevelFromSiriIsAFraction() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + let loadpoints = try rig.start() + let out = await rig.perform(.setLevel(pct: 40), loadpoints) + #expect(out.kind == .done) + #expect(out.text == "FTW now plans from 40\u{00A0}%.") + let cmd = try #require(rig.wire?.commands.last) + #expect(cmd.op == Contract.opLoadpointSocSet) + #expect(cmd.args["soc"]?.double == 0.4) + #expect(cmd.args["id"]?.string == "garage") + #expect(rig.activities.log.isEmpty) + } + } + + /// The box refuses a level for a car that is not plugged in; Siri says + /// what to do. + @Test func aRefusalIsAFailureThatSaysWhatToDo() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + let loadpoints = try rig.start() + rig.box?.setCarPluggedIn(false) + let out = await rig.perform(.setLevel(pct: 40), loadpoints) + #expect(out.kind == .failed) + #expect(out.text == "Plug the car in first. FTW takes a level only while the car is plugged in.") + } + } + + /// No stream in time: nothing is sent and the answer says so. + @Test func noStreamSendsNothing() async { + await AppWords.$language.withValue(.en) { + let scheduler = ManualScheduler(nowMs: CarTextTests.t) + let site = SiteModel(siteId: "home", build: "test", ua: "test", scheduler: scheduler, files: nil) + let loadpoints = LoadpointsModel(site: site, charging: nil, cache: nil) + let task = Task { await loadpoints.perform(.chargeNow, waitMs: 2_000) } + for _ in 0..<50 { + for _ in 0..<5 { await Task.yield() } + scheduler.advance(100) + } + let out = await task.value + #expect(out.kind == .failed) + #expect(out.text == "Not connected to FTW right now. Try again when the connection is back.") + } + } + + // MARK: Is the car ready? + + /// Unlocked, the car view's sentences; locked, the gist alone, with no + /// level, time or power. + @Test func isTheCarReadyHidesTheDetailsWhenLocked() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + let loadpoints = try rig.start() + let read = Task { await loadpoints.refreshForAnswer() } + await rig.run(3_000) + #expect(await read.value) + + let open = loadpoints.answer(locked: false) + #expect(open.gist == .pluggedIn || open.gist == .onTrack || open.gist == .short || open.gist == .ready) + #expect(open.details.first?.hasPrefix("20\u{00A0}%") == true) + #expect(open.canChargeNow) + #expect(!open.holding) + + let locked = loadpoints.answer(locked: true) + #expect(locked.gist == open.gist) + #expect(locked.details.isEmpty) + #expect(!locked.headline.contains("%")) + #expect(!locked.headline.contains(":")) + + _ = await rig.perform(.chargeNow, loadpoints) + let charging = loadpoints.answer(locked: true) + #expect(charging.gist == .chargingNow) + #expect(charging.headline == "Charge now is on.") + #expect(charging.holding) + #expect(!charging.canChargeNow) + #expect(loadpoints.answer(locked: false).headline != charging.headline) + + rig.box?.setCarPluggedIn(false) + let unplug = Task { try? await loadpoints.loadChargers() } + await rig.run(1_000) + await unplug.value + #expect(loadpoints.answer(locked: true).headline == "The car is not plugged in.") + } + } + + @Test func theGistInSwedish() { + AppWords.$language.withValue(.sv) { + for (gist, en) in CarAnswer.gistWords { + let sv = LoadpointsModel.say(en) + #expect(sv != en, "no Swedish for \(gist)") + } + #expect(LoadpointsModel.say("The car is ready.") == "Bilen är klar.") + } + } +} + +// MARK: - Test doubles + +/// The Live Activity, as a list of what the model asked for. +@MainActor +final class FakeActivities: ChargeNowHost { + var states: [String: ChargeNowActivity.State] = [:] + var log: [ChargeNowActivity.Action] = [] + + func current(home: String, loadpointID: String) -> ChargeNowActivity.State? { + states["\(home)/\(loadpointID)"] + } + + func apply(_ action: ChargeNowActivity.Action, home: String, loadpointID: String, zone: TimeZone) { + log.append(action) + switch action { + case .start(let s), .update(let s): states["\(home)/\(loadpointID)"] = s + case .end: states["\(home)/\(loadpointID)"] = nil + case .none: break + } + } +} + +/// A carrier that passes everything through and keeps every `cmd` the app +/// sends, decoded, with the session's uptime when it went. +@MainActor +final class RecordingCarrier: Carrier { + struct Command { + var op: String + var args: CBOR + var notValidAfterMs: Double? + var uptimeMs: Double + } + + let inner: Carrier + private(set) var commands: [Command] = [] + /// Box uptime as the frames say, for the command's validity window. + private var uptimeMs: Double = 0 + + init(inner: Carrier) { + self.inner = inner + } + + var kind: CarrierKind { inner.kind } + var status: CarrierStatus { inner.status } + + func send(_ frame: Bytes) { + if let d = try? Frame.decode(frame), d.envelope.t == "cmd", let b = d.envelope.b { + commands.append(Command(op: b["op"]?.string ?? "", args: b["args"] ?? CBOR.emptyMap, notValidAfterMs: b["notValidAfterMs"]?.double, uptimeMs: uptimeMs)) + } + inner.send(frame) + } + + func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void) { + inner.setHandlers(onFrame: { [weak self] bytes in + if let d = try? Frame.decode(bytes), let up = d.envelope.b?["uptimeMs"]?.double { self?.uptimeMs = up } + onFrame(bytes) + }, onStatus: onStatus) + } + + func wake() { inner.wake() } + func close(reason: String) { inner.close(reason: reason) } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/ChargeNowTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/ChargeNowTests.swift new file mode 100644 index 0000000..8a01f8f --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/ChargeNowTests.swift @@ -0,0 +1,135 @@ +import AppIntents +import Foundation +import Testing +@testable import FTWShared + +/// Charge now outside the app: the Live Activity's state from the box's +/// answers, the rule that every command needs the phone unlocked, and what +/// each intent asks the app to send. In English, the development language. +@Suite struct ChargeNowTests { + /// 2026-10-12 12:02:00 UTC, 14:02 in Stockholm. + static let t: Double = 1_791_806_520_000 + static let stockholm = TimeZone(identifier: "Europe/Stockholm")! + + // MARK: The Live Activity + + /// Only the box's confirmation starts it; a read never does, because a + /// read cannot say when Charge now began. + @Test func onlyAConfirmationStartsIt() { + let t = Self.t + #expect(ChargeNowActivity.next(nil, .started(atMs: t)) == .start(.init(startedAtMs: t, confirmedAtMs: t))) + #expect(ChargeNowActivity.next(nil, .seen(holding: true, atMs: t)) == .none) + #expect(ChargeNowActivity.next(nil, .seen(holding: false, atMs: t)) == .none) + #expect(ChargeNowActivity.next(nil, .stopped) == .none) + } + + /// A read that finds it still on moves the confirmation and keeps the + /// start; a second Charge now does the same. + @Test func aReadKeepsTheStartAndMovesTheConfirmation() { + let t = Self.t + let on = ChargeNowActivity.State(startedAtMs: t, confirmedAtMs: t) + #expect(ChargeNowActivity.next(on, .seen(holding: true, atMs: t + 600_000)) == .update(.init(startedAtMs: t, confirmedAtMs: t + 600_000))) + #expect(ChargeNowActivity.next(on, .started(atMs: t + 60_000)) == .update(.init(startedAtMs: t, confirmedAtMs: t + 60_000))) + // A read older than the confirmation does not move it back. + #expect(ChargeNowActivity.next(.init(startedAtMs: t, confirmedAtMs: t + 60_000), .seen(holding: true, atMs: t)) == .update(.init(startedAtMs: t, confirmedAtMs: t + 60_000))) + } + + /// Back to the plan, Pause, an unplug or a read without the hold end it. + @Test func backToThePlanEndsIt() { + let on = ChargeNowActivity.State(startedAtMs: Self.t, confirmedAtMs: Self.t) + #expect(ChargeNowActivity.next(on, .stopped) == .end) + #expect(ChargeNowActivity.next(on, .seen(holding: false, atMs: Self.t + 1)) == .end) + } + + /// The phone vouches for an hour after the box last confirmed it. + @Test func itGoesStaleAnHourAfterTheLastConfirmation() { + let s = ChargeNowActivity.State(startedAtMs: Self.t, confirmedAtMs: Self.t + 600_000) + #expect(s.staleDate.timeIntervalSince1970 * 1000 == Self.t + 600_000 + 3_600_000) + #expect(s.startedAt.timeIntervalSince1970 * 1000 == Self.t) + } + + /// Static words and the household's clock; no power, ever. + @Test func theWordsSayWhenNotHowMuch() { + let s = ChargeNowActivity.State(startedAtMs: Self.t, confirmedAtMs: Self.t + 1_800_000) + #expect(ChargeNowActivity.since(s, zone: Self.stockholm) == "Charging now since 14:02") + #expect(ChargeNowActivity.sinceShort(s, zone: Self.stockholm) == "Since 14:02") + #expect(ChargeNowActivity.confirmed(s, zone: Self.stockholm) == "Last confirmed 14:32 · open FTW") + #expect(ChargeNowActivity.since(s, zone: TimeZone(identifier: "UTC")!) == "Charging now since 12:02") + #expect(ChargeNowActivity.title == "Charge now") + #expect(ChargeNowActivity.backToPlan == "Back to the plan") + for word in ChargeNowWord.allCases { + #expect(!word.rawValue.contains("kW")) + } + } + + @Test func everyChargeNowWordHasSwedish() throws { + let url = try #require(Words.bundle.url(forResource: "Localizable", withExtension: "strings", subdirectory: nil, localization: "sv")) + let table = try #require(NSDictionary(contentsOf: url) as? [String: String]) + for word in ChargeNowWord.allCases { + let sv = try #require(table[word.rawValue], "no Swedish for \"\(word.rawValue)\"") + #expect(sv.components(separatedBy: "%@").count == word.rawValue.components(separatedBy: "%@").count) + #expect(sv != word.rawValue) + } + #expect(table["Charging now since %@"] == "Laddar nu sedan %@") + #expect(table["Back to the plan"] == "Tillbaka till planen") + } + + // MARK: Unlock + + /// Every command from the lock screen, Siri, a Control or the Live + /// Activity needs the phone unlocked, and runs in the background + /// without opening the app. + @Test func everyCommandNeedsTheUnlockedPhone() { + #expect(ChargeNowIntent.authenticationPolicy == .requiresAuthentication) + #expect(BackToPlanIntent.authenticationPolicy == .requiresAuthentication) + #expect(SetLevelIntent.authenticationPolicy == .requiresAuthentication) + #expect(ChargeNowIntent.supportedModes == .background) + #expect(BackToPlanIntent.supportedModes == .background) + #expect(SetLevelIntent.supportedModes == .background) + } + + // MARK: What each intent sends + + @Test func eachIntentAsksForItsCommand() { + #expect(ChargeNowIntent().command == .chargeNow) + #expect(BackToPlanIntent(loadpointID: "garage").command == .backToPlan(loadpointID: "garage")) + #expect(BackToPlanIntent().command == .backToPlan(loadpointID: nil)) + #expect(SetLevelIntent(level: .p40).command == .setLevel(pct: 40)) + #expect(CarLevel.allCases.map(\.rawValue) == Array(stride(from: 10, through: 100, by: 10))) + } + + /// Without the app's runner nothing is sent, and the person is told to + /// open FTW. With it, the box's sentence is the answer, and a refusal + /// is an error the system shows. + @Test func theAnswerComesFromTheApp() async throws { + await #expect(throws: IntentFailure("Open FTW to charge now.")) { + try await CommandHost.answer(.chargeNow, runner: nil) + } + await #expect(throws: IntentFailure("Open FTW, then try again.")) { + try await CommandHost.answer(.backToPlan(loadpointID: nil), runner: nil) + } + let done = FakeRunner(RemoteOutcome(.done, "FTW started charging.")) + #expect(try await CommandHost.answer(.chargeNow, runner: done) == "FTW started charging.") + #expect(await done.asked == [.chargeNow]) + let pending = FakeRunner(RemoteOutcome(.pending, "FTW received it.")) + #expect(try await CommandHost.answer(.setLevel(pct: 40), runner: pending) == "FTW received it.") + let refused = FakeRunner(RemoteOutcome(.failed, "Plug the car in first.")) + await #expect(throws: IntentFailure("Plug the car in first.")) { + try await CommandHost.answer(.setLevel(pct: 40), runner: refused) + } + } +} + +private actor FakeRunner: CommandRunner { + let outcome: RemoteOutcome + var asked: [RemoteCommand] = [] + + init(_ outcome: RemoteOutcome) { + self.outcome = outcome + } + + func run(_ command: RemoteCommand) async -> RemoteOutcome { + asked.append(command) + return outcome + } +} diff --git a/appleApp/FTWWidgets/Control/ChargeNowControl.swift b/appleApp/FTWWidgets/Control/ChargeNowControl.swift new file mode 100644 index 0000000..9b99ae3 --- /dev/null +++ b/appleApp/FTWWidgets/Control/ChargeNowControl.swift @@ -0,0 +1,32 @@ +import AppIntents +import FTWShared +import SwiftUI +import WidgetKit + +/// Charge now, as a button in Control Center, on the Lock Screen and on the +/// Action button. iPhone's controls also appear on a paired Apple Watch on +/// watchOS 26, where a press runs on the iPhone. +/// +/// The control only draws. Its intent is a Live Activity intent, so the +/// system runs it in the app, which holds the key that reaches the box. If +/// a system ever ran it here, it would send nothing and say "Open FTW to +/// charge now". +struct ChargeNowControl: ControlWidget { + static let kind = "energy.ftw.charge-now" + + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: Self.kind) { + ControlWidgetButton(action: ChargeNowIntent()) { + Label(ChargeNowActivity.title, systemImage: "bolt.car.fill") + } + } + .displayName(LocalizedStringResource(stringLiteral: ChargeNowActivity.title)) + .description(LocalizedStringResource(stringLiteral: ChargeNowActivity.summary)) + } +} + +/// The shared intents, listed for this extension's App Intents metadata: +/// a control's intent must be known to the app and to the extension. +struct FTWWidgetIntents: AppIntentsPackage { + static var includedPackages: [any AppIntentsPackage.Type] { [FTWSharedIntents.self] } +} diff --git a/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift b/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift new file mode 100644 index 0000000..72ad23f --- /dev/null +++ b/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift @@ -0,0 +1,162 @@ +import ActivityKit +import FTWShared +import SwiftUI +import WidgetKit + +/// "Charging now since 14:02", with a timer from then and Back to the plan. +/// On the Lock Screen and in the Dynamic Island, and through `.small` on a +/// paired Apple Watch's Smart Stack and in CarPlay. +/// +/// Static words and a timer only. The phone starts this when the box +/// confirms Charge now and ends it when the box confirms Back to the plan; +/// in between nothing reads the charger, so nothing here claims power or a +/// level. After the stale date it says when the box last confirmed it. +struct ChargeNowLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: ChargeNowAttributes.self) { context in + ChargeNowBanner(attributes: context.attributes, state: context.state, isStale: context.isStale) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Label { + Text(ChargeNowActivity.title) + } icon: { + Bolt() + } + .font(.headline) + .padding(.leading, 4) + } + DynamicIslandExpandedRegion(.trailing) { + Elapsed(state: context.state) + .font(.title3.weight(.semibold)) + .padding(.trailing, 4) + } + DynamicIslandExpandedRegion(.bottom) { + VStack(alignment: .leading, spacing: 8) { + Subtitle(attributes: context.attributes, state: context.state, isStale: context.isStale) + BackToPlan(loadpointID: context.attributes.loadpointID) + } + .padding(.horizontal, 4) + } + } compactLeading: { + Bolt() + } compactTrailing: { + Elapsed(state: context.state) + .frame(maxWidth: 58) + } minimal: { + Bolt() + } + // The island is always dark. + .keylineTint(Tone.accent.dark) + } + .supplementalActivityFamilies([.small]) + } +} + +/// The Lock Screen's banner, and the watch's and CarPlay's smaller one. +struct ChargeNowBanner: View { + let attributes: ChargeNowAttributes + let state: ChargeNowActivity.State + let isStale: Bool + @Environment(\.activityFamily) private var family + @Environment(\.isLuminanceReduced) private var dim + + var body: some View { + switch family { + case .small: small + default: medium + } + } + + private var medium: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center, spacing: 12) { + Bolt().font(.title2) + VStack(alignment: .leading, spacing: 2) { + Text(ChargeNowActivity.since(state, zone: attributes.zone)) + .font(.headline) + Subtitle(attributes: attributes, state: state, isStale: isStale) + } + Spacer(minLength: 8) + Elapsed(state: state) + .font(.title2.weight(.semibold)) + .frame(maxWidth: 110, alignment: .trailing) + } + if !dim { + BackToPlan(loadpointID: attributes.loadpointID) + } + } + .padding(16) + } + + /// Smart Stack on the watch and CarPlay: the same facts, tighter. + private var small: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Bolt() + Text(ChargeNowActivity.title).font(.headline) + Spacer(minLength: 4) + Elapsed(state: state).font(.headline) + } + Text(isStale ? ChargeNowActivity.confirmed(state, zone: attributes.zone) : ChargeNowActivity.sinceShort(state, zone: attributes.zone)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + if !dim { + BackToPlan(loadpointID: attributes.loadpointID) + .font(.caption) + } + } + .padding(10) + } +} + +/// "No time limit", or, once stale, when the box last confirmed it. +private struct Subtitle: View { + let attributes: ChargeNowAttributes + let state: ChargeNowActivity.State + let isStale: Bool + + var body: some View { + Text(isStale ? ChargeNowActivity.confirmed(state, zone: attributes.zone) : ChargeNowActivity.noTimeLimit) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } +} + +/// Time since the box confirmed Charge now, counting up on its own. +private struct Elapsed: View { + let state: ChargeNowActivity.State + + var body: some View { + Text(timerInterval: state.startedAt...Date.distantFuture, countsDown: false) + .monospacedDigit() + .multilineTextAlignment(.trailing) + .foregroundStyle(Tone.accent) + } +} + +private struct Bolt: View { + @Environment(\.isLuminanceReduced) private var dim + + var body: some View { + Image(systemName: "bolt.car.fill") + .foregroundStyle(dim ? AnyShapeStyle(.secondary) : AnyShapeStyle(Tone.accent)) + } +} + +/// The one command here. It asks for the unlocked phone and runs in the +/// app, which ends this Live Activity when the box confirms. +private struct BackToPlan: View { + let loadpointID: String + + var body: some View { + Button(intent: BackToPlanIntent(loadpointID: loadpointID)) { + Label(ChargeNowActivity.backToPlan, systemImage: "calendar") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(Tone.accent) + } +} diff --git a/appleApp/FTWWidgets/PriceWidget.swift b/appleApp/FTWWidgets/PriceWidget.swift index bb50c0f..687c92e 100644 --- a/appleApp/FTWWidgets/PriceWidget.swift +++ b/appleApp/FTWWidgets/PriceWidget.swift @@ -10,6 +10,8 @@ private let signposter = OSSignposter(subsystem: "energy.ftw.app", category: "wi struct FTWWidgets: WidgetBundle { var body: some Widget { PriceWidget() + ChargeNowControl() + ChargeNowLiveActivity() } } From 0e84efa02983d819e1370362fd8fc4fcf327c532 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 21:00:19 +0200 Subject: [PATCH 02/21] 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 5bf1d43d3003f6b93cd8504d4f4a9be17f3448c3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 21:02:43 +0200 Subject: [PATCH 03/21] iPhone M2: no AppIntentsPackage, one-line Live Activity, demo flag iOS 26.5's linkd treats an app whose metadata lists an included package (extract.packagedata) as having no App Intents at all (aggregateMetadataIsEmpty), so Siri, Shortcuts and the Control had nothing to run. Xcode 27 already copies the FTWShared intents into the app's and the extension's metadata without it, and iOS 27 reads both. The Live Activity's title stays on one line, and the expanded island puts it below the camera. -FTWDemoChargeNow presses Charge now in the demo for screenshots (Debug only). Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Intents/IntentRunner.swift | 12 +++-- .../Sources/FTWShared/CommandIntents.swift | 4 -- .../FTWWidgets/Control/ChargeNowControl.swift | 6 --- .../LiveActivity/ChargeNowLiveActivity.swift | 53 +++++++++++++------ 4 files changed, 44 insertions(+), 31 deletions(-) diff --git a/appleApp/FTW/Intents/IntentRunner.swift b/appleApp/FTW/Intents/IntentRunner.swift index ca2fdf6..5e0d139 100644 --- a/appleApp/FTW/Intents/IntentRunner.swift +++ b/appleApp/FTW/Intents/IntentRunner.swift @@ -33,6 +33,13 @@ final class IntentRunner: CommandRunner { shared = runner CommandHost.install(runner) LoadpointsModel.sharedChargeNowHost = ChargeNowActivities() + #if DEBUG + // For screenshots: `-FTWDemo YES -FTWDemoChargeNow YES` presses + // Charge now the way the Control does, once the demo streams. + if UserDefaults.standard.bool(forKey: "FTWDemoChargeNow") { + Task { _ = await runner.run(.chargeNow) } + } + #endif } nonisolated func run(_ command: RemoteCommand) async -> RemoteOutcome { @@ -103,8 +110,3 @@ enum DeviceLock { } } } - -/// The shared intents, listed for this app's App Intents metadata. -struct FTWAppIntents: AppIntentsPackage { - static var includedPackages: [any AppIntentsPackage.Type] { [FTWSharedIntents.self] } -} diff --git a/appleApp/FTWKit/Sources/FTWShared/CommandIntents.swift b/appleApp/FTWKit/Sources/FTWShared/CommandIntents.swift index f6b0f46..816519f 100644 --- a/appleApp/FTWKit/Sources/FTWShared/CommandIntents.swift +++ b/appleApp/FTWKit/Sources/FTWShared/CommandIntents.swift @@ -195,7 +195,3 @@ public enum CarLevel: Int, AppEnum, CaseIterable, Sendable { extension ChargeNowIntent: LiveActivityIntent {} extension BackToPlanIntent: LiveActivityIntent {} #endif - -/// The intents above, for the app and the widget extension to list in -/// their own `AppIntentsPackage`. -public struct FTWSharedIntents: AppIntentsPackage {} diff --git a/appleApp/FTWWidgets/Control/ChargeNowControl.swift b/appleApp/FTWWidgets/Control/ChargeNowControl.swift index 9b99ae3..cbe70ca 100644 --- a/appleApp/FTWWidgets/Control/ChargeNowControl.swift +++ b/appleApp/FTWWidgets/Control/ChargeNowControl.swift @@ -24,9 +24,3 @@ struct ChargeNowControl: ControlWidget { .description(LocalizedStringResource(stringLiteral: ChargeNowActivity.summary)) } } - -/// The shared intents, listed for this extension's App Intents metadata: -/// a control's intent must be known to the app and to the extension. -struct FTWWidgetIntents: AppIntentsPackage { - static var includedPackages: [any AppIntentsPackage.Type] { [FTWSharedIntents.self] } -} diff --git a/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift b/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift index 72ad23f..66ce56e 100644 --- a/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift +++ b/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift @@ -17,26 +17,26 @@ struct ChargeNowLiveActivity: Widget { ChargeNowBanner(attributes: context.attributes, state: context.state, isStale: context.isStale) } dynamicIsland: { context in DynamicIsland { + // The icon and the timer either side of the camera, the + // words below it, where they have the whole width. DynamicIslandExpandedRegion(.leading) { - Label { - Text(ChargeNowActivity.title) - } icon: { - Bolt() - } - .font(.headline) - .padding(.leading, 4) + Bolt() + .font(.title2) + .padding(.leading, 6) } DynamicIslandExpandedRegion(.trailing) { Elapsed(state: context.state) .font(.title3.weight(.semibold)) - .padding(.trailing, 4) + .padding(.trailing, 6) } DynamicIslandExpandedRegion(.bottom) { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Since(attributes: context.attributes, state: context.state) Subtitle(attributes: context.attributes, state: context.state, isStale: context.isStale) BackToPlan(loadpointID: context.attributes.loadpointID) + .padding(.top, 8) } - .padding(.horizontal, 4) + .padding(.horizontal, 6) } } compactLeading: { Bolt() @@ -63,7 +63,7 @@ struct ChargeNowBanner: View { var body: some View { switch family { - case .small: small + case .small: ChargeNowSmall(attributes: attributes, state: state, isStale: isStale) default: medium } } @@ -73,14 +73,15 @@ struct ChargeNowBanner: View { HStack(alignment: .center, spacing: 12) { Bolt().font(.title2) VStack(alignment: .leading, spacing: 2) { - Text(ChargeNowActivity.since(state, zone: attributes.zone)) - .font(.headline) + Since(attributes: attributes, state: state) Subtitle(attributes: attributes, state: state, isStale: isStale) } + // The words first; the timer takes what is left. + .layoutPriority(1) Spacer(minLength: 8) Elapsed(state: state) .font(.title2.weight(.semibold)) - .frame(maxWidth: 110, alignment: .trailing) + .frame(maxWidth: 96, alignment: .trailing) } if !dim { BackToPlan(loadpointID: attributes.loadpointID) @@ -88,9 +89,16 @@ struct ChargeNowBanner: View { } .padding(16) } +} + +/// Smart Stack on the watch and CarPlay: the same facts, tighter. +struct ChargeNowSmall: View { + let attributes: ChargeNowAttributes + let state: ChargeNowActivity.State + let isStale: Bool + @Environment(\.isLuminanceReduced) private var dim - /// Smart Stack on the watch and CarPlay: the same facts, tighter. - private var small: some View { + var body: some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { Bolt() @@ -111,6 +119,19 @@ struct ChargeNowBanner: View { } } +/// "Charging now since 14:02", on one line. +private struct Since: View { + let attributes: ChargeNowAttributes + let state: ChargeNowActivity.State + + var body: some View { + Text(ChargeNowActivity.since(state, zone: attributes.zone)) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.8) + } +} + /// "No time limit", or, once stale, when the box last confirmed it. private struct Subtitle: View { let attributes: ChargeNowAttributes From b0c2a2d582f9cafa6cc3dc548ffe0f7030bcd92f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 21:41:46 +0200 Subject: [PATCH 04/21] iPhone M2: Siri levels are the car view's chips; the card says what changed CarLevel is 20/40/60/80, as the car view's chips: each case is a tile in Shortcuts and Spotlight, and ten were clutter. The card under "Is the car ready?" no longer repeats the answer Siri just gave; it shows the headline again only once a button on it has changed the state. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Intents/CarIntents.swift | 27 ++++++++++++++----- .../FTW/Intents/sv.lproj/Localizable.strings | 1 + .../Sources/FTWShared/CommandIntents.swift | 10 +++---- .../Tests/FTWSharedTests/ChargeNowTests.swift | 3 ++- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/appleApp/FTW/Intents/CarIntents.swift b/appleApp/FTW/Intents/CarIntents.swift index 76f3a53..326018a 100644 --- a/appleApp/FTW/Intents/CarIntents.swift +++ b/appleApp/FTW/Intents/CarIntents.swift @@ -22,33 +22,46 @@ struct CarStatusIntent: AppIntent { throw IntentFailure(ChargeNowActivity.openAndRetry) } let nowMs = Date.now.timeIntervalSince1970 * 1000 - return .result(dialog: IntentDialog(stringLiteral: answer.spoken(nowMs: nowMs)), snippetIntent: CarCardIntent()) + return .result(dialog: IntentDialog(stringLiteral: answer.spoken(nowMs: nowMs)), snippetIntent: CarCardIntent(said: answer.headline)) } } /// The card under the answer. The system runs it again after a button on -/// it, so it shows the box's newest account. +/// it, so it shows the box's newest account. The answer Siri gave sits +/// above the card; the card repeats it only once it has changed. struct CarCardIntent: SnippetIntent { static let title: LocalizedStringResource = "The car" static let isDiscoverable = false + @Parameter(title: "Said") + var said: String? + + init() {} + + init(said: String) { + self.said = said + } + @MainActor func perform() async throws -> some IntentResult & ShowsSnippetView { - .result(view: CarCard(answer: IntentRunner.shared?.currentAnswer(), nowMs: Date.now.timeIntervalSince1970 * 1000)) + .result(view: CarCard(answer: IntentRunner.shared?.currentAnswer(), said: said, nowMs: Date.now.timeIntervalSince1970 * 1000)) } } struct CarCard: View { let answer: CarAnswer? + let said: String? let nowMs: Double var body: some View { VStack(alignment: .leading, spacing: 10) { if let answer { - Label { - Text(answer.headline).font(.headline) - } icon: { - Image(systemName: Self.symbol(answer.gist)).foregroundStyle(.tint) + if answer.headline != said { + Label { + Text(answer.headline).font(.headline) + } icon: { + Image(systemName: Self.symbol(answer.gist)).foregroundStyle(.tint) + } } if !answer.details.isEmpty { VStack(alignment: .leading, spacing: 2) { diff --git a/appleApp/FTW/Intents/sv.lproj/Localizable.strings b/appleApp/FTW/Intents/sv.lproj/Localizable.strings index 257d205..4cc8d23 100644 --- a/appleApp/FTW/Intents/sv.lproj/Localizable.strings +++ b/appleApp/FTW/Intents/sv.lproj/Localizable.strings @@ -14,3 +14,4 @@ "Says whether the car will be ready in time, from what your box says now. A locked iPhone gives the gist only." = "Säger om bilen blir klar i tid, utifrån vad din box säger nu. En låst iPhone svarar utan detaljer."; "The car" = "Bilen"; "Car's level" = "Bilens nivå"; +"Said" = "Sagt"; diff --git a/appleApp/FTWKit/Sources/FTWShared/CommandIntents.swift b/appleApp/FTWKit/Sources/FTWShared/CommandIntents.swift index 816519f..83d8d26 100644 --- a/appleApp/FTWKit/Sources/FTWShared/CommandIntents.swift +++ b/appleApp/FTWKit/Sources/FTWShared/CommandIntents.swift @@ -179,15 +179,15 @@ public struct SetLevelIntent: AppIntent { } } -/// The levels Siri understands. Ten steps: a level said aloud is a rough -/// one, and the car view has ±5 for the rest. +/// The levels Siri understands: the car view's chips. Each one is also a +/// tile in Shortcuts and Spotlight, so there are few; the car view has ±5 +/// for the rest. public enum CarLevel: Int, AppEnum, CaseIterable, Sendable { - case p10 = 10, p20 = 20, p30 = 30, p40 = 40, p50 = 50, p60 = 60, p70 = 70, p80 = 80, p90 = 90, p100 = 100 + case p20 = 20, p40 = 40, p60 = 60, p80 = 80 public static let typeDisplayRepresentation: TypeDisplayRepresentation = "Level" public static let caseDisplayRepresentations: [CarLevel: DisplayRepresentation] = [ - .p10: "10 %", .p20: "20 %", .p30: "30 %", .p40: "40 %", .p50: "50 %", - .p60: "60 %", .p70: "70 %", .p80: "80 %", .p90: "90 %", .p100: "100 %", + .p20: "20 %", .p40: "40 %", .p60: "60 %", .p80: "80 %", ] } diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/ChargeNowTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/ChargeNowTests.swift index 8a01f8f..8988c3d 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/ChargeNowTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/ChargeNowTests.swift @@ -95,7 +95,8 @@ import Testing #expect(BackToPlanIntent(loadpointID: "garage").command == .backToPlan(loadpointID: "garage")) #expect(BackToPlanIntent().command == .backToPlan(loadpointID: nil)) #expect(SetLevelIntent(level: .p40).command == .setLevel(pct: 40)) - #expect(CarLevel.allCases.map(\.rawValue) == Array(stride(from: 10, through: 100, by: 10))) + // The car view's chips, and no more tiles than those. + #expect(CarLevel.allCases.map(\.rawValue) == [20, 40, 60, 80]) } /// Without the app's runner nothing is sent, and the person is told to From c22250d3de352e0dc58e309aca3262587eac1abc Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 21:47:26 +0200 Subject: [PATCH 05/21] iPhone M2: the Live Activity's small view fits the watch's card On a watch's Smart Stack the card is narrow: the timer takes the top line next to the icon, and "Charging now since 14:02" the line below. Co-Authored-By: Claude Opus 5.5 --- .../LiveActivity/ChargeNowLiveActivity.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift b/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift index 66ce56e..2749330 100644 --- a/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift +++ b/appleApp/FTWWidgets/LiveActivity/ChargeNowLiveActivity.swift @@ -100,16 +100,20 @@ struct ChargeNowSmall: View { var body: some View { VStack(alignment: .leading, spacing: 4) { + // The watch's card is narrow: the timer gets the top line, the + // words the one below it. HStack(spacing: 6) { Bolt() - Text(ChargeNowActivity.title).font(.headline) - Spacer(minLength: 4) - Elapsed(state: state).font(.headline) + Elapsed(state: state) + .font(.headline) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .trailing) } - Text(isStale ? ChargeNowActivity.confirmed(state, zone: attributes.zone) : ChargeNowActivity.sinceShort(state, zone: attributes.zone)) + Text(isStale ? ChargeNowActivity.confirmed(state, zone: attributes.zone) : ChargeNowActivity.since(state, zone: attributes.zone)) .font(.caption) .foregroundStyle(.secondary) - .lineLimit(1) + .lineLimit(2) + .minimumScaleFactor(0.85) if !dim { BackToPlan(loadpointID: attributes.loadpointID) .font(.caption) From 2c946bffa670819bf8729e18c9a664f3dfa0e94f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 21:51:33 +0200 Subject: [PATCH 06/21] iPhone M2: build with the iOS 26 SDK too The PR check builds with Xcode 26.6, whose SDK has no IntentExecutionTargets. The iOS 27 declaration now needs Swift 6.4, which ships with that SDK; the release build (Xcode 27.0) keeps it. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Intents/CarIntents.swift | 2 ++ appleApp/FTWKit/Sources/FTWShared/CommandIntents.swift | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/appleApp/FTW/Intents/CarIntents.swift b/appleApp/FTW/Intents/CarIntents.swift index 326018a..22c9e03 100644 --- a/appleApp/FTW/Intents/CarIntents.swift +++ b/appleApp/FTW/Intents/CarIntents.swift @@ -13,8 +13,10 @@ struct CarStatusIntent: AppIntent { static let authenticationPolicy: IntentAuthenticationPolicy = .alwaysAllowed static let supportedModes: IntentModes = .background + #if compiler(>=6.4) @available(iOS 27.0, *) static var allowedExecutionTargets: IntentExecutionTargets { .main } + #endif @MainActor func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetIntent { diff --git a/appleApp/FTWKit/Sources/FTWShared/CommandIntents.swift b/appleApp/FTWKit/Sources/FTWShared/CommandIntents.swift index 83d8d26..dcdbd1d 100644 --- a/appleApp/FTWKit/Sources/FTWShared/CommandIntents.swift +++ b/appleApp/FTWKit/Sources/FTWShared/CommandIntents.swift @@ -100,15 +100,18 @@ public struct IntentFailure: Error, Equatable, CustomLocalizedStringResourceConv /// A Live Activity intent, because it starts one, and because the system /// then runs it in the app's process even when the Control lives in the /// widget extension. `supportedModes` keeps it in the background; iOS 27 -/// can say outright that it runs in the app. +/// can say outright that it runs in the app. That declaration needs the +/// iOS 27 SDK (Swift 6.4); an older Xcode, as in the PR check, leaves it out. public struct ChargeNowIntent: AppIntent { public static let title: LocalizedStringResource = "Charge now" public static let description = IntentDescription("Starts charging the car at once, until you go back to the plan.") public static let authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication public static let supportedModes: IntentModes = .background + #if compiler(>=6.4) @available(iOS 27.0, macOS 27.0, watchOS 27.0, tvOS 27.0, visionOS 27.0, *) public static var allowedExecutionTargets: IntentExecutionTargets { .main } + #endif public init() {} @@ -129,8 +132,10 @@ public struct BackToPlanIntent: AppIntent { /// The Live Activity names its charger; Siri and Shortcuts do not. public static let isDiscoverable = false + #if compiler(>=6.4) @available(iOS 27.0, macOS 27.0, watchOS 27.0, tvOS 27.0, visionOS 27.0, *) public static var allowedExecutionTargets: IntentExecutionTargets { .main } + #endif @Parameter(title: "Charger") public var loadpointID: String? @@ -156,8 +161,10 @@ public struct SetLevelIntent: AppIntent { public static let authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication public static let supportedModes: IntentModes = .background + #if compiler(>=6.4) @available(iOS 27.0, macOS 27.0, watchOS 27.0, tvOS 27.0, visionOS 27.0, *) public static var allowedExecutionTargets: IntentExecutionTargets { .main } + #endif @Parameter(title: "Level") public var level: CarLevel From f69ffabd24b0457e29d7d0aed5ca61ebd4af77aa Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 07:48:48 +0200 Subject: [PATCH 07/21] 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 8c8299d4ff2d0a8107394da30137e7d7bceed2c1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 08:05:20 +0200 Subject: [PATCH 08/21] iPhone M3: check a push, write its words, pick its buttons FTWShared reads a push from FTW Cloud the way the Go reference (cloudwire.ReadAlert) does: a strict CBOR reader for the envelope and the event, ES256 over the bytes received with the box key the phone holds, the box clock checked against the phone's, and the catalogue's kinds only. The tests replay all 14 push vectors and the 16 messages of cloud-vectors.json from srcfl/ftw, then sign edge cases with the vector box key. PushText writes every kind in Swedish and English; the English is the push catalogue's, copied byte for byte to protocol/push-catalogue.yaml and held to it by a test. PushActions picks buttons from a verified kind alone. CloudKeys keeps each home's box key and id tables in the App Group, PushSeen drops a repeated or older seq per home and boot. The app's own strings carry the generic line under the payload's loc keys, for when the extension does not run. Co-Authored-By: Claude Opus 5.5 --- .../FTW/Intents/en.lproj/Localizable.strings | 5 + .../FTW/Intents/sv.lproj/Localizable.strings | 5 + appleApp/FTWKit/Package.swift | 13 +- .../FTWKit/Sources/FTWShared/CloudKeys.swift | 99 ++++++ .../FTWKit/Sources/FTWShared/PushAlert.swift | 240 ++++++++++++++ .../FTWKit/Sources/FTWShared/PushCBOR.swift | 159 +++++++++ .../FTWKit/Sources/FTWShared/PushNotice.swift | 135 ++++++++ .../FTWKit/Sources/FTWShared/PushSeen.swift | 66 ++++ .../FTWKit/Sources/FTWShared/PushText.swift | 134 ++++++++ .../Tests/FTWSharedTests/PushAlertTests.swift | 304 ++++++++++++++++++ .../Tests/FTWSharedTests/PushStoreTests.swift | 98 ++++++ .../Tests/FTWSharedTests/PushTextTests.swift | 235 ++++++++++++++ .../Resources/cloud-vectors.json | 196 +++++++++++ .../Resources/push-vectors.json | 181 +++++++++++ protocol/push-catalogue.yaml | 62 ++++ 15 files changed, 1928 insertions(+), 4 deletions(-) create mode 100644 appleApp/FTW/Intents/en.lproj/Localizable.strings create mode 100644 appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/PushAlert.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/PushCBOR.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/PushNotice.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/PushSeen.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/PushText.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/PushAlertTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/PushStoreTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/PushTextTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/Resources/cloud-vectors.json create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/Resources/push-vectors.json create mode 100644 protocol/push-catalogue.yaml diff --git a/appleApp/FTW/Intents/en.lproj/Localizable.strings b/appleApp/FTW/Intents/en.lproj/Localizable.strings new file mode 100644 index 0000000..0ce5681 --- /dev/null +++ b/appleApp/FTW/Intents/en.lproj/Localizable.strings @@ -0,0 +1,5 @@ +/* A push from FTW Cloud, as Apple shows it if the notification extension + does not run. The extension writes every other line itself. Elsewhere + the English is the key, so nothing else is listed here. */ +"push.title" = "Something happened at home"; +"push.generic" = "Open FTW to see what."; diff --git a/appleApp/FTW/Intents/sv.lproj/Localizable.strings b/appleApp/FTW/Intents/sv.lproj/Localizable.strings index 4cc8d23..5778c0b 100644 --- a/appleApp/FTW/Intents/sv.lproj/Localizable.strings +++ b/appleApp/FTW/Intents/sv.lproj/Localizable.strings @@ -15,3 +15,8 @@ "The car" = "Bilen"; "Car's level" = "Bilens nivå"; "Said" = "Sagt"; + +/* A push from FTW Cloud, as Apple shows it if the notification extension + does not run. The extension writes every other line itself. */ +"push.title" = "Något hände hemma"; +"push.generic" = "Öppna FTW för att se vad."; diff --git a/appleApp/FTWKit/Package.swift b/appleApp/FTWKit/Package.swift index 4ac35c7..2246ba1 100644 --- a/appleApp/FTWKit/Package.swift +++ b/appleApp/FTWKit/Package.swift @@ -4,9 +4,10 @@ // // - FTWKit: pairing, the passkey derivations, Noise IK, frames, the relay, // the session and the state each screen reads. The app alone links it. -// - FTWShared: plain `Sendable` values the app shares with its widgets and, -// later, a notification extension. The App Group cache, its records and -// the formatters a widget needs. Nothing on the main actor, no keys. +// - FTWShared: plain `Sendable` values the app shares with its widgets and +// its notification extension. The App Group cache, its records, the +// formatters a widget needs, and the check and the words of a push. +// Nothing on the main actor, no secret keys. // - FTWDemo: a simulated box in this process, for the demo, App Review and // previews. Kept out of FTWKit so the product module holds no fake box. // @@ -33,6 +34,10 @@ let package = Package( dependencies: ["FTWKit", "FTWDemo"], resources: [.copy("Resources")] ), - .testTarget(name: "FTWSharedTests", dependencies: ["FTWShared"]), + .testTarget( + name: "FTWSharedTests", + dependencies: ["FTWShared"], + resources: [.copy("Resources")] + ), ] ) diff --git a/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift b/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift new file mode 100644 index 0000000..bc07dfc --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift @@ -0,0 +1,99 @@ +import Foundation + +/// What this phone holds about one home for FTW Cloud's pushes: the key the +/// box signs events with, which paired home it is, and the names behind the +/// opaque ids an event uses. The app writes it when the box describes its +/// FTW Cloud link over Noise; the notification extension reads it. +/// +/// Nothing here is a secret. A public key checks signatures and cannot make +/// one, and the names are what the box's own page shows. +public struct CloudHome: Codable, Sendable, Equatable { + /// The home's id at FTW Cloud: 26 characters of lowercase base32. + public var home: String + /// The paired home on this phone that it belongs to. + public var siteId: String + /// The box's cloud key, a 65-byte uncompressed P-256 point. + public var boxKey: Data + /// Opaque loadpoint id → the loadpoint's id at the box. + public var loadpoints: [String: String] + /// Opaque source id → the name the box shows for the device. + public var sources: [String: String] + + public init(home: String, siteId: String, boxKey: Data, loadpoints: [String: String] = [:], sources: [String: String] = [:]) { + self.home = home + self.siteId = siteId + self.boxKey = boxKey + self.loadpoints = loadpoints + self.sources = sources + } + + /// The charger an event means. An event that names none means the only + /// one, if the home has one. An id the table does not know is not + /// guessed at: the table may predate a second charger. + public func loadpointID(opaque: String?) -> String? { + guard let opaque else { return loadpoints.count == 1 ? loadpoints.values.first : nil } + return loadpoints[opaque] + } + + public func sourceName(opaque: String?) -> String? { + opaque.flatMap { sources[$0] } + } +} + +/// The App Group files that hold each home's `CloudHome`, beside the +/// widgets' cache, so signing out clears them with it. The app writes, the +/// notification extension reads. Like the cache they carry +/// `completeUntilFirstUserAuthentication`: a push that arrives while the +/// phone is locked can be checked, one before the first unlock cannot. +public struct CloudKeys: Sendable { + public let directory: URL + + public init(directory: URL) { + self.directory = directory + } + + /// In the App Group container, when this process has the group. + public static func shared(bundle: Bundle = .main) -> CloudKeys? { + AppGroupFiles.shared(bundle: bundle).map { CloudKeys(directory: $0.directory) } + } + + public struct Refused: Error, Equatable { + public let reason: String + } + + /// A home id as FTW Cloud cuts it. Anything else never becomes a file + /// name: the id arrives in a push, and a push is not trusted. + public static func isHomeID(_ s: String) -> Bool { + s.utf8.count == 26 && s.utf8.allSatisfy { (0x61...0x7a).contains($0) || (0x32...0x37).contains($0) } + } + + func url(_ home: String) -> URL { + directory.appendingPathComponent("cloud-\(home)") + } + + public func read(home: String) -> CloudHome? { + guard Self.isHomeID(home), + let data = try? Data(contentsOf: url(home)), + let row = try? JSONDecoder().decode(CloudHome.self, from: data), row.home == home else { return nil } + return row + } + + /// The box key for a home, for `PushAlert.read`. + public func boxKey(home: String) -> Data? { + read(home: home)?.boxKey + } + + /// Only the app writes. Atomic, so the extension never reads half a file. + public func write(_ row: CloudHome) throws { + guard Self.isHomeID(row.home) else { throw Refused(reason: "not a home id") } + guard row.boxKey.count == 65, row.boxKey.first == 0x04 else { throw Refused(reason: "not an uncompressed P-256 key") } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try JSONEncoder().encode(row).write(to: url(row.home), options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + /// FTW Cloud was turned off for this home, or the phone left it. + public func remove(home: String) { + guard Self.isHomeID(home) else { return } + try? FileManager.default.removeItem(at: url(home)) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift b/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift new file mode 100644 index 0000000..44dcd44 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift @@ -0,0 +1,240 @@ +import CryptoKit +import Foundation + +// A push from FTW Cloud, as the phone checks it. The box signs every event; +// the service wraps the box's envelope, unchanged, in an APNs alert. The +// phone's notification extension checks the signature with the box key it +// holds (CloudKeys), writes its own text from the kind (PushText) and picks +// the buttons from a verified kind alone (PushActions). The service picks +// nothing: not the words, not the buttons, not the priority. +// +// This is a port of ReadAlert in srcfl/ftw, go/protocol/cloudwire/push.go, +// and docs/protocol-cloud.md, "Push", is the normative text. The tests +// replay the Go side's push vectors case by case. + +/// The kinds in the push catalogue (`protocol/push-catalogue.yaml`). +public enum PushKind { + public static let chargingConnected = "charging.connected" + public static let chargingSessionComplete = "charging.session_complete" + public static let chargingGoalAtRisk = "charging.goal_at_risk" + public static let chargingInterrupted = "charging.interrupted" + public static let updateInstalled = "update.installed" + public static let driverOffline = "driver.offline" + public static let fuseOverLimit = "fuse.over_limit" + /// The one alert the service writes itself: a box that cannot reach the + /// service cannot sign one. + public static let boxUnreachable = "box.unreachable" + public static let cloudGranted = "cloud.granted" + public static let cloudRevoked = "cloud.revoked" + + /// In the catalogue's order. + public static let all = [ + chargingConnected, chargingSessionComplete, chargingGoalAtRisk, chargingInterrupted, updateInstalled, + driverOffline, fuseOverLimit, boxUnreachable, cloudGranted, cloudRevoked, + ] +} + +/// What a push says once checked. Only `read` makes one, so a kind that has +/// not been verified cannot reach the text or the buttons. +public struct PushAlert: Sendable, Equatable { + public enum Verdict: String, Sendable { + /// Signed by the box, anchored in time and fresh, of a kind the + /// catalogue has. The phone writes its text and offers its buttons. + case event + /// The service's box.unreachable. Its own text, no buttons. + case unreachable + /// Anything else: the generic line, no buttons. + case generic + } + + public let verdict: Verdict + /// The home the payload names. Empty when the payload could not be read. + public let home: String + // The rest is set only for `.event`. + public let kind: String + public let args: [String: String] + /// The opaque id of the device the event concerns. + public let src: String? + /// The opaque id of the loadpoint it concerns. + public let lp: String? + public let boot: Data? + public let seq: UInt64 + /// When it happened by the phone's reading of the box clock: never + /// later than it arrived. + public let atMs: Int64 + + init(_ verdict: Verdict, home: String, kind: String = "", args: [String: String] = [:], src: String? = nil, lp: String? = nil, boot: Data? = nil, seq: UInt64 = 0, atMs: Int64 = 0) { + self.verdict = verdict + self.home = home + self.kind = kind + self.args = args + self.src = src + self.lp = lp + self.boot = boot + self.seq = seq + self.atMs = atMs + } + + /// The payload key the alert sits under, beside Apple's "aps". + public static let key = "ftw" + static let version = 1 + /// How far ahead of the phone a box clock may run before the phone + /// stops trusting it. + static let clockLeadMs: Int64 = 2_000 + /// How old an event may be and still get its text and buttons. + static let maxAgeMs: Int64 = 10 * 60_000 + + /// Check a push as the notification extension receives it. + /// `boxKey` is the key the phone holds for a home, nil when it holds + /// none; `nowMs` is the phone's clock when the push arrived. Whether an + /// event was seen before is `PushSeen`'s question, not this one's. + public static func read(userInfo: [AnyHashable: Any], boxKey: (String) -> Data?, nowMs: Int64) -> PushAlert { + read(ftw: userInfo[key], boxKey: boxKey, nowMs: nowMs) + } + + /// The same, from the payload's JSON as APNs sends it. + public static func read(payload: Data, boxKey: (String) -> Data?, nowMs: Int64) -> PushAlert { + let top = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any] + return read(ftw: top?[key], boxKey: boxKey, nowMs: nowMs) + } + + /// The service's part of the payload. + private struct Wire: Decodable { + var v: Int + var home: String? + /// The box's envelope, base64 with padding. + var event: Data? + var kind: String? + } + + static func read(ftw: Any?, boxKey: (String) -> Data?, nowMs: Int64) -> PushAlert { + guard let ftw, JSONSerialization.isValidJSONObject(ftw), + let json = try? JSONSerialization.data(withJSONObject: ftw), + let a = try? JSONDecoder().decode(Wire.self, from: json), a.v == version else { + return PushAlert(.generic, home: "") + } + let home = a.home ?? "" + let generic = PushAlert(.generic, home: home) + guard let event = a.event, !event.isEmpty else { + return a.kind == PushKind.boxUnreachable ? PushAlert(.unreachable, home: home) : generic + } + guard a.kind == nil || a.kind == "", + let env = Envelope(Array(event)), env.t == "event", + let pk = boxKey(home), verify(pk, home: home, env), + let ev = Event(env.b), let wallMs = ev.wallMs, wallMs <= nowMs &+ clockLeadMs else { + return generic + } + var at = min(wallMs, nowMs) + if let ts = ev.ts { at = min(at, ts) } + let age = nowMs.subtractingReportingOverflow(at) + guard PushKind.all.contains(ev.kind), ev.kind != PushKind.boxUnreachable, !age.overflow, age.partialValue <= maxAgeMs else { + return generic + } + return PushAlert(.event, home: home, kind: ev.kind, args: ev.args, src: ev.src, lp: ev.lp, boot: ev.boot.map { Data($0) }, seq: ev.seq, atMs: at) + } + + /// ES256 over the bytes received, never over a re-encoding. The key is + /// the box's 65-byte uncompressed P-256 point. + static func verify(_ pk: Data, home: String, _ env: Envelope) -> Bool { + guard pk.count == 65, pk.first == 0x04, env.sig.count == 64, + let key = try? P256.Signing.PublicKey(x963Representation: pk), + let sig = try? P256.Signing.ECDSASignature(rawRepresentation: env.sig) else { return false } + return key.isValidSignature(sig, for: PushCBOR.signedInput(home: home, t: env.t, b: env.b)) + } + + /// One message: {t, b, sig}. `b` is the body's exact bytes, so the + /// signature covers what was sent. + struct Envelope { + var t: String + var b: [UInt8] + var sig: [UInt8] + + /// t must be text and b and sig byte strings; unknown keys are + /// ignored. + init?(_ raw: [UInt8]) { + guard let m = (try? PushCBOR.decode(raw))?.textMap, + case .text(let t)? = m["t"], !t.isEmpty, + case .bytes(let b)? = m["b"], + case .bytes(let sig)? = m["sig"] else { return nil } + self.t = t + self.b = b + self.sig = sig + } + } + + /// The event body. A field may be missing or null; one of the wrong + /// type makes the whole body unreadable, as it does in Go. + struct Event { + var seq: UInt64 = 0 + var boot: [UInt8]? + var wallMs: Int64? + var ts: Int64? + var kind = "" + var args: [String: String] = [:] + var src: String? + var lp: String? + + init?(_ b: [UInt8]) { + guard let m = (try? PushCBOR.decode(b))?.textMap else { return nil } + func field(_ key: String, _ read: (PushCBOR) -> T?) throws -> T? { + guard let v = m[key], v != .null else { return nil } + guard let out = read(v) else { throw PushCBOR.Malformed() } + return out + } + do { + seq = try field("seq", \.uint) ?? 0 + boot = try field("boot", \.byteString) + _ = try field("uptimeMs", \.int64) + wallMs = try field("wallMs", \.int64) + _ = try field("eventId", \.text) + ts = try field("ts", \.int64) + kind = try field("kind", \.text) ?? "" + _ = try field("severity", \.text) + args = try field("args", \.textOfText) ?? [:] + src = try field("src", \.text) + lp = try field("lp", \.text) + } catch { + return nil + } + } + } +} + +extension PushCBOR { + var uint: UInt64? { + if case .unsigned(let u) = self { return u } + return nil + } + + var int64: Int64? { + switch self { + case .unsigned(let u): return Int64(exactly: u) + case .negative(let n): return Int64(exactly: n).map { -1 - $0 } + default: return nil + } + } + + var text: String? { + if case .text(let s) = self { return s } + return nil + } + + var byteString: [UInt8]? { + if case .bytes(let b) = self { return b } + return nil + } + + /// A text-to-text map. A null value reads as empty text, as in Go. + var textOfText: [String: String]? { + guard let m = textMap else { return nil } + var out = [String: String]() + for (k, v) in m { + switch v { + case .text(let s): out[k] = s + case .null: out[k] = "" + default: return nil + } + } + return out + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PushCBOR.swift b/appleApp/FTWKit/Sources/FTWShared/PushCBOR.swift new file mode 100644 index 0000000..b59f8df --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PushCBOR.swift @@ -0,0 +1,159 @@ +import Foundation + +/// The CBOR a push carries: the box's envelope and the event inside it. +/// +/// The notification extension does not link FTWKit, so it reads with this +/// small reader instead. It is strict where FTWKit's is lenient: definite +/// lengths only, text must be UTF-8, a key may appear once, and nothing may +/// follow the value. The box writes core-deterministic CBOR, so a genuine +/// event never needs more; anything else is shown as the generic line. +indirect enum PushCBOR: Equatable, Sendable { + case unsigned(UInt64) + /// The value is `-1 - n`. + case negative(UInt64) + case bytes([UInt8]) + case text(String) + case array([PushCBOR]) + case map([Entry]) + case tagged(UInt64, PushCBOR) + /// Null and undefined. Go reads either as "not there". + case null + /// Booleans, floats and other simple values: skipped, never read. + case simple + + struct Entry: Equatable, Sendable { + var key: PushCBOR + var value: PushCBOR + } + + struct Malformed: Error {} + + /// Exactly one value, and nothing after it. + static func decode(_ bytes: [UInt8]) throws -> PushCBOR { + var r = Reader(bytes: bytes) + let value = try r.read(depth: 0) + guard r.at == bytes.count else { throw Malformed() } + return value + } + + /// A map whose keys are all text, as a dictionary. Nil for anything + /// else; a duplicate key never gets this far. + var textMap: [String: PushCBOR]? { + guard case .map(let entries) = self else { return nil } + var out = [String: PushCBOR]() + for e in entries { + guard case .text(let k) = e.key else { return nil } + out[k] = e.value + } + return out + } + + private struct Reader { + let bytes: [UInt8] + var at = 0 + + /// Deeper than any event, shallow enough that a hostile one cannot + /// exhaust the stack. + static let maxDepth = 16 + + mutating func byte() throws -> UInt8 { + guard at < bytes.count else { throw Malformed() } + defer { at += 1 } + return bytes[at] + } + + mutating func take(_ n: UInt64) throws -> [UInt8] { + guard n <= UInt64(bytes.count - at) else { throw Malformed() } + let end = at + Int(n) + defer { at = end } + return Array(bytes[at.. UInt64 { + switch info { + case 0..<24: return UInt64(info) + case 24: return UInt64(try byte()) + case 25: return try take(2).reduce(0) { $0 << 8 | UInt64($1) } + case 26: return try take(4).reduce(0) { $0 << 8 | UInt64($1) } + case 27: return try take(8).reduce(0) { $0 << 8 | UInt64($1) } + // 28–30 are reserved; 31 is an indefinite length. + default: throw Malformed() + } + } + + mutating func read(depth: Int) throws -> PushCBOR { + guard depth < Self.maxDepth else { throw Malformed() } + let initial = try byte() + let major = initial >> 5 + let info = initial & 0x1f + switch major { + case 0: return .unsigned(try argument(info)) + case 1: return .negative(try argument(info)) + case 2: return .bytes(try take(try argument(info))) + case 3: + guard let s = String(validating: try take(try argument(info)), as: UTF8.self) else { throw Malformed() } + return .text(s) + case 4: + let n = try argument(info) + // Each item takes at least a byte. + guard n <= UInt64(bytes.count - at) else { throw Malformed() } + return .array(try (0.. [UInt8] { + var out = Array("ftw.cloud.v1\u{0}".utf8) + out.append(0x83) + head(3, UInt64(home.utf8.count), into: &out) + out.append(contentsOf: home.utf8) + head(3, UInt64(t.utf8.count), into: &out) + out.append(contentsOf: t.utf8) + head(2, UInt64(b.count), into: &out) + out.append(contentsOf: b) + return out + } + + /// A head in its shortest form, as deterministic encoding asks. + private static func head(_ major: UInt8, _ n: UInt64, into out: inout [UInt8]) { + let m = major << 5 + switch n { + case 0..<24: out.append(m | UInt8(n)) + case 24...0xff: out += [m | 24, UInt8(n)] + case 0x100...0xffff: out += [m | 25, UInt8(n >> 8), UInt8(n & 0xff)] + case 0x1_0000...0xffff_ffff: out += [m | 26] + (0..<4).reversed().map { UInt8(truncatingIfNeeded: n >> ($0 * 8)) } + default: out += [m | 27] + (0..<8).reversed().map { UInt8(truncatingIfNeeded: n >> ($0 * 8)) } + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PushNotice.swift b/appleApp/FTWKit/Sources/FTWShared/PushNotice.swift new file mode 100644 index 0000000..f781f59 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PushNotice.swift @@ -0,0 +1,135 @@ +import Foundation +import UserNotifications + +/// The buttons a push may carry, and what each one asks the box. +/// +/// Only a kind the box signed picks them; the service never does, so the +/// extension always sets the category itself, empty when there are none. +/// Every command from a notification needs the phone unlocked (Fredrik, +/// 12 Oct 2026), and runs in the background over the app's own session +/// with the box, as Siri's do. +public enum PushActions { + public enum Action: String, CaseIterable, Sendable { + case level20 = "ftw.level.20" + case level50 = "ftw.level.50" + case level80 = "ftw.level.80" + case chargeNow = "ftw.chargeNow" + case open = "ftw.open" + } + + public enum Category: String, CaseIterable, Sendable { + /// The car was plugged in: tell FTW how full it is. + case level = "ftw.level" + /// The car may not be ready: start charging at once. + case chargeNow = "ftw.chargeNow" + + public var actions: [Action] { + switch self { + case .level: return [.level20, .level50, .level80, .open] + case .chargeNow: return [.chargeNow, .open] + } + } + } + + /// Few kinds get buttons, and only from a verified event. + public static func category(for alert: PushAlert) -> Category? { + guard alert.verdict == .event else { return nil } + switch alert.kind { + case PushKind.chargingConnected: return .level + case PushKind.chargingInterrupted, PushKind.chargingGoalAtRisk: return .chargeNow + default: return nil + } + } + + /// Whether a button may run for this alert: it has to be one its + /// verified kind gets. + public static func allows(_ action: Action, for alert: PushAlert) -> Bool { + category(for: alert)?.actions.contains(action) ?? false + } + + /// What the button asks the box. Open asks nothing; it opens the app. + public static func command(_ action: Action) -> RemoteCommand? { + switch action { + case .level20: return .setLevel(pct: 20) + case .level50: return .setLevel(pct: 50) + case .level80: return .setLevel(pct: 80) + case .chargeNow: return .chargeNow + case .open: return nil + } + } + + /// Whether the button needs the app on screen. The others run behind + /// the lock screen once the phone is unlocked. + public static func opensApp(_ action: Action) -> Bool { + action == .open + } + + public static func title(_ action: Action, _ language: PushText.Language) -> String { + switch action { + case .level20: return "20 %" + case .level50: return "50 %" + case .level80: return "80 %" + case .chargeNow: return language == .sv ? "Ladda nu" : "Charge now" + case .open: return language == .sv ? "Öppna" : "Open" + } + } + + public static func symbol(_ action: Action) -> String { + switch action { + case .level20: return "battery.25percent" + case .level50: return "battery.50percent" + case .level80: return "battery.75percent" + case .chargeNow: return "bolt.fill" + case .open: return "arrow.up.forward.app" + } + } +} + +/// Everything the extension sets on a notification, from one checked alert. +public struct PushNotice: Sendable, Equatable { + public var title: String + public var body: String + /// "" for no buttons. Always set, so a category the service put in the + /// payload never shows. + public var category: String + public var level: UNNotificationInterruptionLevel + /// One thread per home. + public var thread: String + /// The checked fields, for the app. The app checks the payload again + /// before it acts and never trusts these alone. + public var verified: [String: String] + + public init(_ alert: PushAlert, home: CloudHome?, language: PushText.Language, decimalSeparator: String = Locale.current.decimalSeparator ?? ".") { + let words = PushText.content(alert, sourceName: home?.sourceName(opaque: alert.src), language: language, decimalSeparator: decimalSeparator) + title = words.title + body = words.body + category = PushActions.category(for: alert)?.rawValue ?? "" + level = Self.level(alert) + thread = alert.home + verified = alert.verdict == .generic ? [:] : ["verdict": alert.verdict.rawValue, "home": alert.home] + if alert.verdict == .event { + verified["kind"] = alert.kind + verified["seq"] = String(alert.seq) + verified["atMs"] = String(alert.atMs) + if let lp = home?.loadpointID(opaque: alert.lp) { verified["loadpointID"] = lp } + } + } + + /// The generic line, for an extension that ran out of time. + public static func generic(_ language: PushText.Language) -> PushNotice { + PushNotice(PushAlert(.generic, home: ""), home: nil, language: language) + } + + /// The key `verified` sits under in the notification's userInfo. + public static let userInfoKey = "ftwVerified" + + /// The payload never says how urgent it is. A box out of reach and a + /// charge that stopped early break through a Focus; the rest wait. + static func level(_ alert: PushAlert) -> UNNotificationInterruptionLevel { + switch alert.verdict { + case .unreachable: return .timeSensitive + case .event where alert.kind == PushKind.chargingInterrupted: return .timeSensitive + default: return .active + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PushSeen.swift b/appleApp/FTWKit/Sources/FTWShared/PushSeen.swift new file mode 100644 index 0000000..af67a2b --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PushSeen.swift @@ -0,0 +1,66 @@ +import Foundation +import Synchronization + +/// Events this phone has shown, so a push that arrives twice is shown once. +/// +/// The box numbers what it sends: `seq` rises by one per message within a +/// boot. Per home and boot the extension keeps the highest `seq` it showed; +/// an event at or below it is a duplicate or older than one already shown. +/// A new boot starts its own count. The file keeps the most recent few +/// boots and is in the App Group, beside the cache. +public struct PushSeen: Sendable { + public let file: URL + /// Homes times boots worth remembering. Each box starts a new boot on + /// every restart; the oldest goes first. + public static let limit = 32 + + public init(file: URL) { + self.file = file + } + + public static func shared(bundle: Bundle = .main) -> PushSeen? { + AppGroupFiles.shared(bundle: bundle).map { PushSeen(file: $0.directory.appendingPathComponent("push-seen")) } + } + + struct Entry: Codable, Equatable { + var home: String + var boot: Data + var seq: UInt64 + /// The phone's clock when it was last moved, to drop the oldest. + var atMs: Double + } + + /// Whether to show this alert. A new event is remembered before the + /// answer, so a second copy that arrives meanwhile is not. Only events + /// carry a number; the rest are always shown. If the file cannot be + /// written the event is shown anyway: a duplicate is better than a + /// missed alert. + public func accept(_ alert: PushAlert, nowMs: Double) -> Bool { + guard alert.verdict == .event else { return true } + let boot = alert.boot ?? Data() + return Self.lock.withLock { _ in + var entries = load() + if let i = entries.firstIndex(where: { $0.home == alert.home && $0.boot == boot }) { + guard alert.seq > entries[i].seq else { return false } + entries[i].seq = alert.seq + entries[i].atMs = nowMs + } else { + entries.append(Entry(home: alert.home, boot: boot, seq: alert.seq, atMs: nowMs)) + } + entries.sort { $0.atMs > $1.atMs } + try? save(Array(entries.prefix(Self.limit))) + return true + } + } + + private static let lock = Mutex(()) + + func load() -> [Entry] { + (try? Data(contentsOf: file)).flatMap { try? JSONDecoder().decode([Entry].self, from: $0) } ?? [] + } + + private func save(_ entries: [Entry]) throws { + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(entries).write(to: file, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PushText.swift b/appleApp/FTWKit/Sources/FTWShared/PushText.swift new file mode 100644 index 0000000..f9d5fd1 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PushText.swift @@ -0,0 +1,134 @@ +import Foundation + +/// The words of a push, written on the phone from the event's kind and +/// arguments, in Swedish or English (Fredrik, 12 Oct 2026). The box sends +/// codes and the service sends none of its own, so a lock screen never +/// shows a sentence this file does not hold. +/// +/// The English is the push catalogue's (`protocol/push-catalogue.yaml`, +/// the same file as in srcfl/ftw), and a test holds it to that file. +public enum PushText { + public enum Language: String, Sendable, CaseIterable { + case sv, en + + /// The person's first language: Swedish if it is Swedish, else + /// English. + public static func preferred(_ languages: [String] = Locale.preferredLanguages) -> Language { + languages.first?.lowercased().hasPrefix("sv") == true ? .sv : .en + } + } + + public struct Content: Sendable, Equatable { + public var title: String + public var body: String + + public init(title: String, body: String) { + self.title = title + self.body = body + } + } + + /// One kind's sentence. `{name}` marks an argument; `without` is the + /// body to show when an argument is missing or will not do. + struct Sentence { + var title: String + var body: String + var without: String? + } + + /// The line for anything that did not check out: no signature, an old + /// event, a home this phone does not hold. It says nothing the push + /// claimed. + public static func generic(_ language: Language) -> Content { + switch language { + case .sv: return Content(title: "Något hände hemma", body: "Öppna FTW för att se vad.") + case .en: return Content(title: "Something happened at home", body: "Open FTW to see what.") + } + } + + /// The words for a checked alert. `sourceName` is the device's name from + /// the box, for an event about a device. + public static func content(_ alert: PushAlert, sourceName: String? = nil, language: Language, decimalSeparator: String = Locale.current.decimalSeparator ?? ".") -> Content { + let kind: String + switch alert.verdict { + case .generic: return generic(language) + case .unreachable: kind = PushKind.boxUnreachable + case .event: kind = alert.kind + } + guard let s = sentences(language)[kind] else { return generic(language) } + let values = arguments(alert.args, name: sourceName, decimalSeparator: decimalSeparator) + guard let body = fill(s.body, values) else { return Content(title: s.title, body: s.without ?? s.body) } + return Content(title: s.title, body: body) + } + + static func sentences(_ language: Language) -> [String: Sentence] { + language == .sv ? sv : en + } + + static let en: [String: Sentence] = [ + PushKind.chargingConnected: Sentence(title: "Car plugged in", body: "Open to see charging status, check the battery level and set a ready time."), + PushKind.chargingSessionComplete: Sentence(title: "Car charged", body: "{kwh} kWh delivered — ready to go.", without: "Ready to go."), + PushKind.chargingGoalAtRisk: Sentence(title: "The car may not be ready", body: "It will not reach {target} % by {by} at this pace.", without: "It will not reach its goal in time at this pace."), + PushKind.chargingInterrupted: Sentence(title: "Charging stopped early", body: "The car stopped charging before it was done."), + PushKind.updateInstalled: Sentence(title: "Your box updated itself", body: "Now running {version}. Everything came back on its own.", without: "Everything came back on its own."), + PushKind.driverOffline: Sentence(title: "A device went quiet", body: "{name} stopped answering.", without: "A device stopped answering."), + PushKind.fuseOverLimit: Sentence(title: "The house is drawing too much", body: "{phase} is over the fuse rating.", without: "One phase is over the fuse rating."), + PushKind.boxUnreachable: Sentence(title: "Your box is out of reach", body: "It stopped answering. Power cut, or the internet at home is down."), + PushKind.cloudGranted: Sentence(title: "FTW Cloud is on", body: "Your box now shares a copy of its readings with FTW Cloud. You can turn it off in the app or on the box."), + PushKind.cloudRevoked: Sentence(title: "FTW Cloud is off", body: "Your box stopped sharing with FTW Cloud and asked it to delete its copy."), + ] + + static let sv: [String: Sentence] = [ + PushKind.chargingConnected: Sentence(title: "Bilen är inkopplad", body: "Öppna för att se laddningen, kolla bilens nivå och välja klartid."), + PushKind.chargingSessionComplete: Sentence(title: "Bilen är laddad", body: "{kwh} kWh laddat – redo att åka.", without: "Redo att åka."), + PushKind.chargingGoalAtRisk: Sentence(title: "Bilen kanske inte blir klar", body: "Den når inte {target} % till {by} i den här takten.", without: "Den når inte målet i tid i den här takten."), + PushKind.chargingInterrupted: Sentence(title: "Laddningen avbröts", body: "Bilen slutade ladda innan den var klar."), + PushKind.updateInstalled: Sentence(title: "Din box har uppdaterat sig", body: "Nu kör den {version}. Allt kom igång igen av sig självt.", without: "Allt kom igång igen av sig självt."), + PushKind.driverOffline: Sentence(title: "En enhet har tystnat", body: "{name} har slutat svara.", without: "En enhet har slutat svara."), + PushKind.fuseOverLimit: Sentence(title: "Huset drar för mycket ström", body: "{phase} drar mer än huvudsäkringen klarar.", without: "En fas drar mer än huvudsäkringen klarar."), + PushKind.boxUnreachable: Sentence(title: "Din box går inte att nå", body: "Den har slutat svara. Strömavbrott, eller så är internet hemma nere."), + PushKind.cloudGranted: Sentence(title: "FTW Cloud är på", body: "Din box delar nu en kopia av sina mätvärden med FTW Cloud. Du kan stänga av det i appen eller på boxen."), + PushKind.cloudRevoked: Sentence(title: "FTW Cloud är av", body: "Din box har slutat dela med FTW Cloud och bett den radera sin kopia."), + ] + + /// The arguments that will do, ready to show. The box signed them, and + /// they still pass a check: a lock screen is no place for a stray line + /// break or a paragraph. + static func arguments(_ args: [String: String], name: String?, decimalSeparator: String) -> [String: String] { + var out = [String: String]() + // Energy is kWh with the reader's decimal mark, never a percentage. + if let kwh = args["kwh"], kwh.wholeMatch(of: /[0-9]{1,5}(\.[0-9]{1,3})?/) != nil { + out["kwh"] = kwh.replacingOccurrences(of: ".", with: decimalSeparator) + } + if let target = args["target"], target.wholeMatch(of: /[0-9]{1,3}/) != nil, let pct = Int(target), pct <= 100 { + out["target"] = String(pct) + } + if let by = args["by"], let m = by.wholeMatch(of: /([0-9]{2}):([0-9]{2})/), let h = Int(m.1), let min = Int(m.2), h < 24, min < 60 { + out["by"] = by + } + for key in ["version", "phase"] { + if let v = args[key].flatMap(plain) { out[key] = v } + } + if let name = name.flatMap(plain) { out["name"] = name } + return out + } + + /// A short line of text, or nil. + static func plain(_ s: String) -> String? { + let t = s.trimmingCharacters(in: .whitespacesAndNewlines) + guard (1...40).contains(t.count), t.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) && !CharacterSet.newlines.contains($0) }) else { return nil } + return t + } + + /// The template with every `{name}` filled, or nil if one has no value. + static func fill(_ template: String, _ values: [String: String]) -> String? { + var out = "" + var rest = template[...] + while let open = rest.firstIndex(of: "{") { + guard let close = rest[open...].firstIndex(of: "}"), let value = values[String(rest[rest.index(after: open)..(_ name: String, as: T.Type) throws -> T { + let url = try #require(Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Resources")) + return try JSONDecoder().decode(T.self, from: Data(contentsOf: url)) + } + + // MARK: The Go side's vectors + + @Test func everyPushVectorReadsAsTheGoReferenceReadsIt() throws { + let file = try Self.load("push-vectors", as: PushFile.self) + #expect(file.cases.count == 14) + let keys = Dictionary(uniqueKeysWithValues: file.keys.map { ($0.home, Data(hex: $0.pk)) }) + for c in file.cases { + let payload = Data(c.payload.utf8) + let fromJSON = PushAlert.read(payload: payload, boxKey: { keys[$0] }, nowMs: c.nowMs) + // The extension gets userInfo, not the bytes: both roads agree. + let userInfo = try #require(JSONSerialization.jsonObject(with: payload) as? [AnyHashable: Any]) + let fromUserInfo = PushAlert.read(userInfo: userInfo, boxKey: { keys[$0] }, nowMs: c.nowMs) + #expect(fromJSON == fromUserInfo, "\(c.name)") + + let a = fromJSON + let w = c.want + #expect(a.verdict.rawValue == w.verdict, "\(c.name)") + #expect(a.home == w.home, "\(c.name)") + #expect(a.kind == (w.kind ?? ""), "\(c.name)") + #expect(a.args == (w.args ?? [:]), "\(c.name)") + #expect((a.src ?? "") == (w.src ?? ""), "\(c.name)") + #expect((a.lp ?? "") == (w.lp ?? ""), "\(c.name)") + #expect((a.boot?.hex ?? "") == (w.boot ?? ""), "\(c.name)") + #expect(a.seq == (w.seq ?? 0), "\(c.name)") + #expect(a.atMs == (w.atMs ?? 0), "\(c.name)") + } + } + + /// Every envelope parses, its body is the bytes carried, the signed + /// input hashes as Go's does, and each signature verifies with its + /// signer's key and with no other. + @Test func everyCloudMessageVerifiesOverTheBytesItCarries() throws { + let file = try Self.load("cloud-vectors", as: CloudFile.self) + let home = try #require(file.boxKey.home) + let keys = ["box": Data(hex: file.boxKey.pk), "service": Data(hex: file.serviceKey.pk)] + #expect(file.messages.count == 16) + for m in file.messages { + let env = try #require(PushAlert.Envelope(Array(Data(hex: m.envelope))), "\(m.name)") + #expect(env.t == m.t, "\(m.name)") + #expect(Data(env.b) == Data(hex: m.body), "\(m.name)") + #expect(Data(env.sig) == Data(hex: m.sig), "\(m.name)") + let input = PushCBOR.signedInput(home: home, t: env.t, b: env.b) + #expect(Data(SHA256.hash(data: input)) == Data(hex: m.signedInputSha256), "\(m.name)") + for (signer, pk) in keys { + #expect(PushAlert.verify(pk, home: home, env) == (signer == m.signer), "\(m.name) with the \(signer) key") + } + // Signed for another home, never valid. + if let pk = keys[m.signer] { + #expect(!PushAlert.verify(pk, home: "aaaaaaaaaaaaaaaaaaaaaaaaaa", env), "\(m.name)") + } + // The strict reader takes every body the box sends. + #expect((try? PushCBOR.decode(env.b)) != nil, "\(m.name)") + } + } + + /// The three events of the shared vectors, delivered fresh, read as + /// events, and session complete carries its energy. + @Test func theSharedEventsReadAsEvents() throws { + let file = try Self.load("cloud-vectors", as: CloudFile.self) + let home = try #require(file.boxKey.home) + let pk = Data(hex: file.boxKey.pk) + for m in file.messages where m.t == "event" { + let env = try #require(PushAlert.Envelope(Array(Data(hex: m.envelope)))) + let ev = try #require(PushAlert.Event(env.b)) + let now = try #require(ev.wallMs) + 1_200 + let a = PushAlert.read(payload: Self.payload(home: home, event: Data(hex: m.envelope)), boxKey: { $0 == home ? pk : nil }, nowMs: now) + #expect(a.verdict == .event, "\(m.name)") + #expect(a.kind == ev.kind) + #expect(a.lp == "j3HZORNCWHs") + #expect(a.src == "DUYWWQmn-Xg") + #expect(a.boot == Data(repeating: 0xb0, count: 16)) + if ev.kind == PushKind.chargingSessionComplete { + #expect(a.args == ["kwh": "18.4"]) + #expect(PushText.content(a, language: .en, decimalSeparator: ".").body == "18.4 kWh delivered — ready to go.") + #expect(PushText.content(a, language: .sv, decimalSeparator: ",").body == "18,4 kWh laddat – redo att åka.") + } + } + } + + // MARK: Edges, signed here with the vector box + + static let boxKey: P256.Signing.PrivateKey = { + let file = try! load("cloud-vectors", as: CloudFile.self) + return try! P256.Signing.PrivateKey(rawRepresentation: Data(hex: file.boxKey.d!)) + }() + static var home: String { "fjzyr67buzpyncqmjliwgezvde" } + static var pk: Data { boxKey.publicKey.x963Representation } + static let wall: Int64 = 1_791_820_800_000 + + static func body(_ extra: [(String, TestCBOR)] = [], drop: Set = [], kind: String = PushKind.chargingConnected) -> [UInt8] { + var fields: [(String, TestCBOR)] = [ + ("seq", .u(9)), ("boot", .b(Array(repeating: 0xb0, count: 16))), ("uptimeMs", .u(71_000)), + ("wallMs", .u(UInt64(wall))), ("eventId", .t("Q2ZrMjR0dzVh")), ("kind", .t(kind)), + ("severity", .t("info")), ("args", .m([])), ("lp", .t("j3HZORNCWHs")), + ] + fields.removeAll { drop.contains($0.0) || extra.map(\.0).contains($0.0) } + return TestCBOR.m(fields + extra).encoded + } + + static func envelope(_ body: [UInt8], t: String = "event", home: String = home) -> [UInt8] { + let sig = try! boxKey.signature(for: PushCBOR.signedInput(home: home, t: t, b: body)).rawRepresentation + return TestCBOR.m([("b", .b(body)), ("t", .t(t)), ("sig", .b(Array(sig)))]).encoded + } + + static func payload(home: String = home, event: Data? = nil, kind: String? = nil) -> Data { + var ftw: [String: Any] = ["v": 1, "home": home] + if let event { ftw["event"] = event.base64EncodedString() } + if let kind { ftw["kind"] = kind } + let aps: [String: Any] = ["alert": ["title-loc-key": "push.title", "loc-key": "push.generic"], "mutable-content": 1, "sound": "default"] + return try! JSONSerialization.data(withJSONObject: ["aps": aps, "ftw": ftw]) + } + + static func read(_ env: [UInt8], nowMs: Int64 = wall + 1_200, kind: String? = nil) -> PushAlert { + PushAlert.read(payload: payload(event: Data(env), kind: kind), boxKey: { $0 == home ? pk : nil }, nowMs: nowMs) + } + + @Test func aFreshSignedEventReads() { + let a = Self.read(Self.envelope(Self.body())) + #expect(a.verdict == .event) + #expect(a.atMs == Self.wall) + #expect(a.seq == 9) + #expect(a.src == nil) + } + + /// Unknown keys are a newer box, not a fault. + @Test func unknownKeysAreIgnored() { + #expect(Self.read(Self.envelope(Self.body([("later", .m([("x", .u(1))]))]))).verdict == .event) + } + + /// A field of the wrong type is not the event that was meant. + @Test func aFieldOfTheWrongTypeIsGeneric() { + for extra: (String, TestCBOR) in [("seq", .t("9")), ("wallMs", .t("now")), ("kind", .b([1])), ("args", .m([("kwh", .u(18))])), ("lp", .u(1)), ("ts", .neg(UInt64.max))] { + #expect(Self.read(Self.envelope(Self.body([extra]))).verdict == .generic, "\(extra.0)") + } + } + + /// The box clock anchors the event; the phone's bounds it. + @Test func theClockRules() { + let b = Self.body() + #expect(Self.read(Self.envelope(b), nowMs: Self.wall - 2_000).verdict == .event) + #expect(Self.read(Self.envelope(b), nowMs: Self.wall - 2_001).verdict == .generic) + #expect(Self.read(Self.envelope(b), nowMs: Self.wall - 2_000).atMs == Self.wall - 2_000) + #expect(Self.read(Self.envelope(b), nowMs: Self.wall + 600_000).verdict == .event) + #expect(Self.read(Self.envelope(b), nowMs: Self.wall + 600_001).verdict == .generic) + // The event's own time, when earlier, is when it happened. + let held = Self.body([("ts", .u(UInt64(Self.wall - 30_000)))]) + #expect(Self.read(Self.envelope(held)).atMs == Self.wall - 30_000) + #expect(Self.read(Self.envelope(held), nowMs: Self.wall + 570_001).verdict == .generic) + // No wall clock, no anchor. + #expect(Self.read(Self.envelope(Self.body(drop: ["wallMs"]))).verdict == .generic) + } + + /// The service's kind beside an event, or the box signing the one kind + /// only the service may send, is generic. + @Test func onlyTheServiceSaysUnreachableAndOnlyAlone() { + #expect(Self.read(Self.envelope(Self.body()), kind: PushKind.boxUnreachable).verdict == .generic) + #expect(Self.read(Self.envelope(Self.body(kind: PushKind.boxUnreachable))).verdict == .generic) + let alone = PushAlert.read(payload: Self.payload(kind: PushKind.boxUnreachable), boxKey: { _ in nil }, nowMs: Self.wall) + #expect(alone.verdict == .unreachable) + #expect(alone.kind.isEmpty && alone.args.isEmpty) + } + + /// Another type of message, signed by the same box, is not an event. + @Test func onlyAnEventIsAnEvent() { + #expect(Self.read(Self.envelope(Self.body(), t: "hb")).verdict == .generic) + } + + @Test func aPayloadThatIsNotOursIsGenericWithoutAHome() { + for json in [#"{"aps":{}}"#, #"{"ftw":{"v":2,"home":"x"}}"#, #"{"ftw":{"v":true,"home":"x"}}"#, #"{"ftw":{"v":1,"home":7}}"#, #"{"ftw":{"v":1,"home":"x","event":"not base64!"}}"#, #"{"ftw":null}"#, "[]", "nonsense"] { + let a = PushAlert.read(payload: Data(json.utf8), boxKey: { _ in Self.pk }, nowMs: Self.wall) + #expect(a == PushAlert(.generic, home: ""), "\(json)") + } + } + + /// The envelope's shape is checked byte by byte. + @Test func theEnvelopeIsStrict() throws { + let b = Self.body() + let sig = Array(try Self.boxKey.signature(for: PushCBOR.signedInput(home: Self.home, t: "event", b: b)).rawRepresentation) + let good = TestCBOR.m([("b", .b(b)), ("t", .t("event")), ("sig", .b(sig))]).encoded + #expect(PushAlert.Envelope(good) != nil) + // t as bytes, b as text, no sig, an empty t. + #expect(PushAlert.Envelope(TestCBOR.m([("b", .b(b)), ("t", .b(Array("event".utf8))), ("sig", .b(sig))]).encoded) == nil) + #expect(PushAlert.Envelope(TestCBOR.m([("b", .t("x")), ("t", .t("event")), ("sig", .b(sig))]).encoded) == nil) + #expect(PushAlert.Envelope(TestCBOR.m([("b", .b(b)), ("t", .t("event"))]).encoded) == nil) + #expect(PushAlert.Envelope(TestCBOR.m([("b", .b(b)), ("t", .t("")), ("sig", .b(sig))]).encoded) == nil) + // A byte after the value, a key twice, an indefinite length. + #expect(PushAlert.Envelope(good + [0x00]) == nil) + #expect(PushAlert.Envelope(TestCBOR.m([("b", .b(b)), ("t", .t("event")), ("t", .t("event")), ("sig", .b(sig))]).encoded) == nil) + var indefinite = good + indefinite[0] = 0xbf + #expect(PushAlert.Envelope(indefinite + [0xff]) == nil) + // Truncated anywhere. + for n in 0.. [UInt8] { + let m = major << 5 + switch n { + case 0..<24: return [m | UInt8(n)] + case 24...0xff: return [m | 24, UInt8(n)] + case 0x100...0xffff: return [m | 25, UInt8(n >> 8), UInt8(n & 0xff)] + case 0x1_0000...0xffff_ffff: return [m | 26] + (0..<4).reversed().map { UInt8(truncatingIfNeeded: n >> ($0 * 8)) } + default: return [m | 27] + (0..<8).reversed().map { UInt8(truncatingIfNeeded: n >> ($0 * 8)) } + } + } + switch self { + case .u(let n): return head(0, n) + case .neg(let n): return head(1, n) + case .b(let b): return head(2, UInt64(b.count)) + b + case .t(let s): return head(3, UInt64(s.utf8.count)) + Array(s.utf8) + case .m(let pairs): return head(5, UInt64(pairs.count)) + pairs.flatMap { TestCBOR.t($0.0).encoded + $0.1.encoded } + } + } +} + +extension Data { + init(hex: String) { + var out = Data() + var it = hex.makeIterator() + while let a = it.next(), let b = it.next() { + out.append(UInt8(String([a, b]), radix: 16)!) + } + self = out + } + + var hex: String { map { String(format: "%02x", $0) }.joined() } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PushStoreTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PushStoreTests.swift new file mode 100644 index 0000000..af563aa --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PushStoreTests.swift @@ -0,0 +1,98 @@ +import Foundation +import Testing +@testable import FTWShared + +/// What the app leaves for the notification extension, and what the +/// extension remembers between pushes. +@Suite struct PushStoreTests { + static let home = "fjzyr67buzpyncqmjliwgezvde" + static let key = Data([0x04] + Array(repeating: 0x11, count: 64)) + + static func temp() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("ftw-push-\(UUID().uuidString)", isDirectory: true) + } + + static func event(home: String = home, boot: UInt8 = 0xb0, seq: UInt64) -> PushAlert { + PushAlert(.event, home: home, kind: PushKind.chargingConnected, boot: Data(repeating: boot, count: 16), seq: seq, atMs: 1) + } + + // MARK: CloudKeys + + @Test func aHomeReadsBackAsWritten() throws { + let dir = Self.temp() + defer { try? FileManager.default.removeItem(at: dir) } + let keys = CloudKeys(directory: dir) + let row = CloudHome(home: Self.home, siteId: "site", boxKey: Self.key, loadpoints: ["j3HZORNCWHs": "garage"], sources: ["DUYWWQmn-Xg": "Easee"]) + try keys.write(row) + #expect(keys.read(home: Self.home) == row) + #expect(keys.boxKey(home: Self.home) == Self.key) + #expect(keys.read(home: "t3w5bfotxeygcjxjdhjaeo6pvq") == nil) + keys.remove(home: Self.home) + #expect(keys.read(home: Self.home) == nil) + } + + /// The home id comes from a push. It never names a file outside the + /// store, and a key that is not a point is refused at the door. + @Test func onlyAHomeIDAndAPointAreKept() throws { + let dir = Self.temp() + defer { try? FileManager.default.removeItem(at: dir) } + let keys = CloudKeys(directory: dir) + for bad in ["../../etc/passwd", "FJZYR67BUZPYNCQMJLIWGEZVDE", "fjzyr67buzpyncqmjliwgezvd", "fjzyr67buzpyncqmjliwgezvd1", ""] { + #expect(!CloudKeys.isHomeID(bad), "\(bad)") + #expect(throws: CloudKeys.Refused.self) { try keys.write(CloudHome(home: bad, siteId: "s", boxKey: Self.key)) } + #expect(keys.read(home: bad) == nil) + } + #expect(CloudKeys.isHomeID(Self.home)) + #expect(throws: CloudKeys.Refused.self) { try keys.write(CloudHome(home: Self.home, siteId: "s", boxKey: Data(Self.key.dropFirst()))) } + #expect(throws: CloudKeys.Refused.self) { try keys.write(CloudHome(home: Self.home, siteId: "s", boxKey: Data([0x02]) + Self.key.dropFirst())) } + } + + /// The event's charger, through the box's table. A home with one + /// charger needs no name for it; an unknown name is not guessed. + @Test func theChargerAnEventMeans() { + let one = CloudHome(home: Self.home, siteId: "s", boxKey: Self.key, loadpoints: ["j3HZORNCWHs": "garage"]) + #expect(one.loadpointID(opaque: "j3HZORNCWHs") == "garage") + #expect(one.loadpointID(opaque: nil) == "garage") + #expect(one.loadpointID(opaque: "unknown") == nil) + let two = CloudHome(home: Self.home, siteId: "s", boxKey: Self.key, loadpoints: ["a": "garage", "b": "street"]) + #expect(two.loadpointID(opaque: nil) == nil) + #expect(two.loadpointID(opaque: "b") == "street") + #expect(CloudHome(home: Self.home, siteId: "s", boxKey: Self.key).loadpointID(opaque: nil) == nil) + } + + // MARK: PushSeen + + @Test func anEventIsShownOnce() { + let dir = Self.temp() + defer { try? FileManager.default.removeItem(at: dir) } + let seen = PushSeen(file: dir.appendingPathComponent("push-seen")) + #expect(seen.accept(Self.event(seq: 9), nowMs: 1)) + #expect(!seen.accept(Self.event(seq: 9), nowMs: 2)) + // Older than one shown. + #expect(!seen.accept(Self.event(seq: 8), nowMs: 3)) + #expect(seen.accept(Self.event(seq: 10), nowMs: 4)) + // A restart counts again; another home counts on its own. + #expect(seen.accept(Self.event(boot: 0xc0, seq: 1), nowMs: 5)) + #expect(seen.accept(Self.event(home: "t3w5bfotxeygcjxjdhjaeo6pvq", seq: 9), nowMs: 6)) + // Remembered across processes: a fresh reader of the same file. + #expect(!PushSeen(file: seen.file).accept(Self.event(seq: 10), nowMs: 7)) + // Only events carry a number. + #expect(seen.accept(PushAlert(.unreachable, home: Self.home), nowMs: 8)) + #expect(seen.accept(PushAlert(.unreachable, home: Self.home), nowMs: 9)) + #expect(seen.accept(PushAlert(.generic, home: Self.home), nowMs: 10)) + } + + @Test func theFileStaysSmall() { + let dir = Self.temp() + defer { try? FileManager.default.removeItem(at: dir) } + let seen = PushSeen(file: dir.appendingPathComponent("push-seen")) + for boot in 0.. [Entry] { + let yaml = try String(contentsOf: repo.appendingPathComponent("protocol/push-catalogue.yaml"), encoding: .utf8) + var out = [Entry]() + for line in yaml.split(separator: "\n") { + let t = line.trimmingCharacters(in: .whitespaces) + if t.hasPrefix("- kind: ") { + out.append(Entry(kind: String(t.dropFirst(8)), title: "", body: "")) + } else if t.hasPrefix("title: "), !out.isEmpty { + out[out.count - 1].title = String(t.dropFirst(7)) + } else if t.hasPrefix("body: "), !out.isEmpty { + var body = String(t.dropFirst(6)) + if body.hasPrefix("\""), body.hasSuffix("\"") { body = String(body.dropFirst().dropLast()) } + out[out.count - 1].body = body + } + } + return out + } + + static func event(_ kind: String, args: [String: String] = [:], src: String? = nil, lp: String? = nil) -> PushAlert { + PushAlert(.event, home: "fjzyr67buzpyncqmjliwgezvde", kind: kind, args: args, src: src, lp: lp, boot: Data(repeating: 0xb0, count: 16), seq: 9, atMs: 1) + } + + // MARK: The catalogue + + @Test func theCatalogueIsTheOneTheBoxHas() throws { + let entries = try Self.catalogue() + #expect(entries.map(\.kind) == PushKind.all) + #expect(entries.allSatisfy { !$0.title.isEmpty && !$0.body.isEmpty }) + } + + /// Every kind has both languages, and the English is the catalogue's, + /// placeholders and all. + @Test func everyKindHasSwedishAndTheCataloguesEnglish() throws { + for e in try Self.catalogue() { + let en = try #require(PushText.en[e.kind], "\(e.kind)") + let sv = try #require(PushText.sv[e.kind], "\(e.kind)") + #expect(en.title == e.title, "\(e.kind)") + #expect(en.body == e.body, "\(e.kind)") + // Swedish asks for the same arguments, and has the same way out. + #expect(Self.placeholders(sv.body) == Self.placeholders(en.body), "\(e.kind)") + #expect((sv.without == nil) == (en.without == nil), "\(e.kind)") + #expect(Self.placeholders(en.body).isEmpty == (en.without == nil), "\(e.kind)") + #expect(sv.title != en.title, "\(e.kind)") + } + #expect(Set(PushText.en.keys) == Set(PushKind.all)) + #expect(Set(PushText.sv.keys) == Set(PushKind.all)) + } + + static func placeholders(_ s: String) -> [String] { + s.matches(of: /\{([a-z]+)\}/).map { String($0.1) }.sorted() + } + + /// Filled, the English reads exactly as the catalogue with its + /// placeholders filled. + @Test func filledEnglishIsTheCataloguesSentence() throws { + let args = ["kwh": "18.4", "target": "80", "by": "07:00", "version": "0.142.0", "phase": "L2"] + for e in try Self.catalogue() where e.kind != PushKind.boxUnreachable { + var want = e.body + for (k, v) in args { want = want.replacingOccurrences(of: "{\(k)}", with: v) } + want = want.replacingOccurrences(of: "{name}", with: "Easee") + let home = CloudHome(home: "fjzyr67buzpyncqmjliwgezvde", siteId: "s", boxKey: Data(), sources: ["DUYWWQmn-Xg": "Easee"]) + let got = PushText.content(Self.event(e.kind, args: args, src: "DUYWWQmn-Xg"), sourceName: home.sourceName(opaque: "DUYWWQmn-Xg"), language: .en, decimalSeparator: ".") + #expect(got == PushText.Content(title: e.title, body: want), "\(e.kind)") + } + } + + @Test func theSwedishReadsNaturally() { + let sv = { (kind: String, args: [String: String], name: String?) in + PushText.content(Self.event(kind, args: args), sourceName: name, language: .sv, decimalSeparator: ",") + } + #expect(sv(PushKind.chargingConnected, [:], nil) == .init(title: "Bilen är inkopplad", body: "Öppna för att se laddningen, kolla bilens nivå och välja klartid.")) + #expect(sv(PushKind.chargingSessionComplete, ["kwh": "18.4"], nil) == .init(title: "Bilen är laddad", body: "18,4 kWh laddat – redo att åka.")) + #expect(sv(PushKind.chargingGoalAtRisk, ["target": "80", "by": "07:00"], nil).body == "Den når inte 80 % till 07:00 i den här takten.") + #expect(sv(PushKind.updateInstalled, ["version": "0.142.0"], nil).body == "Nu kör den 0.142.0. Allt kom igång igen av sig självt.") + #expect(sv(PushKind.driverOffline, [:], "Easee").body == "Easee har slutat svara.") + #expect(sv(PushKind.fuseOverLimit, ["phase": "L2"], nil).body == "L2 drar mer än huvudsäkringen klarar.") + } + + /// Energy is kWh with the reader's decimal mark. Never a percentage. + @Test func energyIsKilowattHours() { + for (sep, want) in [(".", "18.4 kWh delivered — ready to go."), (",", "18,4 kWh delivered — ready to go.")] { + #expect(PushText.content(Self.event(PushKind.chargingSessionComplete, args: ["kwh": "18.4"]), language: .en, decimalSeparator: sep).body == want) + } + #expect(PushText.content(Self.event(PushKind.chargingSessionComplete, args: ["kwh": "7"]), language: .sv, decimalSeparator: ",").body == "7 kWh laddat – redo att åka.") + for language in PushText.Language.allCases { + #expect(!PushText.sentences(language)[PushKind.chargingSessionComplete]!.body.contains("%")) + } + } + + /// An argument that is missing or will not do gives the sentence + /// without it, never a half-filled one. + @Test func aBadArgumentGivesTheSentenceWithoutIt() { + let cases: [(String, [String: String], String)] = [ + (PushKind.chargingSessionComplete, [:], "Ready to go."), + (PushKind.chargingSessionComplete, ["kwh": "18.4%"], "Ready to go."), + (PushKind.chargingSessionComplete, ["kwh": "1e3"], "Ready to go."), + (PushKind.chargingSessionComplete, ["kwh": "١٨"], "Ready to go."), + (PushKind.chargingGoalAtRisk, ["target": "180", "by": "07:00"], "It will not reach its goal in time at this pace."), + (PushKind.chargingGoalAtRisk, ["target": "80", "by": "7:00"], "It will not reach its goal in time at this pace."), + (PushKind.chargingGoalAtRisk, ["target": "80", "by": "25:00"], "It will not reach its goal in time at this pace."), + (PushKind.updateInstalled, ["version": "1.0\nCall this number"], "Everything came back on its own."), + (PushKind.updateInstalled, ["version": String(repeating: "x", count: 41)], "Everything came back on its own."), + (PushKind.fuseOverLimit, ["phase": ""], "One phase is over the fuse rating."), + (PushKind.driverOffline, [:], "A device stopped answering."), + ] + for (kind, args, body) in cases { + #expect(PushText.content(Self.event(kind, args: args), language: .en, decimalSeparator: ".").body == body, "\(kind) \(args)") + } + // A name comes only from the box's table, never from the event. + #expect(PushText.content(Self.event(PushKind.driverOffline, args: ["name": "Mallory"]), language: .en).body == "A device stopped answering.") + } + + @Test func theGenericLineSaysNothingThePushClaimed() { + #expect(PushText.generic(.sv) == .init(title: "Något hände hemma", body: "Öppna FTW för att se vad.")) + #expect(PushText.generic(.en) == .init(title: "Something happened at home", body: "Open FTW to see what.")) + #expect(PushText.content(PushAlert(.generic, home: "x"), language: .sv) == PushText.generic(.sv)) + #expect(PushText.content(PushAlert(.unreachable, home: "x"), language: .en).title == "Your box is out of reach") + #expect(PushText.content(PushAlert(.unreachable, home: "x"), language: .sv).title == "Din box går inte att nå") + } + + /// What Apple shows if the extension never runs: the app's own strings + /// for the payload's keys, which are the generic line. + @Test func theAppsFallbackStringsAreTheGenericLine() throws { + for language in PushText.Language.allCases { + let file = Self.repo.appendingPathComponent("appleApp/FTW/Intents/\(language.rawValue).lproj/Localizable.strings") + let strings = try #require(NSDictionary(contentsOf: file) as? [String: String], "\(language)") + #expect(strings["push.title"] == PushText.generic(language).title, "\(language)") + #expect(strings["push.generic"] == PushText.generic(language).body, "\(language)") + } + } + + @Test func theLanguageIsThePersonsFirst() { + #expect(PushText.Language.preferred(["sv-SE", "en-US"]) == .sv) + #expect(PushText.Language.preferred(["SV"]) == .sv) + #expect(PushText.Language.preferred(["en-SE", "sv-SE"]) == .en) + #expect(PushText.Language.preferred(["de-DE"]) == .en) + #expect(PushText.Language.preferred([]) == .en) + } + + // MARK: Buttons + + @Test func onlyAVerifiedKindPicksButtons() { + #expect(PushActions.category(for: Self.event(PushKind.chargingConnected)) == .level) + #expect(PushActions.category(for: Self.event(PushKind.chargingInterrupted)) == .chargeNow) + #expect(PushActions.category(for: Self.event(PushKind.chargingGoalAtRisk)) == .chargeNow) + for kind in PushKind.all where ![PushKind.chargingConnected, PushKind.chargingInterrupted, PushKind.chargingGoalAtRisk].contains(kind) { + #expect(PushActions.category(for: Self.event(kind)) == nil, "\(kind)") + } + #expect(PushActions.category(for: PushAlert(.unreachable, home: "x")) == nil) + // A generic alert carries no kind it could be tricked by. + #expect(PushActions.category(for: PushAlert(.generic, home: "x", kind: PushKind.chargingConnected)) == nil) + #expect(PushActions.Category.level.actions == [.level20, .level50, .level80, .open]) + #expect(PushActions.Category.chargeNow.actions == [.chargeNow, .open]) + } + + @Test func aButtonRunsOnlyForTheKindThatOffersIt() { + let connected = Self.event(PushKind.chargingConnected) + let atRisk = Self.event(PushKind.chargingGoalAtRisk) + #expect(PushActions.allows(.level50, for: connected)) + #expect(!PushActions.allows(.chargeNow, for: connected)) + #expect(PushActions.allows(.chargeNow, for: atRisk)) + #expect(!PushActions.allows(.level80, for: atRisk)) + for action in PushActions.Action.allCases { + #expect(!PushActions.allows(action, for: Self.event(PushKind.cloudGranted))) + #expect(!PushActions.allows(action, for: PushAlert(.generic, home: "x", kind: PushKind.chargingConnected))) + } + } + + /// The levels are the car's level, any whole percent; Charge now is the + /// Control's command. Open asks the box nothing. + @Test func whatEachButtonAsks() { + #expect(PushActions.command(.level20) == .setLevel(pct: 20)) + #expect(PushActions.command(.level50) == .setLevel(pct: 50)) + #expect(PushActions.command(.level80) == .setLevel(pct: 80)) + #expect(PushActions.command(.chargeNow) == .chargeNow) + #expect(PushActions.command(.open) == nil) + #expect(PushActions.Action.allCases.filter(PushActions.opensApp) == [.open]) + #expect(PushActions.title(.chargeNow, .sv) == "Ladda nu") + #expect(PushActions.title(.open, .sv) == "Öppna") + #expect(PushActions.title(.open, .en) == "Open") + #expect(PushActions.title(.level50, .sv) == "50 %") + } + + // MARK: The notice + + @Test func theNoticeSetsEverythingItself() { + let home = CloudHome(home: "fjzyr67buzpyncqmjliwgezvde", siteId: "site", boxKey: Data(), loadpoints: ["j3HZORNCWHs": "garage"]) + let connected = PushNotice(Self.event(PushKind.chargingConnected, lp: "j3HZORNCWHs"), home: home, language: .en) + #expect(connected.category == "ftw.level") + #expect(connected.level == .active) + #expect(connected.thread == "fjzyr67buzpyncqmjliwgezvde") + #expect(connected.verified == ["verdict": "event", "home": "fjzyr67buzpyncqmjliwgezvde", "kind": PushKind.chargingConnected, "seq": "9", "atMs": "1", "loadpointID": "garage"]) + + let interrupted = PushNotice(Self.event(PushKind.chargingInterrupted), home: home, language: .sv) + #expect(interrupted.level == .timeSensitive) + #expect(interrupted.category == "ftw.chargeNow") + #expect(interrupted.title == "Laddningen avbröts") + + let unreachable = PushNotice(PushAlert(.unreachable, home: "fjzyr67buzpyncqmjliwgezvde"), home: nil, language: .en) + #expect(unreachable.level == .timeSensitive) + #expect(unreachable.category == "") + #expect(unreachable.verified == ["verdict": "unreachable", "home": "fjzyr67buzpyncqmjliwgezvde"]) + + let generic = PushNotice(PushAlert(.generic, home: "fjzyr67buzpyncqmjliwgezvde"), home: home, language: .sv) + #expect(generic.category == "") + #expect(generic.level == .active) + #expect(generic.verified.isEmpty) + #expect(generic.title == "Något hände hemma") + #expect(PushNotice.generic(.en).category == "") + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/Resources/cloud-vectors.json b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/cloud-vectors.json new file mode 100644 index 0000000..9ad2244 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/cloud-vectors.json @@ -0,0 +1,196 @@ +{ + "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" + }, + { + "name": "event connected", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a00011558", + "signedInputSha256": "6e13d5c342e20d31aef8530bd5d8d89b08540809278bd1aa93d2d156fe2b6d29", + "sig": "490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23", + "envelope": "a3616258a6ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a000115586174656576656e74637369675840490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23", + "frame": "0101000000f9a3616258a6ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a000115586174656576656e74637369675840490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23" + }, + { + "name": "event interrupted", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a00011940", + "signedInputSha256": "b29e1deef680d5b19f631a1ee29965bf70774e690afe1dbab5e7e25c4738ddbb", + "sig": "ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25", + "envelope": "a3616258abab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a000119406174656576656e74637369675840ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25", + "frame": "0101000000fea3616258abab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a000119406174656576656e74637369675840ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25" + }, + { + "name": "event session complete", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d28", + "signedInputSha256": "37a352e6f700b18e8dfec643cc783a67bf284209ec5ef16956266dfe84ed0b0a", + "sig": "e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb", + "envelope": "a3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb", + "frame": "01010000010aa3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb" + } + ] +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/Resources/push-vectors.json b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/push-vectors.json new file mode 100644 index 0000000..218d502 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/push-vectors.json @@ -0,0 +1,181 @@ +{ + "keys": [ + { + "home": "fjzyr67buzpyncqmjliwgezvde", + "pk": "04cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b8" + }, + { + "home": "t3w5bfotxeygcjxjdhjaeo6pvq", + "pk": "04003ed3e19bde3ad2abbe687ce92a9d63cfacd49cb4bace9903c3ee95093700804081439d960c07e29171ab20fdaf94c603d87e4669f961d7a227e8d8616111f9" + } + ], + "cases": [ + { + "name": "connected, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEABMIFtaHgkzfeMrtApu30UgdXtawe2kzcX4lBlhZ9yVOdjZkXlCSoSUrd2nHXDYI4pClpW4ReFKP/H9mqucV2H\"}}", + "sig": "0130816d687824cdf78caed029bb7d1481d5ed6b07b6933717e25065859f7254e7636645e5092a1252b7769c75c3608e290a5a56e1178528ffc7f66aae715d87", + "nowMs": 1791820812200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.connected", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 9, + "atMs": 1791820810600 + } + }, + { + "name": "interrupted, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKurYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw1820Y3NlcQpjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdGNoYXJnaW5nLmludGVycnVwdGVkZndhbGxNcxsAAAGhMNfO4GdldmVudElkbGFXNTBjbkIwWkRBeGhzZXZlcml0eWd3YXJuaW5naHVwdGltZU1zGgABGUBhdGVldmVudGNzaWdYQKJwjC0k3f6XPDpapuR7NTwgus1FBgURGLD5DSpoTIsFWtfQ2iow7T1MWUHBPcWxPna0WQUCzpdkh5EcsZQULTs=\"}}", + "sig": "a2708c2d24ddfe973c3a5aa6e47b353c20bacd4506051118b0f90d2a684c8b055ad7d0da2a30ed3d4c5941c13dc5b13e76b4590502ce976487911cb194142d3b", + "nowMs": 1791820813200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.interrupted", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 10, + "atMs": 1791820811700 + } + }, + { + "name": "goal at risk, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWK+qYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw19ZMY3NlcQ9kYXJnc6JiYnllMDc6MDBmdGFyZ2V0YjgwZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdWNoYXJnaW5nLmdvYWxfYXRfcmlza2Z3YWxsTXMbAAABoTDX1rBnZXZlbnRJZGxaMjloYkhKcGMyc3hoc2V2ZXJpdHlnd2FybmluZ2h1cHRpbWVNcxoAASEQYXRlZXZlbnRjc2lnWEDAGlZS76Snuq3dzP6oBe1buyz2kzW/N79ukTRxpHgali+4Bqh7Um61zOsv4QTfS6Ss6vENXdDC6biX122t3pJg\"}}", + "sig": "c01a5652efa4a7baadddccfea805ed5bbb2cf69335bf37bf6e913471a4781a962fb806a87b526eb5cceb2fe104df4ba4aceaf10d5dd0c2e9b897d76dadde9260", + "nowMs": 1791820815200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.goal_at_risk", + "args": { + "by": "07:00", + "target": "80" + }, + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 15, + "atMs": 1791820813900 + } + }, + { + "name": "one byte of the body changed", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmJvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWECNIbMUmau1F9d1HqNW3rNdV0jlDVXEjK/Q3/b8bBjF7yCpDjUzd9n2FfuL0zIv0aJUJkc3BbScu+pvpr8piwGb\"}}", + "sig": "8d21b31499abb517d7751ea356deb35d5748e50d55c48cafd0dff6fc6c18c5ef20a90e353377d9f615fb8bd3322fd1a25426473705b49cbbea6fa6bf298b019b", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "named for another home the phone holds", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"t3w5bfotxeygcjxjdhjaeo6pvq\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEBH6wZnxbr20B6MN8GTyLZcR25rSVN1bugYCsIleoMmLRG5mELw/q7JcRUOiucK7LLM3ka46TK2T7FnZh3mxuIl\"}}", + "sig": "47eb0667c5baf6d01e8c37c193c8b65c476e6b4953756ee8180ac2257a83262d11b99842f0feaec971150e8ae70aecb2ccde46b8e932b64fb167661de6c6e225", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "t3w5bfotxeygcjxjdhjaeo6pvq" + } + }, + { + "name": "signed by another box for this home", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcRBjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEBJ68gFtSwOcqFpSXiK6MHVsHr2RiTTMxIm9h1lNwn/IroE4rs65mYQCkTY+xLXrenrOasV/suwp6HzhwgoHk8z\"}}", + "sig": "49ebc805b52c0e72a16949788ae8c1d5b07af64624d3331226f61d653709ff22ba04e2bb3ae666100a44d8fb12d7ade9eb39ab15fecbb0a7a1f38708281e4f33", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "two hours old", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEAg1t+OvFYf9y9gpMPg21Q7dzGvrJ5V8CrBNIr2EWpgLZl1QZgbSA8ELs8nrATH2p+tUjDAmJSyG1m8YlsgYEap\"}}", + "sig": "20d6df8ebc561ff72f60a4c3e0db543b7731afac9e55f02ac1348af6116a602d997541981b480f042ecf27ac04c7da9fad5230c09894b21b59bc625b206046a9", + "nowMs": 1791828011000, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "ten minutes old is still fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKurYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw1820Y3NlcQpjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdGNoYXJnaW5nLmludGVycnVwdGVkZndhbGxNcxsAAAGhMNfO4GdldmVudElkbGFXNTBjbkIwWkRBeGhzZXZlcml0eWd3YXJuaW5naHVwdGltZU1zGgABGUBhdGVldmVudGNzaWdYQB7Mfxasg5x9bJLORP3yYOgcto/sS+Y/Z2SgGKkgJucoOlYH2mg5ex6sShXDjDHRYZ4RHhRgC3ENSJ+FxFIryMI=\"}}", + "sig": "1ecc7f16ac839c7d6c92ce44fdf260e81cb68fec4be63f6764a018a92026e7283a5607da68397b1eac4a15c38c31d1619e111e14600b710d489f85c4522bc8c2", + "nowMs": 1791821411700, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.interrupted", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 10, + "atMs": 1791820811700 + } + }, + { + "name": "box clock not synced", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQA4D7BC9hbsDGWWnl69+YzZWkvCG2DyNVfPtNFNW4O27uftmRBxY0ewGRUt+ilCSn+mRKmIIqpbBh0wAgJxCVaI=\"}}", + "sig": "0e03ec10bd85bb031965a797af7e63365692f086d83c8d55f3ed345356e0edbbb9fb66441c58d1ec06454b7e8a50929fe9912a6208aa96c1874c00809c4255a2", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "box clock ahead of the phone", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWJqqYmxwa2ozSFpPUk5DV0hzY3NlcQ1jc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDX7iBnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEDNcUjPNmeqdosZjUen9w+4n17dD+hThNnqvSLF0A89nUqTWpscn7BRZybd7FyfIHQ8We5wyuZLHQYmf4s2Ggd5\"}}", + "sig": "cd7148cf3667aa768b198d47a7f70fb89f5edd0fe85384d9eabd22c5d00f3d9d4a935a9b1c9fb0516726ddec5c9f20743c59ee70cae64b1d06267f8b361a0779", + "nowMs": 1791820817999, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "a kind the phone does not know", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKSrYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQ5jc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcGNoYXJnaW5nLnNvbWVkYXlmd2FsbE1zGwAAAaEw18r4Z2V2ZW50SWRsUTJack1qUjBkelZoaHNldmVyaXR5ZGluZm9odXB0aW1lTXMaAAEVWGF0ZWV2ZW50Y3NpZ1hAuiASso5HdVyi4+dXlmi5PKDjO5DBTXiaChzNJqz5l/nXU7o++4oPDzfzx3k0Ra0tM3vXtZ2GoHJafIP+dypV6g==\"}}", + "sig": "ba2012b28e47755ca2e3e7579668b93ca0e33b90c14d789a0a1ccd26acf997f9d753ba3efb8a0f0f37f3c7793445ad2d337bd7b59d86a0725a7c83fe772a55ea", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "unreachable, from the service", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"kind\":\"box.unreachable\"}}", + "sig": "", + "nowMs": 1791820800000, + "want": { + "verdict": "unreachable", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "another kind from the service", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"kind\":\"charging.connected\"}}", + "sig": "", + "nowMs": 1791820800000, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "a home the phone does not hold", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"aaaaaaaaaaaaaaaaaaaaaaaaaa\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWECFrKOjjhGZn6O0MCWuzT1paANIFK44ZLUd7mRqIzBcRgdME/ZjLp6jHx96zZY87aqltd41D9CG4Fn1IrGtzuNz\"}}", + "sig": "85aca3a38e11999fa3b43025aecd3d6968034814ae3864b51dee646a23305c46074c13f6632e9ea31f1f7acd963cedaaa5b5de350fd086e059f522b1adcee373", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "aaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ] +} diff --git a/protocol/push-catalogue.yaml b/protocol/push-catalogue.yaml new file mode 100644 index 0000000..8473df4 --- /dev/null +++ b/protocol/push-catalogue.yaml @@ -0,0 +1,62 @@ +# The push notification catalogue — every sentence a phone's lock screen +# may carry, and the one place they are written. +# +# The app owns all prose; the box sends codes. Push is the sole exception, +# stated in CLAUDE.md since the beginning: a push must render text when the +# app is not running, so the box renders it — FROM THIS FILE, never from +# words of its own. The box's copy is byte-identical and CI on both sides +# compares them, exactly as contract/registry.yaml is paired. +# +# `{arg}` placeholders are filled by the box from the event's own facts. +# Every event here must be sparse by design: a notification is an +# interruption, and a household that mutes the app because it nagged has +# lost every notification that mattered. +version: 1 +events: + - kind: charging.connected + title: Car plugged in + body: "Open to see charging status, check the battery level and set a ready time." + - kind: charging.session_complete + title: Car charged + body: "{kwh} kWh delivered — ready to go." + - kind: charging.goal_at_risk + # Only when the box knows the car's level, from the car or from someone + # who set it, and the plan cannot reach the goal by the ready time. An + # assumed level would make this a guess. Once per plug-in. + title: The car may not be ready + body: "It will not reach {target} % by {by} at this pace." + - kind: charging.interrupted + # Only after the hysteresis window the box applies: a charge that had + # been running steadily and then stopped without finishing. A flapping + # cable must not become a night of lock-screen noise. + title: Charging stopped early + body: "The car stopped charging before it was done." + - kind: update.installed + title: Your box updated itself + body: "Now running {version}. Everything came back on its own." + - kind: driver.offline + # After the box's own threshold: ten minutes of silence from a driver + # that had been reporting. A blip must not become a lock-screen. + title: A device went quiet + body: "{name} stopped answering." + - kind: fuse.over_limit + # After the box's own threshold: thirty seconds over the rating. A + # kettle must not page anyone. + title: The house is drawing too much + body: "{phase} is over the fuse rating." + - kind: box.unreachable + # The one sentence the box cannot send about itself. The relay holds + # this pre-encrypted and posts it only when the box has missed its + # deadline — the dead man's switch, and the only stored row the relay + # has. The box encrypts it before it ever leaves home. + title: Your box is out of reach + body: "It stopped answering. Power cut, or the internet at home is down." + - kind: cloud.granted + # To every owner, when an owner turns FTW Cloud on. Sharing a readable + # copy of the house is a choice every owner should hear about. + title: FTW Cloud is on + body: "Your box now shares a copy of its readings with FTW Cloud. You can turn it off in the app or on the box." + - kind: cloud.revoked + # To every owner, when FTW Cloud is turned off at either door. + title: FTW Cloud is off + body: "Your box stopped sharing with FTW Cloud and asked it to delete its copy." From c6bf01cfb81d28d4bcd94f110bdd5e3def44dad7 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 08:10:33 +0200 Subject: [PATCH 09/21] iPhone M3: a notification service extension writes every push FTWNotify (energy.ftw.app.notify) links FTWShared only and holds the App Group alone. It checks each push with PushAlert, drops one PushSeen has shown, and delivers new content: the words from PushText in the person's language, the category set every time ("" when the event is not verified), the interruption level from the kind, one thread per home, and the checked fields beside the original payload in userInfo. Out of time, it shows the generic line with no buttons. The target follows the widget's: a synchronized folder, an Info.plist that reads the group from FTW_APP_GROUP, embedded in the app, so the shared scheme builds it. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW.xcodeproj/project.pbxproj | 137 +++++++++++++++++++ appleApp/FTWNotify-Info.plist | 20 +++ appleApp/FTWNotify.entitlements | 10 ++ appleApp/FTWNotify/NotificationService.swift | 62 +++++++++ appleApp/FTWNotify/PrivacyInfo.xcprivacy | 23 ++++ 5 files changed, 252 insertions(+) create mode 100644 appleApp/FTWNotify-Info.plist create mode 100644 appleApp/FTWNotify.entitlements create mode 100644 appleApp/FTWNotify/NotificationService.swift create mode 100644 appleApp/FTWNotify/PrivacyInfo.xcprivacy diff --git a/appleApp/FTW.xcodeproj/project.pbxproj b/appleApp/FTW.xcodeproj/project.pbxproj index f3b1b06..475f9e4 100644 --- a/appleApp/FTW.xcodeproj/project.pbxproj +++ b/appleApp/FTW.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ F7A000000000000000000065 /* NOTICE in Copy the license texts */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000061 /* NOTICE */; }; F7A000000000000000000066 /* LICENSING.md in Copy the license texts */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000062 /* LICENSING.md */; }; F7A000000000000000000034 /* FTWWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000040 /* FTWWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F7A000000000000000000070 /* FTWShared in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000071 /* FTWShared */; }; + F7A000000000000000000072 /* FTWNotify.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000074 /* FTWNotify.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -24,6 +26,13 @@ remoteGlobalIDString = F7A000000000000000000050; remoteInfo = FTWWidgets; }; + F7A000000000000000000073 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F7A000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F7A000000000000000000078; + remoteInfo = FTWNotify; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -34,6 +43,7 @@ dstSubfolderSpec = 13; files = ( F7A000000000000000000034 /* FTWWidgets.appex in Embed Foundation Extensions */, + F7A000000000000000000072 /* FTWNotify.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -64,6 +74,9 @@ F7A000000000000000000061 /* NOTICE */ = {isa = PBXFileReference; lastKnownFileType = text; name = NOTICE; path = ../NOTICE; sourceTree = SOURCE_ROOT; }; F7A000000000000000000062 /* LICENSING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = LICENSING.md; path = ../LICENSING.md; sourceTree = SOURCE_ROOT; }; F7A000000000000000000040 /* FTWWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FTWWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + F7A000000000000000000074 /* FTWNotify.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FTWNotify.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + F7A000000000000000000075 /* FTWNotify.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FTWNotify.entitlements; sourceTree = ""; }; + F7A000000000000000000076 /* FTWNotify-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "FTWNotify-Info.plist"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -77,6 +90,11 @@ path = FTWWidgets; sourceTree = ""; }; + F7A000000000000000000077 /* FTWNotify */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FTWNotify; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -97,6 +115,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F7A00000000000000000007A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F7A000000000000000000070 /* FTWShared in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -105,11 +131,14 @@ children = ( F7A000000000000000000003 /* FTW */, F7A000000000000000000041 /* FTWWidgets */, + F7A000000000000000000077 /* FTWNotify */, F7A000000000000000000008 /* FTW.xcconfig */, F7A000000000000000000006 /* FTW.entitlements */, F7A000000000000000000009 /* FTW-Info.plist */, F7A000000000000000000007 /* FTWWidgets.entitlements */, F7A00000000000000000000A /* FTWWidgets-Info.plist */, + F7A000000000000000000075 /* FTWNotify.entitlements */, + F7A000000000000000000076 /* FTWNotify-Info.plist */, F7A000000000000000000063 /* License texts */, F7A000000000000000000004 /* Products */, ); @@ -130,6 +159,7 @@ children = ( F7A000000000000000000005 /* FTW.app */, F7A000000000000000000040 /* FTWWidgets.appex */, + F7A000000000000000000074 /* FTWNotify.appex */, ); name = Products; sourceTree = ""; @@ -152,6 +182,7 @@ ); dependencies = ( F7A000000000000000000037 /* PBXTargetDependency */, + F7A00000000000000000007C /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( F7A000000000000000000003 /* FTW */, @@ -188,6 +219,29 @@ productReference = F7A000000000000000000040 /* FTWWidgets.appex */; productType = "com.apple.product-type.app-extension"; }; + F7A000000000000000000078 /* FTWNotify */ = { + isa = PBXNativeTarget; + buildConfigurationList = F7A00000000000000000007F /* Build configuration list for PBXNativeTarget "FTWNotify" */; + buildPhases = ( + F7A000000000000000000079 /* Sources */, + F7A00000000000000000007A /* Frameworks */, + F7A00000000000000000007B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F7A000000000000000000077 /* FTWNotify */, + ); + name = FTWNotify; + packageProductDependencies = ( + F7A000000000000000000071 /* FTWShared */, + ); + productName = FTWNotify; + productReference = F7A000000000000000000074 /* FTWNotify.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -204,6 +258,9 @@ F7A000000000000000000050 = { CreatedOnToolsVersion = 27.0; }; + F7A000000000000000000078 = { + CreatedOnToolsVersion = 27.0; + }; }; }; buildConfigurationList = F7A000000000000000000020 /* Build configuration list for PBXProject "FTW" */; @@ -226,6 +283,7 @@ targets = ( F7A000000000000000000010 /* FTW */, F7A000000000000000000050 /* FTWWidgets */, + F7A000000000000000000078 /* FTWNotify */, ); }; /* End PBXProject section */ @@ -245,6 +303,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F7A00000000000000000007B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -286,6 +351,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F7A000000000000000000079 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -294,6 +366,11 @@ target = F7A000000000000000000050 /* FTWWidgets */; targetProxy = F7A000000000000000000035 /* PBXContainerItemProxy */; }; + F7A00000000000000000007C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F7A000000000000000000078 /* FTWNotify */; + targetProxy = F7A000000000000000000073 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -464,6 +541,52 @@ }; name = Release; }; + F7A00000000000000000007D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = FTWNotify.entitlements; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTWNotify-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app.notify; + PRODUCT_NAME = FTWNotify; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + F7A00000000000000000007E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = FTWNotify.entitlements; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTWNotify-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app.notify; + PRODUCT_NAME = FTWNotify; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -494,6 +617,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + F7A00000000000000000007F /* Build configuration list for PBXNativeTarget "FTWNotify" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7A00000000000000000007D /* Debug */, + F7A00000000000000000007E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ @@ -519,6 +651,11 @@ package = F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */; productName = FTWShared; }; + F7A000000000000000000071 /* FTWShared */ = { + isa = XCSwiftPackageProductDependency; + package = F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */; + productName = FTWShared; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = F7A000000000000000000001 /* Project object */; diff --git a/appleApp/FTWNotify-Info.plist b/appleApp/FTWNotify-Info.plist new file mode 100644 index 0000000..041c793 --- /dev/null +++ b/appleApp/FTWNotify-Info.plist @@ -0,0 +1,20 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + FTWAppGroup + $(FTW_APP_GROUP) + CFBundleLocalizations + + en + sv + + + diff --git a/appleApp/FTWNotify.entitlements b/appleApp/FTWNotify.entitlements new file mode 100644 index 0000000..04c6e8a --- /dev/null +++ b/appleApp/FTWNotify.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + $(FTW_APP_GROUP) + + + diff --git a/appleApp/FTWNotify/NotificationService.swift b/appleApp/FTWNotify/NotificationService.swift new file mode 100644 index 0000000..3340353 --- /dev/null +++ b/appleApp/FTWNotify/NotificationService.swift @@ -0,0 +1,62 @@ +import Foundation +import FTWShared +import os +import UserNotifications + +/// Writes every FTW notification on the phone. +/// +/// FTW Cloud forwards the box's signed event and nothing of its own. This +/// checks the signature with the box key the app left in the App Group, +/// writes the words from the kind in the person's language, and picks the +/// buttons from a verified kind alone. Anything that does not check out +/// shows the generic line with no buttons. It links FTWShared only: no +/// session, no secret key, nothing on the main actor. +final class NotificationService: UNNotificationServiceExtension { + private let log = Logger(subsystem: "energy.ftw.app", category: "push") + private var deliver: ((UNNotificationContent) -> Void)? + private var userInfo: [AnyHashable: Any] = [:] + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + deliver = contentHandler + userInfo = request.content.userInfo + let nowMs = Date.now.timeIntervalSince1970 * 1000 + let keys = CloudKeys.shared() + let alert = PushAlert.read(userInfo: userInfo, boxKey: { keys?.boxKey(home: $0) }, nowMs: Int64(nowMs)) + log.info("push: \(alert.verdict.rawValue, privacy: .public) \(alert.kind, privacy: .public)") + if let seen = PushSeen.shared(), !seen.accept(alert, nowMs: nowMs) { + // Shown once already. Empty content asks iOS to show nothing. + log.info("push: seen before, seq \(alert.seq, privacy: .public)") + finish(UNNotificationContent()) + return + } + finish(content(PushNotice(alert, home: keys?.read(home: alert.home), language: .preferred()))) + } + + /// Out of time: the generic line, which claims nothing. + override func serviceExtensionTimeWillExpire() { + finish(content(.generic(.preferred()))) + } + + private func finish(_ content: UNNotificationContent) { + guard let deliver else { return } + self.deliver = nil + deliver(content) + } + + /// New content, so nothing the service put in "aps" survives: not a + /// title, not a category, not a priority. The payload stays in + /// userInfo, because the app checks it again before a button acts. + private func content(_ notice: PushNotice) -> UNNotificationContent { + let c = UNMutableNotificationContent() + c.title = notice.title + c.body = notice.body + c.sound = .default + c.categoryIdentifier = notice.category + c.threadIdentifier = notice.thread + c.interruptionLevel = notice.level + var info = userInfo + info[PushNotice.userInfoKey] = notice.verified + c.userInfo = info + return c + } +} diff --git a/appleApp/FTWNotify/PrivacyInfo.xcprivacy b/appleApp/FTWNotify/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..64e785c --- /dev/null +++ b/appleApp/FTWNotify/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + + From 2f66504116a288a91c179b9df458efc64a508925 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 08:17:28 +0200 Subject: [PATCH 10/21] 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 5d114772ea0ed1a8abe74280410c793800515011 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 08:18:59 +0200 Subject: [PATCH 11/21] iPhone M3: a repeated push shows the generic line, quietly The extension first handed iOS empty content for an event it had shown. In the simulator, `simctl push` never starts a service extension, so whether iOS then shows nothing could not be checked there. Without Apple's filtering entitlement, iOS is documented to show the payload's own alert instead, and that one could carry a category the service chose. So a repeat gets the generic line with the category set to none, no sound and the passive level: it lands in Notification Center and interrupts nobody. Dropping it outright needs the filtering entitlement, which Apple grants on request. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWKit/Sources/FTWShared/PushNotice.swift | 13 +++++++++++++ .../FTWKit/Tests/FTWSharedTests/PushTextTests.swift | 9 +++++++++ appleApp/FTWNotify/NotificationService.swift | 5 ++--- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWShared/PushNotice.swift b/appleApp/FTWKit/Sources/FTWShared/PushNotice.swift index f781f59..8f7a0c0 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PushNotice.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PushNotice.swift @@ -93,6 +93,8 @@ public struct PushNotice: Sendable, Equatable { /// payload never shows. public var category: String public var level: UNNotificationInterruptionLevel + /// Whether it makes a sound. + public var sound = true /// One thread per home. public var thread: String /// The checked fields, for the app. The app checks the payload again @@ -120,6 +122,17 @@ public struct PushNotice: Sendable, Equatable { PushNotice(PushAlert(.generic, home: ""), home: nil, language: language) } + /// An event shown before. iOS shows something for every push unless + /// Apple grants the filtering entitlement, and without it empty content + /// brings back the payload's own alert. So: the generic line, quiet, + /// with no buttons, in Notification Center only. + public static func repeated(_ alert: PushAlert, language: PushText.Language) -> PushNotice { + var n = PushNotice(PushAlert(.generic, home: alert.home), home: nil, language: language) + n.level = .passive + n.sound = false + return n + } + /// The key `verified` sits under in the notification's userInfo. public static let userInfoKey = "ftwVerified" diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PushTextTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PushTextTests.swift index 6638a17..c892f7d 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PushTextTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PushTextTests.swift @@ -231,5 +231,14 @@ import Testing #expect(generic.verified.isEmpty) #expect(generic.title == "Något hände hemma") #expect(PushNotice.generic(.en).category == "") + + // Shown before: the generic line, quiet, no buttons, same thread. + let again = PushNotice.repeated(Self.event(PushKind.chargingConnected), language: .sv) + #expect(again.title == "Något hände hemma") + #expect(again.category == "") + #expect(again.level == .passive) + #expect(!again.sound) + #expect(again.thread == "fjzyr67buzpyncqmjliwgezvde") + #expect(connected.sound && unreachable.sound) } } diff --git a/appleApp/FTWNotify/NotificationService.swift b/appleApp/FTWNotify/NotificationService.swift index 3340353..03c0366 100644 --- a/appleApp/FTWNotify/NotificationService.swift +++ b/appleApp/FTWNotify/NotificationService.swift @@ -24,9 +24,8 @@ final class NotificationService: UNNotificationServiceExtension { let alert = PushAlert.read(userInfo: userInfo, boxKey: { keys?.boxKey(home: $0) }, nowMs: Int64(nowMs)) log.info("push: \(alert.verdict.rawValue, privacy: .public) \(alert.kind, privacy: .public)") if let seen = PushSeen.shared(), !seen.accept(alert, nowMs: nowMs) { - // Shown once already. Empty content asks iOS to show nothing. log.info("push: seen before, seq \(alert.seq, privacy: .public)") - finish(UNNotificationContent()) + finish(content(.repeated(alert, language: .preferred()))) return } finish(content(PushNotice(alert, home: keys?.read(home: alert.home), language: .preferred()))) @@ -50,7 +49,7 @@ final class NotificationService: UNNotificationServiceExtension { let c = UNMutableNotificationContent() c.title = notice.title c.body = notice.body - c.sound = .default + c.sound = notice.sound ? .default : nil c.categoryIdentifier = notice.category c.threadIdentifier = notice.thread c.interruptionLevel = notice.level From 2b801227fccadf018cbc1845f7c807fe715c4b45 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 08:18:59 +0200 Subject: [PATCH 12/21] iPhone M3: notification buttons run the M2 command path At launch the app registers two categories in Swedish or English: 20 %, 50 % and 80 % with Open after "Car plugged in", and Charge now with Open after "Charging stopped early" or "The car may not be ready". The commands need the phone unlocked and run in the background; Open brings the app up. A pressed button checks the original payload again with PushAlert, at the time the notification arrived, and acts only for a verified event whose kind offers that button. CloudKeys maps the event's opaque loadpoint to the charger (the only one, if the event names none), and the command goes only to the home the app opens. It takes the path Siri and the Control take: open the session, wait for the stream, send, follow cmd.result. The box's answer is logged, nothing more. `perform` takes the charger a notification names and never swaps it for another; the level was already any whole percent. The push token: a tiny app delegate (the one UIKit piece besides the camera) hands it to PushTokens in FTWKit, which gives it to a PushTokenSink once one is installed. FTW Cloud's channel (C3) will be that sink, and installing it is when the person is asked; until then a release build asks nothing. The app gets aps-environment and the time-sensitive entitlement. Debug builds take launch arguments for the end-to-end test: -FTWPushRegister YES asks and registers at launch and logs the token, and -FTWCloudHome, -FTWCloudBoxKey, -FTWCloudLoadpoint, -FTWCloudSource and -FTWCloudSite seed one home's CloudKeys. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW.entitlements | 4 + appleApp/FTW/FTWApp.swift | 3 + appleApp/FTW/Intents/IntentRunner.swift | 24 ++- appleApp/FTW/Push/Notifications.swift | 175 ++++++++++++++++++ .../FTWKit/State/LoadpointsModel.swift | 16 +- .../Sources/FTWKit/State/PushTokens.swift | 73 ++++++++ .../Tests/FTWKitTests/PushTokensTests.swift | 41 ++++ .../Tests/FTWKitTests/RemoteTests.swift | 26 ++- 8 files changed, 352 insertions(+), 10 deletions(-) create mode 100644 appleApp/FTW/Push/Notifications.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/PushTokens.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/PushTokensTests.swift diff --git a/appleApp/FTW.entitlements b/appleApp/FTW.entitlements index 1411e58..9c1fd78 100644 --- a/appleApp/FTW.entitlements +++ b/appleApp/FTW.entitlements @@ -11,5 +11,9 @@ $(FTW_APP_GROUP) + aps-environment + development + com.apple.developer.usernotifications.time-sensitive + diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index e599fe9..630aab5 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -9,6 +9,9 @@ import WidgetKit struct FTWApp: App { @State private var app: AppModel @Environment(\.scenePhase) private var scenePhase + /// Only for what SwiftUI has no door for: the push token, and buttons + /// on a notification. It starts after `init`, so the runner is there. + @UIApplicationDelegateAdaptor(AppDelegate.self) private var delegate private let network: NetworkWatch init() { diff --git a/appleApp/FTW/Intents/IntentRunner.swift b/appleApp/FTW/Intents/IntentRunner.swift index 5e0d139..ea19a83 100644 --- a/appleApp/FTW/Intents/IntentRunner.swift +++ b/appleApp/FTW/Intents/IntentRunner.swift @@ -46,11 +46,17 @@ final class IntentRunner: CommandRunner { await perform(command) } - private func perform(_ command: RemoteCommand) async -> RemoteOutcome { + /// A notification's button: the same path, on the charger its event + /// named. + func run(_ command: RemoteCommand, loadpointID: String) async -> RemoteOutcome { + await perform(command, loadpointID: loadpointID) + } + + private func perform(_ command: RemoteCommand, loadpointID: String? = nil) async -> RemoteOutcome { guard let home = await begin() else { return RemoteOutcome(.failed, ChargeNowActivity.openAndRetry) } defer { end() } let started = ContinuousClock.now - let out = await home.loadpoints.perform(command) + let out = await home.loadpoints.perform(command, loadpointID: loadpointID) Self.log.info("\(String(describing: command), privacy: .public) answered in \((ContinuousClock.now - started) / .milliseconds(1), format: .fixed(precision: 0), privacy: .public) ms") return out } @@ -74,6 +80,20 @@ final class IntentRunner: CommandRunner { app.home?.loadpoints.answer(locked: DeviceLock.isLocked) } + /// Whether `siteId` is the home this app opens. A command from a push + /// goes only there; the demo never takes one, except in a debug build + /// that seeded it as "demo" for a test. + func opens(siteId: String) -> Bool { + if app.isDemo { + #if DEBUG + return siteId == "demo" + #else + return false + #endif + } + return (app.site?.siteId ?? app.sites.current()?.siteId) == siteId + } + /// The home, with a session on its way. A launch for an intent has /// already started connecting; an app whose session was closed in the /// background opens a new one. diff --git a/appleApp/FTW/Push/Notifications.swift b/appleApp/FTW/Push/Notifications.swift new file mode 100644 index 0000000..34f7f9f --- /dev/null +++ b/appleApp/FTW/Push/Notifications.swift @@ -0,0 +1,175 @@ +import FTWKit +import os +import UIKit +import UserNotifications + +/// The one UIKit piece besides the camera: Apple hands the push token to an +/// app delegate and nowhere else. +final class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + Notifications.start() + return true + } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Notifications.received(token: deviceToken) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { + Notifications.log.notice("push: no token: \(error.localizedDescription, privacy: .public)") + } +} + +/// FTW's notifications on the app's side: the buttons, the token, and what a +/// button does. The words are the extension's (FTWNotify). +@MainActor +enum Notifications { + static let log = Logger(subsystem: "energy.ftw.app", category: "push") + /// The center holds its delegate weakly. + private static let delegate = NotificationButtons() + + /// A development build talks to Apple's sandbox. + static var environment: PushEnvironment { + #if DEBUG + return .sandbox + #else + return .production + #endif + } + + /// At launch, before a button pressed on the lock screen is handed over. + static func start() { + let center = UNUserNotificationCenter.current() + center.delegate = delegate + center.setNotificationCategories(categories(.preferred())) + PushTokens.setAsk { await askAndRegister() } + #if DEBUG + seedFromLaunchArguments() + if UserDefaults.standard.bool(forKey: "FTWPushRegister") { + Task { await askAndRegister() } + return + } + #endif + // Allowed before: a new launch can bring a new token. Nobody is + // asked here; FTW Cloud asks when it is turned on (PushTokens). + Task { + let status = await center.notificationSettings().authorizationStatus + if status == .authorized || status == .provisional || status == .ephemeral { + UIApplication.shared.registerForRemoteNotifications() + } + } + } + + static func askAndRegister() async { + do { + let allowed = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) + log.info("push: notifications \(allowed ? "allowed" : "not allowed", privacy: .public)") + if allowed { UIApplication.shared.registerForRemoteNotifications() } + } catch { + log.notice("push: could not ask: \(error.localizedDescription, privacy: .public)") + } + } + + static func received(token: Data) { + #if DEBUG + log.notice("push token \(token.byteArray.hex, privacy: .public) (\(environment.rawValue, privacy: .public))") + #endif + Task { await PushTokens.received(token, environment: environment) } + } + + /// The level and Charge now run behind the lock screen once the phone is + /// unlocked; Open brings the app up. + static func categories(_ language: PushText.Language) -> Set { + Set(PushActions.Category.allCases.map { category in + UNNotificationCategory( + identifier: category.rawValue, + actions: category.actions.map { action in + UNNotificationAction( + identifier: action.rawValue, + title: PushActions.title(action, language), + options: PushActions.opensApp(action) ? [.foreground] : [.authenticationRequired], + icon: UNNotificationActionIcon(systemImageName: PushActions.symbol(action)) + ) + }, + intentIdentifiers: [] + ) + }) + } + + /// A button was pressed. The payload is checked again, with the time it + /// arrived, and the button acts only for a verified event whose kind + /// offers it, on the charger the event named, in the home this app + /// opens. The box's answer is logged; nothing here claims more. + static func act(_ action: PushActions.Action, _ command: RemoteCommand, alert: PushAlert, home: CloudHome?) async { + guard PushActions.allows(action, for: alert) else { + log.notice("push: \(action.rawValue, privacy: .public) not sent: \(alert.verdict.rawValue, privacy: .public) \(alert.kind, privacy: .public)") + return + } + guard let home, let loadpointID = home.loadpointID(opaque: alert.lp) else { + log.notice("push: \(action.rawValue, privacy: .public) not sent: no charger for this event on this phone") + return + } + guard let runner = IntentRunner.shared, runner.opens(siteId: home.siteId) else { + log.notice("push: \(action.rawValue, privacy: .public) not sent: not the home this app opens") + return + } + let out = await runner.run(command, loadpointID: loadpointID) + log.info("push: \(action.rawValue, privacy: .public): \(String(describing: out.kind), privacy: .public): \(out.text, privacy: .public)") + } + + #if DEBUG + /// Until the box's session info fills CloudKeys (FTW Cloud C3), a debug + /// build can hold one home for an end-to-end test: + /// + /// -FTWCloudHome -FTWCloudBoxKey + /// -FTWCloudLoadpoint =[,…] + /// -FTWCloudSource =[,…] -FTWCloudSite + /// + /// The site is the open home's by default, or "demo" with `-FTWDemo YES`. + static func seedFromLaunchArguments() { + let d = UserDefaults.standard + guard let home = d.string(forKey: "FTWCloudHome"), let text = d.string(forKey: "FTWCloudBoxKey") else { return } + func pairs(_ key: String) -> [String: String] { + var out = [String: String]() + for pair in (d.string(forKey: key) ?? "").split(separator: ",") { + let parts = pair.split(separator: "=", maxSplits: 1).map(String.init) + if parts.count == 2 { out[parts[0]] = parts[1] } + } + return out + } + let site = d.string(forKey: "FTWCloudSite") ?? (d.bool(forKey: "FTWDemo") ? "demo" : IntentRunner.shared?.app.sites.current()?.siteId ?? "") + guard let keys = CloudKeys.shared() else { + log.error("push: not seeded: no App Group") + return + } + do { + let row = CloudHome(home: home, siteId: site, boxKey: Data(try Base64url.decode(text)), loadpoints: pairs("FTWCloudLoadpoint"), sources: pairs("FTWCloudSource")) + try keys.write(row) + log.notice("push: seeded a home for \(site, privacy: .public) with \(row.loadpoints.count, privacy: .public) chargers") + } catch { + log.error("push: not seeded: \(String(describing: error), privacy: .public)") + } + } + #endif +} + +/// Where the system hands a pressed button and a notification that arrives +/// while FTW is open. +final class NotificationButtons: NSObject, UNUserNotificationCenterDelegate { + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + // A tap on the notification, Open, or a dismissal asks the box + // nothing. + guard let action = PushActions.Action(rawValue: response.actionIdentifier), let command = PushActions.command(action) else { return } + let keys = CloudKeys.shared() + let alert = PushAlert.read( + userInfo: response.notification.request.content.userInfo, + boxKey: { keys?.boxKey(home: $0) }, + nowMs: Int64(response.notification.date.timeIntervalSince1970 * 1000) + ) + await Notifications.act(action, command, alert: alert, home: keys?.read(home: alert.home)) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { + [.banner, .list, .sound] + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index e319570..79c55f8 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -507,11 +507,13 @@ public final class LoadpointsModel: Activatable { /// take 20 of them. public static let remoteStreamWaitMs: Double = 10_000 - /// A command from a Control, Siri or the Live Activity. The caller has - /// opened the session; this waits for the stream, reads the chargers, - /// sends the command exactly as the car view would, and says how it - /// ended. The Live Activity moves as it does for a tap in the car view. - public func perform(_ command: RemoteCommand, waitMs: Double = LoadpointsModel.remoteStreamWaitMs) async -> RemoteOutcome { + /// A command from a Control, Siri, the Live Activity or a notification. + /// The caller has opened the session; this waits for the stream, reads + /// the chargers, sends the command exactly as the car view would, and + /// says how it ended. The Live Activity moves as it does for a tap in + /// the car view. A notification names its charger in `loadpointID`; + /// the others let the box's account pick one. + public func perform(_ command: RemoteCommand, loadpointID: String? = nil, waitMs: Double = LoadpointsModel.remoteStreamWaitMs) async -> RemoteOutcome { guard await streaming(within: waitMs) else { return RemoteOutcome(.failed, AppWords.text("Not connected to FTW right now. Try again when the connection is back.")) } @@ -523,7 +525,9 @@ public final class LoadpointsModel: Activatable { } catch { return RemoteOutcome(.failed, self.error ?? EVText.failure(error)) } - guard let lp = Self.target(command, in: points) else { + // A charger named and not found is not swapped for another. + let named = loadpointID.flatMap { id in points.first { $0.id == id } } + guard let lp = loadpointID == nil ? Self.target(command, in: points) : named else { return RemoteOutcome(.failed, Self.say("FTW has no charger to control.")) } switch command { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PushTokens.swift b/appleApp/FTWKit/Sources/FTWKit/State/PushTokens.swift new file mode 100644 index 0000000..4198e0d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/PushTokens.swift @@ -0,0 +1,73 @@ +import Foundation +import Synchronization + +/// Which of Apple's push services a token belongs to. A development build +/// gets sandbox tokens. +public enum PushEnvironment: String, Sendable { + case sandbox, production +} + +/// Takes the phone's APNs token. FTW Cloud's channel is the one that will: +/// it tells the service, over the app session, where Apple can reach this +/// phone. +public protocol PushTokenSink: Sendable { + func register(apnsToken: Data, environment: PushEnvironment) async +} + +/// The token Apple gave this phone, and who takes it. +/// +/// Nothing asks for notifications until something wants them: a sink +/// arrives when FTW Cloud is on, and only then is the person asked. Once +/// they said yes, Apple hands a token at every launch. A sink installed +/// later gets the latest at once. Kept in memory: Apple gives it again at +/// the next launch. +public enum PushTokens { + public struct Token: Sendable, Equatable { + public var data: Data + public var environment: PushEnvironment + } + + private struct State { + var sink: (any PushTokenSink)? + var token: Token? + var ask: (@Sendable () async -> Void)? + } + + private static let state = Mutex(State()) + + /// The app sets this at launch: ask the person to allow notifications, + /// then register with Apple. + public static func setAsk(_ ask: @escaping @Sendable () async -> Void) { + state.withLock { $0.ask = ask } + } + + /// Something wants pushes. It gets the token now if there is one and + /// whenever it changes; the person is asked if nobody has asked yet. + public static func install(_ sink: any PushTokenSink) async { + let (token, ask) = state.withLock { s in + s.sink = sink + return (s.token, s.ask) + } + if let token { await sink.register(apnsToken: token.data, environment: token.environment) } + await ask?() + } + + /// Nothing wants pushes any more. + public static func uninstall() { + state.withLock { $0.sink = nil } + } + + /// Apple gave a token. The same token twice is handed over once. + public static func received(_ data: Data, environment: PushEnvironment) async { + let token = Token(data: data, environment: environment) + let sink = state.withLock { s -> (any PushTokenSink)? in + defer { s.token = token } + return s.token == token ? nil : s.sink + } + await sink?.register(apnsToken: data, environment: environment) + } + + public static var current: Token? { + state.withLock { $0.token } + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/PushTokensTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/PushTokensTests.swift new file mode 100644 index 0000000..1c92941 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/PushTokensTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Synchronization +import Testing +@testable import FTWKit + +/// The APNs token's way to FTW Cloud: whoever wants it gets the latest, once +/// per change, and the person is asked only when something wants pushes. +@Suite struct PushTokensTests { + final class Sink: PushTokenSink { + let got = Mutex<[PushTokens.Token]>([]) + + func register(apnsToken: Data, environment: PushEnvironment) async { + got.withLock { $0.append(PushTokens.Token(data: apnsToken, environment: environment)) } + } + } + + @Test func theSinkGetsEachTokenOnce() async { + let asked = Mutex(0) + PushTokens.setAsk { asked.withLock { $0 += 1 } } + defer { PushTokens.uninstall() } + let a = Data([1, 2, 3]) + let b = Data([4, 5, 6]) + + // A token from an earlier yes, before anything wants it. + await PushTokens.received(a, environment: .sandbox) + #expect(asked.withLock { $0 } == 0) + #expect(PushTokens.current == PushTokens.Token(data: a, environment: .sandbox)) + + let sink = Sink() + await PushTokens.install(sink) + #expect(asked.withLock { $0 } == 1) + // Apple hands the same token again after the ask: not repeated. + await PushTokens.received(a, environment: .sandbox) + await PushTokens.received(b, environment: .sandbox) + #expect(sink.got.withLock { $0 } == [.init(data: a, environment: .sandbox), .init(data: b, environment: .sandbox)]) + + PushTokens.uninstall() + await PushTokens.received(a, environment: .production) + #expect(sink.got.withLock { $0 }.count == 2) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift index f3a5246..582f259 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift @@ -51,8 +51,8 @@ import FTWShared /// Run a command the way an intent does, from a cold app: nothing /// read yet, the stream not yet up. - func perform(_ command: RemoteCommand, _ loadpoints: LoadpointsModel, for ms: Double = 5_000) async -> RemoteOutcome { - let task = Task { await loadpoints.perform(command) } + func perform(_ command: RemoteCommand, _ loadpoints: LoadpointsModel, loadpointID: String? = nil, for ms: Double = 5_000) async -> RemoteOutcome { + let task = Task { await loadpoints.perform(command, loadpointID: loadpointID) } await run(ms) return await task.value } @@ -187,6 +187,28 @@ import FTWShared } } + /// A notification's 50 %: any whole level, on the charger its event + /// named. A charger the box does not have is never swapped for one it + /// does. + @Test func aNotificationNamesItsCharger() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + let loadpoints = try rig.start() + let out = await rig.perform(.setLevel(pct: 50), loadpoints, loadpointID: "garage") + #expect(out.kind == .done) + let cmd = try #require(rig.wire?.commands.last) + #expect(cmd.op == Contract.opLoadpointSocSet) + #expect(cmd.args["soc"]?.double == 0.5) + #expect(cmd.args["id"]?.string == "garage") + + let sent = rig.wire?.commands.count + let elsewhere = await rig.perform(.chargeNow, loadpoints, loadpointID: "street") + #expect(elsewhere.kind == .failed) + #expect(elsewhere.text == "FTW has no charger to control.") + #expect(rig.wire?.commands.count == sent) + } + } + /// The box refuses a level for a car that is not plugged in; Siri says /// what to do. @Test func aRefusalIsAFailureThatSaysWhatToDo() async throws { From 3bf6347365e1917fa5f782af8d9df957b44a4f16 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 08:43:05 +0200 Subject: [PATCH 13/21] iPhone M3: a debug build can take a pairing link at launch `-FTWPairLink ` offers the link as a scanned QR would. The simulator has no camera, and the FTW Cloud C4 end-to-end test pairs a simulator with a local Core this way. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/FTWApp.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index 630aab5..9652d84 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -38,6 +38,9 @@ struct FTWApp: App { #if DEBUG // For screenshots: `-FTWDemo YES` opens the simulated home. if UserDefaults.standard.bool(forKey: "FTWDemo") { model.startDemo() } + // For end-to-end tests: `-FTWPairLink ` offers a pairing link + // as a scanned QR would, where the simulator has no camera. + if let link = UserDefaults.standard.string(forKey: "FTWPairLink") { model.offer(link) } #endif _app = State(initialValue: model) network = NetworkWatch { [weak model] in model?.networkOnline() } From fca88b0649be66aec14882d688a65f4a97f18183 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 08:55:05 +0200 Subject: [PATCH 14/21] 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 15/21] 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 16/21] 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 17/21] 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 1b79dfc9214deeabd1864fb356a71d2052898718 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 10:12:13 +0200 Subject: [PATCH 18/21] test: the recording carrier returns what send returns C3 made Carrier.send return whether the frame went out; M2's test carrier still returned nothing, so the merged tests did not build. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift index 582f259..06cc24f 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift @@ -335,11 +335,12 @@ final class RecordingCarrier: Carrier { var kind: CarrierKind { inner.kind } var status: CarrierStatus { inner.status } - func send(_ frame: Bytes) { + @discardableResult + func send(_ frame: Bytes) -> Bool { if let d = try? Frame.decode(frame), d.envelope.t == "cmd", let b = d.envelope.b { commands.append(Command(op: b["op"]?.string ?? "", args: b["args"] ?? CBOR.emptyMap, notValidAfterMs: b["notValidAfterMs"]?.double, uptimeMs: uptimeMs)) } - inner.send(frame) + return inner.send(frame) } func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void) { From 31c63d762df6cce96ace9b157008c16353e69a7f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 10:12:13 +0200 Subject: [PATCH 19/21] feat(cloud): the app's FTW Cloud session carries its push token Joins C3 (the app reads its home from FTW Cloud) and C4 (notifications through APNs). - When the box's hello says FTW Cloud is on, the site model writes the box key, the names and rule U's anchor to CloudKeys for the notification extension; the anchor follows at each snapshot write. Turning FTW Cloud off removes the entry. The demo writes none. - While FTW Cloud is on, the cloud session is PushTokens' sink. It sends push.register when the session opens, at every reopening and when Apple gives a new token. The service keeps one row per token. - PushAlert dates an event by rule U when the box sent no wallMs, or a wallMs too far ahead, as ReadAlert now does in srcfl/ftw. The four new push vectors replay here. - CloudWire.verify calls PushAlert's ES256 check, so a reading and a push are checked by one function. - The debug launch arguments that seeded CloudKeys are gone; the box fills them now. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Push/Notifications.swift | 37 +----- .../Sources/FTWKit/Carrier/CloudCarrier.swift | 12 ++ .../Sources/FTWKit/Carrier/CloudWire.swift | 20 ++- .../Sources/FTWKit/State/SiteModel.swift | 93 +++++++++++++- .../FTWKit/Sources/FTWShared/CloudKeys.swift | 29 ++++- .../FTWKit/Sources/FTWShared/PushAlert.swift | 61 +++++++--- .../Tests/FTWKitTests/CloudCarrierTests.swift | 5 + .../Tests/FTWKitTests/CloudPushTests.swift | 114 ++++++++++++++++++ .../FTWKitTests/SiteModelCloudTests.swift | 2 + .../Tests/FTWSharedTests/PushAlertTests.swift | 18 ++- .../Resources/push-vectors.json | 76 ++++++++++++ appleApp/FTWNotify/NotificationService.swift | 2 +- 12 files changed, 391 insertions(+), 78 deletions(-) create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/CloudPushTests.swift diff --git a/appleApp/FTW/Push/Notifications.swift b/appleApp/FTW/Push/Notifications.swift index 34f7f9f..88adf16 100644 --- a/appleApp/FTW/Push/Notifications.swift +++ b/appleApp/FTW/Push/Notifications.swift @@ -44,7 +44,6 @@ enum Notifications { center.setNotificationCategories(categories(.preferred())) PushTokens.setAsk { await askAndRegister() } #if DEBUG - seedFromLaunchArguments() if UserDefaults.standard.bool(forKey: "FTWPushRegister") { Task { await askAndRegister() } return @@ -116,41 +115,6 @@ enum Notifications { let out = await runner.run(command, loadpointID: loadpointID) log.info("push: \(action.rawValue, privacy: .public): \(String(describing: out.kind), privacy: .public): \(out.text, privacy: .public)") } - - #if DEBUG - /// Until the box's session info fills CloudKeys (FTW Cloud C3), a debug - /// build can hold one home for an end-to-end test: - /// - /// -FTWCloudHome -FTWCloudBoxKey - /// -FTWCloudLoadpoint =[,…] - /// -FTWCloudSource =[,…] -FTWCloudSite - /// - /// The site is the open home's by default, or "demo" with `-FTWDemo YES`. - static func seedFromLaunchArguments() { - let d = UserDefaults.standard - guard let home = d.string(forKey: "FTWCloudHome"), let text = d.string(forKey: "FTWCloudBoxKey") else { return } - func pairs(_ key: String) -> [String: String] { - var out = [String: String]() - for pair in (d.string(forKey: key) ?? "").split(separator: ",") { - let parts = pair.split(separator: "=", maxSplits: 1).map(String.init) - if parts.count == 2 { out[parts[0]] = parts[1] } - } - return out - } - let site = d.string(forKey: "FTWCloudSite") ?? (d.bool(forKey: "FTWDemo") ? "demo" : IntentRunner.shared?.app.sites.current()?.siteId ?? "") - guard let keys = CloudKeys.shared() else { - log.error("push: not seeded: no App Group") - return - } - do { - let row = CloudHome(home: home, siteId: site, boxKey: Data(try Base64url.decode(text)), loadpoints: pairs("FTWCloudLoadpoint"), sources: pairs("FTWCloudSource")) - try keys.write(row) - log.notice("push: seeded a home for \(site, privacy: .public) with \(row.loadpoints.count, privacy: .public) chargers") - } catch { - log.error("push: not seeded: \(String(describing: error), privacy: .public)") - } - } - #endif } /// Where the system hands a pressed button and a notification that arrives @@ -164,6 +128,7 @@ final class NotificationButtons: NSObject, UNUserNotificationCenterDelegate { let alert = PushAlert.read( userInfo: response.notification.request.content.userInfo, boxKey: { keys?.boxKey(home: $0) }, + offset: { keys?.offset(home: $0) }, nowMs: Int64(response.notification.date.timeIntervalSince1970 * 1000) ) await Notifications.act(action, command, alert: alert, home: keys?.read(home: alert.home)) diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift index 1016d08..6eb97db 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift @@ -106,6 +106,18 @@ public final class CloudCarrier { send(on ? CloudWire.msgSuspend : CloudWire.msgUnsuspend, .map([("deviceId", .text(deviceID))])) } + /// Tell the service where Apple can reach this phone, for alerts. The + /// service binds the token to this phone's roster entry, so a phone + /// that may not read events registers nothing, and keeps one row per + /// token, so sending it again changes nothing. False when the session + /// is not open and nothing went out. + @discardableResult + public func pushRegister(apnsToken: Data, environment: PushEnvironment) -> Bool { + send(CloudWire.msgPushRegister, .map([ + ("apnsToken", .bytes(Bytes(apnsToken))), ("environment", .text(environment.rawValue)), ("kind", .text(CloudWire.pushKindAlert)), + ])) + } + /// 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() { diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift index 6e9664e..5b54794 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift @@ -1,9 +1,4 @@ import Foundation -#if canImport(CryptoKit) -import CryptoKit -#else -import Crypto -#endif import FTWShared /// The FTW Cloud wire, as a phone reads it. @@ -71,6 +66,11 @@ public enum CloudWire { /// An owner's, `{deviceId}`. They only narrow access. public static let msgSuspend = "suspend" public static let msgUnsuspend = "unsuspend" + /// Phone to service, `{apnsToken, environment, kind}`: where Apple can + /// reach this phone. The service binds it to the phone's roster entry. + public static let msgPushRegister = "push.register" + /// The only kind of push registration so far. + static let pushKindAlert = "alert" public static let hzLooking = 1 public static let hzAway = 0 @@ -216,13 +216,11 @@ public enum CloudWire { /// 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. + /// box gave this phone over Noise, never one from the service. The + /// notification extension's check, so a push and a reading are checked + /// alike. 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))) + PushAlert.verify(boxKey: boxKey, home: home, t: envelope.t, b: envelope.b, sig: envelope.sig) } // MARK: - Ids diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 8ea4707..d8c178a 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -128,6 +128,14 @@ public final class SiteModel { /// When the newest cloud envelope arrived, for the time of a reading the /// anchor cannot place. @ObservationIgnored private var cloudArrivalMs: Double? + /// What the notification extension was last given for this home. + @ObservationIgnored private var sharedCloudHome: CloudHome? + /// Whether FTW Cloud's session takes the phone's APNs token, and the + /// token, sent again at every opening. + @ObservationIgnored private var pushSinkOn = false + @ObservationIgnored private var pushToken: PushTokens.Token? + /// Where the token comes from: `PushTokens`, unless a test hands its own. + @ObservationIgnored var pushTokens = PushTokenSource.app /// Where the readings on screen came from at the last change. @ObservationIgnored private(set) var source: ReadingSource = .relay @ObservationIgnored private var startedAtMs: Double = 0 @@ -458,6 +466,7 @@ public final class SiteModel { if cloudDirty, let identity = cloudIdentity { cloudDirty = false files.writeJSON("cloud-\(siteId)", CloudCache(identity: identity, offset: ruleU), wait: wait) + shareCloudHome() } guard session.phase == .streaming || !session.fields.isEmpty, cachedAtMs == nil else { return } // The cloud's ages move with the clock; take them as of now. @@ -838,6 +847,23 @@ private final class StreamWait { // MARK: - FTW Cloud +/// Where a site model gets the phone's APNs token. +struct PushTokenSource: Sendable { + var install: @Sendable (any PushTokenSink) async -> Void + var uninstall: @Sendable () -> Void + + static let app = PushTokenSource(install: { await PushTokens.install($0) }, uninstall: { PushTokens.uninstall() }) +} + +/// The site model's end of `PushTokens`, which calls it off the main actor. +private struct CloudPushSink: PushTokenSink { + let deliver: @MainActor @Sendable (PushTokens.Token) -> Void + + func register(apnsToken: Data, environment: PushEnvironment) async { + await deliver(PushTokens.Token(data: apnsToken, environment: environment)) + } +} + /// Where the readings on screen come from. enum ReadingSource: Sendable { case relay, cloud @@ -847,11 +873,8 @@ enum ReadingSource: Sendable { /// 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 - } + /// The notification extension dates events with the same anchor. + typealias Offset = CloudHome.Offset var identity: CloudIdentity var offset: Offset? @@ -889,12 +912,14 @@ extension SiteModel { onStatus: { [weak self] status in guard let self else { return } self.cloudStatus = status + if status.isOpen { self.registerPush() } self.publish() self.scheduleClock() } ) cloud = c cloudStatus = c.status + installPushSink() c.start() } @@ -923,8 +948,12 @@ extension SiteModel { /// 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. + // Off, or never on. What the cloud delivered goes with it, and + // so do its pushes. closeCloud() + if let home = cloudIdentity?.home ?? sharedCloudHome?.home { cloudKeys?.remove(home: home) } + sharedCloudHome = nil + uninstallPushSink() cloudView = nil cloudIdentity = nil ruleU = nil @@ -934,11 +963,63 @@ extension SiteModel { return } adoptCloud(identity, offset: nil) + shareCloudHome() cloudDirty = true persistNow() startCloud() } + // MARK: Pushes + + /// The App Group files the notification extension reads. None in the + /// demo, which has no App Group files. + private var cloudKeys: CloudKeys? { + shared.map { CloudKeys(directory: $0.directory) } + } + + /// Give the notification extension what the box said over Noise: its + /// key, its names, and rule U's anchor. Written only when it changed. + private func shareCloudHome() { + guard let siteId, let keys = cloudKeys, let identity = cloudIdentity else { return } + let row = CloudHome(home: identity.home, siteId: siteId, boxKey: Data(identity.pk), + loadpoints: identity.loadpoints, sources: identity.sources, offset: ruleU) + guard row != sharedCloudHome else { return } + if let old = sharedCloudHome, old.home != row.home { keys.remove(home: old.home) } + do { + try keys.write(row) + sharedCloudHome = row + } catch { + log.error("cloud keys not written: \(String(describing: error), privacy: .public)") + } + } + + /// While FTW Cloud is on, its session takes the phone's APNs token: it + /// is how the service learns where Apple can reach this phone. The first + /// install asks the person to allow notifications. + private func installPushSink() { + guard !pushSinkOn else { return } + pushSinkOn = true + let sink = CloudPushSink { [weak self] token in + self?.pushToken = token + self?.registerPush() + } + let source = pushTokens + Task { await source.install(sink) } + } + + private func uninstallPushSink() { + guard pushSinkOn else { return } + pushSinkOn = false + pushToken = nil + pushTokens.uninstall() + } + + /// At every opening of the session, and when Apple gives a new token. + private func registerPush() { + guard let pushToken, let cloud, cloud.status.isOpen else { return } + cloud.pushRegister(apnsToken: pushToken.data, environment: pushToken.environment) + } + /// 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 } diff --git a/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift b/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift index bc07dfc..fdc4e62 100644 --- a/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift +++ b/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift @@ -1,8 +1,8 @@ import Foundation /// What this phone holds about one home for FTW Cloud's pushes: the key the -/// box signs events with, which paired home it is, and the names behind the -/// opaque ids an event uses. The app writes it when the box describes its +/// box signs events with, which paired home it is, the names behind the +/// opaque ids an event uses, and rule U's anchor. The app writes it when the box describes its /// FTW Cloud link over Noise; the notification extension reads it. /// /// Nothing here is a secret. A public key checks signatures and cannot make @@ -18,13 +18,31 @@ public struct CloudHome: Codable, Sendable, Equatable { public var loadpoints: [String: String] /// Opaque source id → the name the box shows for the device. public var sources: [String: String] + /// Rule U's anchor, so an event from a box without a synced clock can + /// still be dated. Nil until the phone has learned one over Noise. + public var offset: Offset? - public init(home: String, siteId: String, boxKey: Data, loadpoints: [String: String] = [:], sources: [String: String] = [:]) { + /// Rule U's anchor: phone time minus box uptime, the smallest the phone + /// learned over Noise for `boot`, and when it last learned it. + public struct Offset: Codable, Sendable, Equatable { + public var boot: [UInt8] + public var offsetMs: Int64 + public var learnedAtMs: Int64 + + public init(boot: [UInt8], offsetMs: Int64, learnedAtMs: Int64) { + self.boot = boot + self.offsetMs = offsetMs + self.learnedAtMs = learnedAtMs + } + } + + public init(home: String, siteId: String, boxKey: Data, loadpoints: [String: String] = [:], sources: [String: String] = [:], offset: Offset? = nil) { self.home = home self.siteId = siteId self.boxKey = boxKey self.loadpoints = loadpoints self.sources = sources + self.offset = offset } /// The charger an event means. An event that names none means the only @@ -83,6 +101,11 @@ public struct CloudKeys: Sendable { read(home: home)?.boxKey } + /// Rule U's anchor for a home, for `PushAlert.read`. + public func offset(home: String) -> CloudHome.Offset? { + read(home: home)?.offset + } + /// Only the app writes. Atomic, so the extension never reads half a file. public func write(_ row: CloudHome) throws { guard Self.isHomeID(row.home) else { throw Refused(reason: "not a home id") } diff --git a/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift b/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift index 44dcd44..91ce52f 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift @@ -59,8 +59,8 @@ public struct PushAlert: Sendable, Equatable { public let lp: String? public let boot: Data? public let seq: UInt64 - /// When it happened by the phone's reading of the box clock: never - /// later than it arrived. + /// When it happened by the phone's reading of the box clock, less rule + /// U's drift margin when rule U dates it: never later than it arrived. public let atMs: Int64 init(_ verdict: Verdict, home: String, kind: String = "", args: [String: String] = [:], src: String? = nil, lp: String? = nil, boot: Data? = nil, seq: UInt64 = 0, atMs: Int64 = 0) { @@ -83,19 +83,24 @@ public struct PushAlert: Sendable, Equatable { static let clockLeadMs: Int64 = 2_000 /// How old an event may be and still get its text and buttons. static let maxAgeMs: Int64 = 10 * 60_000 + /// How long rule U's anchor stays usable after it was last learned. + static let ruleULifetimeMs: Int64 = 24 * 60 * 60_000 + /// Rule U's drift margin: 0.01 % of the time since it was learned. + static let ruleUDriftDivisor: Int64 = 10_000 /// Check a push as the notification extension receives it. /// `boxKey` is the key the phone holds for a home, nil when it holds - /// none; `nowMs` is the phone's clock when the push arrived. Whether an - /// event was seen before is `PushSeen`'s question, not this one's. - public static func read(userInfo: [AnyHashable: Any], boxKey: (String) -> Data?, nowMs: Int64) -> PushAlert { - read(ftw: userInfo[key], boxKey: boxKey, nowMs: nowMs) + /// none; `offset` is rule U's anchor for a home, nil when it holds none; + /// `nowMs` is the phone's clock when the push arrived. Whether an event + /// was seen before is `PushSeen`'s question, not this one's. + public static func read(userInfo: [AnyHashable: Any], boxKey: (String) -> Data?, offset: (String) -> CloudHome.Offset? = { _ in nil }, nowMs: Int64) -> PushAlert { + read(ftw: userInfo[key], boxKey: boxKey, offset: offset, nowMs: nowMs) } /// The same, from the payload's JSON as APNs sends it. - public static func read(payload: Data, boxKey: (String) -> Data?, nowMs: Int64) -> PushAlert { + public static func read(payload: Data, boxKey: (String) -> Data?, offset: (String) -> CloudHome.Offset? = { _ in nil }, nowMs: Int64) -> PushAlert { let top = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any] - return read(ftw: top?[key], boxKey: boxKey, nowMs: nowMs) + return read(ftw: top?[key], boxKey: boxKey, offset: offset, nowMs: nowMs) } /// The service's part of the payload. @@ -107,7 +112,7 @@ public struct PushAlert: Sendable, Equatable { var kind: String? } - static func read(ftw: Any?, boxKey: (String) -> Data?, nowMs: Int64) -> PushAlert { + static func read(ftw: Any?, boxKey: (String) -> Data?, offset: (String) -> CloudHome.Offset?, nowMs: Int64) -> PushAlert { guard let ftw, JSONSerialization.isValidJSONObject(ftw), let json = try? JSONSerialization.data(withJSONObject: ftw), let a = try? JSONDecoder().decode(Wire.self, from: json), a.v == version else { @@ -121,10 +126,9 @@ public struct PushAlert: Sendable, Equatable { guard a.kind == nil || a.kind == "", let env = Envelope(Array(event)), env.t == "event", let pk = boxKey(home), verify(pk, home: home, env), - let ev = Event(env.b), let wallMs = ev.wallMs, wallMs <= nowMs &+ clockLeadMs else { + let ev = Event(env.b), var at = sendTime(ev, offset: offset(home), nowMs: nowMs) else { return generic } - var at = min(wallMs, nowMs) if let ts = ev.ts { at = min(at, ts) } let age = nowMs.subtractingReportingOverflow(at) guard PushKind.all.contains(ev.kind), ev.kind != PushKind.boxUnreachable, !age.overflow, age.partialValue <= maxAgeMs else { @@ -133,13 +137,33 @@ public struct PushAlert: Sendable, Equatable { return PushAlert(.event, home: home, kind: ev.kind, args: ev.args, src: ev.src, lp: ev.lp, boot: ev.boot.map { Data($0) }, seq: ev.seq, atMs: at) } - /// ES256 over the bytes received, never over a re-encoding. The key is - /// the box's 65-byte uncompressed P-256 point. + /// When the event was sent, as the Go reference dates it: the earlier + /// of rule N and rule U, and when rule U gives it, earlier by the drift + /// margin so the margin counts against the age. A box clock more than + /// `clockLeadMs` ahead gives no rule N. Nil when neither rule applies. + /// Arithmetic wraps, as Go's does, so a hostile number cannot trap. + static func sendTime(_ ev: Event, offset: CloudHome.Offset?, nowMs: Int64) -> Int64? { + var at: Int64? + if let wallMs = ev.wallMs, wallMs <= nowMs &+ clockLeadMs { at = min(wallMs, nowMs) } + guard let offset, offset.boot == (ev.boot ?? []), nowMs &- offset.learnedAtMs <= ruleULifetimeMs else { return at } + let u = min(offset.offsetMs &+ ev.uptimeMs, nowMs) + if let n = at, u > n { return n } + return u &- max(0, nowMs &- offset.learnedAtMs) / ruleUDriftDivisor + } + static func verify(_ pk: Data, home: String, _ env: Envelope) -> Bool { - guard pk.count == 65, pk.first == 0x04, env.sig.count == 64, - let key = try? P256.Signing.PublicKey(x963Representation: pk), - let sig = try? P256.Signing.ECDSASignature(rawRepresentation: env.sig) else { return false } - return key.isValidSignature(sig, for: PushCBOR.signedInput(home: home, t: env.t, b: env.b)) + verify(boxKey: [UInt8](pk), home: home, t: env.t, b: env.b, sig: env.sig) + } + + /// ES256 by a box's cloud key, over the bytes received, never over a + /// re-encoding. The key is the box's 65-byte uncompressed P-256 point. + /// The app's FTW Cloud session checks with this too, so there is one + /// check. + public static func verify(boxKey: [UInt8], home: String, t: String, b: [UInt8], sig: [UInt8]) -> Bool { + guard boxKey.count == 65, boxKey.first == 0x04, sig.count == 64, + let key = try? P256.Signing.PublicKey(x963Representation: boxKey), + let sig = try? P256.Signing.ECDSASignature(rawRepresentation: sig) else { return false } + return key.isValidSignature(sig, for: PushCBOR.signedInput(home: home, t: t, b: b)) } /// One message: {t, b, sig}. `b` is the body's exact bytes, so the @@ -167,6 +191,7 @@ public struct PushAlert: Sendable, Equatable { struct Event { var seq: UInt64 = 0 var boot: [UInt8]? + var uptimeMs: Int64 = 0 var wallMs: Int64? var ts: Int64? var kind = "" @@ -184,7 +209,7 @@ public struct PushAlert: Sendable, Equatable { do { seq = try field("seq", \.uint) ?? 0 boot = try field("boot", \.byteString) - _ = try field("uptimeMs", \.int64) + uptimeMs = try field("uptimeMs", \.int64) ?? 0 wallMs = try field("wallMs", \.int64) _ = try field("eventId", \.text) ts = try field("ts", \.int64) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift index af20b49..06107af 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift @@ -93,6 +93,11 @@ final class FakeCloudService { var watches: [Int] { received.filter { $0.t == CloudWire.msgWatch }.compactMap { (try? decodeCBOR($0.b))?["hz"]?.int } } + + /// The APNs tokens the phone registered, in order. + var registrations: [Bytes] { + received.filter { $0.t == CloudWire.msgPushRegister }.compactMap { (try? decodeCBOR($0.b))?["apnsToken"]?.byteString } + } } @MainActor diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudPushTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudPushTests.swift new file mode 100644 index 0000000..973a7f0 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudPushTests.swift @@ -0,0 +1,114 @@ +import Foundation +import Synchronization +import Testing +@testable import FTWKit +import FTWShared + +/// `PushTokens` for one site model, without the process-wide state another +/// suite may be using. +final class FakeTokens: Sendable { + let sink = Mutex<(any PushTokenSink)?>(nil) + + var source: PushTokenSource { + PushTokenSource(install: { s in self.sink.withLock { $0 = s } }, uninstall: { self.sink.withLock { $0 = nil } }) + } + + var installed: (any PushTokenSink)? { sink.withLock { $0 } } +} + +/// FTW Cloud and the phone's pushes: the token goes to the service over the +/// app session, and the notification extension gets what the box said over +/// Noise. +@MainActor +@Suite struct CloudPushTests { + /// push.register in the service's field names, only over an open + /// session. + @Test func theCarrierRegistersOverItsOpenSession() async throws { + let rig = try CloudRig() + let token = Data(repeating: 0xa2, count: 32) + #expect(!rig.carrier.pushRegister(apnsToken: token, environment: .sandbox)) + rig.carrier.start() + await rig.run(50) + #expect(rig.carrier.pushRegister(apnsToken: token, environment: .production)) + await rig.run(30) + let sent = try #require(rig.service.received.first { $0.t == CloudWire.msgPushRegister }) + let body = try decodeCBOR(sent.b) + #expect(body["apnsToken"]?.byteString == Bytes(token)) + #expect(body["environment"]?.string == "production") + #expect(body["kind"]?.string == "alert") + } + + /// While FTW Cloud is on, its session takes the token: at the opening, + /// at every reopening, and at once when Apple gives a new one. Turning + /// FTW Cloud off lets go of it. + @Test func theTokenGoesOutAtEveryOpening() 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(0) + let sink = try #require(w.tokens.installed) + await sink.register(apnsToken: Data([1, 2, 3]), environment: .sandbox) + #expect(w.service.registrations.isEmpty) + await w.run(60) + #expect(w.service.registrations == [[1, 2, 3]]) + + w.service.close(code: 1006) + await w.run(600) + #expect(w.site.cloudStatus?.isOpen == true) + #expect(w.service.registrations == [[1, 2, 3], [1, 2, 3]]) + + await sink.register(apnsToken: Data([4, 5, 6]), environment: .sandbox) + await w.run(30) + #expect(w.service.registrations.last == [4, 5, 6]) + + w.site.connect(w.relay) + w.relay.open(w.scheduler.nowMs) + w.relay.helloOk(uptimeMs: w.uptime, cloud: nil) + await w.run(30) + #expect(w.tokens.installed == nil) + } + + /// hello gives the notification extension the box's key and names, the + /// next write adds rule U's anchor, and turning FTW Cloud off takes the + /// home away. + @Test func helloSharesTheHomeWithTheExtension() 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) + await w.run(60) + let keys = CloudKeys(directory: w.shared.directory) + let row = try #require(keys.read(home: w.identity.home)) + #expect(row.siteId == "s1") + #expect(row.boxKey == Data(w.identity.pk)) + #expect(row.sources == ["m": "p1meter"]) + + w.site.persistNow() + let offset = try #require(keys.offset(home: w.identity.home)) + #expect(offset.boot == w.identity.boot) + #expect(offset.offsetMs == w.box.start) + + w.relay.helloOk(uptimeMs: w.uptime, cloud: nil) + await w.run(30) + #expect(keys.read(home: w.identity.home) == nil) + } + + /// The demo has no FTW Cloud and leaves nothing for the extension. + @Test func theDemoSharesNothing() async throws { + let w = try CloudSite() + w.site = SiteModel(siteId: "s1", build: "test", ua: "test", scheduler: w.scheduler, files: w.files, shared: w.shared, isDemo: true) + w.site.pushTokens = w.tokens.source + w.site.start() + w.connectCloud() + w.site.connect(w.relay) + w.relay.open(w.scheduler.nowMs) + w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity) + await w.run(60) + #expect(CloudKeys(directory: w.shared.directory).read(home: w.identity.home) == nil) + #expect(w.tokens.installed == nil) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift index 7240da6..a838b08 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift @@ -103,6 +103,7 @@ final class CloudSite { let identity: CloudIdentity var site: SiteModel! let relay = ScriptedBox() + let tokens = FakeTokens() init(noClock: Bool = false) throws { files = SealedFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-test-\(UUID().uuidString)"), store: store) @@ -132,6 +133,7 @@ final class CloudSite { func open() { site = SiteModel(siteId: "s1", build: "test", ua: "test", scheduler: scheduler, files: files, shared: shared) + site.pushTokens = tokens.source site.start() } diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PushAlertTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PushAlertTests.swift index 148a28b..3e2574f 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PushAlertTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PushAlertTests.swift @@ -22,9 +22,16 @@ import Testing var seq: UInt64? var atMs: Int64? } + struct Offset: Decodable { + var home: String + var boot: String + var offsetMs: Int64 + var learnedAtMs: Int64 + } struct Case: Decodable { var name: String var payload: String + var offset: Offset? var nowMs: Int64 var want: Want } @@ -57,14 +64,19 @@ import Testing @Test func everyPushVectorReadsAsTheGoReferenceReadsIt() throws { let file = try Self.load("push-vectors", as: PushFile.self) - #expect(file.cases.count == 14) + #expect(file.cases.count == 18) let keys = Dictionary(uniqueKeysWithValues: file.keys.map { ($0.home, Data(hex: $0.pk)) }) for c in file.cases { let payload = Data(c.payload.utf8) - let fromJSON = PushAlert.read(payload: payload, boxKey: { keys[$0] }, nowMs: c.nowMs) + // Rule U's anchor, held for one home, as CloudKeys holds it. + let offset = { (home: String) -> CloudHome.Offset? in + guard let o = c.offset, o.home == home else { return nil } + return CloudHome.Offset(boot: Array(Data(hex: o.boot)), offsetMs: o.offsetMs, learnedAtMs: o.learnedAtMs) + } + let fromJSON = PushAlert.read(payload: payload, boxKey: { keys[$0] }, offset: offset, nowMs: c.nowMs) // The extension gets userInfo, not the bytes: both roads agree. let userInfo = try #require(JSONSerialization.jsonObject(with: payload) as? [AnyHashable: Any]) - let fromUserInfo = PushAlert.read(userInfo: userInfo, boxKey: { keys[$0] }, nowMs: c.nowMs) + let fromUserInfo = PushAlert.read(userInfo: userInfo, boxKey: { keys[$0] }, offset: offset, nowMs: c.nowMs) #expect(fromJSON == fromUserInfo, "\(c.name)") let a = fromJSON diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/Resources/push-vectors.json b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/push-vectors.json index 218d502..e6b4ac6 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/Resources/push-vectors.json +++ b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/push-vectors.json @@ -176,6 +176,82 @@ "verdict": "generic", "home": "aaaaaaaaaaaaaaaaaaaaaaaaaa" } + }, + { + "name": "box clock not synced, anchored by rule U", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQFNotNXPkQUfb07DO7RwspScyA+HtdtuE/R6EAOdZFzmMBTAeJfa+x5KFmmfsdQNg7Q3ffV+WLE0KNonhgL6OXY=\"}}", + "sig": "5368b4d5cf91051f6f4ec33bb470b2949cc80f87b5db6e13f47a10039d645ce63014c07897dafb1e4a16699fb1d40d83b4377df57e58b13428da278602fa3976", + "offset": { + "home": "fjzyr67buzpyncqmjliwgezvde", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "offsetMs": 1791820740000, + "learnedAtMs": 1791817211000 + }, + "nowMs": 1791820812200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.connected", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 12, + "atMs": 1791820810640 + } + }, + { + "name": "box clock ahead, anchored by rule U", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWJqqYmxwa2ozSFpPUk5DV0hzY3NlcQ1jc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDX7iBnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEAT0WQabV0OknG7SmU38U/KFBl7E6LdxOZZMNh9lOK/hi2WqRrVsSfQKUrKe8k65zfTUYHCQjuGSyi03B3vwg4W\"}}", + "sig": "13d1641a6d5d0e9271bb4a6537f14fca14197b13a2ddc4e65930d87d94e2bf862d96a91ad5b127d0294aca7bc93ae737d35181c2423b864b28b4dc1defc20e16", + "offset": { + "home": "fjzyr67buzpyncqmjliwgezvde", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "offsetMs": 1791820740000, + "learnedAtMs": 1791817211000 + }, + "nowMs": 1791820812200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.connected", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 13, + "atMs": 1791820810640 + } + }, + { + "name": "rule U from another boot", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQGk/DH/IlyDThxxoaZe5v0fNthUnu8QnNIlvDjReA36nwi4S1d0vPiDfNV2JwE4iyT57heDhaU27bERXLSNGRnc=\"}}", + "sig": "693f0c7fc89720d3871c686997b9bf47cdb61527bbc42734896f0e345e037ea7c22e12d5dd2f3e20df355d89c04e22c93e7b85e0e1694dbb6c44572d23464677", + "offset": { + "home": "fjzyr67buzpyncqmjliwgezvde", + "boot": "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1", + "offsetMs": 1791820740000, + "learnedAtMs": 1791817211000 + }, + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "rule U learned over a day ago", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQJYY1B25q3N9kJEsHpndMcA9zIZD70klDsFbQdtklHF3eIWxYd1qbRN1eQaszxJWNQc8zatRazb3KwxDyiFMj/0=\"}}", + "sig": "9618d41db9ab737d90912c1e99dd31c03dcc8643ef49250ec15b41db649471777885b161dd6a6d13757906accf125635073ccdab516b36f72b0c43ca214c8ffd", + "offset": { + "home": "fjzyr67buzpyncqmjliwgezvde", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "offsetMs": 1791820740000, + "learnedAtMs": 1791734412199 + }, + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } } ] } diff --git a/appleApp/FTWNotify/NotificationService.swift b/appleApp/FTWNotify/NotificationService.swift index 03c0366..3da6c7b 100644 --- a/appleApp/FTWNotify/NotificationService.swift +++ b/appleApp/FTWNotify/NotificationService.swift @@ -21,7 +21,7 @@ final class NotificationService: UNNotificationServiceExtension { userInfo = request.content.userInfo let nowMs = Date.now.timeIntervalSince1970 * 1000 let keys = CloudKeys.shared() - let alert = PushAlert.read(userInfo: userInfo, boxKey: { keys?.boxKey(home: $0) }, nowMs: Int64(nowMs)) + let alert = PushAlert.read(userInfo: userInfo, boxKey: { keys?.boxKey(home: $0) }, offset: { keys?.offset(home: $0) }, nowMs: Int64(nowMs)) log.info("push: \(alert.verdict.rawValue, privacy: .public) \(alert.kind, privacy: .public)") if let seen = PushSeen.shared(), !seen.accept(alert, nowMs: nowMs) { log.info("push: seen before, seq \(alert.seq, privacy: .public)") From 57afd800566574885c9e014b69137ef5a093f30a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 10:50:53 +0200 Subject: [PATCH 20/21] 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 21/21] 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)