From fc2b752e7d2285192adcf9ee0c0850d0162b2dcd Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 20:22:33 +0200 Subject: [PATCH 01/10] 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 2aff52e4..0682a4c1 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 e0591e11..e599fe93 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 00000000..76f3a53a --- /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 00000000..eee41fce --- /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 00000000..ca2fdf64 --- /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 00000000..9fdd62c7 --- /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 00000000..8a69e9bb --- /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 00000000..257d205c --- /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 86337178..e319570d 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 00000000..5c59ef5e --- /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 00000000..f6b0f46d --- /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 2fe7e81e..4fb73efd 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 00000000..f3a52466 --- /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 00000000..8a01f8fb --- /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 00000000..9b99ae3f --- /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 00000000..72ad23f9 --- /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 bb50c0f6..687c92e1 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 5bf1d43d3003f6b93cd8504d4f4a9be17f3448c3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 21:02:43 +0200 Subject: [PATCH 02/10] 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 ca2fdf64..5e0d1395 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 f6b0f46d..816519f9 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 9b99ae3f..cbe70ca5 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 72ad23f9..66ce56e1 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 03/10] 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 76f3a53a..326018aa 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 257d205c..4cc8d232 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 816519f9..83d8d26a 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 8a01f8fb..8988c3d0 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 04/10] 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 66ce56e1..27493303 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 05/10] 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 326018aa..22c9e03e 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 83d8d26a..dcdbd1df 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 8c8299d4ff2d0a8107394da30137e7d7bceed2c1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 08:05:20 +0200 Subject: [PATCH 06/10] 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 00000000..0ce5681a --- /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 4cc8d232..5778c0bf 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 4ac35c77..2246ba12 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 00000000..bc07dfcc --- /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 00000000..44dcd445 --- /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 00000000..b59f8df4 --- /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 00000000..f781f592 --- /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 00000000..af67a2b1 --- /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 00000000..f9d5fd1c --- /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 00000000..af563aa4 --- /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 00000000..9ad22446 --- /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 00000000..218d502f --- /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 00000000..8473df44 --- /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 07/10] 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 f3b1b06f..475f9e4b 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 00000000..041c793d --- /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 00000000..04c6e8a3 --- /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 00000000..33403533 --- /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 00000000..64e785c8 --- /dev/null +++ b/appleApp/FTWNotify/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + + From 5d114772ea0ed1a8abe74280410c793800515011 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 08:18:59 +0200 Subject: [PATCH 08/10] 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 f781f592..8f7a0c09 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 6638a17d..c892f7d1 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 33403533..03c0366a 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 09/10] 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 1411e583..9c1fd785 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 e599fe93..630aab58 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 5e0d1395..ea19a836 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 00000000..34f7f9f0 --- /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 e319570d..79c55f8c 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 00000000..4198e0df --- /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 00000000..1c92941c --- /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 f3a52466..582f2590 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 10/10] 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 630aab58..9652d847 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() }