diff --git a/appleApp/FTW.entitlements b/appleApp/FTW.entitlements
index 1411e58..9c1fd78 100644
--- a/appleApp/FTW.entitlements
+++ b/appleApp/FTW.entitlements
@@ -11,5 +11,9 @@
$(FTW_APP_GROUP)
+ aps-environment
+ development
+ com.apple.developer.usernotifications.time-sensitive
+
diff --git a/appleApp/FTW.xcodeproj/project.pbxproj b/appleApp/FTW.xcodeproj/project.pbxproj
index 84fbd06..7df1443 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 */
@@ -31,6 +33,13 @@
remoteGlobalIDString = F7A000000000000000000010;
remoteInfo = FTW;
};
+ F7A000000000000000000073 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = F7A000000000000000000001 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = F7A000000000000000000078;
+ remoteInfo = FTWNotify;
+ };
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -41,6 +50,7 @@
dstSubfolderSpec = 13;
files = (
F7A000000000000000000034 /* FTWWidgets.appex in Embed Foundation Extensions */,
+ F7A000000000000000000072 /* FTWNotify.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
@@ -72,6 +82,9 @@
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; };
F7A000000000000000000100 /* FTWUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FTWUITests.xctest; 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 */
@@ -95,6 +108,11 @@
path = FTWCharts;
sourceTree = "";
};
+ F7A000000000000000000077 /* FTWNotify */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = FTWNotify;
+ sourceTree = "";
+ };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -122,6 +140,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ F7A00000000000000000007A /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ F7A000000000000000000070 /* FTWShared in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -132,11 +158,14 @@
F7A000000000000000000041 /* FTWWidgets */,
F7A000000000000000000101 /* FTWUITests */,
F7A000000000000000000110 /* FTWCharts */,
+ 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 */,
);
@@ -158,6 +187,7 @@
F7A000000000000000000005 /* FTW.app */,
F7A000000000000000000040 /* FTWWidgets.appex */,
F7A000000000000000000100 /* FTWUITests.xctest */,
+ F7A000000000000000000074 /* FTWNotify.appex */,
);
name = Products;
sourceTree = "";
@@ -180,6 +210,7 @@
);
dependencies = (
F7A000000000000000000037 /* PBXTargetDependency */,
+ F7A00000000000000000007C /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
F7A000000000000000000003 /* FTW */,
@@ -239,6 +270,29 @@
productReference = F7A000000000000000000100 /* FTWUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
+ 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 */
@@ -259,6 +313,9 @@
CreatedOnToolsVersion = 27.0;
TestTargetID = F7A000000000000000000010;
};
+ F7A000000000000000000078 = {
+ CreatedOnToolsVersion = 27.0;
+ };
};
};
buildConfigurationList = F7A000000000000000000020 /* Build configuration list for PBXProject "FTW" */;
@@ -282,6 +339,7 @@
F7A000000000000000000010 /* FTW */,
F7A000000000000000000050 /* FTWWidgets */,
F7A000000000000000000102 /* FTWUITests */,
+ F7A000000000000000000078 /* FTWNotify */,
);
};
/* End PBXProject section */
@@ -308,6 +366,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ F7A00000000000000000007B /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -356,6 +421,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ F7A000000000000000000079 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -369,6 +441,11 @@
target = F7A000000000000000000010 /* FTW */;
targetProxy = F7A000000000000000000106 /* PBXContainerItemProxy */;
};
+ F7A00000000000000000007C /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = F7A000000000000000000078 /* FTWNotify */;
+ targetProxy = F7A000000000000000000073 /* PBXContainerItemProxy */;
+ };
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@@ -567,6 +644,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 */
@@ -606,6 +729,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 */
@@ -631,6 +763,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/FTW/About/AboutView.swift b/appleApp/FTW/About/AboutView.swift
index 78906e1..1c7f2b2 100644
--- a/appleApp/FTW/About/AboutView.swift
+++ b/appleApp/FTW/About/AboutView.swift
@@ -81,6 +81,7 @@ struct AboutView: View {
}
Section {
+ if !app.isDemo { NavigationLink { CloudSettingsView(app: app) } label: { Label(CloudWords.text("FTW Cloud"), systemImage: "cloud") } }
Link(destination: Origin.privacyURL()) {
Label(AboutWords.text("Privacy policy"), systemImage: "hand.raised")
}
@@ -90,7 +91,7 @@ struct AboutView: View {
} header: {
Text(AboutWords.text("Privacy and support"))
} footer: {
- Text(AboutWords.text("FTW collects nothing about you. Readings and commands travel encrypted between this phone and your box, and Sourceful cannot read them."))
+ Text(app.site?.cloudOn == true ? CloudWords.text("With FTW Cloud on, Sourceful keeps a copy of your home's readings, signed by your box, to serve this app. Commands travel encrypted between this phone and your box, and Sourceful cannot read them.") : AboutWords.text("FTW collects nothing about you. Readings and commands travel encrypted between this phone and your box, and Sourceful cannot read them."))
}
Section {
diff --git a/appleApp/FTW/Cloud/CloudConsentView.swift b/appleApp/FTW/Cloud/CloudConsentView.swift
new file mode 100644
index 0000000..05475a1
--- /dev/null
+++ b/appleApp/FTW/Cloud/CloudConsentView.swift
@@ -0,0 +1,73 @@
+import FTWKit
+import SwiftUI
+
+/// The one screen, at first start: the app uses FTW Cloud, and statistics
+/// are a separate choice, off until ticked. An owner's phone turns FTW Cloud
+/// on at the box when the person goes on; a viewer is told to ask the owner.
+struct CloudConsentView: View {
+ let app: AppModel
+ @Bindable var consent: CloudConsent
+
+ private var audience: CloudConsent.Audience {
+ app.site.map(CloudConsent.audience) ?? .unknown
+ }
+
+ var body: some View {
+ List {
+ Section {
+ VStack(alignment: .leading, spacing: 12) {
+ Image(systemName: "cloud.fill")
+ .font(.largeTitle)
+ .foregroundStyle(Theme.accent)
+ .symbolEffect(.bounce, value: consent.answered)
+ .accessibilityHidden(true)
+ Text(CloudWords.text("The FTW app uses FTW Cloud"))
+ .font(.title2.weight(.bold))
+ Text(CloudWords.text("FTW Cloud is part of the service. It keeps a copy of your home, signed by your box, so the app opens at once and stays up to date."))
+ Text(CloudWords.text("Your box still decides. Commands go straight to it, never through FTW Cloud."))
+ .foregroundStyle(Theme.fgDim)
+ }
+ .padding(.vertical, 4)
+ }
+
+ switch audience {
+ case .viewer:
+ Section {
+ Label(CloudWords.text("Ask the owner of this home to turn on FTW Cloud."), systemImage: "person.crop.circle.badge.questionmark")
+ }
+ case .owner, .ownerLater, .unknown:
+ Section {
+ Toggle(CloudWords.text("Contribute to statistics"), isOn: $consent.contributeStats)
+ } footer: {
+ Text(CloudWords.text("Hourly energy totals, without your name or address, help make FTW better. Off unless you turn it on."))
+ }
+ if audience == .ownerLater {
+ Section {
+ Label(CloudWords.text("Your box turns on FTW Cloud after its next update."), systemImage: "arrow.down.circle")
+ }
+ }
+ }
+
+ Section {
+ Link(destination: Origin.privacyURL()) {
+ Label(CloudWords.text("Privacy policy"), systemImage: "hand.raised")
+ }
+ } footer: {
+ Text(CloudWords.text("You can turn FTW Cloud off in About."))
+ }
+ }
+ .scrollContentBackground(.hidden)
+ .background(Theme.surface)
+ .safeAreaInset(edge: .bottom) {
+ Button(CloudWords.text("Continue")) {
+ Task { await consent.accept(app.site) }
+ }
+ .buttonStyle(FTWButtonStyle(kind: .primary))
+ .padding(16)
+ .background(Theme.surface)
+ }
+ .animation(Theme.spring, value: audience)
+ .sensoryFeedback(.success, trigger: consent.answered)
+ .interactiveDismissDisabled()
+ }
+}
diff --git a/appleApp/FTW/Cloud/CloudGate.swift b/appleApp/FTW/Cloud/CloudGate.swift
new file mode 100644
index 0000000..0f70688
--- /dev/null
+++ b/appleApp/FTW/Cloud/CloudGate.swift
@@ -0,0 +1,35 @@
+import FTWKit
+import SwiftUI
+
+extension View {
+ /// The FTW Cloud consent, once, over a real home, and the owner's grant
+ /// at the box once the box can take it. RootView's one line.
+ func cloudConsent(_ app: AppModel) -> some View {
+ modifier(CloudGate(app: app))
+ }
+}
+
+private struct CloudGate: ViewModifier {
+ let app: AppModel
+ @State private var consent = CloudConsent()
+
+ func body(content: Content) -> some View {
+ content
+ .sheet(isPresented: Binding(get: { !app.needsPairing && consent.shouldAsk(app.site) }, set: { _ in })) {
+ CloudConsentView(app: app, consent: consent)
+ .presentationDragIndicator(.hidden)
+ .tint(Theme.accent)
+ }
+ // When the box answers, an owner who agreed turns FTW Cloud on
+ // there, once per home.
+ .task(id: ensureKey) {
+ guard let site = app.site else { return }
+ await consent.ensure(site)
+ }
+ }
+
+ private var ensureKey: String {
+ guard let site = app.site else { return "" }
+ return "\(site.siteId ?? "")-\(site.heardFromBox)-\(site.cloudOn)-\(consent.answered)"
+ }
+}
diff --git a/appleApp/FTW/Cloud/CloudSettingsView.swift b/appleApp/FTW/Cloud/CloudSettingsView.swift
new file mode 100644
index 0000000..2c6ef90
--- /dev/null
+++ b/appleApp/FTW/Cloud/CloudSettingsView.swift
@@ -0,0 +1,91 @@
+import FTWKit
+import SwiftUI
+
+/// FTW Cloud in About: on or off, and the receipt once the copy is erased.
+/// An owner changes it at the box over Noise; a viewer sees it.
+struct CloudSettingsView: View {
+ let app: AppModel
+ @State private var consent = CloudConsent()
+
+ var body: some View {
+ List {
+ if let site = app.site {
+ switch CloudConsent.audience(site) {
+ case .owner:
+ owner(site)
+ case .ownerLater:
+ Section { Text(CloudWords.text("Your box can't use FTW Cloud yet.")) }
+ case .viewer, .unknown:
+ Section {
+ Text(CloudWords.text(site.cloudOn ? "FTW Cloud is on for this home." : "FTW Cloud is off for this home."))
+ } footer: {
+ Text(CloudWords.text("Only the owner of this home can change this."))
+ }
+ }
+ }
+ Section {
+ Link(destination: Origin.privacyURL()) {
+ Label(CloudWords.text("Privacy policy"), systemImage: "hand.raised")
+ }
+ }
+ }
+ .scrollContentBackground(.hidden)
+ .background(Theme.surface)
+ .navigationTitle(CloudWords.text("FTW Cloud"))
+ .navigationBarTitleDisplayMode(.inline)
+ .task {
+ guard let site = app.site else { return }
+ await consent.refresh(site)
+ }
+ }
+
+ @ViewBuilder
+ private func owner(_ site: SiteModel) -> some View {
+ let box = consent.box
+ let on = box?.on ?? site.cloudOn
+ Section {
+ Toggle(CloudWords.text("FTW Cloud"), isOn: Binding(get: { on }, set: { want in
+ Task {
+ if want { await consent.turnOn(site) } else { await followErasure(site) }
+ }
+ }))
+ .disabled(consent.working || box?.erasing == true)
+ if on {
+ Label(CloudWords.text(site.cloudStatus?.isOpen == true || box?.connected == true ? "Connected" : "Not connected"), systemImage: "dot.radiowaves.left.and.right")
+ .foregroundStyle(Theme.fgDim)
+ }
+ if box?.erasing == true {
+ Label(CloudWords.text("Erasing the copy"), systemImage: "hourglass")
+ } else if !on, let at = box?.erasedAtMs {
+ Label(CloudWords.format("Copy erased %@", AppWords.day(at, nowMs: Date().timeIntervalSince1970 * 1000, zone: .current)), systemImage: "checkmark.seal")
+ .foregroundStyle(Theme.fgDim)
+ }
+ } footer: {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(CloudWords.text("Turning it off erases the copy in FTW Cloud. Your box shows when it is gone."))
+ if let help = consent.help { Text(help).foregroundStyle(Theme.importing) }
+ }
+ }
+ if on {
+ Section {
+ Toggle(CloudWords.text("Contribute to statistics"), isOn: Binding(get: { box?.stats ?? false }, set: { want in
+ Task { await consent.setStats(want, site) }
+ }))
+ .disabled(consent.working)
+ } footer: {
+ Text(CloudWords.text("Hourly energy totals, without your name or address, help make FTW better. Off unless you turn it on."))
+ }
+ }
+ }
+
+ /// Off, then the receipt: the box has it once the service confirms the
+ /// copy is gone, usually within seconds. Asked a few times after the
+ /// tap, never on a timer of its own.
+ private func followErasure(_ site: SiteModel) async {
+ guard await consent.turnOff(site) else { return }
+ for _ in 0..<5 where consent.box?.erasing == true {
+ try? await Task.sleep(for: .seconds(2))
+ await consent.refresh(site)
+ }
+ }
+}
diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift
index e599fe9..9652d84 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() {
@@ -35,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() }
diff --git a/appleApp/FTW/Intents/IntentRunner.swift b/appleApp/FTW/Intents/IntentRunner.swift
index 13c118b..37e0d84 100644
--- a/appleApp/FTW/Intents/IntentRunner.swift
+++ b/appleApp/FTW/Intents/IntentRunner.swift
@@ -47,7 +47,13 @@ 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 {
// No home on this phone: a Live Activity left from one can only
// mislead, and its button has nothing to act on.
@@ -57,7 +63,7 @@ final class IntentRunner: CommandRunner {
}
defer { end() }
let started = ContinuousClock.now
- let out = await home.loadpoints.perform(command)
+ let out = await home.loadpoints.perform(command, loadpointID: loadpointID)
// The system redraws the Control when this returns: the record it
// reads must already hold the box's answer, or the tile would flip
// back and then forward again.
@@ -99,6 +105,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/Intents/en.lproj/Localizable.strings b/appleApp/FTW/Intents/en.lproj/Localizable.strings
new file mode 100644
index 0000000..0ce5681
--- /dev/null
+++ b/appleApp/FTW/Intents/en.lproj/Localizable.strings
@@ -0,0 +1,5 @@
+/* A push from FTW Cloud, as Apple shows it if the notification extension
+ does not run. The extension writes every other line itself. Elsewhere
+ the English is the key, so nothing else is listed here. */
+"push.title" = "Something happened at home";
+"push.generic" = "Open FTW to see what.";
diff --git a/appleApp/FTW/Intents/sv.lproj/Localizable.strings b/appleApp/FTW/Intents/sv.lproj/Localizable.strings
index 5f28f57..cf60fd6 100644
--- a/appleApp/FTW/Intents/sv.lproj/Localizable.strings
+++ b/appleApp/FTW/Intents/sv.lproj/Localizable.strings
@@ -20,3 +20,8 @@
"On" = "På";
"Open FTW" = "Öppna FTW";
"Reason" = "Orsak";
+
+/* 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/FTW/Platform/KeychainStore.swift b/appleApp/FTW/Platform/KeychainStore.swift
index 16f5611..19d1133 100644
--- a/appleApp/FTW/Platform/KeychainStore.swift
+++ b/appleApp/FTW/Platform/KeychainStore.swift
@@ -14,13 +14,27 @@ final class KeychainStore: SecureStore {
private let service = "energy.ftw.app"
func get(_ key: String) -> Bytes? {
+ (try? read(key)) ?? nil
+ }
+
+ /// Only errSecItemNotFound means absent. Before the first unlock the
+ /// keychain answers errSecInteractionNotAllowed, and that must never
+ /// lead to a new key written over the old one.
+ func read(_ key: String) throws -> Bytes? {
var query = base(key)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
- guard status == errSecSuccess, let data = result as? Data else { return nil }
- return data.byteArray
+ switch status {
+ case errSecSuccess:
+ guard let data = result as? Data else { throw SecureStoreUnavailable(status: status) }
+ return data.byteArray
+ case errSecItemNotFound:
+ return nil
+ default:
+ throw SecureStoreUnavailable(status: status)
+ }
}
func put(_ key: String, _ value: Bytes) {
diff --git a/appleApp/FTW/Push/Notifications.swift b/appleApp/FTW/Push/Notifications.swift
new file mode 100644
index 0000000..88adf16
--- /dev/null
+++ b/appleApp/FTW/Push/Notifications.swift
@@ -0,0 +1,140 @@
+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
+ 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)")
+ }
+}
+
+/// 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) },
+ offset: { keys?.offset(home: $0) },
+ nowMs: Int64(response.notification.date.timeIntervalSince1970 * 1000)
+ )
+ await Notifications.act(action, command, alert: alert, home: keys?.read(home: alert.home))
+ }
+
+ func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
+ [.banner, .list, .sound]
+ }
+}
diff --git a/appleApp/FTW/UI/LaunchClock.swift b/appleApp/FTW/UI/LaunchClock.swift
index 27ef7a0..119aa55 100644
--- a/appleApp/FTW/UI/LaunchClock.swift
+++ b/appleApp/FTW/UI/LaunchClock.swift
@@ -1,36 +1,15 @@
-import Darwin
-import os
+import FTWKit
/// How long from the process starting to each first thing on screen,
-/// logged once each and marked for Instruments. Read them with:
+/// logged once each and marked for Instruments: the first frame, the first
+/// content, and (from SiteModel) the first live reading. Read them with:
///
/// xcrun simctl spawn log show --last 2m \
/// --predicate 'subsystem == "energy.ftw.app" AND category == "launch"'
@MainActor
enum LaunchClock {
- static let log = Logger(subsystem: "energy.ftw.app", category: "launch")
- static let signposter = OSSignposter(logger: log)
- private static var seen: Set = []
-
- /// Once per event: the first frame, the first content, the stream.
+ /// Once per event: the first frame, the first content.
static func mark(_ event: StaticString) {
- let name = "\(event)"
- guard seen.insert(name).inserted else { return }
- signposter.emitEvent(event)
- if let ms = sinceProcessStartMs() {
- log.notice("\(name, privacy: .public) \(Int(ms.rounded()), privacy: .public) ms after process start")
- }
- }
-
- /// The kernel's record of when this process started.
- static func sinceProcessStartMs() -> Double? {
- var info = kinfo_proc()
- var size = MemoryLayout.stride
- var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
- guard sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) == 0 else { return nil }
- let start = info.kp_proc.p_starttime
- var now = timeval()
- gettimeofday(&now, nil)
- return Double(now.tv_sec - start.tv_sec) * 1000 + Double(now.tv_usec - start.tv_usec) / 1000
+ ProcessClock.markOnce(event)
}
}
diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift
index 43d93b4..06bb47c 100644
--- a/appleApp/FTW/UI/RootView.swift
+++ b/appleApp/FTW/UI/RootView.swift
@@ -19,6 +19,7 @@ struct RootView: View {
.background(Theme.surface.ignoresSafeArea())
.tint(Theme.accent)
.onAppear { LaunchClock.mark("first frame") }
+ .cloudConsent(app)
}
}
diff --git a/appleApp/FTWKit/Package.swift b/appleApp/FTWKit/Package.swift
index 4ac35c7..2246ba1 100644
--- a/appleApp/FTWKit/Package.swift
+++ b/appleApp/FTWKit/Package.swift
@@ -4,9 +4,10 @@
//
// - FTWKit: pairing, the passkey derivations, Noise IK, frames, the relay,
// the session and the state each screen reads. The app alone links it.
-// - FTWShared: plain `Sendable` values the app shares with its widgets and,
-// later, a notification extension. The App Group cache, its records and
-// the formatters a widget needs. Nothing on the main actor, no keys.
+// - FTWShared: plain `Sendable` values the app shares with its widgets and
+// its notification extension. The App Group cache, its records, the
+// formatters a widget needs, and the check and the words of a push.
+// Nothing on the main actor, no secret keys.
// - FTWDemo: a simulated box in this process, for the demo, App Review and
// previews. Kept out of FTWKit so the product module holds no fake box.
//
@@ -33,6 +34,10 @@ let package = Package(
dependencies: ["FTWKit", "FTWDemo"],
resources: [.copy("Resources")]
),
- .testTarget(name: "FTWSharedTests", dependencies: ["FTWShared"]),
+ .testTarget(
+ name: "FTWSharedTests",
+ dependencies: ["FTWShared"],
+ resources: [.copy("Resources")]
+ ),
]
)
diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
index b00a0cf..4383055 100644
--- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
+++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
@@ -693,13 +693,15 @@ public final class LoopbackCarrier: Carrier {
self.onStatus = onStatus
}
- public func send(_ frame: Bytes) {
- guard status.isOpen, !closed else { return }
+ @discardableResult
+ public func send(_ frame: Bytes) -> Bool {
+ guard status.isOpen, !closed else { return false }
toBox.append(frame)
scheduler.after(latencyMs) { [weak self] in
guard let self, !self.closed, !self.toBox.isEmpty else { return }
self.box.receive(self.toBox.removeFirst())
}
+ return true
}
/// Pull to refresh in the demo: the house moves on a second.
diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift
index f3fdce7..c806929 100644
--- a/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift
+++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift
@@ -35,7 +35,11 @@ public enum CarrierStatus: Equatable, Sendable {
public protocol Carrier: AnyObject {
var kind: CarrierKind { get }
var status: CarrierStatus { get }
- func send(_ frame: Bytes)
+ /// Whether the frame went out. A carrier that is not open drops it and
+ /// says so, so a command can fail at once instead of waiting out a
+ /// deadline for an answer that cannot come.
+ @discardableResult
+ func send(_ frame: Bytes) -> Bool
/// Replaces the handlers. One owner at a time: the carrier stack is
/// built as a chain, each layer owning the one below it.
func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void)
diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift
new file mode 100644
index 0000000..6eb97db
--- /dev/null
+++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift
@@ -0,0 +1,303 @@
+import Foundation
+import FTWShared
+
+/// The phone's session with FTW Cloud: one WebSocket, Noise IK as initiator
+/// to the service's key, the home id in the prologue.
+///
+/// Message 1 says whether the phone is looking, message 2 brings the copy the
+/// service holds in memory, and every change the box sends after that comes
+/// down the same socket. What arrives is the box's own signed envelopes; this
+/// file moves them and checks nothing about them. `CloudView` checks every
+/// signature with the key the box gave this phone over Noise.
+///
+/// The service reads and never controls. Nothing sent here can change the
+/// house: a command still goes to the box over the relay.
+@MainActor
+public final class CloudCarrier {
+ public enum Status: Equatable, Sendable {
+ case connecting
+ case open(sinceMs: Double)
+ /// `retrying` is false once another session of this phone took
+ /// over: asking again would only take it back.
+ case closed(code: Int, retrying: Bool)
+
+ public var isOpen: Bool {
+ if case .open = self { return true }
+ return false
+ }
+ }
+
+ static let backoffBaseMs: Double = 500
+ static let backoffCapMs: Double = 60_000
+ /// A refusal changes only when the box's roster or grant does, or when
+ /// a service that just restarted has heard from the box again. Asked
+ /// again slowly, and at once on `wake`.
+ static let refusedBaseMs: Double = 15_000
+ static let refusedCapMs: Double = 300_000
+ /// Message 2 is one round trip after message 1; a service this slow is
+ /// not one to wait on.
+ static let handshakeTimeoutMs: Double = 10_000
+
+ public private(set) var status: Status = .connecting
+
+ private let endpoint: CloudEndpoint
+ private let home: String
+ private let deviceKey: Primitives.KeyPair
+ private let scheduler: Scheduler
+ private let makeSocket: WebSocketFactory
+ private var looking: Bool
+
+ private var socket: WebSocketConnection?
+ private var generation = 0
+ private var handshake: HandshakeState?
+ private var transport: NoiseTransport?
+ /// Which of the app's service keys this dial uses. A refusal right after
+ /// message 1 may be a rotated key; the next one gets one try.
+ private var keyIndex = 0
+ private var attempt = 0
+ private var refusals = 0
+ private var retry: Cancellable?
+ private var deadline: Cancellable?
+ private var shutdown = false
+
+ private var onSnapshot: @MainActor (CloudWire.Snapshot, Double) -> Void = { _, _ in }
+ private var onEnvelope: @MainActor (CloudWire.Envelope, Double) -> Void = { _, _ in }
+ private var onStatus: @MainActor (Status) -> Void = { _ in }
+
+ public init(endpoint: CloudEndpoint, home: String, deviceKey: Primitives.KeyPair, looking: Bool, scheduler: Scheduler, makeSocket: @escaping WebSocketFactory) {
+ self.endpoint = endpoint
+ self.home = home
+ self.deviceKey = deviceKey
+ self.looking = looking
+ self.scheduler = scheduler
+ self.makeSocket = makeSocket
+ }
+
+ /// One owner: the site model. `start` dials once the handlers are set.
+ public func setHandlers(
+ onSnapshot: @escaping @MainActor (CloudWire.Snapshot, Double) -> Void,
+ onEnvelope: @escaping @MainActor (CloudWire.Envelope, Double) -> Void,
+ onStatus: @escaping @MainActor (Status) -> Void
+ ) {
+ self.onSnapshot = onSnapshot
+ self.onEnvelope = onEnvelope
+ self.onStatus = onStatus
+ }
+
+ public func start() {
+ guard !shutdown, socket == nil, retry == nil else { return }
+ dial()
+ }
+
+ /// Whether anyone can see the home. While someone can, the service keeps
+ /// the box at one update a second. A closed session takes the change
+ /// with it into message 1 of the next.
+ public func setLooking(_ on: Bool) {
+ guard on != looking else { return }
+ looking = on
+ send(CloudWire.msgWatch, .map([("hz", .int(on ? CloudWire.hzLooking : CloudWire.hzAway))]))
+ }
+
+ /// An owner suspends a device at FTW Cloud, or lifts it. It only narrows
+ /// what the box's roster allows. False when the session is not open and
+ /// nothing went out.
+ @discardableResult
+ public func suspend(deviceID: String, _ on: Bool) -> Bool {
+ send(on ? CloudWire.msgSuspend : CloudWire.msgUnsuspend, .map([("deviceId", .text(deviceID))]))
+ }
+
+ /// Tell the service where Apple can reach this phone, for alerts. The
+ /// service binds the token to this phone's roster entry, so a phone
+ /// that may not read events registers nothing, and keeps one row per
+ /// token, so sending it again changes nothing. False when the session
+ /// is not open and nothing went out.
+ @discardableResult
+ public func pushRegister(apnsToken: Data, environment: PushEnvironment) -> Bool {
+ send(CloudWire.msgPushRegister, .map([
+ ("apnsToken", .bytes(Bytes(apnsToken))), ("environment", .text(environment.rawValue)), ("kind", .text(CloudWire.pushKindAlert)),
+ ]))
+ }
+
+ /// The app came to the front or the network changed: dial now if the
+ /// session is not open, after a refusal too.
+ public func wake() {
+ guard !shutdown, !status.isOpen else { return }
+ retry?.cancel()
+ retry = nil
+ attempt = 0
+ refusals = 0
+ keyIndex = 0
+ drop()
+ dial()
+ }
+
+ public func close() {
+ guard !shutdown else { return }
+ shutdown = true
+ retry?.cancel()
+ deadline?.cancel()
+ drop()
+ onSnapshot = { _, _ in }
+ onEnvelope = { _, _ in }
+ onStatus = { _ in }
+ }
+
+ // MARK: The wire
+
+ private func dial() {
+ guard !shutdown else { return }
+ setStatus(.connecting)
+ generation += 1
+ let mine = generation
+ handshake = nil
+ transport = nil
+ socket = makeSocket(endpoint.url(home: home), WebSocketEvents(
+ onOpen: { [weak self] in
+ guard let self, mine == self.generation else { return }
+ self.sendMessage1()
+ },
+ onText: { _ in },
+ onBinary: { [weak self] bytes in
+ guard let self, mine == self.generation else { return }
+ self.receive(bytes)
+ },
+ onClose: { [weak self] code, _ in
+ guard let self, mine == self.generation else { return }
+ self.closed(code: code)
+ }
+ ))
+ }
+
+ private func sendMessage1() {
+ guard handshake == nil, transport == nil, let socket else { return }
+ let keys = endpoint.serviceKeys
+ guard keyIndex < keys.count,
+ let hs = try? HandshakeState.initiator(staticKey: deviceKey, remoteStatic: keys[keyIndex], prologue: CloudWire.appPrologue(home: home)) else {
+ shutdown = true
+ drop()
+ setStatus(.closed(code: CloudWire.closeDenied, retrying: false))
+ return
+ }
+ let now = CloudWire.encodeUnsigned(t: CloudWire.msgNow, body: .map([("hz", .int(looking ? CloudWire.hzLooking : CloudWire.hzAway))]))
+ guard let msg1 = try? hs.writeMessage(now) else { return }
+ handshake = hs
+ socket.send(msg1)
+ deadline?.cancel()
+ deadline = scheduler.after(Self.handshakeTimeoutMs) { [weak self] in
+ guard let self, self.transport == nil else { return }
+ self.fail(code: 1006, retrying: true)
+ }
+ }
+
+ private func receive(_ bytes: Bytes) {
+ let arrival = scheduler.nowMs
+ if let transport {
+ guard let frame = try? transport.decrypt(bytes),
+ let payload = try? CloudWire.decodeFrame(frame),
+ let env = try? CloudWire.Envelope.decode(payload) else {
+ fail(code: CloudWire.closeProtocol, retrying: true)
+ return
+ }
+ // Only a delivered message proves the path works.
+ attempt = 0
+ onEnvelope(env, arrival)
+ return
+ }
+ guard let hs = handshake,
+ let payload = try? hs.readMessage(bytes),
+ let snapshot = try? CloudWire.parseSnapshot(payload: payload),
+ let keys = try? hs.split() else {
+ fail(code: CloudWire.closeProtocol, retrying: true)
+ return
+ }
+ deadline?.cancel()
+ handshake = nil
+ transport = NoiseTransport(keys)
+ attempt = 0
+ refusals = 0
+ setStatus(.open(sinceMs: arrival))
+ onSnapshot(snapshot, arrival)
+ }
+
+ /// One message to the service, or false when the session is not open.
+ @discardableResult
+ private func send(_ t: String, _ body: CBOR) -> Bool {
+ guard let transport, let socket, status.isOpen else { return false }
+ let env = CloudWire.encodeUnsigned(t: t, body: body)
+ guard let frame = try? CloudWire.encodeFrame(env), let msg = try? transport.encrypt(frame) else { return false }
+ socket.send(msg)
+ return true
+ }
+
+ private func closed(code: Int) {
+ let beforeMessage2 = transport == nil
+ socket = nil
+ generation += 1
+ deadline?.cancel()
+ switch code {
+ case CloudWire.closeDenied where beforeMessage2 && keyIndex + 1 < endpoint.serviceKeys.count:
+ // Perhaps the service already runs on the app's other key.
+ keyIndex += 1
+ dial()
+ case CloudWire.closeReplaced:
+ setStatus(.closed(code: code, retrying: false))
+ case CloudWire.closeDenied:
+ keyIndex = 0
+ setStatus(.closed(code: code, retrying: true))
+ let ceiling = min(Self.refusedCapMs, Self.refusedBaseMs * pow(2, Double(refusals)))
+ refusals = min(refusals + 1, 16)
+ schedule(ceiling / 2 + scheduler.random() * ceiling / 2)
+ case CloudWire.closeChanged:
+ // The roster changed what this phone may read: ask again now,
+ // once, to learn what that is.
+ keyIndex = 0
+ setStatus(.closed(code: code, retrying: true))
+ if attempt == 0 {
+ attempt = 1
+ schedule(0)
+ } else {
+ schedule(backoff())
+ }
+ default:
+ keyIndex = 0
+ setStatus(.closed(code: code, retrying: true))
+ schedule(backoff())
+ }
+ }
+
+ private func fail(code: Int, retrying: Bool) {
+ drop()
+ setStatus(.closed(code: code, retrying: retrying))
+ if retrying { schedule(backoff()) }
+ }
+
+ /// Full jitter, as the relay carrier and the box's uplink do.
+ private func backoff() -> Double {
+ let ceiling = min(Self.backoffCapMs, Self.backoffBaseMs * pow(2, Double(attempt)))
+ attempt = min(attempt + 1, 16)
+ return scheduler.random() * ceiling
+ }
+
+ private func schedule(_ delayMs: Double) {
+ retry?.cancel()
+ retry = scheduler.after(delayMs) { [weak self] in
+ guard let self else { return }
+ self.retry = nil
+ self.dial()
+ }
+ }
+
+ private func drop() {
+ generation += 1
+ socket?.close()
+ socket = nil
+ handshake = nil
+ transport = nil
+ }
+
+ private func setStatus(_ next: Status) {
+ guard next != status else { return }
+ status = next
+ onStatus(next)
+ }
+}
diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift
new file mode 100644
index 0000000..bcb5245
--- /dev/null
+++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift
@@ -0,0 +1,383 @@
+import Foundation
+import FTWShared
+
+/// What a phone holds for one home read through FTW Cloud, and how fresh it
+/// is.
+///
+/// The service cannot sign, so it cannot make data look fresher than the box
+/// graded it. It can only delay, drop or replay, and each of those must age
+/// the data or mark it stale here. A port of Go's `cloudwire.View`
+/// (srcfl/ftw `go/protocol/cloudwire/view.go`); `CloudViewTests` replays the
+/// protocol's freshness cases against it.
+///
+/// A plain value with no clock of its own: the caller passes phone time in,
+/// so a widget or an extension can run the same rules on what the app
+/// stored. Everything it holds belongs to the current boot.
+///
+/// Assumes the phone's clock is close to true time. One that runs behind
+/// lets a delaying service hide that much delay.
+public struct CloudView: Sendable {
+ /// How far ahead of the phone clock a box's `wallMs` may be before the
+ /// phone stops trusting the box clock for that boot (rule N).
+ public static let ruleNLeadMs: Int64 = 2_000
+ /// How long an uptime offset learned over Noise stays usable (rule U).
+ public static let ruleULifetimeMs: Int64 = 24 * 60 * 60 * 1000
+ /// Rule U adds 0.01 % of the time since the offset was learned to every
+ /// age, because box clocks drift.
+ static let ruleUDriftDivisor: Int64 = 10_000
+
+ /// Why a message was dropped. The view is unchanged after each.
+ public enum AcceptError: Error, Equatable {
+ /// It does not verify with the box key for this home.
+ case signature
+ /// The phone holds this class at the same or a higher seq, or the
+ /// message is from another boot and not anchored later than what
+ /// the phone holds.
+ case notNewer
+ /// The box sends it, but never to a phone.
+ case notAClass(String)
+ /// Signed by the box, but the body is not what the class says.
+ case undecodable(String)
+ }
+
+ /// How a phone would show what it holds at one moment.
+ public struct Grades: Equatable, Sendable {
+ /// False when no send time can be trusted: "cloud, age unverified",
+ /// at best stale and never live.
+ public var anchored = false
+ /// How old the anchor is now, drift margin included. Nil when
+ /// unanchored.
+ public var ageMs: Int64?
+ /// Each source's effective state, keyed by opaque source id.
+ public var sources: [String: SourceState] = [:]
+ /// Each live field's state, keyed by field id as text.
+ public var fields: [String: SourceState] = [:]
+ /// Each loadpoint's state, keyed by opaque loadpoint id.
+ public var loadpoints: [String: SourceState] = [:]
+ }
+
+ public let home: String
+ /// The box's 65-byte public key, as the box gave it over Noise. Never
+ /// one from the service.
+ public let boxKey: Bytes
+
+ /// Empty until the first message. Go compares boots with bytes.Equal,
+ /// where nil and empty are one value, and so does this.
+ private var currentBoot: Bytes = []
+ private var seq: [String: UInt64] = [:]
+ private var noRuleN = false
+ /// The latest anchored send time held, phone clock. Survives a reboot,
+ /// so an older boot cannot come back.
+ private var newest: Int64 = 0
+ private var m: Anchor?
+ private var offsets: [Bytes: UptimeOffset] = [:]
+
+ /// The source table and its revision.
+ public private(set) var sources: [String: CloudWire.Source] = [:]
+ public private(set) var srcRev: UInt64 = 0
+ /// The sources each field's freshness follows, sent with the table.
+ public private(set) var fieldSources: [String: [String]] = [:]
+ /// The newest of each class this phone shows.
+ public private(set) var live: CloudWire.Live?
+ public private(set) var lp: CloudWire.LP?
+ /// Kept for the screens and left out of `grade`, as in Go: a price
+ /// window ages by its own slots, not by the box's sources.
+ public private(set) var prices: CloudWire.Prices?
+ public private(set) var hb: CloudWire.HB?
+
+ /// M: the newest anchored live or hb.
+ private struct Anchor: Sendable {
+ var seq: UInt64
+ var hb: Bool
+ var boot: Bytes
+ /// Send time by rule N, or nil.
+ var wallSend: Int64?
+ var uptimeMs: Int64
+ var srcRev: UInt64
+ var liveSeq: UInt64
+ var lpSeq: UInt64
+ var ages: [String: Int64]
+ }
+
+ private struct UptimeOffset: Sendable {
+ var offsetMs: Int64
+ var learnedAtMs: Int64
+ }
+
+ public init(home: String, boxKey: Bytes) {
+ self.home = home
+ self.boxKey = boxKey
+ }
+
+ /// M's box uptime and the age of each source's newest reading at M, so a
+ /// screen can say how old a reading is. Nil before an anchored message.
+ public var anchorReadings: (uptimeMs: Int64, ages: [String: Int64])? {
+ m.map { ($0.uptimeMs, $0.ages) }
+ }
+
+ /// The boot the view holds, nil before the first message.
+ public var boot: Bytes? { currentBoot.isEmpty ? nil : currentBoot }
+
+ /// H: half the smallest `staleAfterMs`, between 2 and 15 s.
+ public var heartbeatMs: Int64 { Self.heartbeat(for: sources) }
+
+ /// H for a source table: clamp(smallest staleAfterMs ÷ 2, 2 s, 15 s),
+ /// and 15 s when no source has a cadence.
+ public static func heartbeat(for table: [String: CloudWire.Source]) -> Int64 {
+ guard let smallest = table.values.map(\.staleAfterMs).filter({ $0 > 0 }).min() else { return 15_000 }
+ return min(max(smallest / 2, 2_000), 15_000)
+ }
+
+ /// Rule U's anchor, from a Noise session with the box: phone time minus
+ /// box uptime, the smallest seen per boot. Every call renews its 24
+ /// hours.
+ public mutating func learnOffset(boot: Bytes, phoneMs: Int64, uptimeMs: Int64) {
+ let offset = phoneMs &- uptimeMs
+ if let current = offsets[boot], current.offsetMs <= offset {
+ offsets[boot]?.learnedAtMs = phoneMs
+ return
+ }
+ offsets[boot] = UptimeOffset(offsetMs: offset, learnedAtMs: phoneMs)
+ }
+
+ /// Takes one box envelope that arrived at `arrivalMs` on the phone
+ /// clock. Throws `AcceptError`, and changes nothing, for a message it
+ /// drops. A snapshot's envelopes go in one at a time, in rising seq.
+ public mutating func accept(_ envelope: CloudWire.Envelope, arrivalMs: Int64) throws {
+ guard CloudWire.verify(boxKey: boxKey, home: home, envelope: envelope) else { throw AcceptError.signature }
+ guard CloudWire.phoneClasses.contains(envelope.t) else { throw AcceptError.notAClass(envelope.t) }
+ // Decoded in full before anything changes, so a body that does not
+ // decode leaves the view as it was.
+ let message: Message
+ do {
+ message = try Message(t: envelope.t, b: envelope.b)
+ } catch {
+ throw AcceptError.undecodable("\(envelope.t): \(error)")
+ }
+ let h = message.header
+ let newBoot = h.boot != currentBoot
+ let lead = Self.leads(h, arrivalMs: arrivalMs)
+ let wall = Self.wallSend(h, arrivalMs: arrivalMs, off: lead || (!newBoot && noRuleN))
+ let send = sendTime(boot: h.boot, wall: wall, uptimeMs: h.uptimeMs, nowMs: arrivalMs)
+
+ // Another boot replaces this one only with an anchored message sent
+ // after the newest one held, so a replay of an older boot cannot
+ // wipe a newer one.
+ if newBoot, !currentBoot.isEmpty {
+ guard let send, send > newest else { throw AcceptError.notNewer }
+ }
+ guard h.seq > (newBoot ? 0 : seq[envelope.t, default: 0]) else { throw AcceptError.notNewer }
+
+ if newBoot { reset(to: h.boot) }
+ if lead { noRuleN = true }
+ switch message {
+ case .live(let l):
+ live = l
+ if let table = l.sources {
+ sources = table
+ srcRev = l.srcRev
+ fieldSources = l.fieldSources ?? [:]
+ }
+ if send != nil {
+ setM(Anchor(seq: h.seq, hb: false, boot: h.boot, wallSend: wall, uptimeMs: h.uptimeMs,
+ srcRev: l.srcRev, liveSeq: h.seq, lpSeq: 0, ages: l.ages))
+ }
+ case .lp(let p):
+ lp = p
+ case .prices(let p):
+ prices = p
+ case .hb(let b):
+ hb = b
+ if send != nil {
+ setM(Anchor(seq: h.seq, hb: true, boot: h.boot, wallSend: wall, uptimeMs: h.uptimeMs,
+ srcRev: b.srcRev, liveSeq: b.liveSeq, lpSeq: b.lpSeq, ages: b.ages))
+ }
+ case .other:
+ break
+ }
+ seq[envelope.t] = h.seq
+ if let send, send > newest { newest = send }
+ }
+
+ /// The send time the phone trusts for one box message arriving at
+ /// `arrivalMs`, or nil when neither rule anchors it. What an extension
+ /// asks of an alert before it shows the event's own text.
+ public func sentMs(_ header: CloudWire.Header, arrivalMs: Int64) -> Int64? {
+ let off = Self.leads(header, arrivalMs: arrivalMs) || (header.boot == currentBoot && noRuleN)
+ return sendTime(boot: header.boot, wall: Self.wallSend(header, arrivalMs: arrivalMs, off: off), uptimeMs: header.uptimeMs, nowMs: arrivalMs)
+ }
+
+ /// When the box sent M, by the phone clock, or nil while nothing
+ /// anchored is held. The age on screen counts from here.
+ public func anchorSentMs(nowMs: Int64) -> Int64? {
+ guard let m else { return nil }
+ return sendTime(boot: m.boot, wall: noRuleN ? nil : m.wallSend, uptimeMs: m.uptimeMs, nowMs: nowMs)
+ }
+
+ /// The protocol's state rules at `nowMs`. They can only make the box's
+ /// own grades worse.
+ public func grade(nowMs: Int64) -> Grades {
+ var g = Grades()
+ var t: Int64 = 0
+ var anchor: Anchor?
+ if let m, let send = anchorSentMs(nowMs: nowMs) {
+ anchor = m
+ t = max(0, nowMs &- send)
+ if let u = ruleU(boot: m.boot, uptimeMs: m.uptimeMs, nowMs: nowMs), u == send, let off = offsets[m.boot] {
+ t = t &+ (nowMs &- off.learnedAtMs) / Self.ruleUDriftDivisor
+ }
+ g.anchored = true
+ g.ageMs = t
+ }
+
+ // 1. A table older than M names leaves every source stale until the
+ // new one arrives. 3 and 4. Otherwise each age moves on by t, and
+ // the worse of the box's grade and that one wins.
+ let tableBehind = anchor.map { $0.srcRev > srcRev } ?? false
+ for (id, s) in sources {
+ var state = s.state
+ if let anchor, !tableBehind {
+ if let age = anchor.ages[id] { state = max(state, Self.ladder(ageMs: age &+ t, staleAfterMs: s.staleAfterMs)) }
+ } else {
+ state = max(state, .stale)
+ }
+ g.sources[id] = state
+ }
+
+ // 2. An hb that names a newer live or lp than the phone holds makes
+ // those values stale. 5. A field follows the worst of its sources;
+ // one with none is live while t ≤ 2H.
+ if let live {
+ let behind = anchor.map { $0.hb && $0.liveSeq > seq[CloudWire.msgLive, default: 0] } ?? true
+ for f in live.fields.keys {
+ var state = SourceState.live
+ let srcs = fieldSources[f] ?? []
+ if srcs.isEmpty, t > 2 * heartbeatMs { state = .stale }
+ for s in srcs { state = max(state, g.sources[s] ?? .stale) }
+ if behind { state = max(state, .stale) }
+ g.fields[f] = state
+ }
+ }
+ if let lp {
+ let behind = anchor.map { $0.hb && $0.lpSeq > seq[CloudWire.msgLP, default: 0] } ?? true
+ for l in lp.loadpoints {
+ var state = g.sources[l.src] ?? .stale
+ if behind { state = max(state, .stale) }
+ g.loadpoints[l.id] = state
+ }
+ }
+ return g
+ }
+
+ /// The next moment `grade` would change with no message arriving: a
+ /// source's age passing S, 2S or 6S, a field with no source passing 2H,
+ /// or rule U's anchor lapsing. Nil while nothing anchored is held. It may
+ /// come a little early, never late, so a clock can wait for exactly this.
+ public func nextGradeChange(nowMs: Int64) -> Int64? {
+ guard let m, let send = anchorSentMs(nowMs: nowMs) else { return nil }
+ var next: Int64?
+ func consider(_ at: Int64) {
+ if at > nowMs, next.map({ at < $0 }) ?? true { next = at }
+ }
+ // Rule U's margin makes ages run a little fast; starting the wait
+ // that much sooner keeps the clock from passing a change.
+ var drift: Int64 = 0
+ if let u = ruleU(boot: m.boot, uptimeMs: m.uptimeMs, nowMs: nowMs), u == send, let off = offsets[m.boot] {
+ drift = (nowMs &- off.learnedAtMs) / Self.ruleUDriftDivisor
+ consider(off.learnedAtMs &+ Self.ruleULifetimeMs &+ 1)
+ }
+ for (id, s) in sources where s.staleAfterMs > 0 {
+ guard let age = m.ages[id] else { continue }
+ for k: Int64 in [1, 2, 6] { consider(send &+ k &* s.staleAfterMs &- age &+ 1 &- drift) }
+ }
+ consider(send &+ 2 &* heartbeatMs &+ 1 &- drift)
+ return next
+ }
+
+ /// Grades an age the way the box grades its own sources.
+ static func ladder(ageMs age: Int64, staleAfterMs s: Int64) -> SourceState {
+ if s <= 0 { return .never }
+ if age <= s { return .live }
+ if age <= 2 &* s { return .lagging }
+ if age <= 6 &* s { return .stale }
+ return .down
+ }
+
+ // MARK: - Private
+
+ private enum Message {
+ case live(CloudWire.Live)
+ case lp(CloudWire.LP)
+ case prices(CloudWire.Prices)
+ case hb(CloudWire.HB)
+ /// Classes the view only orders by seq.
+ case other(CloudWire.Header)
+
+ init(t: String, b: Bytes) throws {
+ switch t {
+ case CloudWire.msgLive: self = .live(try CloudWire.Live(body: b))
+ case CloudWire.msgLP: self = .lp(try CloudWire.LP(body: b))
+ case CloudWire.msgPrices: self = .prices(try CloudWire.Prices(body: b))
+ case CloudWire.msgHB: self = .hb(try CloudWire.HB(body: b))
+ default: self = .other(try CloudWire.Header(body: b))
+ }
+ }
+
+ var header: CloudWire.Header {
+ switch self {
+ case .live(let l): return l.header
+ case .lp(let p): return p.header
+ case .prices(let p): return p.header
+ case .hb(let b): return b.header
+ case .other(let h): return h
+ }
+ }
+ }
+
+ private mutating func setM(_ a: Anchor) {
+ if m.map({ a.seq > $0.seq }) ?? true { m = a }
+ }
+
+ /// When the boot changes, the phone drops what it held and waits for
+ /// that boot's full live.
+ private mutating func reset(to boot: Bytes) {
+ currentBoot = boot
+ seq = [:]
+ noRuleN = false
+ sources = [:]
+ srcRev = 0
+ fieldSources = [:]
+ live = nil
+ lp = nil
+ prices = nil
+ hb = nil
+ m = nil
+ }
+
+ /// A `wallMs` this far ahead of the phone turns rule N off for its boot.
+ private static func leads(_ h: CloudWire.Header, arrivalMs: Int64) -> Bool {
+ h.wallMs.map { $0 > arrivalMs &+ ruleNLeadMs } ?? false
+ }
+
+ /// Rule N: the earlier of `wallMs` and arrival, so a fast box clock
+ /// cannot make data look fresher. Nil without a wall time, with rule N
+ /// off, or before 1970, which Go's -1 sentinel also refuses.
+ private static func wallSend(_ h: CloudWire.Header, arrivalMs: Int64, off: Bool) -> Int64? {
+ guard let wallMs = h.wallMs, !off else { return nil }
+ let send = min(wallMs, arrivalMs)
+ return send >= 0 ? send : nil
+ }
+
+ /// The earlier of rule N and rule U, or nil when neither applies at
+ /// `nowMs`.
+ private func sendTime(boot: Bytes, wall: Int64?, uptimeMs: Int64, nowMs: Int64) -> Int64? {
+ guard let u = ruleU(boot: boot, uptimeMs: uptimeMs, nowMs: nowMs) else { return wall }
+ guard let wall else { return u }
+ return min(wall, u)
+ }
+
+ private func ruleU(boot: Bytes, uptimeMs: Int64, nowMs: Int64) -> Int64? {
+ guard let off = offsets[boot], nowMs &- off.learnedAtMs <= Self.ruleULifetimeMs else { return nil }
+ return off.offsetMs &+ uptimeMs
+ }
+}
diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift
new file mode 100644
index 0000000..5b54794
--- /dev/null
+++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift
@@ -0,0 +1,693 @@
+import Foundation
+import FTWShared
+
+/// The FTW Cloud wire, as a phone reads it.
+///
+/// FTW Cloud keeps a box-signed copy of a home and serves it to that home's
+/// phones. The service cannot sign for the box, so this file is what lets
+/// the phone check the box's bytes itself: the unpadded frame, the envelope
+/// and what its signature covers, the home id, and the bodies of the classes
+/// a phone reads. srcfl/ftw `docs/protocol-cloud.md` is the normative text
+/// and `go/protocol/cloudwire` the reference; its golden vectors replay in
+/// `CloudWireTests` byte for byte.
+///
+/// Bodies are read as Go's decoder reads them: unknown keys are ignored,
+/// null reads as absent, and a known key of the wrong type refuses the body,
+/// so the phone never keeps a message the reference would drop.
+public enum CloudWire {
+ // MARK: - Constants
+
+ /// Byte 0 of every frame.
+ public static let version: UInt8 = 1
+ /// Byte 1 of every frame. Lane 1 without the app protocol's bulk
+ /// buckets: the service reads the content, so padding would hide little.
+ public static let lane: UInt8 = 1
+ /// ver, lane, flags, rsvd and a u16 length.
+ public static let headerBytes = 6
+ public static let maxPayload = 0xffff
+
+ /// "ftw.cloud.v1" and a zero byte. Starts every signed input.
+ public static let signTag = Bytes("ftw.cloud.v1\0".utf8)
+ /// Starts the hash a home id is cut from.
+ public static let homeTag = Bytes("ftw.cloud.home.v1".utf8)
+ /// Starts the app session's Noise prologue; the home id follows.
+ public static let appPrologueTag = Bytes("ftw.cloud.v1:".utf8)
+
+ /// ES256 as r ‖ s, each 32 bytes big-endian.
+ public static let sigBytes = 64
+ /// An uncompressed SEC1 P-256 point: the box key as the phone holds it.
+ public static let publicKeyBytes = 65
+ public static let bootBytes = 16
+
+ // MARK: - Message types
+
+ // The classes a phone may read. hb goes to every device in the roster;
+ // the service forwards the rest by the device's scope.
+ public static let msgLive = "live"
+ public static let msgLP = "lp"
+ public static let msgPrices = "prices"
+ public static let msgPlan = "plan"
+ public static let msgHealth = "health"
+ public static let msgEvent = "event"
+ public static let msgTiles = "tiles"
+ public static let msgHB = "hb"
+
+ /// Everything else the box sends (hello, roster, bye) is the service's
+ /// alone and never reaches a phone.
+ public static let phoneClasses: Set = [msgLive, msgLP, msgPrices, msgPlan, msgHealth, msgEvent, msgTiles, msgHB]
+
+ // The app session's own types. All unsigned.
+ /// Handshake message 1, `{hz}`.
+ public static let msgNow = "now"
+ /// Handshake message 2: what the service holds.
+ public static let msgSnapshot = "snapshot"
+ /// Phone to service, `{hz}`: whether the phone can see the home.
+ public static let msgWatch = "watch"
+ /// An owner's, `{deviceId}`. They only narrow access.
+ public static let msgSuspend = "suspend"
+ public static let msgUnsuspend = "unsuspend"
+ /// Phone to service, `{apnsToken, environment, kind}`: where Apple can
+ /// reach this phone. The service binds it to the phone's roster entry.
+ public static let msgPushRegister = "push.register"
+ /// The only kind of push registration so far.
+ static let pushKindAlert = "alert"
+
+ public static let hzLooking = 1
+ public static let hzAway = 0
+
+ // Close codes on /v1/app.
+ /// The phone sent something the protocol does not allow.
+ public static let closeProtocol = 4400
+ /// Every refusal, without saying which: no such home, not in the
+ /// roster, suspended, roster expired, grant off.
+ public static let closeDenied = 4403
+ /// A newer session from the same device took over.
+ public static let closeReplaced = 4408
+ /// The roster changed what this device may read. Reconnect at once.
+ public static let closeChanged = 4409
+ /// The home has as many sessions as the service allows, as on the relay.
+ public static let closeBusy = 4429
+
+ /// A frame, envelope or body this protocol does not send. Local only.
+ public struct WireError: Error, Equatable {
+ public let code: String
+ public let message: String
+ }
+
+ // MARK: - Frames
+
+ /// One envelope in one frame: lane 1, flags 0 and no padding, so the
+ /// message is exactly 6 + len bytes.
+ public static func encodeFrame(_ envelope: Bytes) throws -> Bytes {
+ guard envelope.count <= maxPayload else {
+ throw WireError(code: "E_CLOUD_FRAME", message: "\(envelope.count) bytes do not fit the length field")
+ }
+ return [version, lane, 0, 0, UInt8(envelope.count >> 8), UInt8(envelope.count & 0xff)] + envelope
+ }
+
+ /// The envelope bytes of one frame. Stricter than `Frame.decode`: lane 0,
+ /// a flag, a reserved bit or a byte past the declared length is not a
+ /// frame this protocol sends.
+ public static func decodeFrame(_ message: Bytes) throws -> Bytes {
+ func refuse(_ why: String) -> WireError { WireError(code: "E_CLOUD_FRAME", message: why) }
+ guard message.count >= headerBytes else { throw refuse("\(message.count) bytes") }
+ guard message[0] == version else { throw refuse("version \(message[0])") }
+ guard message[1] == lane else { throw refuse("lane \(message[1])") }
+ guard message[2] == 0, message[3] == 0 else { throw refuse("flags \(message[2]) \(message[3])") }
+ let len = Int(message[4]) << 8 | Int(message[5])
+ guard len == message.count - headerBytes else {
+ throw refuse("declares \(len) bytes, carries \(message.count - headerBytes)")
+ }
+ return Array(message[headerBytes...])
+ }
+
+ // MARK: - Envelope
+
+ /// One message, `{t, b, sig}`. `b` is the body's exact bytes, carried as
+ /// a byte string so the signature covers what was sent and the service
+ /// can forward it unchanged. `sig` is empty on whatever the box did not
+ /// sign.
+ public struct Envelope: Equatable, Sendable {
+ public var t: String
+ public var b: Bytes
+ public var sig: Bytes
+
+ public init(t: String, b: Bytes, sig: Bytes = []) {
+ self.t = t
+ self.b = b
+ self.sig = sig
+ }
+
+ /// Parses one envelope. `t` must be text and `b` and `sig` byte
+ /// strings, checked rather than converted: an envelope whose body is
+ /// text is not the envelope that was signed. Unknown keys are
+ /// ignored.
+ public static func decode(_ raw: Bytes) throws -> Envelope {
+ let value: CBOR
+ do {
+ value = try decodeCBOR(raw)
+ } catch {
+ throw envelopeError("\(error)")
+ }
+ return try Envelope(value)
+ }
+
+ /// An envelope embedded as a CBOR map, as in a snapshot.
+ init(_ value: CBOR) throws {
+ guard case .map(let entries) = value else { throw envelopeError("not a map") }
+ var t: String?
+ var b: Bytes?
+ var sig: Bytes?
+ for e in entries {
+ guard case .text(let key) = e.key else { throw envelopeError("a key is not text") }
+ switch (key, e.value) {
+ case ("t", .text(let v)): t = v
+ case ("b", .bytes(let v)): b = v
+ case ("sig", .bytes(let v)): sig = v
+ case ("t", _), ("b", _), ("sig", _): throw envelopeError("\(key) has the wrong type")
+ default: continue
+ }
+ }
+ guard let t, let b, let sig else { throw envelopeError("t, b and sig are all required") }
+ guard !t.isEmpty else { throw envelopeError("empty type") }
+ self.init(t: t, b: b, sig: sig)
+ }
+
+ /// A three-entry map in core deterministic key order, b, t, sig. An
+ /// empty signature is an empty byte string, never null.
+ public func encode() -> Bytes {
+ encodeCBOR(.map([("b", .bytes(b)), ("t", .text(t)), ("sig", .bytes(sig))]))
+ }
+ }
+
+ /// An envelope the phone says itself: the body in core deterministic
+ /// encoding and an empty `sig`. The same bytes as Go's
+ /// `cloudwire.Unsigned` for the same body.
+ public static func encodeUnsigned(t: String, body: CBOR) -> Bytes {
+ Envelope(t: t, b: encodeCBOR(deterministic(body))).encode()
+ }
+
+ /// RFC 8949 core deterministic order: each map's keys sorted by the bytes
+ /// of their own encoding, which puts shorter text keys first. The encoder
+ /// already writes every head in its shortest form.
+ static func deterministic(_ value: CBOR) -> CBOR {
+ switch value {
+ case .array(let items):
+ return .array(items.map(deterministic))
+ case .map(let entries):
+ return .map(entries
+ .map { CBOR.Entry(deterministic($0.key), deterministic($0.value)) }
+ .sorted { encodeCBOR($0.key).lexicographicallyPrecedes(encodeCBOR($1.key)) })
+ case .tagged(let tag, let inner):
+ return .tagged(tag, deterministic(inner))
+ default:
+ return value
+ }
+ }
+
+ // MARK: - Signatures
+
+ /// What a signature covers: `signTag`, then the CBOR array
+ /// `[home, t, b]`. The home id is inside, so a message signed for one
+ /// home never verifies for another.
+ public static func signedInput(home: String, t: String, b: Bytes) -> Bytes {
+ signTag + encodeCBOR(.array([.text(home), .text(t), .bytes(b)]))
+ }
+
+ /// Whether `envelope` carries an ES256 signature by `boxKey` for `home`,
+ /// over the bytes received, never a re-encoding. The key is the one the
+ /// box gave this phone over Noise, never one from the service. The
+ /// notification extension's check, so a push and a reading are checked
+ /// alike.
+ public static func verify(boxKey: Bytes, home: String, envelope: Envelope) -> Bool {
+ PushAlert.verify(boxKey: boxKey, home: home, t: envelope.t, b: envelope.b, sig: envelope.sig)
+ }
+
+ // MARK: - Ids
+
+ /// The home's name at the service: lowercase base32 without padding of
+ /// the first 16 bytes of SHA-256(`homeTag` ‖ pk), 26 characters. A hash
+ /// of the key, so no other box can claim the home.
+ public static func homeID(pk: Bytes) -> String {
+ base32(Array(Primitives.sha256(homeTag + pk).prefix(16)))
+ }
+
+ /// A source's opaque id: the first 8 bytes of
+ /// HMAC-SHA256(grantKey, "src\0" ‖ driver), base64url without padding.
+ /// The phone maps these to names from the table the box sends over
+ /// Noise; computing one here is for the vectors.
+ static func sourceID(grantKey: Bytes, driver: String) -> String {
+ opaqueID(grantKey, "src\0", driver)
+ }
+
+ /// A loadpoint's opaque id, cut the same way with "lp\0".
+ static func loadpointID(grantKey: Bytes, loadpoint: String) -> String {
+ opaqueID(grantKey, "lp\0", loadpoint)
+ }
+
+ private static func opaqueID(_ key: Bytes, _ tag: String, _ name: String) -> String {
+ Base64url.encode(Array(Primitives.hmacSHA256(key: key, Bytes(tag.utf8) + Bytes(name.utf8)).prefix(8)))
+ }
+
+ /// The Noise prologue of one home's app session. The phone learned the
+ /// home id from the box, so a service that answers for another home
+ /// fails the handshake.
+ public static func appPrologue(home: String) -> Bytes {
+ appPrologueTag + Bytes(home.utf8)
+ }
+
+ private static func base32(_ bytes: Bytes) -> String {
+ let alphabet = Bytes("abcdefghijklmnopqrstuvwxyz234567".utf8)
+ var out = Bytes()
+ var buffer: UInt32 = 0
+ var bits = 0
+ for byte in bytes {
+ buffer = buffer << 8 | UInt32(byte)
+ bits += 8
+ while bits >= 5 {
+ out.append(alphabet[Int(buffer >> UInt32(bits - 5) & 31)])
+ bits -= 5
+ }
+ }
+ if bits > 0 { out.append(alphabet[Int(buffer << UInt32(5 - bits) & 31)]) }
+ return String(decoding: out, as: UTF8.self)
+ }
+
+ // MARK: - App session
+
+ /// Handshake message 2: for each class the device may read, the newest
+ /// envelope the service holds, the newest live that carried sources and
+ /// the newest hb, in rising seq. `lastSeenMs` and `online` are the
+ /// service's own word, unsigned, and never shown as a reading. `more`
+ /// counts envelopes that did not fit and follow as ordinary frames.
+ public struct Snapshot: Equatable, Sendable {
+ public var envelopes: [Envelope]
+ public var lastSeenMs: Int64
+ public var online: Bool
+ public var more: Int
+ }
+
+ /// Reads message 2's payload. Each entry is a box envelope embedded as
+ /// a map; its `b` is the box's exact bytes, so it verifies as sent.
+ public static func parseSnapshot(payload: Bytes) throws -> Snapshot {
+ let env = try Envelope.decode(payload)
+ guard env.t == msgSnapshot else { throw envelopeError("message 2 is \(env.t), not a snapshot") }
+ let f = try Fields(body: env.b)
+ return Snapshot(
+ envelopes: try f.list("envelopes") { try Envelope($0) },
+ lastSeenMs: try f.int("lastSeenMs"),
+ online: try f.bool("online"),
+ more: Int(try f.int("more"))
+ )
+ }
+
+ // MARK: - Bodies
+
+ /// In every body the box sends.
+ public struct Header: Equatable, Sendable {
+ /// Starts at 1 and rises by 1 per message, per boot.
+ public var seq: UInt64
+ /// Drawn at random at each Core start.
+ public var boot: Bytes
+ /// Box monotonic time since start, at send.
+ public var uptimeMs: Int64
+ /// Box wall time at send, present only while NTP has synced it.
+ public var wallMs: Int64?
+ }
+
+ /// The site's flows and the freshness of every source behind them.
+ public struct Live: Equatable, Sendable {
+ public var header: Header
+ /// Frozen field ids 1–6 and 10, keyed as text as the app protocol's
+ /// delta keys them. Field 1 is the mode index; the rest are watts.
+ public var fields: [String: Int64]
+ /// Present only when the source table changed and in every full
+ /// message, as is `sources`.
+ public var fieldSources: [String: [String]]?
+ public var sources: [String: Source]?
+ /// Rises by 1, per boot, each time the source table changes.
+ public var srcRev: UInt64
+ /// For each live or lagging source, the age of its newest reading at
+ /// send, in ms of box uptime.
+ public var ages: [String: Int64]
+ public var dispatchBlockedBy: [String]
+ }
+
+ /// One source's freshness as the box grades it, keyed by opaque id.
+ public struct Source: Equatable, Sendable {
+ public var kind: String
+ /// A state this build does not know ranks as never.
+ public var state: SourceState
+ /// Box uptime at the last good reading, when the table was sent.
+ public var lastOkMs: Int64
+ public var staleAfterMs: Int64
+ }
+
+ /// Loadpoint and control feedback state.
+ public struct LP: Equatable, Sendable {
+ public var header: Header
+ public var controlRev: UInt64
+ public var loadpoints: [Loadpoint]
+ public var feedback: [Feedback]
+ }
+
+ /// One charger as the household sees it. Nil is the box's "not known".
+ public struct Loadpoint: Equatable, Sendable {
+ /// Opaque loadpoint id.
+ public var id: String
+ /// Opaque source id of the charger, whose freshness this follows.
+ public var src: String
+ public var plugged: Bool
+ public var socPermille: Int64?
+ /// vehicle, inferred or assumed; empty when unplugged.
+ public var socSource: String
+ /// Seconds since the level was confirmed, at send.
+ public var socAgeS: Int64?
+ public var targetPermille: Int64?
+ /// Box wall time.
+ public var readyByMs: Int64?
+ public var recurring: Bool
+ public var powerW: Int64
+ public var sessionWh: Int64
+ public var session: String
+ public var manual: Manual?
+ public var commandedW: Int64?
+ public var commandedReason: String
+ public var surplusOnly: Bool
+ public var gridDeferred: Bool
+ public var planPending: Bool
+ public var boost: Bool
+ public var nextStartMs: Int64?
+ public var nextEndMs: Int64?
+ public var nextWh: Int64?
+ }
+
+ /// A manual charging hold.
+ public struct Manual: Equatable, Sendable {
+ public var state: String
+ public var sinceAgeMs: Int64
+ public var requestedW: Int64?
+ public var commandedW: Int64?
+ public var reason: String
+ }
+
+ /// Core's verdict on whether one device function follows.
+ public struct Feedback: Equatable, Sendable {
+ public var kind: String
+ public var src: String
+ public var status: String
+ public var reason: String
+ public var severity: String
+ public var evidence: String
+ public var fresh: Bool
+ public var actualW: Int64?
+ /// Age of that reading by box uptime, at send.
+ public var ageMs: Int64?
+ }
+
+ /// The price window, in columns. Nil where a slot has no price.
+ public struct Prices: Equatable, Sendable {
+ public var header: Header
+ public var zone: String
+ public var currency: String
+ /// Box wall time the first slot starts.
+ public var t0Ms: Int64
+ public var slotMs: Int64
+ /// Minor units per kWh.
+ public var spotMinor: [Int64?]
+ /// What the household pays, tariff and VAT included.
+ public var totalMinor: [Int64?]
+ }
+
+ /// The box is there, and this is how old its readings are. It names the
+ /// newest live and lp the box sent, so it never vouches for one the
+ /// phone has not seen.
+ public struct HB: Equatable, Sendable {
+ public var header: Header
+ public var clock: String
+ public var srcRev: UInt64
+ public var liveSeq: UInt64
+ /// 0 if the box has sent no lp.
+ public var lpSeq: UInt64
+ public var ages: [String: Int64]
+ }
+
+ static func envelopeError(_ why: String) -> WireError { WireError(code: "E_CLOUD_ENVELOPE", message: why) }
+ static func bodyError(_ key: String, _ want: String) -> WireError { WireError(code: "E_CLOUD_BODY", message: "\(key) is not \(want)") }
+}
+
+// MARK: - Body decoding
+
+extension CloudWire.Header {
+ public init(body: Bytes) throws { try self.init(CloudWire.Fields(body: body)) }
+
+ init(_ f: CloudWire.Fields) throws {
+ self.init(seq: try f.uint("seq"), boot: try f.bytes("boot"), uptimeMs: try f.int("uptimeMs"), wallMs: try f.optionalInt("wallMs"))
+ }
+}
+
+extension CloudWire.Live {
+ public init(body: Bytes) throws {
+ let f = try CloudWire.Fields(body: body)
+ self.init(
+ header: try CloudWire.Header(f),
+ fields: try f.dictionary("fields") { try CloudWire.int($0, "fields") } ?? [:],
+ fieldSources: try f.dictionary("fieldSources") { try CloudWire.texts($0, "fieldSources") },
+ sources: try f.dictionary("sources") { try CloudWire.Source(CloudWire.Fields($0, "sources")) },
+ srcRev: try f.uint("srcRev"),
+ ages: try f.dictionary("ages") { try CloudWire.int($0, "ages") } ?? [:],
+ dispatchBlockedBy: try f.list("dispatchBlockedBy") { try CloudWire.text($0, "dispatchBlockedBy") }
+ )
+ }
+}
+
+extension CloudWire.Source {
+ init(_ f: CloudWire.Fields) throws {
+ self.init(
+ kind: try f.text("kind"),
+ state: SourceState(rawValue: try f.text("state")) ?? .never,
+ lastOkMs: try f.int("lastOkMs"),
+ staleAfterMs: try f.int("staleAfterMs")
+ )
+ }
+}
+
+extension CloudWire.LP {
+ public init(body: Bytes) throws {
+ let f = try CloudWire.Fields(body: body)
+ self.init(
+ header: try CloudWire.Header(f),
+ controlRev: try f.uint("controlRev"),
+ loadpoints: try f.list("loadpoints") { try CloudWire.Loadpoint(CloudWire.Fields($0, "loadpoints")) },
+ feedback: try f.list("feedback") { try CloudWire.Feedback(CloudWire.Fields($0, "feedback")) }
+ )
+ }
+}
+
+extension CloudWire.Loadpoint {
+ init(_ f: CloudWire.Fields) throws {
+ self.init(
+ id: try f.text("id"),
+ src: try f.text("src"),
+ plugged: try f.bool("plugged"),
+ socPermille: try f.optionalInt("socPermille"),
+ socSource: try f.text("socSource"),
+ socAgeS: try f.optionalInt("socAgeS"),
+ targetPermille: try f.optionalInt("targetPermille"),
+ readyByMs: try f.optionalInt("readyByMs"),
+ recurring: try f.bool("recurring"),
+ powerW: try f.int("powerW"),
+ sessionWh: try f.int("sessionWh"),
+ session: try f.text("session"),
+ manual: try f.optional("manual").map(CloudWire.Manual.init),
+ commandedW: try f.optionalInt("commandedW"),
+ commandedReason: try f.text("commandedReason"),
+ surplusOnly: try f.bool("surplusOnly"),
+ gridDeferred: try f.bool("gridDeferred"),
+ planPending: try f.bool("planPending"),
+ boost: try f.bool("boost"),
+ nextStartMs: try f.optionalInt("nextStartMs"),
+ nextEndMs: try f.optionalInt("nextEndMs"),
+ nextWh: try f.optionalInt("nextWh")
+ )
+ }
+}
+
+extension CloudWire.Manual {
+ init(_ f: CloudWire.Fields) throws {
+ self.init(
+ state: try f.text("state"),
+ sinceAgeMs: try f.int("sinceAgeMs"),
+ requestedW: try f.optionalInt("requestedW"),
+ commandedW: try f.optionalInt("commandedW"),
+ reason: try f.text("reason")
+ )
+ }
+}
+
+extension CloudWire.Feedback {
+ init(_ f: CloudWire.Fields) throws {
+ self.init(
+ kind: try f.text("kind"),
+ src: try f.text("src"),
+ status: try f.text("status"),
+ reason: try f.text("reason"),
+ severity: try f.text("severity"),
+ evidence: try f.text("evidence"),
+ fresh: try f.bool("fresh"),
+ actualW: try f.optionalInt("actualW"),
+ ageMs: try f.optionalInt("ageMs")
+ )
+ }
+}
+
+extension CloudWire.Prices {
+ public init(body: Bytes) throws {
+ let f = try CloudWire.Fields(body: body)
+ self.init(
+ header: try CloudWire.Header(f),
+ zone: try f.text("zone"),
+ currency: try f.text("currency"),
+ t0Ms: try f.int("t0Ms"),
+ slotMs: try f.int("slotMs"),
+ spotMinor: try f.list("spotMinor") { $0.isNull ? nil : try CloudWire.int($0, "spotMinor") },
+ totalMinor: try f.list("totalMinor") { $0.isNull ? nil : try CloudWire.int($0, "totalMinor") }
+ )
+ }
+}
+
+extension CloudWire.HB {
+ public init(body: Bytes) throws {
+ let f = try CloudWire.Fields(body: body)
+ self.init(
+ header: try CloudWire.Header(f),
+ clock: try f.text("clock"),
+ srcRev: try f.uint("srcRev"),
+ liveSeq: try f.uint("liveSeq"),
+ lpSeq: try f.uint("lpSeq"),
+ ages: try f.dictionary("ages") { try CloudWire.int($0, "ages") } ?? [:]
+ )
+ }
+}
+
+extension CloudWire {
+ /// A body's text keys. Absent and null both read as the zero value, as
+ /// Go leaves a field it was not given.
+ struct Fields {
+ private let map: [String: CBOR]
+
+ init(body: Bytes) throws {
+ guard !body.isEmpty else { throw WireError(code: "E_CLOUD_BODY", message: "empty body") }
+ let value: CBOR
+ do {
+ value = try decodeCBOR(body)
+ } catch {
+ throw WireError(code: "E_CLOUD_BODY", message: "\(error)")
+ }
+ try self.init(value, "body")
+ }
+
+ init(_ value: CBOR, _ key: String) throws {
+ var map = [String: CBOR]()
+ switch value {
+ case .map(let entries):
+ for e in entries {
+ if case .text(let k) = e.key { map[k] = e.value }
+ }
+ case .null, .undefined:
+ break
+ default:
+ throw bodyError(key, "a map")
+ }
+ self.map = map
+ }
+
+ subscript(key: String) -> CBOR { map[key] ?? .null }
+
+ func uint(_ key: String) throws -> UInt64 { try CloudWire.uint(self[key], key) }
+ func int(_ key: String) throws -> Int64 { try CloudWire.int(self[key], key) }
+ func text(_ key: String) throws -> String { try CloudWire.text(self[key], key) }
+ func bool(_ key: String) throws -> Bool { try CloudWire.bool(self[key], key) }
+
+ func bytes(_ key: String) throws -> Bytes {
+ switch self[key] {
+ case .bytes(let b): return b
+ case .null, .undefined: return []
+ default: throw bodyError(key, "a byte string")
+ }
+ }
+
+ func optionalInt(_ key: String) throws -> Int64? {
+ self[key].isNull ? nil : try CloudWire.int(self[key], key)
+ }
+
+ func optional(_ key: String) throws -> Fields? {
+ self[key].isNull ? nil : try Fields(self[key], key)
+ }
+
+ func list(_ key: String, _ element: (CBOR) throws -> T) throws -> [T] {
+ switch self[key] {
+ case .array(let items): return try items.map(element)
+ case .null, .undefined: return []
+ default: throw bodyError(key, "an array")
+ }
+ }
+
+ /// Nil when absent, so "no table in this message" stays apart from
+ /// an empty one.
+ func dictionary(_ key: String, _ value: (CBOR) throws -> T) throws -> [String: T]? {
+ switch self[key] {
+ case .map(let entries):
+ var out = [String: T]()
+ for e in entries {
+ guard case .text(let k) = e.key else { throw bodyError(key, "keyed by text") }
+ out[k] = try value(e.value)
+ }
+ return out
+ case .null, .undefined: return nil
+ default: throw bodyError(key, "a map")
+ }
+ }
+ }
+
+ static func uint(_ v: CBOR, _ key: String) throws -> UInt64 {
+ switch v {
+ case .unsigned(let u): return u
+ case .null, .undefined: return 0
+ default: throw bodyError(key, "an unsigned integer")
+ }
+ }
+
+ static func int(_ v: CBOR, _ key: String) throws -> Int64 {
+ switch v {
+ case .unsigned(let u) where u <= UInt64(Int64.max): return Int64(u)
+ case .negative(let n) where n <= UInt64(Int64.max): return -1 - Int64(n)
+ case .null, .undefined: return 0
+ default: throw bodyError(key, "a 64-bit integer")
+ }
+ }
+
+ static func text(_ v: CBOR, _ key: String) throws -> String {
+ switch v {
+ case .text(let s): return s
+ case .null, .undefined: return ""
+ default: throw bodyError(key, "text")
+ }
+ }
+
+ static func bool(_ v: CBOR, _ key: String) throws -> Bool {
+ switch v {
+ case .bool(let b): return b
+ case .null, .undefined: return false
+ default: throw bodyError(key, "a bool")
+ }
+ }
+
+ static func texts(_ v: CBOR, _ key: String) throws -> [String] {
+ switch v {
+ case .array(let items): return try items.map { try text($0, key) }
+ case .null, .undefined: return []
+ default: throw bodyError(key, "an array of text")
+ }
+ }
+}
diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift
index 72f725e..b2c068f 100644
--- a/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift
+++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift
@@ -75,16 +75,18 @@ public final class NoiseCarrier: Carrier {
self.onStatus = onStatus
}
- public func send(_ frame: Bytes) {
+ @discardableResult
+ public func send(_ frame: Bytes) -> Bool {
// Before the handshake there is no key, and sending in the clear to
// keep a caller happy would be worse than dropping.
- guard let transport, status.isOpen else { return }
+ guard let transport, status.isOpen else { return false }
do {
- inner.send(try transport.encrypt(frame))
+ return inner.send(try transport.encrypt(frame))
} catch {
// Nonce exhaustion or a destroyed cipher: continuing would risk
// reusing a (key, nonce) pair.
fail("encryption failed", retryable: true)
+ return false
}
}
diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift
index aaac9f8..75d46eb 100644
--- a/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift
+++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/RelayCarrier.swift
@@ -68,9 +68,11 @@ public final class RelayCarrier: Carrier {
self.onStatus = onStatus
}
- public func send(_ frame: Bytes) {
- guard status.isOpen, let socket else { return }
+ @discardableResult
+ public func send(_ frame: Bytes) -> Bool {
+ guard status.isOpen, let socket else { return false }
socket.send(frame)
+ return true
}
/// Drop an apparently live socket and dial now, after a foreground wake.
diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift
index 2c91992..9b723e1 100644
--- a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift
+++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift
@@ -326,6 +326,16 @@ public enum AppWords {
static let monthLabels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
static let sv: [String: String] = [
+ // Connecting, and the device key (AppModel.connect, Vault)
+ "This phone has no record of that home.": "Telefonen har inga uppgifter om det hemmet.",
+ "This device has no key for that home.": "Den här enheten har ingen nyckel till det hemmet.",
+ "This home was paired before this app could reach it privately.": "Hemmet parkopplades innan appen kunde nå det privat.",
+ "Unlocking was cancelled. Try again to open your home.": "Upplåsningen avbröts. Försök igen för att öppna ditt hem.",
+ "This device can no longer unlock its key. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR.": "Enheten kan inte längre låsa upp sin nyckel. Öppna boxens lokala dashboard, välj Settings → FTW app → Show pairing code och skanna en ny QR-kod.",
+ "This phone is already set up for a home. Sign out of it first.": "Telefonen är redan kopplad till ett hem. Logga ut från det först.",
+ "That is the only passkey that can unlock this device. Add another one first.": "Det är den enda passkey som kan låsa upp enheten. Lägg till en till först.",
+ "This passkey has not been given access yet. Unlock with the one you set up first.": "Den här passkeyn har inte fått åtkomst än. Lås upp med den du skapade först.",
+ "Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan the QR.": "Öppna boxens lokala dashboard, välj Settings → FTW app → Show pairing code och skanna QR-koden.",
// Clock and calendar
"today %@": "i dag %@",
"tomorrow %@": "i morgon %@",
diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift
new file mode 100644
index 0000000..8db09e6
--- /dev/null
+++ b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift
@@ -0,0 +1,223 @@
+import Foundation
+import Observation
+
+/// FTW Cloud as the person agrees to it, and as the box holds it.
+///
+/// The app uses FTW Cloud as part of the service, so the answer is given
+/// once, when the person starts using the app, on one screen. Statistics are
+/// a separate box on that screen, off until ticked. The grant itself lives on
+/// the box: an owner's phone turns it on there over its Noise session, the
+/// first time, and an owner can turn it off again from About. A viewer cannot
+/// change it and is told to ask the owner.
+@Observable
+@MainActor
+public final class CloudConsent {
+ /// The consent texts the person read. The box records the names.
+ public static let policy = "ftw-cloud-2026-10"
+ public static let statsPolicy = "ftw-stats-2026-10"
+
+ /// What the box says about FTW Cloud, for an owner.
+ public struct BoxState: Equatable, Sendable {
+ /// off, on or revoking.
+ public var state: String
+ public var stats: Bool
+ public var connected: Bool
+ /// When the service confirmed it erased the home's copy.
+ public var erasedAtMs: Double?
+
+ public var on: Bool { state == "on" }
+ public var erasing: Bool { state == "revoking" }
+
+ init(_ json: JSON) {
+ state = json["state"]?.string ?? "off"
+ stats = (json["purposes"]?.array ?? []).contains { $0.string == Contract.cloudPurposeStats }
+ connected = json["connected"]?.bool ?? false
+ erasedAtMs = json["purgedAtMs"]?.number.flatMap { $0 > 0 ? $0 : nil }
+ }
+ }
+
+ /// Whom the screen speaks to.
+ public enum Audience: Equatable, Sendable {
+ /// An owner whose box can run FTW Cloud.
+ case owner
+ /// An owner whose box cannot yet: it turns FTW Cloud on after an
+ /// update.
+ case ownerLater
+ case viewer
+ /// The box has not answered yet.
+ case unknown
+ }
+
+ public private(set) var answered: Bool
+ /// The statistics box. Off until the person ticks it.
+ public var contributeStats = false
+ public private(set) var box: BoxState?
+ public private(set) var working = false
+ public private(set) var help: String?
+
+ @ObservationIgnored private let defaults: UserDefaults
+
+ public init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ answered = defaults.string(forKey: Keys.answered) == Self.policy
+ contributeStats = defaults.bool(forKey: Keys.stats)
+ }
+
+ /// The one screen, once, on a real home.
+ public func shouldAsk(_ site: SiteModel?) -> Bool {
+ guard let site, !site.isDemo, site.siteId != nil else { return false }
+ return !answered
+ }
+
+ public static func audience(_ site: SiteModel) -> Audience {
+ guard site.heardFromBox else { return .unknown }
+ guard site.canConfigure else { return .viewer }
+ return site.session.caps.contains(Contract.capCloudLink) ? .owner : .ownerLater
+ }
+
+ /// The person read the screen and went on. An owner's phone turns FTW
+ /// Cloud on at the box now, or once the box can.
+ public func accept(_ site: SiteModel?) async {
+ defaults.set(Self.policy, forKey: Keys.answered)
+ defaults.set(contributeStats, forKey: Keys.stats)
+ answered = true
+ guard let site else { return }
+ await ensure(site)
+ }
+
+ /// Turn FTW Cloud on at the box for an owner who agreed, once per home.
+ /// After that the box's word stands: if someone turned it off, on the box
+ /// or in About, it stays off until an owner turns it on again.
+ public func ensure(_ site: SiteModel) async {
+ guard answered, let siteId = site.siteId, !site.isDemo, Self.audience(site) == .owner,
+ !defaults.bool(forKey: Keys.asked(siteId)) else { return }
+ // Already on: nothing to ask for, now or later.
+ if site.cloudOn {
+ defaults.set(true, forKey: Keys.asked(siteId))
+ return
+ }
+ if await turnOn(site) {
+ defaults.set(true, forKey: Keys.asked(siteId))
+ }
+ }
+
+ /// What the box holds now. Owners only; a viewer's phone may not ask.
+ public func refresh(_ site: SiteModel) async {
+ guard site.canConfigure else { return }
+ do {
+ box = try await site.callBox(.get, "/api/cloud").map(BoxState.init)
+ help = nil
+ } catch let e as BoxAPIError {
+ help = e.help
+ } catch {
+ help = nil
+ }
+ }
+
+ /// FTW Cloud on, with statistics if the person agreed to them.
+ @discardableResult
+ public func turnOn(_ site: SiteModel) async -> Bool {
+ await act {
+ let scopes = JSON.array(Contract.cloudScopes.map { .string($0) })
+ let granted = try await site.callBox(.post, "/api/cloud/grant", body: .object([("scopes", scopes), ("policy", .string(Self.policy))]))
+ box = granted.map(BoxState.init)
+ if contributeStats {
+ box = try await site.callBox(.post, "/api/cloud/stats", body: .object([("policy", .string(Self.statsPolicy))])).map(BoxState.init)
+ }
+ if let siteId = site.siteId { defaults.set(true, forKey: Keys.asked(siteId)) }
+ }
+ }
+
+ /// FTW Cloud off. The box asks the service to erase its copy and shows
+ /// the receipt once it has one.
+ @discardableResult
+ public func turnOff(_ site: SiteModel) async -> Bool {
+ // Off by hand is the person's word: the app never turns it on again
+ // by itself.
+ if let siteId = site.siteId { defaults.set(true, forKey: Keys.asked(siteId)) }
+ return await act {
+ box = try await site.callBox(.delete, "/api/cloud/grant").map(BoxState.init)
+ }
+ }
+
+ @discardableResult
+ public func setStats(_ on: Bool, _ site: SiteModel) async -> Bool {
+ contributeStats = on
+ defaults.set(on, forKey: Keys.stats)
+ return await act {
+ box = on
+ ? try await site.callBox(.post, "/api/cloud/stats", body: .object([("policy", .string(Self.statsPolicy))])).map(BoxState.init)
+ : try await site.callBox(.delete, "/api/cloud/stats").map(BoxState.init)
+ }
+ }
+
+ private func act(_ work: () async throws -> Void) async -> Bool {
+ working = true
+ defer { working = false }
+ do {
+ try await work()
+ help = nil
+ return true
+ } catch let e as BoxAPIError {
+ help = e.help
+ } catch {
+ help = CloudWords.text("That didn't work. Nothing on your box has changed.")
+ }
+ return false
+ }
+
+ enum Keys {
+ static let answered = "FTWCloudConsent"
+ static let stats = "FTWCloudStats"
+ static func asked(_ siteId: String) -> String { "FTWCloudAsked-\(siteId)" }
+ }
+}
+
+/// The words of the consent screen and the FTW Cloud row in About, in
+/// English or Swedish, with the language `AppWords` uses.
+public enum CloudWords {
+ public static func text(_ en: String) -> String {
+ AppWords.language == .sv ? (sv[en] ?? en) : en
+ }
+
+ public static func format(_ en: String, _ args: String...) -> String {
+ var out = text(en)
+ for a in args {
+ guard let r = out.range(of: "%@") else { break }
+ out.replaceSubrange(r, with: a)
+ }
+ return out
+ }
+
+ static let sv: [String: String] = [
+ "The FTW app uses FTW Cloud": "FTW-appen använder FTW Cloud",
+ "FTW Cloud is part of the service. It keeps a copy of your home, signed by your box, so the app opens at once and stays up to date.":
+ "FTW Cloud ingår i tjänsten. Den håller en kopia av ditt hem, signerad av din box, så att appen öppnar direkt och håller sig uppdaterad.",
+ "Your box still decides. Commands go straight to it, never through FTW Cloud.":
+ "Boxen bestämmer fortfarande. Kommandon går direkt till den, aldrig via FTW Cloud.",
+ "Contribute to statistics": "Bidra till statistik",
+ "Hourly energy totals, without your name or address, help make FTW better. Off unless you turn it on.":
+ "Energisummor per timme, utan namn eller adress, hjälper oss att göra FTW bättre. Av tills du slår på det.",
+ "Ask the owner of this home to turn on FTW Cloud.": "Be ägaren av hemmet att slå på FTW Cloud.",
+ "Your box turns on FTW Cloud after its next update.": "Din box slår på FTW Cloud efter nästa uppdatering.",
+ "Continue": "Fortsätt",
+ "Privacy policy": "Integritetspolicy",
+ "You can turn FTW Cloud off in About.": "Du kan stänga av FTW Cloud under Om.",
+ "That didn't work. Nothing on your box has changed.": "Det gick inte. Inget på din box har ändrats.",
+ "FTW Cloud": "FTW Cloud",
+ "On": "På",
+ "Off": "Av",
+ "Erasing the copy": "Raderar kopian",
+ "Copy erased %@": "Kopian raderad %@",
+ "Connected": "Ansluten",
+ "Not connected": "Inte ansluten",
+ "Only the owner of this home can change this.": "Bara ägaren av hemmet kan ändra det här.",
+ "FTW Cloud is on for this home.": "FTW Cloud är på för det här hemmet.",
+ "FTW Cloud is off for this home.": "FTW Cloud är av för det här hemmet.",
+ "Your box can't use FTW Cloud yet.": "Din box kan inte använda FTW Cloud än.",
+ "Turning it off erases the copy in FTW Cloud. Your box shows when it is gone.":
+ "Stänger du av raderas kopian i FTW Cloud. Boxen visar när den är borta.",
+ "With FTW Cloud on, Sourceful keeps a copy of your home's readings, signed by your box, to serve this app. Commands travel encrypted between this phone and your box, and Sourceful cannot read them.":
+ "Med FTW Cloud på håller Sourceful en kopia av hemmets mätvärden, signerad av din box, för den här appen. Kommandon går krypterade mellan telefonen och boxen, och Sourceful kan inte läsa dem.",
+ ]
+}
diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift
new file mode 100644
index 0000000..6bae79e
--- /dev/null
+++ b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift
@@ -0,0 +1,104 @@
+import Foundation
+
+/// Where FTW Cloud is and the keys it answers with.
+///
+/// The address is fixed: `wss://cloud.ftw.energy/v1/app`, as the relay's is.
+/// The app carries up to two of the service's Noise keys so the service can
+/// rotate across an app release. Only a debug build may point elsewhere,
+/// for a local `ftwcloudd`:
+///
+/// -FTWCloudURL wss://127.0.0.1:18443/v1/app -FTWCloudKey
+public struct CloudEndpoint: Equatable, Sendable {
+ public var url: URL
+ /// X25519 public keys, 32 bytes each, tried in order.
+ public var serviceKeys: [Bytes]
+
+ public init(url: URL, serviceKeys: [Bytes]) {
+ self.url = url
+ self.serviceKeys = serviceKeys
+ }
+
+ public static let releaseURL = URL(string: "wss://cloud.ftw.energy/v1/app")!
+
+ /// The service's Noise keys, base64url of the X25519 public half. Up to
+ /// two, so the key can rotate across an app release: ship the new key
+ /// beside the old one, switch the service, then drop the old one. The
+ /// one key is cloud.ftw.energy's, kept as `/ftw-cloud/noise-key` in
+ /// Parameter Store (srcfl/ftw-cloud `deploy/noise.md`). Empty would mean
+ /// a release build reads through the relay alone and offers no FTW Cloud.
+ static let releaseKeyText = ["6-1WbbDzKvbwODSwhUN24MPemfLNRhcbMP2tpU_QAEk"]
+ static let releaseKeys: [Bytes] = releaseKeyText.compactMap { try? Base64url.decode($0) }
+
+ /// The endpoint this build uses, or nil when it has none.
+ public static var current: CloudEndpoint? {
+ #if DEBUG
+ resolve(defaults: .standard, debug: true)
+ #else
+ resolve(defaults: .standard, debug: false)
+ #endif
+ }
+
+ /// Debug builds read the override; every other build ignores it.
+ static func resolve(defaults: UserDefaults, debug: Bool) -> CloudEndpoint? {
+ if debug, let raw = defaults.string(forKey: "FTWCloudURL"), let url = URL(string: raw),
+ url.scheme == "wss" || url.scheme == "ws",
+ let raw = defaults.string(forKey: "FTWCloudKey"), let key = try? Base64url.decode(raw), key.count == Noise.dhBytes {
+ return CloudEndpoint(url: url, serviceKeys: [key])
+ }
+ let keys = releaseKeys.filter { $0.count == Noise.dhBytes }.prefix(2)
+ return keys.isEmpty ? nil : CloudEndpoint(url: releaseURL, serviceKeys: Array(keys))
+ }
+
+ /// The address for one home's session.
+ func url(home: String) -> URL {
+ var c = URLComponents(url: url, resolvingAgainstBaseURL: false)!
+ c.queryItems = [URLQueryItem(name: "home", value: home)]
+ return c.url!
+ }
+}
+
+/// What the box tells its phones about FTW Cloud, over their own Noise
+/// session: the key it signs with, its home id, its current boot and the
+/// names behind its anonymous ids. It never comes from the service, which
+/// could otherwise sign for the box. Present only while the grant is on.
+public struct CloudIdentity: Codable, Equatable, Sendable {
+ /// The box's P-256 key, 65 bytes uncompressed.
+ public var pk: Bytes
+ /// Computed here from `pk`, never taken from the box's word for it.
+ public var home: String
+ public var boot: Bytes
+ /// Anonymous source id to the driver name the box session uses.
+ public var sources: [String: String]
+ /// Anonymous loadpoint id to the loadpoint id the box session uses.
+ public var loadpoints: [String: String]
+
+ public init(pk: Bytes, boot: Bytes, sources: [String: String] = [:], loadpoints: [String: String] = [:]) {
+ self.pk = pk
+ home = CloudWire.homeID(pk: pk)
+ self.boot = boot
+ self.sources = sources
+ self.loadpoints = loadpoints
+ }
+
+ /// hello_ok's `cloud`, or nil when it is absent or malformed. A home id
+ /// that is not the hash of the key is refused.
+ init?(_ c: CBOR?) {
+ guard let c, let pk = c["pk"]?.byteString, pk.count == CloudWire.publicKeyBytes,
+ let boot = c["boot"]?.byteString, boot.count == CloudWire.bootBytes else { return nil }
+ self.init(pk: pk, boot: boot, sources: Self.names(c["sources"]), loadpoints: Self.names(c["loadpoints"]))
+ if let said = c["home"]?.string, said != home { return nil }
+ }
+
+ private static func names(_ c: CBOR?) -> [String: String] {
+ var out = [String: String]()
+ for e in c?.entries ?? [] {
+ if let k = e.key.string, let v = e.value.string { out[k] = v }
+ }
+ return out
+ }
+
+ /// The same box and home, whatever its boot or names.
+ func sameHome(_ other: CloudIdentity?) -> Bool {
+ other?.pk == pk
+ }
+}
diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Vault.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Vault.swift
index 1f9a91d..b41fc1e 100644
--- a/appleApp/FTWKit/Sources/FTWKit/Identity/Vault.swift
+++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Vault.swift
@@ -106,7 +106,7 @@ public final class Vault {
let pair = try Primitives.keyPair(fromSecret: scalar)
let existing = record
if let existing, existing.publicKey.byteArray != pair.publicKey {
- throw VaultError(code: "E_VAULT_OTHER_KEY", message: "a different device key is already stored", help: "This phone is already set up for a home. Sign out of it first.")
+ throw VaultError(code: "E_VAULT_OTHER_KEY", message: "a different device key is already stored", help: AppWords.text("This phone is already set up for a home. Sign out of it first."))
}
var pkcs8 = Self.pkcs8Prefix + scalar
defer { wipe(&pkcs8) }
@@ -136,7 +136,7 @@ public final class Vault {
let copies = record.copies.filter { $0.credentialId != credentialID }
if copies.count == record.copies.count { return }
if copies.isEmpty {
- throw VaultError(code: "E_VAULT_LAST_COPY", message: "refusing to remove the only wrapped copy", help: "That is the only passkey that can unlock this device. Add another one first.")
+ throw VaultError(code: "E_VAULT_LAST_COPY", message: "refusing to remove the only wrapped copy", help: AppWords.text("That is the only passkey that can unlock this device. Add another one first."))
}
write(Record(publicKey: record.publicKey, copies: copies))
}
@@ -152,8 +152,10 @@ public final class Vault {
// MARK: Wrapping keys
/// The local key: random, in the Keychain, no ceremony in front of it.
- public func localWrappingKey() -> WrappingKey {
- if let existing = store.get(Self.localWrapKey), existing.count == 32 {
+ /// Made only when the Keychain says there is none; one it cannot read,
+ /// as before the first unlock, throws rather than being replaced.
+ public func localWrappingKey() throws -> WrappingKey {
+ if let existing = try store.read(Self.localWrapKey), existing.count == 32 {
return WrappingKey(credentialID: Self.localCredentialID, source: .local, key: existing)
}
let key = randomBytes(32)
@@ -165,39 +167,39 @@ public final class Vault {
/// copies exist. Reading your own house is not a privilege.
public func silentWrappingKey() throws -> WrappingKey? {
guard let record else { throw emptyVault() }
- return record.copies.contains { $0.credentialId == Self.localCredentialID } ? localWrappingKey() : nil
+ return record.copies.contains { $0.credentialId == Self.localCredentialID } ? try localWrappingKey() : nil
}
/// Add the local copy, so the next start never prompts.
public func ensureLocalCopy(_ current: WrappingKey) throws {
guard let record else { throw emptyVault() }
if record.copies.contains(where: { $0.credentialId == Self.localCredentialID }) { return }
- try addCredential(current: current, next: localWrappingKey())
+ try addCredential(current: current, next: try localWrappingKey())
}
/// A key for a device enrolling now: one prompt, no questions. Where PRF
/// is missing this still returns a key, the local one, and `source` says
/// so. A decline is rethrown; any other platform failure falls back.
public func enrollWrappingKey(_ passkeys: PasskeyAuthenticator?, label: String = Origin.rpName) async throws -> WrappingKey {
- guard let passkeys, passkeys.isAvailable else { return localWrappingKey() }
+ guard let passkeys, passkeys.isAvailable else { return try localWrappingKey() }
do {
- let outcome = try await passkeys.register(label: label, userHandle: userHandle(), excludeCredentialIDs: credentialIDs)
+ let outcome = try await passkeys.register(label: label, userHandle: try userHandle(), excludeCredentialIDs: credentialIDs)
if let prf = outcome.prfOutput {
return PRF.wrappingKey(credentialID: outcome.credentialID, prfOutput: prf)
}
// Some platforms register the passkey and only evaluate PRF on an
// assertion. One more prompt now, while the person is looking,
// beats a vault nothing can open later.
- guard outcome.prfEnabled else { return localWrappingKey() }
+ guard outcome.prfEnabled else { return try localWrappingKey() }
let asserted = try await passkeys.assert(credentialIDs: [outcome.credentialID])
if let prf = asserted.prfOutput {
return PRF.wrappingKey(credentialID: asserted.credentialID, prfOutput: prf)
}
- return localWrappingKey()
+ return try localWrappingKey()
} catch is PasskeyCancelled {
throw PasskeyCancelled()
} catch {
- return localWrappingKey()
+ return try localWrappingKey()
}
}
@@ -219,7 +221,7 @@ public final class Vault {
} catch {}
}
if record.copies.contains(where: { $0.credentialId == Self.localCredentialID }) {
- return localWrappingKey()
+ return try localWrappingKey()
}
throw locked()
}
@@ -234,8 +236,8 @@ public final class Vault {
store.putJSON(Self.vaultKey, record)
}
- func userHandle() -> Bytes {
- if let existing = store.get(Self.userHandleKey) { return existing }
+ func userHandle() throws -> Bytes {
+ if let existing = try store.read(Self.userHandleKey) { return existing }
// Stable per install, so a second passkey replaces the first in the
// platform's list instead of stacking up identical entries.
let handle = randomBytes(16)
@@ -265,16 +267,20 @@ public final class Vault {
}
}
+ /// What to do when no key opens the stored copy. The shell says the
+ /// same when a connect fails for a reason it cannot name.
+ static let lockedHelp = "This device can no longer unlock its key. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR."
+
private func locked() -> VaultError {
- VaultError(code: "E_VAULT_LOCKED", message: "no wrapping key opens the stored copy", help: "This device can no longer unlock its key. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR.")
+ VaultError(code: "E_VAULT_LOCKED", message: "no wrapping key opens the stored copy", help: AppWords.text(Self.lockedHelp))
}
private func noCopy(_ credentialID: String) -> VaultError {
- VaultError(code: "E_VAULT_NO_COPY", message: "no wrapped copy for credential \(credentialID)", help: "This passkey has not been given access yet. Unlock with the one you set up first.")
+ VaultError(code: "E_VAULT_NO_COPY", message: "no wrapped copy for credential \(credentialID)", help: AppWords.text("This passkey has not been given access yet. Unlock with the one you set up first."))
}
private func emptyVault() -> VaultError {
- VaultError(code: "E_VAULT_EMPTY", message: "no device key has been created", help: "Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan the QR.")
+ VaultError(code: "E_VAULT_EMPTY", message: "no device key has been created", help: AppWords.text("Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan the QR."))
}
}
diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift
index d76891b..689cb35 100644
--- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift
+++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift
@@ -52,6 +52,15 @@ public enum Contract {
public static let capDerEV = "der.ev"
public static let capPriceSpot = "price.spot"
public static let capAPIPassthrough = "api.passthrough"
+ /// The box can run the FTW Cloud uplink. Absent, the app offers no FTW
+ /// Cloud and reads through the relay alone.
+ public static let capCloudLink = "cloud.link"
+
+ /// The scopes an FTW Cloud grant may name, and what its data may be
+ /// used for: the registry's `cloud_scopes` and `cloud_purposes`.
+ public static let cloudScopes = ["ftw.live.read", "ftw.history.read", "ftw.plan.read", "ftw.health.read", "ftw.events.read"]
+ public static let cloudPurposes = ["serve", "stats"]
+ public static let cloudPurposeStats = "stats"
public static let opSetMode = "site.mode.set"
public static let opPlannerPrefsSet = "planner.prefs.set"
diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift
index 0636938..697ec92 100644
--- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift
+++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift
@@ -100,9 +100,12 @@ public struct HelloOk: Equatable, Sendable {
public var boot: BootProgress?
public var hint: String?
public var subscribed: Bool
+ /// The box's FTW Cloud key, home and names, while the grant is on.
+ public var cloud: CloudIdentity?
init(_ c: CBOR) {
proto = c["proto"]?.int ?? Proto.max
+ cloud = CloudIdentity(c["cloud"])
mode = c["mode"]?.string.flatMap(BoxMode.init(rawValue:)) ?? .full
let b = c["box"]
box = BoxInfo(id: b?["id"]?.string ?? "", build: b?["build"]?.string ?? "", tz: b?["tz"]?.string ?? "")
diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift
index cfaf842..c671560 100644
--- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift
+++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift
@@ -26,6 +26,9 @@ public struct SessionState: Sendable {
/// that states something about the box from them would be inventing it.
public var heardFromBox = false
public var box: BoxInfo?
+ /// FTW Cloud as the box described it in hello: its key, home and names.
+ /// Nil while the grant is off.
+ public var cloud: CloudIdentity?
/// Box uptime at the last frame. All ages are deltas against this.
public var uptimeMs: Double = 0
public var controlRev: UInt64 = 0
@@ -404,6 +407,9 @@ public final class Session {
])),
])
let frame = try Frame.encode(lane: Frame.laneControl, envelope: Envelope(t: "cmd", b: body), bucket: subscription.bucket)
+ // A carrier that is reconnecting inside itself would drop the frame
+ // in silence; saying so now beats waiting out the ack deadline.
+ guard carrier.status.isOpen else { throw Self.notSent }
return try await withCheckedThrowingContinuation { cont in
let ackTimer = scheduler.after(Self.cmdAckTimeoutMs) { [weak self] in
guard let self, let p = self.pendingCommands[cmdId], !p.acked else { return }
@@ -419,10 +425,18 @@ public final class Session {
p.continuation.resume(returning: CmdResult(cmdId: cmdId, state: .unconfirmed))
}
pendingCommands[cmdId] = PendingCommand(continuation: cont, ackTimer: ackTimer, confirmTimer: confirmTimer, onAck: onAck)
- carrier.send(frame)
+ if !carrier.send(frame), let p = pendingCommands.removeValue(forKey: cmdId) {
+ p.ackTimer.cancel()
+ p.confirmTimer.cancel()
+ p.continuation.resume(throwing: Self.notSent)
+ }
}
}
+ /// A command that never left this phone. The registry's no-ack code,
+ /// because that is what it is: the box never had it.
+ static let notSent = CommandError(code: "E_NO_ACK", help: "That didn't reach your box. Try again.")
+
/// UUIDv7: time-ordered, so the box can expire idempotency keys by prefix.
static func uuidv7(nowMs: Double) -> String {
var bytes = randomBytes(16)
@@ -543,6 +557,7 @@ public final class Session {
s.heardFromBox = true
s.modes = b.modes
s.box = b.box
+ s.cloud = b.cloud
s.uptimeMs = b.uptimeMs
s.boot = b.boot
s.needsUpdate = b.hint == "app_update" || b.proto == Proto.floor
diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift
index 9b5582c..3c45a5a 100644
--- a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift
+++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift
@@ -142,15 +142,15 @@ public final class AppModel {
let mine = connectGeneration
do {
guard let stored = sites.get(siteId) else {
- throw ConnectError(kind: .notPaired, help: "This phone has no record of that home.")
+ throw ConnectError(kind: .notPaired, help: AppWords.text("This phone has no record of that home."))
}
guard vault.isEnrolled else {
- throw ConnectError(kind: .notEnrolled, help: "This device has no key for that home.")
+ throw ConnectError(kind: .notEnrolled, help: AppWords.text("This device has no key for that home."))
}
// Read from the QR, never derived: a handle derived from the box
// key would be one household identifier good for years.
guard let secret = stored.rendezvousSecret?.byteArray else {
- throw ConnectError(kind: .stalePairing, help: "This home was paired before this app could reach it privately.")
+ throw ConnectError(kind: .stalePairing, help: AppWords.text("This home was paired before this app could reach it privately."))
}
var wrapping = try vault.silentWrappingKey()
if wrapping == nil {
@@ -172,12 +172,18 @@ public final class AppModel {
scheduler: scheduler
)
if site.connect(noise) { connectHelp = nil }
+ site.connectCloud(deviceKey: device, makeSocket: makeSocket)
+ } catch is PasskeyCancelled {
+ // A closed sheet is not a lost key: say what happened and let
+ // the person try again.
+ guard mine == connectGeneration else { return }
+ connectHelp = AppWords.text("Unlocking was cancelled. Try again to open your home.")
} catch let e as HelpfulError {
guard mine == connectGeneration else { return }
connectHelp = e.help
} catch {
guard mine == connectGeneration else { return }
- connectHelp = "This device can no longer unlock its key. Open your box's local dashboard, then Settings → FTW app → Show pairing code, and scan a new QR."
+ connectHelp = AppWords.text(Vault.lockedHelp)
}
}
diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift
index 94166ae..4152543 100644
--- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift
+++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift
@@ -569,11 +569,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 {
// A Live Activity's button names its home. One from another home,
// or from the demo, never reaches this one: its card closes instead.
if case .backToPlan(let id, let home?) = command, home != activityHome {
@@ -596,7 +598,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."))
}
// Charge now with no car would hold a charger that has nothing to
diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PushTokens.swift b/appleApp/FTWKit/Sources/FTWKit/State/PushTokens.swift
new file mode 100644
index 0000000..4198e0d
--- /dev/null
+++ b/appleApp/FTWKit/Sources/FTWKit/State/PushTokens.swift
@@ -0,0 +1,73 @@
+import Foundation
+import Synchronization
+
+/// Which of Apple's push services a token belongs to. A development build
+/// gets sandbox tokens.
+public enum PushEnvironment: String, Sendable {
+ case sandbox, production
+}
+
+/// Takes the phone's APNs token. FTW Cloud's channel is the one that will:
+/// it tells the service, over the app session, where Apple can reach this
+/// phone.
+public protocol PushTokenSink: Sendable {
+ func register(apnsToken: Data, environment: PushEnvironment) async
+}
+
+/// The token Apple gave this phone, and who takes it.
+///
+/// Nothing asks for notifications until something wants them: a sink
+/// arrives when FTW Cloud is on, and only then is the person asked. Once
+/// they said yes, Apple hands a token at every launch. A sink installed
+/// later gets the latest at once. Kept in memory: Apple gives it again at
+/// the next launch.
+public enum PushTokens {
+ public struct Token: Sendable, Equatable {
+ public var data: Data
+ public var environment: PushEnvironment
+ }
+
+ private struct State {
+ var sink: (any PushTokenSink)?
+ var token: Token?
+ var ask: (@Sendable () async -> Void)?
+ }
+
+ private static let state = Mutex(State())
+
+ /// The app sets this at launch: ask the person to allow notifications,
+ /// then register with Apple.
+ public static func setAsk(_ ask: @escaping @Sendable () async -> Void) {
+ state.withLock { $0.ask = ask }
+ }
+
+ /// Something wants pushes. It gets the token now if there is one and
+ /// whenever it changes; the person is asked if nobody has asked yet.
+ public static func install(_ sink: any PushTokenSink) async {
+ let (token, ask) = state.withLock { s in
+ s.sink = sink
+ return (s.token, s.ask)
+ }
+ if let token { await sink.register(apnsToken: token.data, environment: token.environment) }
+ await ask?()
+ }
+
+ /// Nothing wants pushes any more.
+ public static func uninstall() {
+ state.withLock { $0.sink = nil }
+ }
+
+ /// Apple gave a token. The same token twice is handed over once.
+ public static func received(_ data: Data, environment: PushEnvironment) async {
+ let token = Token(data: data, environment: environment)
+ let sink = state.withLock { s -> (any PushTokenSink)? in
+ defer { s.token = token }
+ return s.token == token ? nil : s.sink
+ }
+ await sink?.register(apnsToken: data, environment: environment)
+ }
+
+ public static var current: Token? {
+ state.withLock { $0.token }
+ }
+}
diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift
index 9b1a1f8..89bbd3c 100644
--- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift
+++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift
@@ -4,12 +4,24 @@ import Observation
import os
private let log = Logger(subsystem: "energy.ftw.app", category: "widgets")
+/// When each source first delivered, counted from `start`, so the three
+/// layers of a cold start and the relay's path can be compared. Read with
+/// `log show --predicate 'subsystem == "energy.ftw.app" AND category == "reading"'`.
+private let readingLog = Logger(subsystem: "energy.ftw.app", category: "reading")
+private let signposter = OSSignposter(subsystem: "energy.ftw.app", category: "reading")
/// The bridge between the session and the screens.
///
/// The session owns protocol state; this exposes it, restores the cached
/// snapshot on start, keeps freshness honest and derives what the screens
/// ask for. Nothing above this touches a frame.
+///
+/// Readings come from one of two places. With FTW Cloud on, the cloud is the
+/// first: its copy arrives from memory within a round trip and every change
+/// follows on the same socket, each envelope signed by the box and checked
+/// here. The box session over the relay stays open beside it for commands,
+/// and its readings take over when the cloud fails, is out of reach, or has
+/// nothing better. `session` is what the screens read either way.
@Observable
@MainActor
public final class SiteModel {
@@ -28,8 +40,19 @@ public final class SiteModel {
public private(set) var cachedAtMs: Double?
public private(set) var lastFrameAtMs: Double?
public private(set) var documentVisible = true
- /// The one clock screens depend on, so derived ages move on screen.
+ /// The clock screens read for ages. It moves with every reading, and
+ /// between readings only when something on screen would change: the
+ /// minute, a stream going quiet, a grade lapsing, or a counter the band
+ /// shows in seconds.
public private(set) var nowMs: Double
+ /// The current minute. Text that needs no finer clock reads this and
+ /// redraws once a minute.
+ public private(set) var minuteMs: Double
+ /// When each value on screen was read, by the phone clock. A view can
+ /// glide from one value to the next over the time between them.
+ public private(set) var fieldAtMs: [Int: Double] = [:]
+ /// When the newest reading on screen was read, by the phone clock.
+ public private(set) var readingAtMs: Double?
/// Import ceiling the optimiser defends, when known.
public var ceilingW: Double?
@@ -52,18 +75,80 @@ public final class SiteModel {
/// Called after every write to `shared`, so the app can wake its widgets.
@ObservationIgnored public var sharedWritten: (@MainActor () -> Void)?
@ObservationIgnored private var attemptStartedAtMs: Double
- @ObservationIgnored private var ticker: Cancellable?
+ @ObservationIgnored private var clock: Cancellable?
@ObservationIgnored private var resumeTimer: Cancellable?
@ObservationIgnored private var resumeWaiting = false
@ObservationIgnored private var recent: [(t: Double, v: [Int: Double])] = []
@ObservationIgnored private var lastSnapshotMs: Double = 0
@ObservationIgnored private var snapshotTimer: Cancellable?
+ /// The last snapshot written, without its time, so the same home is not
+ /// written twice.
+ @ObservationIgnored private var lastSnapshot: CachedSnapshot?
@ObservationIgnored private var destroyed = false
+ /// The box session's own state, as the last frame left it.
+ @ObservationIgnored private var relay = SessionState()
+ /// When the box session last delivered a reading. Waking after sleep
+ /// waits for this one, whatever the cloud does meanwhile.
+ @ObservationIgnored private var relayFrameAtMs: Double?
@ObservationIgnored private var listeners: [Int: @MainActor () -> Void] = [:]
+ /// A burst of messages within one display frame reaches the screens as
+ /// one change: the first goes at once, the rest together after it.
+ @ObservationIgnored private var publishTimer: Cancellable?
+ @ObservationIgnored private var lastPublishMs: Double = -.infinity
+ /// Whether a source delivered a reading since the last publish.
+ @ObservationIgnored private var relayDelivered = false
+ @ObservationIgnored private var cloudDelivered = false
+ /// From a start or a return to the front until the screen is live, as a
+ /// signpost interval.
+ @ObservationIgnored private var liveSpan: (name: StaticString, state: OSSignpostIntervalState, fromMs: Double)?
+ /// What the band and the cards derive from, taken in the same change as
+ /// `session`, so they never disagree while a burst waits for its frame.
+ private var shown = Shown()
+
+ private struct Shown {
+ var source: ReadingSource = .relay
+ var relay = SessionState()
+ var relayFrameAtMs: Double?
+ var cloudOpen = false
+ var cloudView: CloudView?
+ }
+
+ // FTW Cloud. All nil while the home has no grant or this build no
+ // endpoint.
+ @ObservationIgnored private var cloud: CloudCarrier?
+ @ObservationIgnored private(set) var cloudView: CloudView?
+ @ObservationIgnored private(set) var cloudIdentity: CloudIdentity?
+ @ObservationIgnored private var cloudDevice: Primitives.KeyPair?
+ @ObservationIgnored private var cloudSocket: WebSocketFactory?
+ @ObservationIgnored private var cloudEndpoint: CloudEndpoint?
+ /// Rule U's anchor as last learned, kept with the identity so a cold
+ /// start can age the cloud's copy of a box without a clock.
+ @ObservationIgnored private var ruleU: CloudCache.Offset?
+ @ObservationIgnored private var cloudDirty = false
+ /// When the newest cloud envelope arrived, for the time of a reading the
+ /// anchor cannot place.
+ @ObservationIgnored private var cloudArrivalMs: Double?
+ /// What the notification extension was last given for this home.
+ @ObservationIgnored private var sharedCloudHome: CloudHome?
+ /// Whether FTW Cloud's session takes the phone's APNs token, and the
+ /// token, sent again at every opening.
+ @ObservationIgnored private var pushSinkOn = false
+ @ObservationIgnored private var pushToken: PushTokens.Token?
+ /// Where the token comes from: `PushTokens`, unless a test hands its own.
+ @ObservationIgnored var pushTokens = PushTokenSource.app
+ /// Where the readings on screen came from at the last change.
+ @ObservationIgnored private(set) var source: ReadingSource = .relay
+ @ObservationIgnored private var startedAtMs: Double = 0
+ @ObservationIgnored private var firsts: Set = []
+ /// The cloud session, for the About screen. Nil while there is none.
+ public private(set) var cloudStatus: CloudCarrier.Status?
@ObservationIgnored private var nextListener = 0
/// Runs the passkey ceremony a write needs. Injected by the shell.
@ObservationIgnored public var stepUp: (@MainActor () async -> StepUpOutcome)?
+ /// One display frame: what arrives within it is drawn together.
+ static let coalesceMs: Double = 16
+
public init(siteId: String?, build: String, ua: String, scheduler: Scheduler, files: SealedFiles?, shared: AppGroupFiles? = nil, isDemo: Bool = false) {
self.siteId = siteId
self.scheduler = scheduler
@@ -71,6 +156,7 @@ public final class SiteModel {
self.shared = isDemo ? nil : shared
self.isDemo = isDemo
nowMs = scheduler.nowMs
+ minuteMs = (scheduler.nowMs / 60_000).rounded(.down) * 60_000
attemptStartedAtMs = scheduler.nowMs
core = Session(build: build, ua: ua, scheduler: scheduler)
core.onChange = { [weak self] s in self?.onSession(s) }
@@ -81,25 +167,25 @@ public final class SiteModel {
/// Paint from cache, then let the shell connect. The cache is a carrier,
/// not a failure: it is how the first frame has something honest.
public func start() {
+ startedAtMs = scheduler.nowMs
+ beginLiveSpan("cold start to live")
if let siteId, let cached = files?.readJSON(CachedSnapshot.self, "snapshot-\(siteId)"), cached.siteId == siteId {
core.restore(cached)
cachedAtMs = cached.savedAtMs
+ lastSnapshot = cached.timeless
}
- startTicker()
- }
-
- private func startTicker() {
- ticker?.cancel()
- ticker = scheduler.after(1_000) { [weak self] in
- guard let self, !self.destroyed else { return }
- self.nowMs = self.scheduler.nowMs
- self.notify()
- self.startTicker()
+ if let siteId, let kept = files?.readJSON(CloudCache.self, "cloud-\(siteId)") {
+ adoptCloud(kept.identity, offset: kept.offset)
}
+ scheduleClock()
}
/// Attach a carrier. Refused once the model is gone, so a slow connect
/// can never hand an old home's stream to a new one.
+ ///
+ /// A carrier made for this call is already dialling. Waking it would
+ /// only report a reconnect that never happened, so it gets the deadline
+ /// and nothing more.
@discardableResult
public func connect(_ carrier: Carrier) -> Bool {
if destroyed {
@@ -107,59 +193,86 @@ public final class SiteModel {
return false
}
core.connect(carrier)
- if documentVisible { beginResume(immediate: true) }
+ if documentVisible { beginResume(wake: false) }
return true
}
public func destroy() {
destroyed = true
- ticker?.cancel()
+ clock?.cancel()
resumeTimer?.cancel()
snapshotTimer?.cancel()
+ publishTimer?.cancel()
core.onChange = nil
core.close()
+ cloud?.close()
+ cloud = nil
listeners = [:]
}
- /// The app went to the background. Write what is on screen and close the
- /// session: iOS freezes the app within seconds anyway, and an open socket
- /// holds one of the four streams the relay allows a home. The readings
- /// stay, and the band dates them until a new carrier streams again.
+ /// The app went to the background. Close the session and write what is
+ /// on screen, once: iOS freezes the app within seconds anyway, and an
+ /// open socket holds one of the four streams the relay allows a home. The
+ /// readings stay, and the band dates them until a new carrier streams
+ /// again.
public func suspend() {
guard !destroyed else { return }
- persistNow()
core.close()
+ closeCloud()
// After the close, so no last change of rate goes out on the wire.
- setVisible(false)
+ hide()
+ // Settle what the screens say before iOS freezes them.
+ publishTimer?.cancel()
+ publishTimer = nil
+ source = readingSource()
+ publishNow()
+ persistNow(wait: true)
}
/// Match the stream's cadence to whether anyone can see it.
public func setVisible(_ visible: Bool) {
- let was = documentVisible
- documentVisible = visible
- core.setTelemetryHz(visible ? 1 : 0.2)
- if !visible {
- cancelResume()
+ guard visible else {
+ hide()
persistNow()
return
}
- if !was { beginResume(immediate: false) }
+ let was = documentVisible
+ documentVisible = true
+ core.setTelemetryHz(1)
+ cloud?.setLooking(true)
+ if !was {
+ // A start passes through inactive on its way to the front; its
+ // own interval keeps running.
+ if liveSpan == nil { beginLiveSpan("foreground to live") }
+ beginResume(wake: relay.phase != .streaming || relayFrameAtMs == nil)
+ }
+ scheduleClock()
+ }
+
+ private func hide() {
+ documentVisible = false
+ core.setTelemetryHz(0.2)
+ cloud?.setLooking(false)
+ cancelResume()
+ scheduleClock()
}
/// A new network path should replace the old one without waiting.
public func networkOnline() {
guard documentVisible else { return }
- beginResume(immediate: true)
+ cloud?.wake()
+ beginResume(wake: true)
}
/// Pull to refresh: ask for a fresh stream in place.
public func refresh() {
core.wake()
+ cloud?.wake()
}
// MARK: Observation for feature models
- /// Called after every session change and every clock tick.
+ /// Called after every change the screens see, and when the clock moves.
@discardableResult
public func observe(_ f: @escaping @MainActor () -> Void) -> Int {
nextListener += 1
@@ -178,26 +291,163 @@ public final class SiteModel {
// MARK: Session changes
private func onSession(_ s: SessionState) {
- let previous = session
+ let previous = relay
+ let now = scheduler.nowMs
if previous.phase == .streaming, s.phase != .streaming {
- attemptStartedAtMs = scheduler.nowMs
+ attemptStartedAtMs = now
}
// Only a streaming session is a reading arriving. Restoring the cache
// and answering hello both move the clock and carry no reading.
- if s.phase == .streaming, previous.phase != .streaming || s.uptimeMs != previous.uptimeMs {
- let now = scheduler.nowMs
- lastFrameAtMs = now
+ let reading = s.phase == .streaming && (previous.phase != .streaming || s.uptimeMs != previous.uptimeMs)
+ if reading {
+ relayFrameAtMs = now
+ relayDelivered = true
finishResume()
+ }
+ relay = s
+ if s.heardFromBox, !previous.heardFromBox || previous.cloud != s.cloud {
+ cloudHello(s.cloud)
+ }
+ // Every box message over Noise that names its uptime is a chance to
+ // learn rule U, once hello has named the boot it belongs to.
+ if s.heardFromBox, let boot = s.cloud?.boot, s.carrier == .relay || s.carrier == .webrtc, s.uptimeMs != previous.uptimeMs || !previous.heardFromBox {
+ learnRuleU(boot: boot, uptimeMs: s.uptimeMs, phoneMs: now)
+ }
+ if s.phase == .streaming { scheduleSnapshot() }
+ if reading { first("relay reading") }
+ publish()
+ scheduleClock()
+ }
+
+ /// Logs once per home how long after `start` something first happened.
+ func first(_ what: String) {
+ guard startedAtMs > 0, firsts.insert(what).inserted else { return }
+ let ms = Int((scheduler.nowMs - startedAtMs).rounded())
+ readingLog.notice("\(what, privacy: .public) \(ms, privacy: .public) ms after start")
+ }
+
+ /// Picks where readings come from now, and draws the change: at once
+ /// when the screens have been still for a frame, else together with
+ /// whatever else arrives within it.
+ private func publish() {
+ source = readingSource()
+ guard publishTimer == nil else { return }
+ let since = scheduler.nowMs - lastPublishMs
+ if since >= Self.coalesceMs {
+ publishNow()
+ return
+ }
+ publishTimer = scheduler.after(Self.coalesceMs - since) { [weak self] in
+ guard let self, !self.destroyed else { return }
+ self.publishTimer = nil
+ self.source = self.readingSource()
+ self.publishNow()
+ }
+ }
+
+ /// What the screens read: the box session's state, with the cloud's
+ /// readings in it while the cloud is where readings come from, each value
+ /// with the time it was read. The clock moves in the same change, so a
+ /// reading redraws a screen once.
+ private func publishNow() {
+ let now = scheduler.nowMs
+ lastPublishMs = now
+ let s = composed()
+ let delivered = source == .cloud ? cloudDelivered : relayDelivered
+ relayDelivered = false
+ cloudDelivered = false
+ let at = readingTime(source) ?? now
+ var times = fieldAtMs
+ for (fid, v) in s.fields where session.fields[fid] != v || times[fid] == nil {
+ times[fid] = at
+ }
+ for fid in times.keys where s.fields[fid] == nil { times[fid] = nil }
+ if delivered {
+ lastFrameAtMs = now
recordRecent(s.fields, now)
}
+ if times != fieldAtMs { fieldAtMs = times }
+ if delivered || readingAtMs == nil || s.fields.isEmpty != session.fields.isEmpty { readingAtMs = s.fields.isEmpty ? nil : at }
+ // A stream, or a reading from the cloud, retires the cache's claim.
+ if relay.phase == .streaming || (source == .cloud && delivered), cachedAtMs != nil { cachedAtMs = nil }
+ advanceClock()
+ shown = Shown(source: source, relay: relay, relayFrameAtMs: relayFrameAtMs, cloudOpen: cloud?.status.isOpen == true, cloudView: cloudView)
session = s
- if s.phase == .streaming {
- cachedAtMs = nil
- scheduleSnapshot()
- }
+ endLiveSpanIfLive()
notify()
}
+ /// The readings the source in use gives, aged to now.
+ private func composed() -> SessionState {
+ var s = relay
+ if source == .cloud, let view = cloudView, let identity = cloudIdentity {
+ compose(&s, view: view, identity: identity)
+ }
+ return s
+ }
+
+ /// When the newest reading from a source was read, by the phone clock.
+ /// For the cloud that is the anchor's send time; nil when it cannot be
+ /// placed.
+ private func readingTime(_ from: ReadingSource) -> Double? {
+ switch from {
+ case .relay:
+ return relayFrameAtMs ?? cachedAtMs
+ case .cloud:
+ guard let view = cloudView else { return nil }
+ return view.anchorSentMs(nowMs: Int64(scheduler.nowMs)).map(Double.init) ?? cloudArrivalMs
+ }
+ }
+
+ // MARK: The clock
+
+ /// Arms the one timer the clock needs: the next moment something on
+ /// screen would change with no reading arriving. While live that is the
+ /// minute, the moment the stream would count as quiet, or a cloud grade
+ /// lapsing. A counter in seconds runs only while the band shows one.
+ private func scheduleClock() {
+ clock?.cancel()
+ clock = nil
+ guard !destroyed else { return }
+ let now = scheduler.nowMs
+ var next = ((now / 60_000).rounded(.down) + 1) * 60_000
+ if documentVisible {
+ if relay.phase == .streaming, let at = relayFrameAtMs, at + Self.streamQuietAfterMs > now {
+ next = min(next, at + Self.streamQuietAfterMs)
+ }
+ if source == .cloud, let at = cloudView?.nextGradeChange(nowMs: Int64(now)) {
+ next = min(next, Double(at))
+ }
+ if bandCountsSeconds {
+ next = min(next, now + 1_000)
+ }
+ }
+ clock = scheduler.after(max(1, next - now)) { [weak self] in
+ guard let self, !self.destroyed else { return }
+ self.clock = nil
+ self.advanceClock()
+ self.notify()
+ self.scheduleClock()
+ }
+ }
+
+ /// The band shows seconds while it counts a wait or an age under a
+ /// minute; the clock ticks each second only then.
+ private var bandCountsSeconds: Bool {
+ let band = freshness(noCarrier: false)
+ if band.wait != nil { return true }
+ if band.age != nil, let age = ageMs, age < 60_000 { return true }
+ return false
+ }
+
+ private func advanceClock() {
+ nowMs = scheduler.nowMs
+ let minute = (nowMs / 60_000).rounded(.down) * 60_000
+ if minute != minuteMs { minuteMs = minute }
+ }
+
+ // MARK: Snapshots
+
private func scheduleSnapshot() {
guard siteId != nil, files != nil, snapshotTimer == nil else { return }
let due = max(0, lastSnapshotMs + Self.snapshotIntervalMs - scheduler.nowMs)
@@ -208,25 +458,57 @@ public final class SiteModel {
}
}
- /// Write the last streaming state now, before the app can be suspended.
- public func persistNow() {
- guard let siteId, let files, !destroyed, session.phase == .streaming || !session.fields.isEmpty, cachedAtMs == nil else { return }
+ /// Write what is on screen, unless it is what was written last. The seal
+ /// and the file write run off the main actor; `wait` keeps them on it,
+ /// for the moment before iOS freezes the app.
+ public func persistNow(wait: Bool = false) {
+ guard let siteId, let files, !destroyed else { return }
+ if cloudDirty, let identity = cloudIdentity {
+ cloudDirty = false
+ files.writeJSON("cloud-\(siteId)", CloudCache(identity: identity, offset: ruleU), wait: wait)
+ shareCloudHome()
+ }
+ guard session.phase == .streaming || !session.fields.isEmpty, cachedAtMs == nil else { return }
+ // The cloud's ages move with the clock; take them as of now.
+ let state = source == .cloud ? composed() : session
+ let snapshot = CachedSnapshot(siteId: siteId, savedAtMs: scheduler.nowMs, state: state)
+ guard snapshot.timeless != lastSnapshot else { return }
+ lastSnapshot = snapshot.timeless
lastSnapshotMs = scheduler.nowMs
- files.writeJSON("snapshot-\(siteId)", CachedSnapshot(siteId: siteId, savedAtMs: scheduler.nowMs, state: session))
+ files.writeJSON("snapshot-\(siteId)", snapshot, wait: wait)
+ }
+
+ // MARK: Time to live
+
+ private func beginLiveSpan(_ name: StaticString) {
+ if let span = liveSpan { signposter.endInterval(span.name, span.state) }
+ liveSpan = (name, signposter.beginInterval(name), scheduler.nowMs)
+ }
+
+ /// Ends the interval the first time the screen is live after a start or
+ /// a return, and logs the process's first live beside LaunchClock's
+ /// first frame.
+ private func endLiveSpanIfLive() {
+ guard let span = liveSpan, isLive else { return }
+ liveSpan = nil
+ signposter.endInterval(span.name, span.state)
+ let ms = Int((scheduler.nowMs - span.fromMs).rounded())
+ readingLog.notice("\(span.name, privacy: .public) \(ms, privacy: .public) ms via \(self.carrier.rawValue, privacy: .public)")
+ ProcessClock.markOnce("live")
}
// MARK: Waking after sleep
- private func beginResume(immediate: Bool) {
+ /// Give a foreground stream a deadline to prove itself, and with `wake`
+ /// ask the carrier to replace a path that may have slept stale.
+ private func beginResume(wake: Bool) {
guard !destroyed, documentVisible else { return }
resumeTimer?.cancel()
resumeWaiting = true
- if immediate || session.phase != .streaming || lastFrameAtMs == nil {
- core.wake()
- }
- let frameAtStart = lastFrameAtMs
+ if wake { core.wake() }
+ let frameAtStart = relayFrameAtMs
resumeTimer = scheduler.after(Self.foregroundFrameDeadlineMs) { [weak self] in
- guard let self, !self.destroyed, self.documentVisible, self.lastFrameAtMs == frameAtStart else { return }
+ guard let self, !self.destroyed, self.documentVisible, self.relayFrameAtMs == frameAtStart else { return }
self.core.wake()
}
}
@@ -257,7 +539,11 @@ public final class SiteModel {
/// How the readings on screen reach us. A carrier is claimed only once it
/// has delivered a reading: an open socket is evidence of nothing.
public var carrier: CarrierKind {
- if session.phase == .streaming, lastFrameAtMs != nil { return session.carrier }
+ if shown.source == .cloud {
+ if shown.cloudOpen { return .cloud }
+ return cachedAtMs != nil ? .cache : .none
+ }
+ if shown.relay.phase == .streaming, shown.relayFrameAtMs != nil { return shown.relay.carrier }
return cachedAtMs != nil ? .cache : .none
}
@@ -278,39 +564,56 @@ public final class SiteModel {
waitMs: connectionWaitMs,
bootPct: session.boot?.pct,
noCarrier: noCarrier,
- busy: busy
+ busy: busy,
+ lastReading: (readingAtMs ?? cachedAtMs).map { AppWords.clock($0, zone: householdZone) }
)
}
+ /// The household's time zone, as the box names it, else the phone's.
+ var householdZone: TimeZone {
+ session.box.flatMap { TimeZone(identifier: $0.tz) } ?? .current
+ }
+
/// The sources behind what the Now screen draws, named by the box.
- var nowSourceIDs: [String] {
+ /// The box session's sources behind what Now draws. The cloud's are
+ /// graded by `CloudView`, not by these.
+ var nowSourceIDs: [String] { Self.nowSourceIDs(shown.relay) }
+
+ static func nowSourceIDs(_ r: SessionState) -> [String] {
var ids = [String]()
- for fid in Self.nowFids where session.fields[fid] != nil {
- if let src = session.dict[fid]?.srcId, !ids.contains(src) { ids.append(src) }
+ for fid in nowFids where r.fields[fid] != nil {
+ if let src = r.dict[fid]?.srcId, !ids.contains(src) { ids.append(src) }
}
return ids
}
public var srcState: SourceState {
if session.fields.isEmpty { return .never }
+ if shown.source == .cloud, let view = shown.cloudView { return Self.cloudState(view, nowMs: nowMs) }
+ return Self.relayState(shown.relay, frameAtMs: shown.relayFrameAtMs, nowMs: nowMs)
+ }
+
+ /// The box session's grade for what Now draws, from the box's own
+ /// source states and how long the stream has been quiet.
+ static func relayState(_ r: SessionState, frameAtMs: Double?, nowMs: Double) -> SourceState {
+ if r.fields.isEmpty { return .never }
// Every source state was read off the disk: true when written.
- if lastFrameAtMs == nil { return .stale }
- let ids = nowSourceIDs
- let worst: SourceState
- if !ids.isEmpty {
- worst = core.worstSourceState(ids)
- } else if !session.sources.isEmpty {
- worst = core.worstSourceState(Array(session.sources.keys))
- } else {
- worst = .live
- }
+ guard let frameAtMs else { return .stale }
+ let ids = nowSourceIDs(r)
+ let keys = ids.isEmpty ? Array(r.sources.keys) : ids
+ let worst = keys.isEmpty ? .live : keys.map { r.sources[$0]?.state ?? .never }.max() ?? .live
// A stream gone quiet is not live however healthy it looked.
- if worst == .live, sinceLastFrameMs > 0 { return .lagging }
+ if worst == .live, nowMs - frameAtMs >= streamQuietAfterMs { return .lagging }
return worst
}
+ /// The same grade from the newest state, for choosing a source.
+ private var relaySrcState: SourceState {
+ Self.relayState(relay, frameAtMs: relayFrameAtMs, nowMs: scheduler.nowMs)
+ }
+
public var sinceLastFrameMs: Double {
- guard let at = lastFrameAtMs else { return 0 }
+ guard let at = shown.relayFrameAtMs else { return 0 }
let since = nowMs - at
return since < Self.streamQuietAfterMs ? 0 : since
}
@@ -318,7 +621,17 @@ public final class SiteModel {
/// Age of the oldest reading on screen. Nil means unknown, which is the
/// honest answer after a box restart.
public var ageMs: Double? {
- let ages = nowSourceIDs.compactMap { core.ageOf($0) }
+ if shown.source == .cloud, let view = shown.cloudView {
+ guard let age = Self.cloudAge(view, nowMs: nowMs) else { return nil }
+ if carrier == .cache, let at = cachedAtMs { return max(age, nowMs - at) }
+ return age
+ }
+ let r = shown.relay
+ let ages = nowSourceIDs.compactMap { id -> Double? in
+ guard let src = r.sources[id] else { return nil }
+ let age = r.uptimeMs - src.lastOkMs
+ return age < 0 ? nil : age
+ }
if carrier == .cache, let at = cachedAtMs {
let shelf = nowMs - at
return shelf + (ages.max() ?? 0)
@@ -330,7 +643,8 @@ public final class SiteModel {
/// Live is three claims at once: streaming, not the cache, and every
/// source answering.
public var isLive: Bool {
- session.phase == .streaming && carrier != .cache && srcState == .live
+ let streaming = shown.source == .cloud ? shown.cloudOpen : session.phase == .streaming
+ return streaming && carrier != .cache && srcState == .live
}
public var explanation: Explanation.Result {
@@ -381,14 +695,14 @@ public final class SiteModel {
/// reload would only redraw it. Returns the write, for a caller that
/// must not end before it lands.
@discardableResult
- func sharePrices(_ prices: Prices, fromMs: Double) -> Task? {
+ func sharePrices(_ prices: Prices, fromMs: Double, via: CarrierKind? = nil) -> Task? {
guard let siteId, let shared else { return nil }
- let entry = PriceEntry(fromMs: fromMs, prices: prices)
+ let entry = PriceEntry(fromMs: fromMs, prices: prices, timeZone: session.box?.tz)
guard entry != lastPrices else { return nil }
lastPrices = entry
let record = CacheRecord(
siteId: siteId,
- carrier: session.carrier,
+ carrier: via ?? session.carrier,
srcState: prices.stale ? .stale : .live,
readAtMs: scheduler.nowMs,
value: entry
@@ -399,7 +713,6 @@ public final class SiteModel {
/// Leave the chargers for the widgets, as the box just described them.
/// `fresh` says whether the box's own reading of the charger is current.
func shareCars(_ points: [Loadpoint], fresh: Bool) {
- guard let siteId, let shared else { return }
let now = scheduler.nowMs
let entry = CarEntry(cars: points.map { lp in
CarEntry.Car(
@@ -413,10 +726,17 @@ public final class SiteModel {
holding: LoadpointsModel.holding(lp)
)
})
+ shareCarEntry(entry, answering: fresh && points.allSatisfy { $0.charger?.available != false })
+ }
+
+ /// One car record for the widgets; the same car again is skipped for a
+ /// while.
+ private func shareCarEntry(_ entry: CarEntry, answering: Bool, via: CarrierKind? = nil) {
+ guard let siteId, let shared else { return }
+ let now = scheduler.nowMs
if let last = lastCars, last.entry.sameReading(entry), now - last.atMs < Self.carRewriteMs { return }
lastCars = (entry, now)
- let answering = fresh && points.allSatisfy { $0.charger?.available != false }
- write(CacheRecord(siteId: siteId, carrier: session.carrier, srcState: answering ? .live : .stale, readAtMs: now, value: entry), to: shared)
+ write(CacheRecord(siteId: siteId, carrier: via ?? session.carrier, srcState: answering ? .live : .stale, readAtMs: now, value: entry), to: shared)
}
/// Leave the plan for the plan widget. `sentAtMs` is when the box sent
@@ -538,3 +858,359 @@ private final class StreamWait {
cont.resume(returning: ok)
}
}
+
+// MARK: - FTW Cloud
+
+/// Where a site model gets the phone's APNs token.
+struct PushTokenSource: Sendable {
+ var install: @Sendable (any PushTokenSink) async -> Void
+ var uninstall: @Sendable () -> Void
+
+ static let app = PushTokenSource(install: { await PushTokens.install($0) }, uninstall: { PushTokens.uninstall() })
+}
+
+/// The site model's end of `PushTokens`, which calls it off the main actor.
+private struct CloudPushSink: PushTokenSink {
+ let deliver: @MainActor @Sendable (PushTokens.Token) -> Void
+
+ func register(apnsToken: Data, environment: PushEnvironment) async {
+ await deliver(PushTokens.Token(data: apnsToken, environment: environment))
+ }
+}
+
+/// Where the readings on screen come from.
+enum ReadingSource: Sendable {
+ case relay, cloud
+}
+
+/// What the app keeps of a home's FTW Cloud between launches: the box's word
+/// on its key and names, and rule U's anchor. Both came from the box over
+/// Noise; neither from the service.
+struct CloudCache: Codable, Equatable, Sendable {
+ /// The notification extension dates events with the same anchor.
+ typealias Offset = CloudHome.Offset
+
+ var identity: CloudIdentity
+ var offset: Offset?
+}
+
+extension SiteModel {
+ /// Read the home from FTW Cloud beside the box session, with the same
+ /// device key. It starts once the box has said, now or at an earlier
+ /// launch, that FTW Cloud is on and how to check what it forwards.
+ public func connectCloud(deviceKey: Primitives.KeyPair, makeSocket: @escaping WebSocketFactory, endpoint: CloudEndpoint? = CloudEndpoint.current) {
+ guard !destroyed, !isDemo else { return }
+ cloudDevice = deviceKey
+ cloudSocket = makeSocket
+ cloudEndpoint = endpoint
+ startCloud()
+ }
+
+ /// An owner pauses FTW Cloud for one of the home's phones, or lifts the
+ /// pause. It only narrows what the box allows.
+ @discardableResult
+ public func suspendAtCloud(deviceID: String, _ on: Bool) -> Bool {
+ cloud?.suspend(deviceID: deviceID, on) ?? false
+ }
+
+ /// Whether the box said FTW Cloud is on for this home.
+ public var cloudOn: Bool { cloudIdentity != nil }
+
+ private func startCloud() {
+ guard cloud == nil, !destroyed, let endpoint = cloudEndpoint, let device = cloudDevice,
+ let socket = cloudSocket, let identity = cloudIdentity else { return }
+ let c = CloudCarrier(endpoint: endpoint, home: identity.home, deviceKey: device, looking: documentVisible, scheduler: scheduler, makeSocket: socket)
+ c.setHandlers(
+ onSnapshot: { [weak self] snap, at in self?.onCloudSnapshot(snap, at) },
+ onEnvelope: { [weak self] env, at in self?.onCloudEnvelope(env, at) },
+ onStatus: { [weak self] status in
+ guard let self else { return }
+ self.cloudStatus = status
+ if status.isOpen { self.registerPush() }
+ self.publish()
+ self.scheduleClock()
+ }
+ )
+ cloud = c
+ cloudStatus = c.status
+ installPushSink()
+ c.start()
+ }
+
+ /// Close the session and keep what it delivered, which ages on screen.
+ private func closeCloud() {
+ cloud?.close()
+ cloud = nil
+ cloudStatus = nil
+ publish()
+ }
+
+ private func adoptCloud(_ identity: CloudIdentity, offset: CloudCache.Offset?) {
+ if !identity.sameHome(cloudIdentity) {
+ closeCloud()
+ cloudView = CloudView(home: identity.home, boxKey: identity.pk)
+ ruleU = nil
+ }
+ cloudIdentity = identity
+ if let offset, var view = cloudView {
+ view.learnOffset(boot: offset.boot, phoneMs: offset.learnedAtMs, uptimeMs: offset.learnedAtMs - offset.offsetMs)
+ cloudView = view
+ ruleU = offset
+ }
+ }
+
+ /// The box answered hello: FTW Cloud is on with this key, or off.
+ private func cloudHello(_ identity: CloudIdentity?) {
+ guard let identity else {
+ // Off, or never on. What the cloud delivered goes with it, and
+ // so do its pushes.
+ closeCloud()
+ if let home = cloudIdentity?.home ?? sharedCloudHome?.home { cloudKeys?.remove(home: home) }
+ sharedCloudHome = nil
+ uninstallPushSink()
+ cloudView = nil
+ cloudIdentity = nil
+ ruleU = nil
+ cloudDirty = false
+ if let siteId { files?.remove("cloud-\(siteId)") }
+ publish()
+ return
+ }
+ adoptCloud(identity, offset: nil)
+ shareCloudHome()
+ cloudDirty = true
+ persistNow()
+ startCloud()
+ }
+
+ // MARK: Pushes
+
+ /// The App Group files the notification extension reads. None in the
+ /// demo, which has no App Group files.
+ private var cloudKeys: CloudKeys? {
+ shared.map { CloudKeys(directory: $0.directory) }
+ }
+
+ /// Give the notification extension what the box said over Noise: its
+ /// key, its names, and rule U's anchor. Written only when it changed.
+ private func shareCloudHome() {
+ guard let siteId, let keys = cloudKeys, let identity = cloudIdentity else { return }
+ let row = CloudHome(home: identity.home, siteId: siteId, boxKey: Data(identity.pk),
+ loadpoints: identity.loadpoints, sources: identity.sources, offset: ruleU)
+ guard row != sharedCloudHome else { return }
+ if let old = sharedCloudHome, old.home != row.home { keys.remove(home: old.home) }
+ do {
+ try keys.write(row)
+ sharedCloudHome = row
+ } catch {
+ log.error("cloud keys not written: \(String(describing: error), privacy: .public)")
+ }
+ }
+
+ /// While FTW Cloud is on, its session takes the phone's APNs token: it
+ /// is how the service learns where Apple can reach this phone. The first
+ /// install asks the person to allow notifications.
+ private func installPushSink() {
+ guard !pushSinkOn else { return }
+ pushSinkOn = true
+ let sink = CloudPushSink { [weak self] token in
+ self?.pushToken = token
+ self?.registerPush()
+ }
+ let source = pushTokens
+ Task { await source.install(sink) }
+ }
+
+ private func uninstallPushSink() {
+ guard pushSinkOn else { return }
+ pushSinkOn = false
+ pushToken = nil
+ pushTokens.uninstall()
+ }
+
+ /// At every opening of the session, and when Apple gives a new token.
+ private func registerPush() {
+ guard let pushToken, let cloud, cloud.status.isOpen else { return }
+ cloud.pushRegister(apnsToken: pushToken.data, environment: pushToken.environment)
+ }
+
+ /// Rule U: phone time minus box uptime, the smallest seen per boot.
+ private func learnRuleU(boot: Bytes, uptimeMs: Double, phoneMs: Double) {
+ guard var view = cloudView, uptimeMs > 0 else { return }
+ let up = Int64(uptimeMs), now = Int64(phoneMs)
+ view.learnOffset(boot: boot, phoneMs: now, uptimeMs: up)
+ cloudView = view
+ let offset = now - up
+ if let held = ruleU, held.boot == boot, held.offsetMs <= offset {
+ // Not smaller: only the time it was confirmed moves, which the
+ // drift margin counts from. Kept in memory; written with the
+ // next snapshot.
+ ruleU = CloudCache.Offset(boot: boot, offsetMs: held.offsetMs, learnedAtMs: now)
+ } else {
+ ruleU = CloudCache.Offset(boot: boot, offsetMs: offset, learnedAtMs: now)
+ }
+ cloudDirty = true
+ }
+
+ private func onCloudSnapshot(_ snapshot: CloudWire.Snapshot, _ at: Double) {
+ var any = false
+ for env in snapshot.envelopes where acceptCloud(env, at) { any = true }
+ first("cloud snapshot")
+ if any { cloudRead(at) } else { publish() }
+ scheduleClock()
+ }
+
+ private func onCloudEnvelope(_ env: CloudWire.Envelope, _ at: Double) {
+ guard acceptCloud(env, at) else { return }
+ if env.t == CloudWire.msgLive { first("cloud live") }
+ cloudRead(at)
+ }
+
+ /// Check one envelope against the box's key and the freshness rules.
+ /// The service cannot sign, so whatever fails here never reaches a screen.
+ private func acceptCloud(_ env: CloudWire.Envelope, _ at: Double) -> Bool {
+ guard var view = cloudView, (try? view.accept(env, arrivalMs: Int64(at))) != nil else { return false }
+ cloudView = view
+ switch env.t {
+ case CloudWire.msgLP: shareCloudCars()
+ case CloudWire.msgPrices: shareCloudPrices()
+ default: break
+ }
+ return true
+ }
+
+ private func cloudRead(_ at: Double) {
+ cloudArrivalMs = at
+ if readingSource() == .cloud {
+ cloudDelivered = true
+ scheduleSnapshot()
+ }
+ publish()
+ scheduleClock()
+ }
+
+ /// The cloud is where readings come from while its session is open and it
+ /// holds a live, unless the box session streams live readings and the
+ /// cloud's are not live. Numbers never step back in time: a copy that is
+ /// not live takes over only when it is at least as new as what is on
+ /// screen. With neither streaming, the newer copy stays.
+ private func readingSource() -> ReadingSource {
+ guard let view = cloudView, view.live != nil else { return .relay }
+ let now = scheduler.nowMs
+ let relayStreaming = relay.phase == .streaming && relayFrameAtMs != nil
+ let cloudLive = Self.cloudState(view, nowMs: now) == .live
+ if relayStreaming, relaySrcState == .live, !cloudLive {
+ return .relay
+ }
+ let cloudAt = view.anchorSentMs(nowMs: Int64(now)).map(Double.init)
+ let relayAt = relayFrameAtMs ?? cachedAtMs
+ if cloud?.status.isOpen == true {
+ if cloudLive || source == .cloud { return .cloud }
+ guard let relayAt else { return .cloud }
+ return (cloudAt ?? -.infinity) >= relayAt ? .cloud : .relay
+ }
+ if relayStreaming { return .relay }
+ guard let relayAt else { return .cloud }
+ return (cloudAt ?? -.infinity) >= relayAt ? .cloud : .relay
+ }
+
+ /// The cloud's readings in the shape the box session gives them, under
+ /// the names the box session uses, aged to now.
+ private func compose(_ s: inout SessionState, view: CloudView, identity: CloudIdentity) {
+ guard let live = view.live else { return }
+ let now = scheduler.nowMs
+ let g = view.grade(nowMs: Int64(now))
+ let name = { (id: String) in identity.sources[id] ?? id }
+ var fields = [Int: Double]()
+ for (k, v) in live.fields {
+ if let fid = Int(k) { fields[fid] = Double(v) }
+ }
+ var dict = [Int: FieldDef]()
+ for fid in fields.keys {
+ let known = relay.dict[fid]
+ dict[fid] = FieldDef(name: known?.name ?? "", unit: known?.unit, srcId: view.fieldSources[String(fid)]?.first.map(name))
+ }
+ let anchor = view.anchorReadings
+ let t = Double(g.ageMs ?? 0)
+ let upNow = Double(anchor?.uptimeMs ?? live.header.uptimeMs) + t
+ var sources = [String: Source]()
+ for (id, src) in view.sources {
+ let n = name(id)
+ let age = anchor?.ages[id].map { Double($0) + t } ?? (upNow - Double(src.lastOkMs))
+ sources[n] = Source(kind: src.kind, name: relay.sources[n]?.name ?? n, lastOkMs: upNow - age,
+ staleAfterMs: Double(src.staleAfterMs), state: g.sources[id] ?? .never)
+ }
+ s.fields = fields
+ s.dict = dict
+ s.sources = sources
+ s.dispatchBlockedBy = live.dispatchBlockedBy.map(name)
+ s.uptimeMs = upNow
+ s.carrier = cloud?.status.isOpen == true ? .cloud : .none
+ }
+
+ /// The worst grade among the fields Now draws. Never live without an
+ /// anchor: a copy whose age cannot be checked is at best stale.
+ static func cloudState(_ view: CloudView, nowMs: Double) -> SourceState {
+ let g = view.grade(nowMs: Int64(nowMs))
+ let graded = nowFids.compactMap { g.fields[String($0)] }
+ var worst = graded.max() ?? g.sources.values.max() ?? .live
+ if !g.anchored { worst = max(worst, .stale) }
+ return worst
+ }
+
+ /// The age of the oldest reading Now draws, by the anchor. Nil when the
+ /// copy cannot be anchored: unverified, shown as "—".
+ static func cloudAge(_ view: CloudView, nowMs: Double) -> Double? {
+ let g = view.grade(nowMs: Int64(nowMs))
+ guard g.anchored, let t = g.ageMs, let anchor = view.anchorReadings else { return nil }
+ let ids = Set(nowFids.flatMap { view.fieldSources[String($0)] ?? [] })
+ let ages = ids.compactMap { id -> Int64? in
+ if let a = anchor.ages[id] { return a }
+ return view.sources[id].map { anchor.uptimeMs - $0.lastOkMs }
+ }
+ guard let oldest = ages.max() else { return Double(t) }
+ return Double(max(0, oldest) + t)
+ }
+
+ /// The cars as the cloud's lp has them, for the widgets.
+ private func shareCloudCars() {
+ guard let view = cloudView, let lp = view.lp, let identity = cloudIdentity else { return }
+ let now = scheduler.nowMs
+ let g = view.grade(nowMs: Int64(now))
+ let entry = CarEntry(cars: lp.loadpoints.map { l in
+ CarEntry.Car(
+ loadpointID: identity.loadpoints[l.id] ?? l.id,
+ pluggedIn: l.plugged,
+ socPct: l.socPermille.map { (Double($0) / 10).rounded() },
+ socSource: l.socSource,
+ socReadAtMs: l.socAgeS.map { now - Double($0) * 1000 },
+ powerW: Double(l.powerW),
+ targetSocPct: l.targetPermille.map { (Double($0) / 10).rounded() }
+ )
+ })
+ shareCarEntry(entry, answering: lp.loadpoints.allSatisfy { g.loadpoints[$0.id] == .live }, via: .cloud)
+ }
+
+ /// The cloud's price window, for the widgets, in the box session's shape.
+ private func shareCloudPrices() {
+ guard let p = cloudView?.prices, p.slotMs > 0 else { return }
+ var slots = [PriceSlot]()
+ for i in p.spotMinor.indices {
+ guard let spot = p.spotMinor[i], i < p.totalMinor.count, let total = p.totalMinor[i] else { continue }
+ slots.append(PriceSlot(startMs: Double(p.t0Ms + Int64(i) * p.slotMs), durationMs: Double(p.slotMs),
+ spotMinor: Double(spot), totalMinor: Double(total)))
+ }
+ guard !slots.isEmpty else { return }
+ sharePrices(Prices(zone: p.zone, currency: p.currency, slots: slots, stale: false), fromMs: Double(p.t0Ms), via: .cloud)
+ }
+}
+
+extension CachedSnapshot {
+ /// The same snapshot without the time it was written, to tell whether a
+ /// new one says anything new.
+ var timeless: CachedSnapshot {
+ var c = self
+ c.savedAtMs = 0
+ return c
+ }
+}
diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift b/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift
index 95a0929..7fc55b9 100644
--- a/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift
+++ b/appleApp/FTWKit/Sources/FTWKit/Store/SealedFiles.swift
@@ -15,6 +15,10 @@ public final class SealedFiles {
static let keyName = "cache-key"
private let directory: URL
private let store: SecureStore
+ /// The key, once read, so a write does not ask the keychain again.
+ private var cachedKey: Bytes?
+ /// The latest write off the main actor; each waits for the one before.
+ private var writing: Task?
public init(directory: URL, store: SecureStore) {
self.directory = directory
@@ -28,10 +32,23 @@ public final class SealedFiles {
return base.appendingPathComponent("FTW", isDirectory: true)
}
- private var key: Bytes {
- if let k = store.get(Self.keyName), k.count == 32 { return k }
+ /// The cache key, made only when the store says it has none. A store
+ /// that cannot answer gives no key, and nothing is written or read.
+ private var key: Bytes? {
+ if let cachedKey { return cachedKey }
+ let held: Bytes?
+ do {
+ held = try store.read(Self.keyName)
+ } catch {
+ return nil
+ }
+ if let held, held.count == 32 {
+ cachedKey = held
+ return held
+ }
let k = randomBytes(32)
store.put(Self.keyName, k)
+ cachedKey = k
return k
}
@@ -40,6 +57,31 @@ public final class SealedFiles {
}
public func write(_ name: String, _ plain: Bytes) {
+ guard let key else { return }
+ Self.seal(key: key, name: name, plain: plain, to: url(name), in: directory)
+ }
+
+ /// Seal and write off the main actor, in the order asked for. Returns
+ /// the write, for a caller that must not end before it lands.
+ @discardableResult
+ public func writeLater(_ name: String, _ plain: Bytes) -> Task? {
+ guard let key else { return nil }
+ let previous = writing
+ let target = url(name), directory = directory
+ let task = Task {
+ await previous?.value
+ await Self.sealOffMain(key: key, name: name, plain: plain, to: target, in: directory)
+ }
+ writing = task
+ return task
+ }
+
+ @concurrent
+ private nonisolated static func sealOffMain(key: Bytes, name: String, plain: Bytes, to target: URL, in directory: URL) async {
+ seal(key: key, name: name, plain: plain, to: target, in: directory)
+ }
+
+ private nonisolated static func seal(key: Bytes, name: String, plain: Bytes, to target: URL, in directory: URL) {
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let nonce = randomBytes(12)
@@ -48,14 +90,14 @@ public final class SealedFiles {
#if os(iOS)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
- try Data(sealed).write(to: url(name), options: options)
+ try Data(sealed).write(to: target, options: options)
} catch {
// A cache that could not be written costs a slower next start.
}
}
public func read(_ name: String) -> Bytes? {
- guard let data = try? Data(contentsOf: url(name)), data.count > 28 else { return nil }
+ guard let key, let data = try? Data(contentsOf: url(name)), data.count > 28 else { return nil }
let bytes = data.byteArray
return try? Primitives.aesGCMOpen(key: key, nonce: Array(bytes[0..<12]), ad: Array(name.utf8), ciphertext: Array(bytes[12...]))
}
@@ -69,11 +111,18 @@ public final class SealedFiles {
public func clear() {
try? FileManager.default.removeItem(at: directory)
store.remove(Self.keyName)
+ cachedKey = nil
}
- public func writeJSON(_ name: String, _ value: T) {
+ /// Encode here; with `wait` false the seal and the write happen off the
+ /// main actor.
+ public func writeJSON(_ name: String, _ value: T, wait: Bool = true) {
guard let data = try? JSONEncoder().encode(value) else { return }
- write(name, data.byteArray)
+ if wait {
+ write(name, data.byteArray)
+ } else {
+ writeLater(name, data.byteArray)
+ }
}
public func readJSON(_ type: T.Type, _ name: String) -> T? {
diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift b/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift
index c43f86a..a65ee79 100644
--- a/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift
+++ b/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift
@@ -9,11 +9,26 @@ import Foundation
/// which is this phone's identity. Clearing the store is signing out.
@MainActor
public protocol SecureStore: AnyObject {
+ /// The value, or nil when it is absent or could not be read.
func get(_ key: String) -> Bytes?
+ /// The value, nil only when it is absent, and an error when the store
+ /// could not answer, as before the first unlock. Anything that would
+ /// create a value on nil must use this: a store that cannot answer does
+ /// not mean the value is gone.
+ func read(_ key: String) throws -> Bytes?
func put(_ key: String, _ value: Bytes)
func remove(_ key: String)
}
+/// The store could not answer, so nothing may be written on its word.
+public struct SecureStoreUnavailable: Error, Equatable {
+ public let status: Int32
+
+ public init(status: Int32) {
+ self.status = status
+ }
+}
+
@MainActor
public final class MemoryStore: SecureStore {
private var map: [String: Bytes] = [:]
@@ -28,6 +43,9 @@ public final class MemoryStore: SecureStore {
}
extension SecureStore {
+ /// A store that cannot fail to answer, such as `MemoryStore`.
+ public func read(_ key: String) throws -> Bytes? { get(key) }
+
func getJSON(_ type: T.Type, _ key: String) -> T? {
guard let raw = get(key) else { return nil }
return try? JSONDecoder().decode(T.self, from: Data(raw))
diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift b/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift
index 58b2fe7..3a615ec 100644
--- a/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift
+++ b/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift
@@ -51,8 +51,17 @@ public final class SiteList {
all().first { $0.siteId == siteId }
}
+ /// Adds or replaces one home. When the store cannot say which homes it
+ /// holds, nothing is written: a list written then would drop the others.
public func put(_ site: StoredSite) {
- var rows = all().filter { $0.siteId != site.siteId }
+ let held: Bytes?
+ do {
+ held = try store.read(Self.sitesKey)
+ } catch {
+ return
+ }
+ let existing = held.flatMap { try? JSONDecoder().decode([StoredSite].self, from: Data($0)) } ?? []
+ var rows = existing.filter { $0.siteId != site.siteId }
rows.append(site)
store.putJSON(Self.sitesKey, rows)
}
diff --git a/appleApp/FTWKit/Sources/FTWKit/Support/ProcessClock.swift b/appleApp/FTWKit/Sources/FTWKit/Support/ProcessClock.swift
new file mode 100644
index 0000000..cf27ee7
--- /dev/null
+++ b/appleApp/FTWKit/Sources/FTWKit/Support/ProcessClock.swift
@@ -0,0 +1,37 @@
+import Darwin
+import os
+
+/// How long since this process started, by the kernel's record, and a mark
+/// logged once per process. The app's LaunchClock marks the first frame and
+/// the first content with it; SiteModel marks the first live reading, so all
+/// three count from the same moment. Read them with:
+///
+/// xcrun simctl spawn log show --last 2m \
+/// --predicate 'subsystem == "energy.ftw.app" AND category == "launch"'
+@MainActor
+public enum ProcessClock {
+ static let log = Logger(subsystem: "energy.ftw.app", category: "launch")
+ private static var seen: Set = []
+
+ /// Once per name: a signpost event and a line with the time since start.
+ public static func markOnce(_ event: StaticString) {
+ let name = "\(event)"
+ guard seen.insert(name).inserted else { return }
+ OSSignposter(logger: log).emitEvent(event)
+ if let ms = sinceStartMs() {
+ log.notice("\(name, privacy: .public) \(Int(ms.rounded()), privacy: .public) ms after process start")
+ }
+ }
+
+ /// The kernel's record of when this process started.
+ public static func sinceStartMs() -> Double? {
+ var info = kinfo_proc()
+ var size = MemoryLayout.stride
+ var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
+ guard sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) == 0 else { return nil }
+ let start = info.kp_proc.p_starttime
+ var now = timeval()
+ gettimeofday(&now, nil)
+ return Double(now.tv_sec - start.tv_sec) * 1000 + Double(now.tv_usec - start.tv_usec) / 1000
+ }
+}
diff --git a/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift b/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift
new file mode 100644
index 0000000..fdc4e62
--- /dev/null
+++ b/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift
@@ -0,0 +1,122 @@
+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, the names behind the
+/// opaque ids an event uses, and rule U's anchor. The app writes it when the box describes its
+/// FTW Cloud link over Noise; the notification extension reads it.
+///
+/// Nothing here is a secret. A public key checks signatures and cannot make
+/// 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]
+ /// Rule U's anchor, so an event from a box without a synced clock can
+ /// still be dated. Nil until the phone has learned one over Noise.
+ public var offset: Offset?
+
+ /// Rule U's anchor: phone time minus box uptime, the smallest the phone
+ /// learned over Noise for `boot`, and when it last learned it.
+ public struct Offset: Codable, Sendable, Equatable {
+ public var boot: [UInt8]
+ public var offsetMs: Int64
+ public var learnedAtMs: Int64
+
+ public init(boot: [UInt8], offsetMs: Int64, learnedAtMs: Int64) {
+ self.boot = boot
+ self.offsetMs = offsetMs
+ self.learnedAtMs = learnedAtMs
+ }
+ }
+
+ public init(home: String, siteId: String, boxKey: Data, loadpoints: [String: String] = [:], sources: [String: String] = [:], offset: Offset? = nil) {
+ self.home = home
+ self.siteId = siteId
+ self.boxKey = boxKey
+ self.loadpoints = loadpoints
+ self.sources = sources
+ self.offset = offset
+ }
+
+ /// The charger an event means. An event that names none means the only
+ /// 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
+ }
+
+ /// Rule U's anchor for a home, for `PushAlert.read`.
+ public func offset(home: String) -> CloudHome.Offset? {
+ read(home: home)?.offset
+ }
+
+ /// Only the app writes. Atomic, so the extension never reads half a file.
+ public func write(_ row: CloudHome) throws {
+ guard Self.isHomeID(row.home) else { throw Refused(reason: "not a home id") }
+ 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/Freshness.swift b/appleApp/FTWKit/Sources/FTWShared/Freshness.swift
index 521b286..2d97aeb 100644
--- a/appleApp/FTWKit/Sources/FTWShared/Freshness.swift
+++ b/appleApp/FTWKit/Sources/FTWShared/Freshness.swift
@@ -10,6 +10,11 @@ import Foundation
public enum Freshness {
public enum Tone: Sendable { case live, stale, reaching, lost, busy }
+ /// How long the band counts a wait in seconds. Past this, a growing
+ /// counter only looks broken; the band says it cannot reach the box and
+ /// when the last reading was.
+ public static let countForMs: Double = 20_000
+
public struct Band: Equatable, Sendable {
public let tone: Tone
public let message: String
@@ -29,18 +34,31 @@ public enum Freshness {
waitMs: Double,
bootPct: Int?,
noCarrier: Bool,
- busy: Bool = false
+ busy: Bool = false,
+ lastReading: String? = nil
) -> Band {
let reaching = !noCarrier && [.idle, .handshaking, .subscribing, .failed, .booting].contains(phase)
- let connected = carrier == .relay || carrier == .webrtc
+ let connected = carrier == .relay || carrier == .webrtc || carrier == .cloud
// The relay turned this phone away because the home already has as
// many phones watching as it serves. Not a fault, and not ours to fix.
let turnedAway = busy && !connected && reaching
- let tone: Tone = connected ? (srcState == .live ? .live : .stale) : turnedAway ? .busy : (reaching ? .reaching : .lost)
+ // A box that is starting says so for as long as it takes. Anything
+ // else that has not connected for a while is said as such.
+ let longWait = reaching && !turnedAway && phase != .booting && waitMs >= countForMs
+ let tone: Tone = connected ? (srcState == .live ? .live : .stale) : turnedAway ? .busy : (reaching && !longWait ? .reaching : .lost)
let message: String
- if connected {
+ if connected, carrier == .cloud {
+ // The box signed every reading; the anchor says how old it is.
+ // Without one the copy is never live and its age is unverified.
+ switch srcState {
+ case .live: message = Words.text("Live via FTW Cloud")
+ case .never: message = Words.text("FTW Cloud connected · no reading yet")
+ case .down: message = Words.text("FTW Cloud connected · a device stopped responding")
+ default: message = ageMs == nil ? Words.text("FTW Cloud · age unverified") : Words.text("FTW Cloud connected · readings")
+ }
+ } else if connected {
let webrtc = carrier == .webrtc
switch srcState {
case .live: message = webrtc ? Words.text("Live at home") : Words.text("Live via encrypted relay")
@@ -55,6 +73,8 @@ public enum Freshness {
message = Words.text("FTW is busy")
} else if phase == .booting {
message = Words.text("Your box is starting")
+ } else if longWait {
+ message = lastReading.map { Words.format("Can't reach your box · last reading %@", $0) } ?? Words.text("Can't reach your box")
} else if reaching {
if phase == .failed {
message = Words.text("Reconnecting to your box")
@@ -72,7 +92,7 @@ public enum Freshness {
}
var wait: String?
- if reaching, !turnedAway {
+ if reaching, !turnedAway, !longWait {
if phase == .booting, let bootPct {
wait = "\(bootPct)%"
} else {
diff --git a/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift b/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift
new file mode 100644
index 0000000..91ce52f
--- /dev/null
+++ b/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift
@@ -0,0 +1,265 @@
+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, less rule
+ /// U's drift margin when rule U dates it: never later than it arrived.
+ public let atMs: Int64
+
+ init(_ verdict: Verdict, home: String, kind: String = "", args: [String: String] = [:], src: String? = nil, lp: String? = nil, boot: Data? = nil, seq: UInt64 = 0, atMs: Int64 = 0) {
+ 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
+ /// How long rule U's anchor stays usable after it was last learned.
+ static let ruleULifetimeMs: Int64 = 24 * 60 * 60_000
+ /// Rule U's drift margin: 0.01 % of the time since it was learned.
+ static let ruleUDriftDivisor: Int64 = 10_000
+
+ /// Check a push as the notification extension receives it.
+ /// `boxKey` is the key the phone holds for a home, nil when it holds
+ /// none; `offset` is rule U's anchor for a home, nil when it holds none;
+ /// `nowMs` is the phone's clock when the push arrived. Whether an event
+ /// was seen before is `PushSeen`'s question, not this one's.
+ public static func read(userInfo: [AnyHashable: Any], boxKey: (String) -> Data?, offset: (String) -> CloudHome.Offset? = { _ in nil }, nowMs: Int64) -> PushAlert {
+ read(ftw: userInfo[key], boxKey: boxKey, offset: offset, nowMs: nowMs)
+ }
+
+ /// The same, from the payload's JSON as APNs sends it.
+ public static func read(payload: Data, boxKey: (String) -> Data?, offset: (String) -> CloudHome.Offset? = { _ in nil }, nowMs: Int64) -> PushAlert {
+ let top = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any]
+ return read(ftw: top?[key], boxKey: boxKey, offset: offset, 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?, offset: (String) -> CloudHome.Offset?, nowMs: Int64) -> PushAlert {
+ guard let ftw, JSONSerialization.isValidJSONObject(ftw),
+ let json = try? JSONSerialization.data(withJSONObject: ftw),
+ let a = try? JSONDecoder().decode(Wire.self, from: json), a.v == version else {
+ 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), var at = sendTime(ev, offset: offset(home), nowMs: nowMs) else {
+ return generic
+ }
+ 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)
+ }
+
+ /// When the event was sent, as the Go reference dates it: the earlier
+ /// of rule N and rule U, and when rule U gives it, earlier by the drift
+ /// margin so the margin counts against the age. A box clock more than
+ /// `clockLeadMs` ahead gives no rule N. Nil when neither rule applies.
+ /// Arithmetic wraps, as Go's does, so a hostile number cannot trap.
+ static func sendTime(_ ev: Event, offset: CloudHome.Offset?, nowMs: Int64) -> Int64? {
+ var at: Int64?
+ if let wallMs = ev.wallMs, wallMs <= nowMs &+ clockLeadMs { at = min(wallMs, nowMs) }
+ guard let offset, offset.boot == (ev.boot ?? []), nowMs &- offset.learnedAtMs <= ruleULifetimeMs else { return at }
+ let u = min(offset.offsetMs &+ ev.uptimeMs, nowMs)
+ if let n = at, u > n { return n }
+ return u &- max(0, nowMs &- offset.learnedAtMs) / ruleUDriftDivisor
+ }
+
+ static func verify(_ pk: Data, home: String, _ env: Envelope) -> Bool {
+ verify(boxKey: [UInt8](pk), home: home, t: env.t, b: env.b, sig: env.sig)
+ }
+
+ /// ES256 by a box's cloud key, over the bytes received, never over a
+ /// re-encoding. The key is the box's 65-byte uncompressed P-256 point.
+ /// The app's FTW Cloud session checks with this too, so there is one
+ /// check.
+ public static func verify(boxKey: [UInt8], home: String, t: String, b: [UInt8], sig: [UInt8]) -> Bool {
+ guard boxKey.count == 65, boxKey.first == 0x04, sig.count == 64,
+ let key = try? P256.Signing.PublicKey(x963Representation: boxKey),
+ let sig = try? P256.Signing.ECDSASignature(rawRepresentation: sig) else { return false }
+ return key.isValidSignature(sig, for: PushCBOR.signedInput(home: home, t: t, b: b))
+ }
+
+ /// One message: {t, b, sig}. `b` is the body's exact bytes, so the
+ /// 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 uptimeMs: Int64 = 0
+ 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)
+ uptimeMs = try field("uptimeMs", \.int64) ?? 0
+ wallMs = try field("wallMs", \.int64)
+ _ = try field("eventId", \.text)
+ ts = try field("ts", \.int64)
+ kind = try field("kind", \.text) ?? ""
+ _ = try field("severity", \.text)
+ args = try field("args", \.textOfText) ?? [:]
+ src = try field("src", \.text)
+ lp = try field("lp", \.text)
+ } catch {
+ return nil
+ }
+ }
+ }
+}
+
+extension PushCBOR {
+ var uint: UInt64? {
+ if case .unsigned(let u) = self { return u }
+ return nil
+ }
+
+ var int64: Int64? {
+ switch self {
+ case .unsigned(let u): return Int64(exactly: u)
+ case .negative(let n): return Int64(exactly: n).map { -1 - $0 }
+ default: return nil
+ }
+ }
+
+ var text: String? {
+ if case .text(let s) = self { return s }
+ return nil
+ }
+
+ var byteString: [UInt8]? {
+ if case .bytes(let b) = self { return b }
+ return nil
+ }
+
+ /// A text-to-text map. A null value reads as empty text, as in Go.
+ var textOfText: [String: String]? {
+ guard let m = textMap else { return nil }
+ var out = [String: String]()
+ for (k, v) in m {
+ switch v {
+ case .text(let s): out[k] = s
+ case .null: out[k] = ""
+ default: return nil
+ }
+ }
+ return out
+ }
+}
diff --git a/appleApp/FTWKit/Sources/FTWShared/PushCBOR.swift b/appleApp/FTWKit/Sources/FTWShared/PushCBOR.swift
new file mode 100644
index 0000000..b59f8df
--- /dev/null
+++ b/appleApp/FTWKit/Sources/FTWShared/PushCBOR.swift
@@ -0,0 +1,159 @@
+import Foundation
+
+/// The CBOR a push carries: the box's envelope and the event inside it.
+///
+/// The notification extension does not link FTWKit, so it reads with this
+/// small reader instead. It is strict where FTWKit's is lenient: definite
+/// lengths only, text must be UTF-8, a key may appear once, and nothing may
+/// follow the value. The box writes core-deterministic CBOR, so a genuine
+/// event never needs more; anything else is shown as the generic line.
+indirect enum PushCBOR: Equatable, Sendable {
+ case unsigned(UInt64)
+ /// The value is `-1 - n`.
+ case negative(UInt64)
+ case bytes([UInt8])
+ case text(String)
+ case array([PushCBOR])
+ case map([Entry])
+ case tagged(UInt64, PushCBOR)
+ /// Null and undefined. Go reads either as "not there".
+ case null
+ /// Booleans, floats and other simple values: skipped, never read.
+ case simple
+
+ struct Entry: Equatable, Sendable {
+ var key: PushCBOR
+ var value: PushCBOR
+ }
+
+ struct Malformed: Error {}
+
+ /// Exactly one value, and nothing after it.
+ static func decode(_ bytes: [UInt8]) throws -> PushCBOR {
+ var r = Reader(bytes: bytes)
+ let value = try r.read(depth: 0)
+ guard r.at == bytes.count else { throw Malformed() }
+ return value
+ }
+
+ /// A map whose keys are all text, as a dictionary. Nil for anything
+ /// else; a duplicate key never gets this far.
+ var textMap: [String: PushCBOR]? {
+ guard case .map(let entries) = self else { return nil }
+ var out = [String: PushCBOR]()
+ for e in entries {
+ guard case .text(let k) = e.key else { return nil }
+ out[k] = e.value
+ }
+ return out
+ }
+
+ private struct Reader {
+ let bytes: [UInt8]
+ var at = 0
+
+ /// Deeper than any event, shallow enough that a hostile one cannot
+ /// exhaust the stack.
+ static let maxDepth = 16
+
+ mutating func byte() throws -> UInt8 {
+ guard at < bytes.count else { throw Malformed() }
+ defer { at += 1 }
+ return bytes[at]
+ }
+
+ mutating func take(_ n: UInt64) throws -> [UInt8] {
+ guard n <= UInt64(bytes.count - at) else { throw Malformed() }
+ let end = at + Int(n)
+ defer { at = end }
+ return Array(bytes[at.. UInt64 {
+ switch info {
+ case 0..<24: return UInt64(info)
+ case 24: return UInt64(try byte())
+ case 25: return try take(2).reduce(0) { $0 << 8 | UInt64($1) }
+ case 26: return try take(4).reduce(0) { $0 << 8 | UInt64($1) }
+ case 27: return try take(8).reduce(0) { $0 << 8 | UInt64($1) }
+ // 28–30 are reserved; 31 is an indefinite length.
+ default: throw Malformed()
+ }
+ }
+
+ mutating func read(depth: Int) throws -> PushCBOR {
+ guard depth < Self.maxDepth else { throw Malformed() }
+ let initial = try byte()
+ let major = initial >> 5
+ let info = initial & 0x1f
+ switch major {
+ case 0: return .unsigned(try argument(info))
+ case 1: return .negative(try argument(info))
+ case 2: return .bytes(try take(try argument(info)))
+ case 3:
+ guard let s = String(validating: try take(try argument(info)), as: UTF8.self) else { throw Malformed() }
+ return .text(s)
+ case 4:
+ let n = try argument(info)
+ // Each item takes at least a byte.
+ guard n <= UInt64(bytes.count - at) else { throw Malformed() }
+ return .array(try (0.. [UInt8] {
+ var out = Array("ftw.cloud.v1\u{0}".utf8)
+ out.append(0x83)
+ head(3, UInt64(home.utf8.count), into: &out)
+ out.append(contentsOf: home.utf8)
+ head(3, UInt64(t.utf8.count), into: &out)
+ out.append(contentsOf: t.utf8)
+ head(2, UInt64(b.count), into: &out)
+ out.append(contentsOf: b)
+ return out
+ }
+
+ /// A head in its shortest form, as deterministic encoding asks.
+ private static func head(_ major: UInt8, _ n: UInt64, into out: inout [UInt8]) {
+ let m = major << 5
+ switch n {
+ case 0..<24: out.append(m | UInt8(n))
+ case 24...0xff: out += [m | 24, UInt8(n)]
+ case 0x100...0xffff: out += [m | 25, UInt8(n >> 8), UInt8(n & 0xff)]
+ case 0x1_0000...0xffff_ffff: out += [m | 26] + (0..<4).reversed().map { UInt8(truncatingIfNeeded: n >> ($0 * 8)) }
+ default: out += [m | 27] + (0..<8).reversed().map { UInt8(truncatingIfNeeded: n >> ($0 * 8)) }
+ }
+ }
+}
diff --git a/appleApp/FTWKit/Sources/FTWShared/PushNotice.swift b/appleApp/FTWKit/Sources/FTWShared/PushNotice.swift
new file mode 100644
index 0000000..8f7a0c0
--- /dev/null
+++ b/appleApp/FTWKit/Sources/FTWShared/PushNotice.swift
@@ -0,0 +1,148 @@
+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
+ /// 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
+ /// 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)
+ }
+
+ /// 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"
+
+ /// The payload never says how urgent it is. A box out of reach and a
+ /// charge that stopped early break through a Focus; the rest wait.
+ static func level(_ alert: PushAlert) -> UNNotificationInterruptionLevel {
+ switch alert.verdict {
+ case .unreachable: return .timeSensitive
+ case .event where alert.kind == PushKind.chargingInterrupted: return .timeSensitive
+ default: return .active
+ }
+ }
+}
diff --git a/appleApp/FTWKit/Sources/FTWShared/PushSeen.swift b/appleApp/FTWKit/Sources/FTWShared/PushSeen.swift
new file mode 100644
index 0000000..af67a2b
--- /dev/null
+++ b/appleApp/FTWKit/Sources/FTWShared/PushSeen.swift
@@ -0,0 +1,66 @@
+import Foundation
+import Synchronization
+
+/// Events this phone has shown, so a push that arrives twice is shown once.
+///
+/// The box numbers what it sends: `seq` rises by one per message within a
+/// boot. Per home and boot the extension keeps the highest `seq` it showed;
+/// an event at or below it is a duplicate or older than one already shown.
+/// A new boot starts its own count. The file keeps the most recent few
+/// boots and is in the App Group, beside the cache.
+public struct PushSeen: Sendable {
+ public let file: URL
+ /// Homes times boots worth remembering. Each box starts a new boot on
+ /// every restart; the oldest goes first.
+ public static let limit = 32
+
+ public init(file: URL) {
+ self.file = file
+ }
+
+ public static func shared(bundle: Bundle = .main) -> PushSeen? {
+ AppGroupFiles.shared(bundle: bundle).map { PushSeen(file: $0.directory.appendingPathComponent("push-seen")) }
+ }
+
+ struct Entry: Codable, Equatable {
+ var home: String
+ var boot: Data
+ var seq: UInt64
+ /// The phone's clock when it was last moved, to drop the oldest.
+ var atMs: Double
+ }
+
+ /// Whether to show this alert. A new event is remembered before the
+ /// answer, so a second copy that arrives meanwhile is not. Only events
+ /// carry a number; the rest are always shown. If the file cannot be
+ /// written the event is shown anyway: a duplicate is better than a
+ /// missed alert.
+ public func accept(_ alert: PushAlert, nowMs: Double) -> Bool {
+ guard alert.verdict == .event else { return true }
+ let boot = alert.boot ?? Data()
+ return Self.lock.withLock { _ in
+ var entries = load()
+ if let i = entries.firstIndex(where: { $0.home == alert.home && $0.boot == boot }) {
+ guard alert.seq > entries[i].seq else { return false }
+ entries[i].seq = alert.seq
+ entries[i].atMs = nowMs
+ } else {
+ entries.append(Entry(home: alert.home, boot: boot, seq: alert.seq, atMs: nowMs))
+ }
+ entries.sort { $0.atMs > $1.atMs }
+ try? save(Array(entries.prefix(Self.limit)))
+ return true
+ }
+ }
+
+ private static let lock = Mutex(())
+
+ func load() -> [Entry] {
+ (try? Data(contentsOf: file)).flatMap { try? JSONDecoder().decode([Entry].self, from: $0) } ?? []
+ }
+
+ private func save(_ entries: [Entry]) throws {
+ try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try JSONEncoder().encode(entries).write(to: file, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
+ }
+}
diff --git a/appleApp/FTWKit/Sources/FTWShared/PushText.swift b/appleApp/FTWKit/Sources/FTWShared/PushText.swift
new file mode 100644
index 0000000..f9d5fd1
--- /dev/null
+++ b/appleApp/FTWKit/Sources/FTWShared/PushText.swift
@@ -0,0 +1,134 @@
+import Foundation
+
+/// The words of a push, written on the phone from the event's kind and
+/// arguments, in Swedish or English (Fredrik, 12 Oct 2026). The box sends
+/// codes and the service sends none of its own, so a lock screen never
+/// shows a sentence this file does not hold.
+///
+/// The English is the push catalogue's (`protocol/push-catalogue.yaml`,
+/// the same file as in srcfl/ftw), and a test holds it to that file.
+public enum PushText {
+ public enum Language: String, Sendable, CaseIterable {
+ case sv, en
+
+ /// The person's first language: Swedish if it is Swedish, else
+ /// English.
+ public static func preferred(_ languages: [String] = Locale.preferredLanguages) -> Language {
+ languages.first?.lowercased().hasPrefix("sv") == true ? .sv : .en
+ }
+ }
+
+ public struct Content: Sendable, Equatable {
+ public var title: String
+ public var body: String
+
+ public init(title: String, body: String) {
+ self.title = title
+ self.body = body
+ }
+ }
+
+ /// One kind's sentence. `{name}` marks an argument; `without` is the
+ /// body to show when an argument is missing or will not do.
+ struct Sentence {
+ var title: String
+ var body: String
+ var without: String?
+ }
+
+ /// The line for anything that did not check out: no signature, an old
+ /// event, a home this phone does not hold. It says nothing the push
+ /// claimed.
+ public static func generic(_ language: Language) -> Content {
+ switch language {
+ case .sv: return Content(title: "Något hände hemma", body: "Öppna FTW för att se vad.")
+ case .en: return Content(title: "Something happened at home", body: "Open FTW to see what.")
+ }
+ }
+
+ /// The words for a checked alert. `sourceName` is the device's name from
+ /// the box, for an event about a device.
+ public static func content(_ alert: PushAlert, sourceName: String? = nil, language: Language, decimalSeparator: String = Locale.current.decimalSeparator ?? ".") -> Content {
+ let kind: String
+ switch alert.verdict {
+ case .generic: return generic(language)
+ case .unreachable: kind = PushKind.boxUnreachable
+ case .event: kind = alert.kind
+ }
+ guard let s = sentences(language)[kind] else { return generic(language) }
+ let values = arguments(alert.args, name: sourceName, decimalSeparator: decimalSeparator)
+ guard let body = fill(s.body, values) else { return Content(title: s.title, body: s.without ?? s.body) }
+ return Content(title: s.title, body: body)
+ }
+
+ static func sentences(_ language: Language) -> [String: Sentence] {
+ language == .sv ? sv : en
+ }
+
+ static let en: [String: Sentence] = [
+ PushKind.chargingConnected: Sentence(title: "Car plugged in", body: "Open to see charging status, check the battery level and set a ready time."),
+ PushKind.chargingSessionComplete: Sentence(title: "Car charged", body: "{kwh} kWh delivered — ready to go.", without: "Ready to go."),
+ PushKind.chargingGoalAtRisk: Sentence(title: "The car may not be ready", body: "It will not reach {target} % by {by} at this pace.", without: "It will not reach its goal in time at this pace."),
+ PushKind.chargingInterrupted: Sentence(title: "Charging stopped early", body: "The car stopped charging before it was done."),
+ PushKind.updateInstalled: Sentence(title: "Your box updated itself", body: "Now running {version}. Everything came back on its own.", without: "Everything came back on its own."),
+ PushKind.driverOffline: Sentence(title: "A device went quiet", body: "{name} stopped answering.", without: "A device stopped answering."),
+ PushKind.fuseOverLimit: Sentence(title: "The house is drawing too much", body: "{phase} is over the fuse rating.", without: "One phase is over the fuse rating."),
+ PushKind.boxUnreachable: Sentence(title: "Your box is out of reach", body: "It stopped answering. Power cut, or the internet at home is down."),
+ PushKind.cloudGranted: Sentence(title: "FTW Cloud is on", body: "Your box now shares a copy of its readings with FTW Cloud. You can turn it off in the app or on the box."),
+ PushKind.cloudRevoked: Sentence(title: "FTW Cloud is off", body: "Your box stopped sharing with FTW Cloud and asked it to delete its copy."),
+ ]
+
+ static let sv: [String: Sentence] = [
+ PushKind.chargingConnected: Sentence(title: "Bilen är inkopplad", body: "Öppna för att se laddningen, kolla bilens nivå och välja klartid."),
+ PushKind.chargingSessionComplete: Sentence(title: "Bilen är laddad", body: "{kwh} kWh laddat – redo att åka.", without: "Redo att åka."),
+ PushKind.chargingGoalAtRisk: Sentence(title: "Bilen kanske inte blir klar", body: "Den når inte {target} % till {by} i den här takten.", without: "Den når inte målet i tid i den här takten."),
+ PushKind.chargingInterrupted: Sentence(title: "Laddningen avbröts", body: "Bilen slutade ladda innan den var klar."),
+ PushKind.updateInstalled: Sentence(title: "Din box har uppdaterat sig", body: "Nu kör den {version}. Allt kom igång igen av sig självt.", without: "Allt kom igång igen av sig självt."),
+ PushKind.driverOffline: Sentence(title: "En enhet har tystnat", body: "{name} har slutat svara.", without: "En enhet har slutat svara."),
+ PushKind.fuseOverLimit: Sentence(title: "Huset drar för mycket ström", body: "{phase} drar mer än huvudsäkringen klarar.", without: "En fas drar mer än huvudsäkringen klarar."),
+ PushKind.boxUnreachable: Sentence(title: "Din box går inte att nå", body: "Den har slutat svara. Strömavbrott, eller så är internet hemma nere."),
+ PushKind.cloudGranted: Sentence(title: "FTW Cloud är på", body: "Din box delar nu en kopia av sina mätvärden med FTW Cloud. Du kan stänga av det i appen eller på boxen."),
+ PushKind.cloudRevoked: Sentence(title: "FTW Cloud är av", body: "Din box har slutat dela med FTW Cloud och bett den radera sin kopia."),
+ ]
+
+ /// The arguments that will do, ready to show. The box signed them, and
+ /// they still pass a check: a lock screen is no place for a stray line
+ /// break or a paragraph.
+ static func arguments(_ args: [String: String], name: String?, decimalSeparator: String) -> [String: String] {
+ var out = [String: String]()
+ // Energy is kWh with the reader's decimal mark, never a percentage.
+ if let kwh = args["kwh"], kwh.wholeMatch(of: /[0-9]{1,5}(\.[0-9]{1,3})?/) != nil {
+ out["kwh"] = kwh.replacingOccurrences(of: ".", with: decimalSeparator)
+ }
+ if let target = args["target"], target.wholeMatch(of: /[0-9]{1,3}/) != nil, let pct = Int(target), pct <= 100 {
+ out["target"] = String(pct)
+ }
+ if let by = args["by"], let m = by.wholeMatch(of: /([0-9]{2}):([0-9]{2})/), let h = Int(m.1), let min = Int(m.2), h < 24, min < 60 {
+ out["by"] = by
+ }
+ for key in ["version", "phase"] {
+ if let v = args[key].flatMap(plain) { out[key] = v }
+ }
+ if let name = name.flatMap(plain) { out["name"] = name }
+ return out
+ }
+
+ /// A short line of text, or nil.
+ static func plain(_ s: String) -> String? {
+ let t = s.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard (1...40).contains(t.count), t.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) && !CharacterSet.newlines.contains($0) }) else { return nil }
+ return t
+ }
+
+ /// The template with every `{name}` filled, or nil if one has no value.
+ static func fill(_ template: String, _ values: [String: String]) -> String? {
+ var out = ""
+ var rest = template[...]
+ while let open = rest.firstIndex(of: "{") {
+ guard let close = rest[open...].firstIndex(of: "}"), let value = values[String(rest[rest.index(after: open)..= 7)
+ #expect(AppWords.sv[Vault.lockedHelp] != nil)
+ AppWords.$language.withValue(.sv) {
+ #expect(AppWords.text(Vault.lockedHelp).hasPrefix("Enheten kan inte längre"))
+ }
+ }
+
@Test func noKeyIsSaidPlainlyAndNothingRetries() async throws {
let world = World()
let app = world.app()
diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift
new file mode 100644
index 0000000..b819b8d
--- /dev/null
+++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift
@@ -0,0 +1,364 @@
+import Foundation
+import Testing
+@testable import FTWKit
+
+/// FTW Cloud's app endpoint, as `ftwcloudd` answers it, behind fake sockets:
+/// Noise IK as responder, a phone admitted only by a key in its roster, the
+/// snapshot in message 2, then pushes.
+@MainActor
+final class FakeCloudService {
+ let scheduler: ManualScheduler
+ let staticKey = Primitives.generateKeyPair()
+ var roster: Set = []
+ var held: [CloudWire.Envelope] = []
+ var online = true
+ var latencyMs: Double = 10
+ /// Refuse every handshake made with this key, as a service that has
+ /// moved to another key would.
+ var refuseKey: Bytes?
+ private(set) var dialled: [URL] = []
+ private(set) var nows: [Int] = []
+ private(set) var received: [CloudWire.Envelope] = []
+ private var sessions: [FakeCloudSocket] = []
+
+ init(scheduler: ManualScheduler) {
+ self.scheduler = scheduler
+ }
+
+ var endpoint: CloudEndpoint {
+ CloudEndpoint(url: URL(string: "wss://cloud.test/v1/app")!, serviceKeys: [staticKey.publicKey])
+ }
+
+ var factory: WebSocketFactory {
+ { [unowned self] url, events in
+ self.dialled.append(url)
+ let socket = FakeCloudSocket(service: self, url: url, events: events)
+ self.sessions.append(socket)
+ _ = self.scheduler.after(self.latencyMs) {
+ guard !socket.closed else { return }
+ events.onOpen()
+ }
+ return socket
+ }
+ }
+
+ var current: FakeCloudSocket? { sessions.last(where: { !$0.closed }) }
+
+ func push(_ env: CloudWire.Envelope) {
+ held.append(env)
+ current?.push(env)
+ }
+
+ func close(code: Int) {
+ guard let s = current else { return }
+ s.closed = true
+ s.events.onClose(code, "")
+ }
+
+ fileprivate func receive(_ bytes: Bytes, on socket: FakeCloudSocket) {
+ if let transport = socket.transport {
+ guard let frame = try? transport.decrypt(bytes), let payload = try? CloudWire.decodeFrame(frame),
+ let env = try? CloudWire.Envelope.decode(payload) else {
+ socket.closeFromService(CloudWire.closeProtocol)
+ return
+ }
+ received.append(env)
+ return
+ }
+ let home = URLComponents(url: socket.url, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "home" }?.value ?? ""
+ let key = refuseKey == staticKey.publicKey ? Primitives.generateKeyPair() : staticKey
+ let hs = HandshakeState.responder(staticKey: key, prologue: CloudWire.appPrologue(home: home))
+ guard let payload = try? hs.readMessage(bytes),
+ let now = try? CloudWire.Envelope.decode(payload), now.t == CloudWire.msgNow else {
+ socket.closeFromService(CloudWire.closeDenied)
+ return
+ }
+ nows.append((try? decodeCBOR(now.b))?["hz"]?.int ?? -1)
+ guard let msg2 = try? hs.writeMessage(snapshotPayload()), let keys = try? hs.split(),
+ roster.contains(keys.remoteStatic) else {
+ socket.closeFromService(CloudWire.closeDenied)
+ return
+ }
+ socket.transport = NoiseTransport(keys)
+ socket.deliver(msg2)
+ }
+
+ func snapshotPayload(_ envelopes: [CloudWire.Envelope]? = nil) -> Bytes {
+ let envs = (envelopes ?? held).map { e -> CBOR in .map([("b", .bytes(e.b)), ("t", .text(e.t)), ("sig", .bytes(e.sig))]) }
+ return CloudWire.encodeUnsigned(t: CloudWire.msgSnapshot, body: .map([
+ ("envelopes", .array(envs)), ("lastSeenMs", .int(Int64(scheduler.nowMs))), ("online", .bool(online)), ("more", .int(0)),
+ ]))
+ }
+
+ var watches: [Int] {
+ received.filter { $0.t == CloudWire.msgWatch }.compactMap { (try? decodeCBOR($0.b))?["hz"]?.int }
+ }
+
+ /// The APNs tokens the phone registered, in order.
+ var registrations: [Bytes] {
+ received.filter { $0.t == CloudWire.msgPushRegister }.compactMap { (try? decodeCBOR($0.b))?["apnsToken"]?.byteString }
+ }
+}
+
+@MainActor
+final class FakeCloudSocket: WebSocketConnection {
+ unowned let service: FakeCloudService
+ let url: URL
+ let events: WebSocketEvents
+ var closed = false
+ var transport: NoiseTransport?
+
+ init(service: FakeCloudService, url: URL, events: WebSocketEvents) {
+ self.service = service
+ self.url = url
+ self.events = events
+ }
+
+ func send(_ data: Bytes) {
+ guard !closed else { return }
+ _ = service.scheduler.after(service.latencyMs) { [weak self] in
+ guard let self, !self.closed else { return }
+ self.service.receive(data, on: self)
+ }
+ }
+
+ func deliver(_ bytes: Bytes) {
+ _ = service.scheduler.after(service.latencyMs) { [weak self] in
+ guard let self, !self.closed else { return }
+ self.events.onBinary(bytes)
+ }
+ }
+
+ func push(_ env: CloudWire.Envelope) {
+ guard let transport, let frame = try? CloudWire.encodeFrame(env.encode()), let msg = try? transport.encrypt(frame) else { return }
+ deliver(msg)
+ }
+
+ func closeFromService(_ code: Int) {
+ _ = service.scheduler.after(service.latencyMs) { [weak self] in
+ guard let self, !self.closed else { return }
+ self.closed = true
+ self.events.onClose(code, "")
+ }
+ }
+
+ func close() {
+ closed = true
+ }
+}
+
+/// A phone's carrier and the events it delivered.
+@MainActor
+final class CloudRig {
+ let scheduler = ManualScheduler()
+ let service: FakeCloudService
+ let device = Primitives.generateKeyPair()
+ var box: FakeCloudBox
+ let carrier: CloudCarrier
+ var snapshots: [CloudWire.Snapshot] = []
+ var envelopes: [CloudWire.Envelope] = []
+ var statuses: [CloudCarrier.Status] = []
+
+ init(looking: Bool = true, endpoint: ((FakeCloudService) -> CloudEndpoint)? = nil) throws {
+ service = FakeCloudService(scheduler: scheduler)
+ box = try FakeCloudBox(boot: 1)
+ service.roster = [device.publicKey]
+ carrier = CloudCarrier(endpoint: endpoint?(service) ?? service.endpoint, home: box.home, deviceKey: device, looking: looking, scheduler: scheduler, makeSocket: service.factory)
+ carrier.setHandlers(
+ onSnapshot: { [unowned self] s, _ in self.snapshots.append(s) },
+ onEnvelope: { [unowned self] e, _ in self.envelopes.append(e) },
+ onStatus: { [unowned self] s in self.statuses.append(s) }
+ )
+ }
+
+ var at: Int64 { Int64(scheduler.nowMs) }
+
+ func run(_ ms: Double) async {
+ var left = ms
+ while left > 0 {
+ for _ in 0..<5 { await Task.yield() }
+ scheduler.advance(min(10, left))
+ left -= 10
+ }
+ }
+}
+
+@MainActor
+@Suite struct CloudCarrierTests {
+ /// One round trip after the socket opens, the service's copy is here;
+ /// each later change follows on the same socket.
+ @Test func theSnapshotComesInMessage2AndChangesFollow() async throws {
+ let rig = try CloudRig()
+ rig.box.start = rig.at - 60_000
+ rig.service.held = [try rig.box.fullLive(rig.at, age: 500)]
+ rig.carrier.start()
+ await rig.run(50)
+ #expect(rig.service.dialled.first?.absoluteString == "wss://cloud.test/v1/app?home=\(rig.box.home)")
+ #expect(rig.service.nows == [CloudWire.hzLooking])
+ #expect(rig.carrier.status.isOpen)
+ #expect(rig.snapshots.count == 1)
+ #expect(rig.snapshots.first?.envelopes == rig.service.held)
+
+ let hb = try rig.box.hb(rig.at, age: 900, liveSeq: 1)
+ rig.service.push(hb)
+ await rig.run(30)
+ #expect(rig.envelopes == [hb])
+ }
+
+ @Test func lookingAwayAndBackIsSaid() async throws {
+ let rig = try CloudRig(looking: false)
+ rig.carrier.start()
+ await rig.run(50)
+ #expect(rig.service.nows == [CloudWire.hzAway])
+ rig.carrier.setLooking(true)
+ rig.carrier.setLooking(true)
+ rig.carrier.setLooking(false)
+ await rig.run(30)
+ #expect(rig.service.watches == [1, 0])
+ }
+
+ /// A refusal is asked again slowly, as after a service restart that has
+ /// not heard from the box yet, and at once on the next foreground.
+ @Test func aRefusalIsAskedAgainSlowly() async throws {
+ let rig = try CloudRig()
+ rig.service.roster = []
+ rig.carrier.start()
+ await rig.run(100)
+ #expect(rig.carrier.status == .closed(code: CloudWire.closeDenied, retrying: true))
+ await rig.run(10_000)
+ #expect(rig.service.dialled.count == 1)
+ await rig.run(10_000)
+ #expect(rig.service.dialled.count == 2)
+ rig.service.roster = [rig.device.publicKey]
+ rig.carrier.wake()
+ await rig.run(100)
+ #expect(rig.carrier.status.isOpen)
+ }
+
+ /// Another session of this phone took over: this one stays closed.
+ @Test func aReplacedSessionStaysClosed() async throws {
+ let rig = try CloudRig()
+ rig.carrier.start()
+ await rig.run(50)
+ rig.service.close(code: CloudWire.closeReplaced)
+ await rig.run(120_000)
+ #expect(rig.carrier.status == .closed(code: CloudWire.closeReplaced, retrying: false))
+ #expect(rig.service.dialled.count == 1)
+ }
+
+ /// The roster changed what this phone may read: it asks again at once.
+ @Test func aChangedRosterReconnectsAtOnce() async throws {
+ let rig = try CloudRig()
+ rig.carrier.start()
+ await rig.run(50)
+ rig.service.close(code: CloudWire.closeChanged)
+ await rig.run(60)
+ #expect(rig.service.dialled.count == 2)
+ #expect(rig.carrier.status.isOpen)
+ }
+
+ /// Anything else heals by itself, with full jitter.
+ @Test func aLostSocketRedialsWithBackoff() async throws {
+ let rig = try CloudRig()
+ rig.carrier.start()
+ await rig.run(50)
+ rig.service.close(code: 1006)
+ #expect(rig.carrier.status == .closed(code: 1006, retrying: true))
+ await rig.run(600)
+ #expect(rig.carrier.status.isOpen)
+ #expect(rig.service.dialled.count == 2)
+ }
+
+ /// The app carries two service keys so the service can rotate: a refusal
+ /// right after message 1 tries the other key once.
+ @Test func aRotatedServiceKeyIsTriedNext() async throws {
+ let rig = try CloudRig(endpoint: { service in
+ CloudEndpoint(url: URL(string: "wss://cloud.test/v1/app")!, serviceKeys: [Primitives.generateKeyPair().publicKey, service.staticKey.publicKey])
+ })
+ rig.carrier.start()
+ await rig.run(100)
+ #expect(rig.carrier.status.isOpen)
+ #expect(rig.service.dialled.count == 2)
+ }
+
+ @Test func aMessageThatDoesNotDecryptEndsTheSession() async throws {
+ let rig = try CloudRig()
+ rig.carrier.start()
+ await rig.run(50)
+ rig.service.current?.deliver(randomBytes(40))
+ await rig.run(20)
+ #expect(!rig.carrier.status.isOpen)
+ #expect(rig.envelopes.isEmpty)
+ await rig.run(600)
+ #expect(rig.carrier.status.isOpen)
+ }
+
+ @Test func closedMeansClosed() async throws {
+ let rig = try CloudRig()
+ rig.carrier.start()
+ await rig.run(50)
+ rig.carrier.close()
+ rig.carrier.wake()
+ await rig.run(120_000)
+ #expect(rig.service.dialled.count == 1)
+ }
+}
+
+@Suite struct CloudEndpointTests {
+ /// Release builds read the fixed address and ignore any override.
+ @Test func onlyADebugBuildMayPointElsewhere() throws {
+ let defaults = try #require(UserDefaults(suiteName: "ftw-cloud-\(UUID().uuidString)"))
+ let key = Base64url.encode(Primitives.generateKeyPair().publicKey)
+ defaults.set("wss://127.0.0.1:18443/v1/app", forKey: "FTWCloudURL")
+ defaults.set(key, forKey: "FTWCloudKey")
+ let debug = try #require(CloudEndpoint.resolve(defaults: defaults, debug: true))
+ #expect(debug.url.absoluteString == "wss://127.0.0.1:18443/v1/app")
+ #expect(CloudEndpoint.resolve(defaults: defaults, debug: false)?.url ?? CloudEndpoint.releaseURL == CloudEndpoint.releaseURL)
+ #expect(CloudEndpoint.releaseURL.absoluteString == "wss://cloud.ftw.energy/v1/app")
+ defaults.set("not a key", forKey: "FTWCloudKey")
+ #expect(CloudEndpoint.resolve(defaults: defaults, debug: true)?.url != debug.url)
+ }
+
+ /// A release build dials cloud.ftw.energy with its one Noise key, as a
+ /// debug build does when nothing points it elsewhere.
+ @Test func aReleaseBuildCarriesTheServicesKey() throws {
+ #expect(CloudEndpoint.releaseKeys.count == 1)
+ #expect(CloudEndpoint.releaseKeys.map(Base64url.encode) == CloudEndpoint.releaseKeyText)
+ #expect(CloudEndpoint.releaseKeys.allSatisfy { $0.count == Noise.dhBytes })
+ let defaults = try #require(UserDefaults(suiteName: "ftw-cloud-\(UUID().uuidString)"))
+ for debug in [false, true] {
+ let e = try #require(CloudEndpoint.resolve(defaults: defaults, debug: debug))
+ #expect(e.url == CloudEndpoint.releaseURL)
+ #expect(e.serviceKeys == CloudEndpoint.releaseKeys)
+ }
+ }
+
+ /// A service that answers with any other key refuses message 1, so the
+ /// phone never opens a session and holds no snapshot.
+ @MainActor
+ @Test func aServiceWithAnotherKeyIsNotTheCloud() async throws {
+ let rig = try CloudRig { svc in CloudEndpoint(url: svc.endpoint.url, serviceKeys: CloudEndpoint.releaseKeys) }
+ rig.box.start = rig.at - 60_000
+ rig.service.held = [try rig.box.fullLive(rig.at, age: 500)]
+ rig.carrier.start()
+ await rig.run(100)
+ #expect(!rig.carrier.status.isOpen)
+ #expect(rig.snapshots.isEmpty)
+ #expect(rig.statuses.contains(.closed(code: CloudWire.closeDenied, retrying: true)))
+ }
+
+ /// The identity comes from the box. A home that is not the hash of the
+ /// key is refused.
+ @Test func theHomeIsTheHashOfTheKey() throws {
+ let v = try CloudVectors.load()
+ let pk = Bytes(hex: v.boxKey.pk)
+ let boot = Bytes(repeating: 0xb0, count: CloudWire.bootBytes)
+ let good = CloudIdentity(.map([("pk", .bytes(pk)), ("home", .text(v.home)), ("boot", .bytes(boot)),
+ ("sources", .map(["ACfHt4DWL7g": .text("p1meter")])), ("loadpoints", .map([:]))]))
+ #expect(good?.home == v.home)
+ #expect(good?.sources["ACfHt4DWL7g"] == "p1meter")
+ #expect(CloudIdentity(.map([("pk", .bytes(pk)), ("home", .text("aaaaaaaaaaaaaaaaaaaaaaaaaa")), ("boot", .bytes(boot))])) == nil)
+ #expect(CloudIdentity(.map([("pk", .bytes(Bytes(pk.prefix(33)))), ("boot", .bytes(boot))])) == nil)
+ #expect(CloudIdentity(nil) == nil)
+ }
+}
diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift
new file mode 100644
index 0000000..e7c830d
--- /dev/null
+++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift
@@ -0,0 +1,171 @@
+import Foundation
+import Testing
+@testable import FTWKit
+
+/// The first-start consent and the owner's grant at the box.
+@MainActor
+@Suite struct CloudConsentTests {
+ func defaults() throws -> UserDefaults {
+ try #require(UserDefaults(suiteName: "ftw-consent-\(UUID().uuidString)"))
+ }
+
+ /// A home whose box answers hello and the cloud routes as Core does.
+ func home(role: String = Contract.roleOwner, caps: [String] = [Contract.capCloudLink, Contract.capAPIPassthrough], cloud: Bool = false) async throws -> CloudSite {
+ let w = try CloudSite()
+ w.open()
+ w.site.connect(w.relay)
+ w.relay.api = { method, path, _ in
+ switch (method, path) {
+ case ("POST", "/api/cloud/grant"): return (200, #"{"state":"on","purposes":["serve"],"connected":false}"#)
+ case ("POST", "/api/cloud/stats"): return (200, #"{"state":"on","purposes":["serve","stats"]}"#)
+ case ("DELETE", "/api/cloud/grant"): return (200, #"{"state":"revoking","purposes":["serve"]}"#)
+ case ("GET", "/api/cloud"): return (200, #"{"state":"off","purposes":[],"purgedAtMs":1791821000000}"#)
+ default: return (404, #"{"code":"E_NOT_FOUND"}"#)
+ }
+ }
+ w.relay.open(w.scheduler.nowMs)
+ w.relay.helloOk(uptimeMs: w.uptime, cloud: cloud ? w.identity : nil, role: role, caps: caps)
+ // The screens see a burst one display frame later.
+ await w.run(20)
+ return w
+ }
+
+ @Test func askedOnceOnARealHome() throws {
+ let d = try defaults()
+ let consent = CloudConsent(defaults: d)
+ #expect(!consent.shouldAsk(nil))
+ let demo = SiteModel(siteId: nil, build: "t", ua: "t", scheduler: ManualScheduler(), files: nil, isDemo: true)
+ #expect(!consent.shouldAsk(demo))
+ let w = try CloudSite()
+ w.open()
+ #expect(consent.shouldAsk(w.site))
+ #expect(!consent.contributeStats)
+ }
+
+ @Test func anOwnerTurnsItOnAtTheBoxWithEveryCloudScope() async throws {
+ let w = try await home()
+ let d = try defaults()
+ let consent = CloudConsent(defaults: d)
+ #expect(CloudConsent.audience(w.site) == .owner)
+ await consent.accept(w.site)
+ #expect(consent.answered)
+ #expect(!consent.shouldAsk(w.site))
+ #expect(CloudConsent(defaults: d).answered)
+ let grant = try #require(w.relay.calls.first)
+ #expect(grant.method == "POST" && grant.path == "/api/cloud/grant")
+ let body = try JSON(parsing: Bytes(grant.body.utf8))
+ #expect(body["scopes"]?.array?.compactMap(\.string) == Contract.cloudScopes)
+ #expect(body["policy"]?.string == CloudConsent.policy)
+ // No statistics unless ticked.
+ #expect(w.relay.calls.count == 1)
+ #expect(consent.box?.on == true)
+ }
+
+ @Test func statisticsGoOnlyWhenTicked() async throws {
+ let w = try await home()
+ let consent = CloudConsent(defaults: try defaults())
+ consent.contributeStats = true
+ await consent.accept(w.site)
+ #expect(w.relay.calls.map(\.path) == ["/api/cloud/grant", "/api/cloud/stats"])
+ #expect(consent.box?.stats == true)
+ }
+
+ /// The grant is asked for once per home. After that the box's word
+ /// stands: turned off on the box or in About, it stays off.
+ @Test func theGrantIsAskedForOncePerHome() async throws {
+ let w = try await home()
+ let d = try defaults()
+ let consent = CloudConsent(defaults: d)
+ await consent.accept(w.site)
+ await consent.ensure(w.site)
+ await CloudConsent(defaults: d).ensure(w.site)
+ #expect(w.relay.calls.count == 1)
+ }
+
+ @Test func aViewerIsToldToAskTheOwner() async throws {
+ let w = try await home(role: Contract.roleViewer)
+ let consent = CloudConsent(defaults: try defaults())
+ #expect(CloudConsent.audience(w.site) == .viewer)
+ await consent.accept(w.site)
+ await consent.refresh(w.site)
+ #expect(w.relay.calls.isEmpty)
+ #expect(consent.answered)
+ }
+
+ /// A box that cannot run the uplink yet gets the grant once it can.
+ @Test func aBoxWithoutTheUplinkIsAskedLater() async throws {
+ let w = try await home(caps: [Contract.capAPIPassthrough])
+ let d = try defaults()
+ let consent = CloudConsent(defaults: d)
+ #expect(CloudConsent.audience(w.site) == .ownerLater)
+ await consent.accept(w.site)
+ #expect(w.relay.calls.isEmpty)
+ w.relay.helloOk(uptimeMs: w.uptime, cloud: nil, caps: [Contract.capCloudLink, Contract.capAPIPassthrough])
+ await w.run(20)
+ await consent.ensure(w.site)
+ #expect(w.relay.calls.map(\.path) == ["/api/cloud/grant"])
+ }
+
+ @Test func alreadyOnIsLeftAlone() async throws {
+ let w = try await home(cloud: true)
+ let consent = CloudConsent(defaults: try defaults())
+ await consent.accept(w.site)
+ #expect(w.relay.calls.isEmpty)
+ }
+
+ /// Found in the simulator: consent given while FTW Cloud was already
+ /// on, then turned off in About. The next start must not turn it on
+ /// again.
+ @Test func offInAboutStaysOffAfterARestart() async throws {
+ let d = try defaults()
+ let w = try await home(cloud: true)
+ let consent = CloudConsent(defaults: d)
+ await consent.accept(w.site)
+ await consent.turnOff(w.site)
+ // The next start: the box says FTW Cloud is off now.
+ let later = try await home(cloud: false)
+ await CloudConsent(defaults: d).ensure(later.site)
+ #expect(later.relay.calls.isEmpty)
+ }
+
+ /// Turned off on the box itself, not in the app: the app still leaves
+ /// it off, because it already asked once.
+ @Test func offOnTheBoxStaysOff() async throws {
+ let d = try defaults()
+ let w = try await home(cloud: true)
+ await CloudConsent(defaults: d).accept(w.site)
+ let later = try await home(cloud: false)
+ await CloudConsent(defaults: d).ensure(later.site)
+ #expect(later.relay.calls.isEmpty)
+ }
+
+ /// About: off asks the box to erase the copy; the receipt is the time on
+ /// the service's confirmation.
+ @Test func offAndTheReceipt() async throws {
+ let w = try await home(cloud: true)
+ let consent = CloudConsent(defaults: try defaults())
+ #expect(await consent.turnOff(w.site))
+ #expect(consent.box?.erasing == true)
+ await consent.refresh(w.site)
+ #expect(consent.box?.on == false)
+ #expect(consent.box?.erasedAtMs == 1_791_821_000_000)
+ }
+
+ @Test func theScopesAreTheRegistrys() throws {
+ let yaml = try RepoFiles.read("protocol/registry.yaml")
+ #expect(yaml.contains("cloud_scopes: [\(Contract.cloudScopes.joined(separator: ", "))]"))
+ #expect(yaml.contains("cloud_purposes: [\(Contract.cloudPurposes.joined(separator: ", "))]"))
+ #expect(Contract.cloudPurposes.contains(Contract.cloudPurposeStats))
+ }
+
+ /// Every sentence of the screen has Swedish.
+ @Test func theWordsAreInBothLanguages() {
+ let keys = ["The FTW app uses FTW Cloud", "Contribute to statistics", "Ask the owner of this home to turn on FTW Cloud.", "Continue",
+ "Copy erased %@", "FTW Cloud is on for this home."]
+ for k in keys { #expect(CloudWords.sv[k] != nil, "\(k)") }
+ AppWords.$language.withValue(.sv) {
+ #expect(CloudWords.text("Continue") == "Fortsätt")
+ #expect(CloudWords.text("Contribute to statistics") == "Bidra till statistik")
+ }
+ }
+}
diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudPushTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudPushTests.swift
new file mode 100644
index 0000000..973a7f0
--- /dev/null
+++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudPushTests.swift
@@ -0,0 +1,114 @@
+import Foundation
+import Synchronization
+import Testing
+@testable import FTWKit
+import FTWShared
+
+/// `PushTokens` for one site model, without the process-wide state another
+/// suite may be using.
+final class FakeTokens: Sendable {
+ let sink = Mutex<(any PushTokenSink)?>(nil)
+
+ var source: PushTokenSource {
+ PushTokenSource(install: { s in self.sink.withLock { $0 = s } }, uninstall: { self.sink.withLock { $0 = nil } })
+ }
+
+ var installed: (any PushTokenSink)? { sink.withLock { $0 } }
+}
+
+/// FTW Cloud and the phone's pushes: the token goes to the service over the
+/// app session, and the notification extension gets what the box said over
+/// Noise.
+@MainActor
+@Suite struct CloudPushTests {
+ /// push.register in the service's field names, only over an open
+ /// session.
+ @Test func theCarrierRegistersOverItsOpenSession() async throws {
+ let rig = try CloudRig()
+ let token = Data(repeating: 0xa2, count: 32)
+ #expect(!rig.carrier.pushRegister(apnsToken: token, environment: .sandbox))
+ rig.carrier.start()
+ await rig.run(50)
+ #expect(rig.carrier.pushRegister(apnsToken: token, environment: .production))
+ await rig.run(30)
+ let sent = try #require(rig.service.received.first { $0.t == CloudWire.msgPushRegister })
+ let body = try decodeCBOR(sent.b)
+ #expect(body["apnsToken"]?.byteString == Bytes(token))
+ #expect(body["environment"]?.string == "production")
+ #expect(body["kind"]?.string == "alert")
+ }
+
+ /// While FTW Cloud is on, its session takes the token: at the opening,
+ /// at every reopening, and at once when Apple gives a new one. Turning
+ /// FTW Cloud off lets go of it.
+ @Test func theTokenGoesOutAtEveryOpening() async throws {
+ let w = try CloudSite()
+ w.keepFromEarlierLaunch(gridW: nil)
+ w.service.held = [try w.live(gridW: 1000)]
+ w.open()
+ w.connectCloud()
+ await w.run(0)
+ let sink = try #require(w.tokens.installed)
+ await sink.register(apnsToken: Data([1, 2, 3]), environment: .sandbox)
+ #expect(w.service.registrations.isEmpty)
+ await w.run(60)
+ #expect(w.service.registrations == [[1, 2, 3]])
+
+ w.service.close(code: 1006)
+ await w.run(600)
+ #expect(w.site.cloudStatus?.isOpen == true)
+ #expect(w.service.registrations == [[1, 2, 3], [1, 2, 3]])
+
+ await sink.register(apnsToken: Data([4, 5, 6]), environment: .sandbox)
+ await w.run(30)
+ #expect(w.service.registrations.last == [4, 5, 6])
+
+ w.site.connect(w.relay)
+ w.relay.open(w.scheduler.nowMs)
+ w.relay.helloOk(uptimeMs: w.uptime, cloud: nil)
+ await w.run(30)
+ #expect(w.tokens.installed == nil)
+ }
+
+ /// hello gives the notification extension the box's key and names, the
+ /// next write adds rule U's anchor, and turning FTW Cloud off takes the
+ /// home away.
+ @Test func helloSharesTheHomeWithTheExtension() async throws {
+ let w = try CloudSite(noClock: true)
+ w.open()
+ w.connectCloud()
+ w.site.connect(w.relay)
+ w.relay.open(w.scheduler.nowMs)
+ w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity)
+ await w.run(60)
+ let keys = CloudKeys(directory: w.shared.directory)
+ let row = try #require(keys.read(home: w.identity.home))
+ #expect(row.siteId == "s1")
+ #expect(row.boxKey == Data(w.identity.pk))
+ #expect(row.sources == ["m": "p1meter"])
+
+ w.site.persistNow()
+ let offset = try #require(keys.offset(home: w.identity.home))
+ #expect(offset.boot == w.identity.boot)
+ #expect(offset.offsetMs == w.box.start)
+
+ w.relay.helloOk(uptimeMs: w.uptime, cloud: nil)
+ await w.run(30)
+ #expect(keys.read(home: w.identity.home) == nil)
+ }
+
+ /// The demo has no FTW Cloud and leaves nothing for the extension.
+ @Test func theDemoSharesNothing() async throws {
+ let w = try CloudSite()
+ w.site = SiteModel(siteId: "s1", build: "test", ua: "test", scheduler: w.scheduler, files: w.files, shared: w.shared, isDemo: true)
+ w.site.pushTokens = w.tokens.source
+ w.site.start()
+ w.connectCloud()
+ w.site.connect(w.relay)
+ w.relay.open(w.scheduler.nowMs)
+ w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity)
+ await w.run(60)
+ #expect(CloudKeys(directory: w.shared.directory).read(home: w.identity.home) == nil)
+ #expect(w.tokens.installed == nil)
+ }
+}
diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudViewTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudViewTests.swift
new file mode 100644
index 0000000..94e2459
--- /dev/null
+++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudViewTests.swift
@@ -0,0 +1,444 @@
+import CryptoKit
+import Foundation
+import Testing
+@testable import FTWKit
+
+// The freshness cases from srcfl/ftw docs/protocol-cloud.md, as
+// go/protocol/cloudwire/view_test.go runs them, against CloudView. Times are
+// ms on one clock unless a case moves the box's. Rule N with 100 ms in
+// transit unless stated. The box signs with the golden vectors' box key.
+
+private let transit: Int64 = 100
+private let hour: Int64 = 3_600_000
+
+/// A box that signs what it sends. `start` is the phone time at which its
+/// uptime was zero; `clockLead` moves its wall clock against the phone's.
+struct FakeCloudBox {
+ let key: P256.Signing.PrivateKey
+ let home: String
+ let pk: Bytes
+ var boot: Bytes
+ var seq: UInt64 = 0
+ var start: Int64 = 1_000_000
+ var clockLead: Int64 = 0
+ var noClock = false
+
+ init(boot byte: UInt8) throws {
+ let v = try CloudVectors.load()
+ key = try v.boxSigner()
+ home = v.home
+ pk = Bytes(hex: v.boxKey.pk)
+ boot = Bytes(repeating: byte, count: CloudWire.bootBytes)
+ }
+
+ mutating func header(_ at: Int64) -> [(String, CBOR)] {
+ seq += 1
+ var h: [(String, CBOR)] = [("seq", .unsigned(seq)), ("boot", .bytes(boot)), ("uptimeMs", .int(at - start))]
+ if !noClock { h.append(("wallMs", .int(at + clockLead))) }
+ return h
+ }
+
+ func seal(_ t: String, _ body: [(String, CBOR)]) throws -> CloudWire.Envelope {
+ let b = encodeCBOR(CloudWire.deterministic(.map(body)))
+ return CloudWire.Envelope(t: t, b: b, sig: try cloudSign(key, home: home, t: t, b: b))
+ }
+
+ /// A full live with one source "m", S = 10 s, read `age` ms before `at`.
+ mutating func fullLive(_ at: Int64, age: Int64, state: String = "live", srcRev: UInt64 = 1) throws -> CloudWire.Envelope {
+ let h = header(at)
+ let m = CBOR.map([("kind", .text("driver")), ("state", .text(state)), ("lastOkMs", .int(at - start - age)), ("staleAfterMs", .int(10_000))])
+ return try seal(CloudWire.msgLive, h + [
+ ("fields", .map(["2": .int(1000), "1": .int(0)])),
+ ("fieldSources", .map(["2": .array([.text("m")])])),
+ ("sources", .map(["m": m])),
+ ("srcRev", .unsigned(srcRev)),
+ ("ages", .map(["m": .int(age)])),
+ ("dispatchBlockedBy", .array([])),
+ ])
+ }
+
+ mutating func hb(_ at: Int64, age: Int64, srcRev: UInt64 = 1, liveSeq: UInt64, lpSeq: UInt64 = 0) throws -> CloudWire.Envelope {
+ let h = header(at)
+ return try seal(CloudWire.msgHB, h + [
+ ("clock", .text("ntp")), ("srcRev", .unsigned(srcRev)), ("liveSeq", .unsigned(liveSeq)),
+ ("lpSeq", .unsigned(lpSeq)), ("ages", .map(["m": .int(age)])),
+ ])
+ }
+
+ /// A message the service drops: it only uses up a seq.
+ mutating func dropped(_ at: Int64) { _ = header(at) }
+
+ func view() -> CloudView { CloudView(home: home, boxKey: pk) }
+}
+
+private func source(_ v: CloudView, _ at: Int64) -> SourceState? { v.grade(nowMs: at).sources["m"] }
+
+/// The header of an hb the box signs at `at`, as the phone decodes it.
+private func header(_ box: inout FakeCloudBox, at: Int64) throws -> CloudWire.Header {
+ let h = box.header(at)
+ return try CloudWire.Header(body: box.seal(CloudWire.msgHB, h).b)
+}
+
+@Suite struct CloudViewTests {
+ // 1. A quiet live source stays live.
+ @Test func aQuietLiveSourceStaysLive() throws {
+ var box = try FakeCloudBox(boot: 1)
+ var v = box.view()
+ let t0: Int64 = 2_000_000
+ try v.accept(box.fullLive(t0, age: 1_000), arrivalMs: t0 + transit)
+ for at in stride(from: t0 + 5_000, through: t0 + 120_000, by: 5_000) {
+ try v.accept(box.hb(at, age: 4_500, liveSeq: 1), arrivalMs: at + transit)
+ for probe in stride(from: at + transit, to: at + 5_000 + transit, by: 500) {
+ let g = v.grade(nowMs: probe)
+ #expect(g.sources["m"] == .live, "at \(probe)")
+ #expect(g.fields["2"] == .live, "at \(probe)")
+ #expect(g.fields["1"] == .live, "at \(probe)")
+ }
+ }
+ }
+
+ // 2. A dead uplink ages on time.
+ @Test func aDeadUplinkAgesOnTime() throws {
+ var box = try FakeCloudBox(boot: 1)
+ var v = box.view()
+ let t0: Int64 = 2_000_000
+ try v.accept(box.fullLive(t0, age: 1_000), arrivalMs: t0 + transit)
+ let last = t0 + 5_000
+ try v.accept(box.hb(last, age: 2_000, liveSeq: 1), arrivalMs: last + transit)
+ // The send time is the box's wallMs, so t counts from `last`.
+ #expect(source(v, last + 8_000) == .live)
+ #expect(source(v, last + 8_001) == .lagging)
+ #expect(source(v, last + 18_000) == .lagging)
+ #expect(source(v, last + 18_001) == .stale)
+ #expect(source(v, last + 58_000) == .stale)
+ #expect(source(v, last + 58_001) == .down)
+ }
+
+ // 3. A replay ages out, and a phone that holds newer drops it.
+ @Test func aReplayAgesOut() throws {
+ var box = try FakeCloudBox(boot: 1)
+ let t0: Int64 = 2_000_000
+ let live = try box.fullLive(t0, age: 1_000)
+ let hb = try box.hb(t0 + 5_000, age: 2_000, liveSeq: 1)
+
+ var fresh = box.view()
+ try fresh.accept(live, arrivalMs: t0 + hour)
+ try fresh.accept(hb, arrivalMs: t0 + hour + 10)
+ #expect(source(fresh, t0 + hour + 20) == .down)
+ #expect(fresh.grade(nowMs: t0 + hour + 20).fields["2"] == .down)
+
+ var current = box.view()
+ try current.accept(live, arrivalMs: t0 + transit)
+ try current.accept(hb, arrivalMs: t0 + 5_000 + transit)
+ #expect(throws: CloudView.AcceptError.notNewer) { try current.accept(live, arrivalMs: t0 + hour) }
+ #expect(throws: CloudView.AcceptError.notNewer) { try current.accept(hb, arrivalMs: t0 + hour) }
+ }
+
+ // 4. A withheld table goes stale until it arrives.
+ @Test func aWithheldTableGoesStale() throws {
+ var box = try FakeCloudBox(boot: 1)
+ var v = box.view()
+ let t0: Int64 = 2_000_000
+ try v.accept(box.fullLive(t0, age: 1_000), arrivalMs: t0 + transit)
+ _ = try box.fullLive(t0 + 3_000, age: 12_000, state: "lagging", srcRev: 2) // dropped by the service
+ try v.accept(box.hb(t0 + 5_000, age: 1_000, srcRev: 2, liveSeq: 2), arrivalMs: t0 + 5_000 + transit)
+ #expect(source(v, t0 + 5_200) == .stale)
+ // The next full live brings revision 2, and the box's own grade.
+ try v.accept(box.fullLive(t0 + 6_000, age: 15_000, state: "lagging", srcRev: 2), arrivalMs: t0 + 6_000 + transit)
+ #expect(source(v, t0 + 6_200) == .lagging)
+ #expect(v.srcRev == 2)
+ }
+
+ // 5. No anchor, never live.
+ @Test func noAnchorIsNeverLive() throws {
+ var box = try FakeCloudBox(boot: 1)
+ box.noClock = true
+ var v = box.view()
+ let t0: Int64 = 2_000_000
+ try v.accept(box.fullLive(t0, age: 1_000), arrivalMs: t0 + transit)
+ try v.accept(box.hb(t0 + 5_000, age: 500, liveSeq: 1), arrivalMs: t0 + 5_000 + transit)
+ let g = v.grade(nowMs: t0 + 5_200)
+ #expect(!g.anchored)
+ #expect(g.ageMs == nil)
+ #expect(v.anchorSentMs(nowMs: t0 + 5_200) == nil)
+ #expect(g.sources["m"] == .stale)
+ #expect(g.fields == ["1": .stale, "2": .stale])
+ }
+
+ // 6. A fast box clock loses rule N, for the rest of the boot.
+ @Test func aFastBoxClockLosesRuleN() throws {
+ var box = try FakeCloudBox(boot: 1)
+ box.clockLead = 3_000
+ var v = box.view()
+ let t0: Int64 = 2_000_000
+ try v.accept(box.fullLive(t0, age: 1_000), arrivalMs: t0 + transit)
+ let g = v.grade(nowMs: t0 + 200)
+ #expect(!g.anchored)
+ #expect(g.sources["m"] != .live)
+ box.clockLead = 0
+ try v.accept(box.hb(t0 + 5_000, age: 500, liveSeq: 1), arrivalMs: t0 + 5_000 + transit)
+ #expect(!v.grade(nowMs: t0 + 5_200).anchored, "rule N came back within the boot")
+ }
+
+ // 7. A small clock lead gains nothing.
+ @Test func aSmallClockLeadGainsNothing() throws {
+ var box = try FakeCloudBox(boot: 1)
+ box.clockLead = 1_500
+ var v = box.view()
+ let t0: Int64 = 2_000_000
+ try v.accept(box.fullLive(t0, age: 2_000), arrivalMs: t0 + transit)
+ // The send time is the arrival, t0+100: age 2000 + t reaches S at
+ // t0+8100, not 1.5 s later.
+ #expect(v.anchorSentMs(nowMs: t0 + 200) == t0 + transit)
+ #expect(source(v, t0 + 8_100) == .live)
+ #expect(source(v, t0 + 8_101) == .lagging)
+ }
+
+ // 8. A changed byte is dropped.
+ @Test func aChangedByteIsDropped() throws {
+ var box = try FakeCloudBox(boot: 1)
+ var v = box.view()
+ var env = try box.fullLive(2_000_000, age: 1_000)
+ env.b[env.b.count - 1] ^= 1
+ #expect(throws: CloudView.AcceptError.signature) { try v.accept(env, arrivalMs: 2_000_100) }
+ #expect(v.live == nil && v.boot == nil)
+ }
+
+ // 9 and 10. A dropped live or a held-back lp goes stale.
+ @Test func aDroppedLiveAndAHeldBackLPGoStale() throws {
+ var box = try FakeCloudBox(boot: 1)
+ var v = box.view()
+ let t0: Int64 = 2_000_000
+ try v.accept(box.fullLive(t0, age: 1_000), arrivalMs: t0 + transit)
+ let h = box.header(t0 + 10)
+ let car = CBOR.map([("id", .text("lp1")), ("src", .text("m")), ("plugged", .bool(true)), ("socPermille", .int(430))])
+ try v.accept(box.seal(CloudWire.msgLP, h + [("loadpoints", .array([car]))]), arrivalMs: t0 + 10 + transit)
+ #expect(v.grade(nowMs: t0 + 200).loadpoints["lp1"] == .live)
+ let (heldLive, heldLP) = (box.seq - 1, box.seq)
+ box.dropped(t0 + 3_000) // live
+ box.dropped(t0 + 3_100) // lp
+ try v.accept(box.hb(t0 + 5_000, age: 1_000, liveSeq: heldLive + 2, lpSeq: heldLP + 2), arrivalMs: t0 + 5_000 + transit)
+ let g = v.grade(nowMs: t0 + 5_200)
+ #expect(g.sources["m"] == .live)
+ #expect(g.fields == ["1": .stale, "2": .stale])
+ #expect(g.loadpoints == ["lp1": .stale])
+ }
+
+ // 11. The byte budget runs out: the box stops sending live and hb, and
+ // the data ages as in case 2.
+ @Test func aSpentByteBudgetAgesLikeADeadUplink() throws {
+ var box = try FakeCloudBox(boot: 1)
+ var v = box.view()
+ let t0: Int64 = 2_000_000
+ try v.accept(box.fullLive(t0, age: 1_000), arrivalMs: t0 + transit)
+ let last = t0 + 30_000
+ for at in stride(from: t0 + 5_000, through: last, by: 5_000) {
+ try v.accept(box.hb(at, age: 4_500, liveSeq: 1), arrivalMs: at + transit)
+ }
+ #expect(source(v, last + 5_500) == .live)
+ #expect(source(v, last + 5_501) == .lagging)
+ #expect(source(v, last + 15_501) == .stale)
+ #expect(source(v, last + 55_501) == .down)
+ // The mode has no source and goes stale after 2H = 10 s.
+ #expect(v.grade(nowMs: last + 10_000).fields["1"] == .live)
+ #expect(v.grade(nowMs: last + 10_001).fields["1"] == .stale)
+ }
+
+ // 12. A reboot clears the old table.
+ @Test func aRebootClearsTheOldTable() throws {
+ var x = try FakeCloudBox(boot: 1)
+ var v = x.view()
+ let t0: Int64 = 2_000_000
+ try v.accept(x.fullLive(t0, age: 1_000, srcRev: 9), arrivalMs: t0 + transit)
+
+ var y = try FakeCloudBox(boot: 2)
+ y.start = t0 + 30_000
+ _ = try y.fullLive(t0 + 60_000, age: 1_000, srcRev: 3) // dropped
+ try v.accept(y.hb(t0 + 61_000, age: 1_000, srcRev: 3, liveSeq: 1), arrivalMs: t0 + 61_000 + transit)
+ let g = v.grade(nowMs: t0 + 61_200)
+ #expect(g.anchored)
+ #expect(g.sources.isEmpty && g.fields.isEmpty)
+ #expect(v.boot == y.boot && v.live == nil && v.srcRev == 0)
+ // An older message from boot X can no longer take the view back.
+ #expect(throws: CloudView.AcceptError.notNewer) {
+ try v.accept(x.hb(t0 + 1_000, age: 1_000, srcRev: 9, liveSeq: 1), arrivalMs: t0 + 61_300)
+ }
+ #expect(v.boot == y.boot)
+ }
+
+ // 13. Rule U drift counts against freshness, and the anchor lapses.
+ @Test func ruleUDriftCountsAgainstFreshness() throws {
+ var box = try FakeCloudBox(boot: 1)
+ box.noClock = true
+ var v = box.view()
+ let learned: Int64 = 2_000_000
+ v.learnOffset(boot: box.boot, phoneMs: learned, uptimeMs: learned - box.start)
+
+ let at = learned + 10 * hour
+ try v.accept(box.fullLive(at, age: 0), arrivalMs: at + transit)
+ // The margin is 3.6 s: a reading sent with age 0 reaches S 3.6 s early.
+ #expect(source(v, at + 6_400) == .live)
+ #expect(source(v, at + 6_401) == .lagging)
+ #expect(v.grade(nowMs: at + 6_400).ageMs == 10_000)
+ #expect(!v.grade(nowMs: learned + CloudView.ruleULifetimeMs + 1).anchored, "rule U outlived its 24 hours")
+ }
+
+ // 14. A snapshot with mixed seq.
+ @Test func aSnapshotWithMixedSeq() throws {
+ var box = try FakeCloudBox(boot: 1)
+ var v = box.view()
+ let t0: Int64 = 2_000_000
+ box.seq = 4
+ let h = box.header(t0)
+ let prices = try box.seal(CloudWire.msgPrices, h + [("zone", .text("SE3")), ("spotMinor", .array([.int(42), .null]))])
+ box.seq = 2989
+ try v.accept(box.fullLive(t0 + 10, age: 1_000), arrivalMs: t0 + 20)
+ box.seq = 2999
+ try v.accept(box.fullLive(t0 + 20, age: 1_000), arrivalMs: t0 + 30)
+ try v.accept(prices, arrivalMs: t0 + 40)
+ #expect(v.live?.header.seq == 3000)
+ #expect(v.prices?.header.seq == 5)
+ #expect(v.prices?.spotMinor == [42, nil])
+ }
+
+ // 15. An event replayed two hours late is anchored two hours old, past
+ // the ten minutes an alert may be for its own text and buttons, and a
+ // second copy of it is dropped.
+ @Test func anOldAlertIsTwoHoursOld() throws {
+ var box = try FakeCloudBox(boot: 1)
+ var v = box.view()
+ let sent: Int64 = 2_000_000
+ let h = box.header(sent)
+ let event = try box.seal(CloudWire.msgEvent, h + [("eventId", .text("e1")), ("kind", .text("driver.offline")), ("src", .text("m"))])
+ let arrival = sent + 2 * hour
+ let trusted = try #require(v.sentMs(CloudWire.Header(body: event.b), arrivalMs: arrival))
+ #expect(arrival - trusted == 2 * hour)
+ #expect(arrival - trusted > 10 * 60_000)
+ try v.accept(event, arrivalMs: arrival)
+ #expect(throws: CloudView.AcceptError.notNewer) { try v.accept(event, arrivalMs: arrival + 1) }
+
+ box.noClock = true
+ let unanchored = box.header(sent)
+ #expect(v.sentMs(try CloudWire.Header(body: box.seal(CloudWire.msgEvent, unanchored).b), arrivalMs: arrival) == nil)
+ }
+
+ // 16. The box's state wins.
+ @Test func theBoxStateWins() throws {
+ var box = try FakeCloudBox(boot: 1)
+ var v = box.view()
+ let t0: Int64 = 2_000_000
+ try v.accept(box.fullLive(t0, age: 0, state: "down"), arrivalMs: t0 + transit)
+ #expect(source(v, t0 + 1_000) == .down)
+ }
+
+ @Test func theHeartbeatIsClamped() {
+ func table(_ stale: [Int64]) -> [String: CloudWire.Source] {
+ Dictionary(uniqueKeysWithValues: stale.enumerated().map {
+ ("s\($0.offset)", CloudWire.Source(kind: "driver", state: .live, lastOkMs: 0, staleAfterMs: $0.element))
+ })
+ }
+ #expect(CloudView.heartbeat(for: [:]) == 15_000)
+ #expect(CloudView.heartbeat(for: table([10_000])) == 5_000)
+ #expect(CloudView.heartbeat(for: table([1_000])) == 2_000)
+ #expect(CloudView.heartbeat(for: table([120_000, 0])) == 15_000)
+ }
+
+ @Test func theGoldenSnapshotGradesThroughTheView() throws {
+ let vectors = try CloudVectors.load()
+ let grantKey = Bytes(hex: vectors.grantKey)
+ let meter = CloudWire.sourceID(grantKey: grantKey, driver: "p1meter")
+ let inverter = CloudWire.sourceID(grantKey: grantKey, driver: "sungrow")
+ let charger = CloudWire.sourceID(grantKey: grantKey, driver: "easee")
+ let garage = CloudWire.loadpointID(grantKey: grantKey, loadpoint: "garage")
+ let wall: Int64 = 1_791_820_800_000
+
+ var v = CloudView(home: vectors.home, boxKey: Bytes(hex: vectors.boxKey.pk))
+ for name in ["live full", "live delta", "lp", "prices", "hb"] {
+ let env = try vectors.envelope(name)
+ let wallMs = try #require(try CloudWire.Header(body: env.b).wallMs)
+ try v.accept(env, arrivalMs: wallMs + transit)
+ }
+ let hbSent = wall + 9_020
+ #expect(v.anchorSentMs(nowMs: hbSent + transit) == hbSent)
+ let g = v.grade(nowMs: hbSent + transit)
+ #expect(g.anchored && g.ageMs == transit)
+ // The charger has no age in hb, so it keeps the box's lagging.
+ #expect(g.sources == [meter: .live, inverter: .live, charger: .lagging])
+ #expect(g.fields == ["1": .live, "2": .live, "3": .live, "4": .live, "5": .live, "6": .lagging, "10": .lagging])
+ #expect(g.loadpoints == [garage: .lagging])
+ // The delta carried no table, so the full live's still holds.
+ #expect(v.live?.header.seq == 4 && v.live?.fields["2"] == 1795)
+ #expect(v.srcRev == 1 && v.fieldSources["6"] == [charger, inverter, meter])
+ #expect(v.prices?.zone == "SE3" && v.hb?.lpSeq == 5 && v.lp?.controlRev == 812)
+ // The meter's 1.4 s reading reaches its 10 s at hb + 8.6 s.
+ #expect(v.grade(nowMs: hbSent + 8_600).sources[meter] == .live)
+ #expect(v.grade(nowMs: hbSent + 8_601).sources[meter] == .lagging)
+ }
+
+ @Test func whatThePhoneNeverReadsIsRefused() throws {
+ let vectors = try CloudVectors.load()
+ var v = CloudView(home: vectors.home, boxKey: Bytes(hex: vectors.boxKey.pk))
+ for name in ["hello", "roster", "bye"] {
+ let env = try vectors.envelope(name)
+ #expect(throws: CloudView.AcceptError.notAClass(env.t)) { try v.accept(env, arrivalMs: 0) }
+ }
+ // Signed for this home by another key, or for another home by this
+ // key: the signature fails either way.
+ var other = CloudView(home: "aaaaaaaaaaaaaaaaaaaaaaaaaa", boxKey: Bytes(hex: vectors.boxKey.pk))
+ #expect(throws: CloudView.AcceptError.signature) { try other.accept(vectors.envelope("hb"), arrivalMs: 0) }
+ var service = CloudView(home: vectors.home, boxKey: Bytes(hex: vectors.serviceKey.pk))
+ #expect(throws: CloudView.AcceptError.signature) { try service.accept(vectors.envelope("hb"), arrivalMs: 0) }
+ }
+
+ @Test func aBodyThatDoesNotDecodeChangesNothing() throws {
+ var x = try FakeCloudBox(boot: 1)
+ var v = x.view()
+ let t0: Int64 = 2_000_000
+ try v.accept(x.fullLive(t0, age: 1_000), arrivalMs: t0 + transit)
+ let before = v.grade(nowMs: t0 + 200)
+
+ // Signed by the box, from a later boot, with a field that is text.
+ var y = try FakeCloudBox(boot: 2)
+ let h = y.header(t0 + 1_000)
+ let bad = try y.seal(CloudWire.msgLive, h + [("fields", .map(["2": .text("1000")]))])
+ #expect(throws: CloudView.AcceptError.self) { try v.accept(bad, arrivalMs: t0 + 1_000 + transit) }
+ #expect(v.boot == x.boot)
+ #expect(v.grade(nowMs: t0 + 200) == before)
+ }
+
+ @Test func aStateThisBuildDoesNotKnowRanksAsNever() throws {
+ var box = try FakeCloudBox(boot: 1)
+ var v = box.view()
+ let t0: Int64 = 2_000_000
+ try v.accept(box.fullLive(t0, age: 0, state: "melting"), arrivalMs: t0 + transit)
+ let g = v.grade(nowMs: t0 + 200)
+ #expect(g.sources["m"] == .never)
+ #expect(g.fields["2"] == .never)
+ }
+
+ @Test func ruleUKeepsTheSmallestOffsetAndTheEarlierRuleWins() throws {
+ var box = try FakeCloudBox(boot: 1)
+ let v0 = box.view()
+ var v = v0
+ // Offsets of 1 000 000 and then 1 001 000: the smaller stays, and the
+ // second call renews its lifetime.
+ v.learnOffset(boot: box.boot, phoneMs: 2_000_000, uptimeMs: 1_000_000)
+ v.learnOffset(boot: box.boot, phoneMs: 2_500_000, uptimeMs: 1_499_000)
+ box.noClock = true
+ let h = try header(&box, at: 3_000_000)
+ #expect(v.sentMs(h, arrivalMs: 3_000_100) == 3_000_000)
+ #expect(v.sentMs(h, arrivalMs: 2_500_000 + CloudView.ruleULifetimeMs) == 3_000_000)
+ #expect(v.sentMs(h, arrivalMs: 2_500_001 + CloudView.ruleULifetimeMs) == nil)
+ #expect(v0.sentMs(h, arrivalMs: 3_000_100) == nil)
+
+ // With both rules, the earlier send time: here the box's wall clock
+ // is 500 ms behind, so rule N gives the earlier time.
+ box.noClock = false
+ box.clockLead = -500
+ let both = try header(&box, at: 3_000_000)
+ #expect(v.sentMs(both, arrivalMs: 3_000_100) == 2_999_500)
+ box.clockLead = 500
+ let ahead = try header(&box, at: 3_000_000)
+ #expect(v.sentMs(ahead, arrivalMs: 3_000_100) == 3_000_000)
+ }
+}
diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudWireTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudWireTests.swift
new file mode 100644
index 0000000..7fb4893
--- /dev/null
+++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudWireTests.swift
@@ -0,0 +1,401 @@
+import CryptoKit
+import Foundation
+import Testing
+@testable import FTWKit
+
+/// srcfl/ftw `go/protocol/cloudwire/testdata/cloud-vectors.json`, copied
+/// unchanged: fixed keys, every message type and the exact bytes Go makes.
+/// ECDSA signatures are randomised, so the stored ones are checked by
+/// verifying them.
+struct CloudVectors: Decodable {
+ struct Key: Decodable {
+ let d: String
+ let pk: String
+ let home: String?
+ }
+
+ struct OpaqueID: Decodable {
+ let kind: String
+ let name: String
+ let id: String
+ }
+
+ struct Message: Decodable {
+ let name: String
+ /// "box", "service", or empty for unsigned.
+ let signer: String
+ let t: String
+ let body: String
+ let signedInputSha256: String
+ let sig: String
+ let envelope: String
+ let frame: String
+ }
+
+ let boxKey: Key
+ let serviceKey: Key
+ let grantKey: String
+ let ids: [OpaqueID]
+ let messages: [Message]
+
+ var home: String { boxKey.home ?? "" }
+
+ static func load() throws -> CloudVectors {
+ guard let url = Bundle.module.url(forResource: "cloud-vectors", withExtension: "json", subdirectory: "Resources") else {
+ throw CocoaError(.fileNoSuchFile)
+ }
+ return try JSONDecoder().decode(CloudVectors.self, from: Data(contentsOf: url))
+ }
+
+ func message(_ name: String) throws -> Message {
+ try #require(messages.first { $0.name == name }, "no vector \(name)")
+ }
+
+ func envelope(_ name: String) throws -> CloudWire.Envelope {
+ try CloudWire.Envelope.decode(Bytes(hex: message(name).envelope))
+ }
+
+ /// The box's signing key, for tests that need the box to say more.
+ func boxSigner() throws -> P256.Signing.PrivateKey {
+ try P256.Signing.PrivateKey(rawRepresentation: Bytes(hex: boxKey.d))
+ }
+}
+
+/// srcfl/ftw `go/protocol/cloudwire/testdata/cloud-app-vectors.json`: one app
+/// session frozen byte for byte, which the phone replays as the initiator.
+struct CloudAppVectors: Decodable {
+ struct Step: Decodable {
+ let from: String
+ let name: String
+ let frame: String
+ let message: String
+ }
+
+ let serviceStatic: String
+ let servicePublic: String
+ let serviceEphemeral: String
+ let phoneStatic: String
+ let phonePublic: String
+ let phoneEphemeral: String
+ let home: String
+ let prologue: String
+ let msg1Payload: String
+ let msg1: String
+ let msg2Payload: String
+ let msg2: String
+ let handshakeHash: String
+ let snapshot: [String]
+ let transport: [Step]
+
+ static func load() throws -> CloudAppVectors {
+ guard let url = Bundle.module.url(forResource: "cloud-app-vectors", withExtension: "json", subdirectory: "Resources") else {
+ throw CocoaError(.fileNoSuchFile)
+ }
+ return try JSONDecoder().decode(CloudAppVectors.self, from: Data(contentsOf: url))
+ }
+}
+
+/// ES256 over the signed input, as the box signs. The phone never signs;
+/// only tests do.
+func cloudSign(_ key: P256.Signing.PrivateKey, home: String, t: String, b: Bytes) throws -> Bytes {
+ Bytes(try key.signature(for: SHA256.hash(data: CloudWire.signedInput(home: home, t: t, b: b))).rawRepresentation)
+}
+
+@Suite struct CloudWireTests {
+ @Test func everyGoldenMessageDecodesVerifiesAndReencodes() throws {
+ let v = try CloudVectors.load()
+ let boxPK = Bytes(hex: v.boxKey.pk)
+ let servicePK = Bytes(hex: v.serviceKey.pk)
+ #expect(v.messages.count == 13)
+
+ for m in v.messages {
+ let frame = Bytes(hex: m.frame)
+ let payload = try CloudWire.decodeFrame(frame)
+ #expect(payload.hex == m.envelope, "\(m.name)")
+ #expect(try CloudWire.encodeFrame(payload) == frame, "\(m.name)")
+
+ let env = try CloudWire.Envelope.decode(payload)
+ #expect(env.t == m.t, "\(m.name)")
+ #expect(env.b.hex == m.body, "\(m.name)")
+ #expect(env.sig.hex == m.sig, "\(m.name)")
+ #expect(env.encode() == payload, "\(m.name)")
+ #expect(Primitives.sha256(CloudWire.signedInput(home: v.home, t: m.t, b: env.b)).hex == m.signedInputSha256, "\(m.name)")
+
+ if m.signer.isEmpty {
+ #expect(env.sig.isEmpty, "\(m.name)")
+ #expect(CloudWire.encodeUnsigned(t: m.t, body: try decodeCBOR(env.b)) == payload, "\(m.name)")
+ continue
+ }
+ let (key, other) = m.signer == "box" ? (boxPK, servicePK) : (servicePK, boxPK)
+ #expect(CloudWire.verify(boxKey: key, home: v.home, envelope: env), "\(m.name)")
+ #expect(!CloudWire.verify(boxKey: other, home: v.home, envelope: env), "\(m.name) verified with the wrong key")
+ var changed = env
+ changed.b[changed.b.count / 2] ^= 0x01
+ #expect(!CloudWire.verify(boxKey: key, home: v.home, envelope: changed), "\(m.name) verified with a flipped byte")
+ }
+ }
+
+ @Test func homeAndOpaqueIDsMatchGo() throws {
+ let v = try CloudVectors.load()
+ let boxPK = Bytes(hex: v.boxKey.pk)
+ #expect(CloudWire.homeID(pk: boxPK) == v.home)
+ #expect(Bytes(try v.boxSigner().publicKey.x963Representation) == boxPK)
+ let service = try P256.Signing.PrivateKey(rawRepresentation: Bytes(hex: v.serviceKey.d))
+ #expect(Bytes(service.publicKey.x963Representation).hex == v.serviceKey.pk)
+
+ let grantKey = Bytes(hex: v.grantKey)
+ #expect(v.ids.count == 4)
+ for id in v.ids {
+ let got = id.kind == "src"
+ ? CloudWire.sourceID(grantKey: grantKey, driver: id.name)
+ : CloudWire.loadpointID(grantKey: grantKey, loadpoint: id.name)
+ #expect(got == id.id, "\(id.kind) \(id.name)")
+ }
+ // A loadpoint and a source with one name do not share an id.
+ #expect(CloudWire.sourceID(grantKey: grantKey, driver: "garage") != CloudWire.loadpointID(grantKey: grantKey, loadpoint: "garage"))
+ }
+
+ @Test func goldenBodiesDecodeToTheirFields() throws {
+ let v = try CloudVectors.load()
+ let grantKey = Bytes(hex: v.grantKey)
+ let meter = CloudWire.sourceID(grantKey: grantKey, driver: "p1meter")
+ let inverter = CloudWire.sourceID(grantKey: grantKey, driver: "sungrow")
+ let charger = CloudWire.sourceID(grantKey: grantKey, driver: "easee")
+ let garage = CloudWire.loadpointID(grantKey: grantKey, loadpoint: "garage")
+ let wall: Int64 = 1_791_820_800_000
+ let boot = Bytes(repeating: 0xb0, count: CloudWire.bootBytes)
+
+ let full = try CloudWire.Live(body: v.envelope("live full").b)
+ #expect(full.header == CloudWire.Header(seq: 3, boot: boot, uptimeMs: 60_020, wallMs: wall + 20))
+ #expect(full.fields == ["1": 0, "2": 1840, "3": -2310, "4": 950, "5": 612, "6": 1100, "10": 2100])
+ #expect(full.fieldSources?["6"] == [charger, inverter, meter])
+ #expect(full.sources?[charger] == CloudWire.Source(kind: "driver", state: .lagging, lastOkMs: 1_000, staleAfterMs: 30_000))
+ #expect(full.sources?.count == 3)
+ #expect(full.srcRev == 1)
+ #expect(full.ages == [meter: 920, inverter: 2620, charger: 59_020])
+ #expect(full.dispatchBlockedBy.isEmpty)
+
+ // A delta carries no table, which is not the same as an empty one.
+ let delta = try CloudWire.Live(body: v.envelope("live delta").b)
+ #expect(delta.sources == nil)
+ #expect(delta.fieldSources == nil)
+ #expect(delta.fields["2"] == 1795)
+
+ let lp = try CloudWire.LP(body: v.envelope("lp").b)
+ #expect(lp.header.seq == 5)
+ #expect(lp.controlRev == 812)
+ let car = try #require(lp.loadpoints.first)
+ #expect(lp.loadpoints.count == 1)
+ #expect(car.id == garage && car.src == charger && car.plugged)
+ #expect(car.socPermille == 430 && car.socSource == "inferred" && car.socAgeS == 1800)
+ #expect(car.targetPermille == 800 && car.readyByMs == 1_791_867_600_000 && car.recurring)
+ #expect(car.powerW == 2100 && car.sessionWh == 3400 && car.session == "s1")
+ #expect(car.manual == nil)
+ #expect(car.commandedW == 2070 && car.commandedReason == "plan")
+ #expect(!car.surplusOnly && !car.gridDeferred && !car.planPending && !car.boost)
+ #expect(car.nextStartMs == 1_791_850_000_000 && car.nextEndMs == 1_791_857_200_000 && car.nextWh == 7400)
+ #expect(lp.feedback == [CloudWire.Feedback(kind: "ev", src: charger, status: "following", reason: "plan", severity: "ok",
+ evidence: "measured", fresh: true, actualW: 2100, ageMs: 900)])
+
+ let prices = try CloudWire.Prices(body: v.envelope("prices").b)
+ #expect(prices.zone == "SE3" && prices.currency == "SEK")
+ #expect(prices.t0Ms == 1_791_820_800_000 && prices.slotMs == 900_000)
+ #expect(prices.spotMinor == [42, 40, nil, -3])
+ #expect(prices.totalMinor == [118, 115, nil, 71])
+
+ let hb = try CloudWire.HB(body: v.envelope("hb").b)
+ #expect(hb.header == CloudWire.Header(seq: 7, boot: boot, uptimeMs: 69_020, wallMs: wall + 9_020))
+ #expect(hb.clock == "ntp" && hb.srcRev == 1 && hb.liveSeq == 4 && hb.lpSeq == 5)
+ #expect(hb.ages == [meter: 1_400, inverter: 3_100])
+ }
+
+ @Test func unsignedEnvelopesPutTheirBodyInDeterministicOrder() throws {
+ let v = try CloudVectors.load()
+ // Keys given out of order come out as Go's core deterministic
+ // encoder writes them: shorter first, then by bytes.
+ let challenge = CBOR.map([("nonce", .bytes(Bytes(repeating: 0x11, count: 32))), ("v", .int(1))])
+ #expect(CloudWire.encodeUnsigned(t: "challenge", body: challenge).hex == (try v.message("challenge").envelope))
+ #expect(CloudWire.encodeUnsigned(t: CloudWire.msgWatch, body: .map(["hz": .int(1)])).hex == (try v.message("watch").envelope))
+ #expect(CloudWire.encodeUnsigned(t: "resync", body: .map(["what": .array([.text("full")])])).hex == (try v.message("resync").envelope))
+ let nested = CBOR.map([("zz", .map([("bb", .int(1)), ("a", .int(2))])), ("y", .array([.map([("cc", .int(3)), ("d", .int(4))])]))])
+ // {"y": [{"d": 4, "cc": 3}], "zz": {"a": 2, "bb": 1}}
+ #expect(encodeCBOR(CloudWire.deterministic(nested)).hex == "a2617981a261640462636303627a7aa261610262626201")
+ }
+
+ @Test func appSessionReplaysAsTheInitiator() throws {
+ let v = try CloudAppVectors.load()
+ let signed = try CloudVectors.load()
+ let boxPK = Bytes(hex: signed.boxKey.pk)
+
+ let phone = try Primitives.keyPair(fromSecret: Bytes(hex: v.phoneStatic))
+ #expect(phone.publicKey.hex == v.phonePublic)
+ #expect(try Primitives.keyPair(fromSecret: Bytes(hex: v.serviceStatic)).publicKey.hex == v.servicePublic)
+ #expect(v.home == signed.home)
+ let prologue = CloudWire.appPrologue(home: v.home)
+ #expect(prologue.hex == v.prologue)
+
+ let msg1Payload = CloudWire.encodeUnsigned(t: CloudWire.msgNow, body: .map(["hz": .int(CloudWire.hzLooking)]))
+ #expect(msg1Payload.hex == v.msg1Payload)
+ let handshake = try HandshakeState.initiator(
+ staticKey: phone,
+ remoteStatic: Bytes(hex: v.servicePublic),
+ prologue: prologue,
+ ephemeral: try Primitives.keyPair(fromSecret: Bytes(hex: v.phoneEphemeral))
+ )
+ #expect(try handshake.writeMessage(msg1Payload).hex == v.msg1)
+ let msg2Payload = try handshake.readMessage(Bytes(hex: v.msg2))
+ #expect(msg2Payload.hex == v.msg2Payload)
+
+ let snapshot = try CloudWire.parseSnapshot(payload: msg2Payload)
+ #expect(snapshot.online)
+ #expect(snapshot.more == 0)
+ #expect(snapshot.lastSeenMs == 1_791_820_869_000)
+ #expect(snapshot.envelopes.count == v.snapshot.count)
+ for (entry, name) in zip(snapshot.envelopes, v.snapshot) {
+ #expect(entry == (try signed.envelope(name)), "\(name)")
+ #expect(entry.encode().hex == (try signed.message(name).envelope), "\(name)")
+ #expect(CloudWire.verify(boxKey: boxPK, home: v.home, envelope: entry), "\(name)")
+ }
+
+ let transport = NoiseTransport(try handshake.split())
+ #expect(transport.handshakeHash.hex == v.handshakeHash)
+ #expect(v.transport.count == 4)
+ for step in v.transport {
+ let frame = Bytes(hex: step.frame)
+ if step.from == "service" {
+ #expect(try transport.decrypt(Bytes(hex: step.message)) == frame, "\(step.name)")
+ let env = try CloudWire.Envelope.decode(CloudWire.decodeFrame(frame))
+ #expect(env == (try signed.envelope(step.name)), "\(step.name)")
+ #expect(CloudWire.verify(boxKey: boxPK, home: v.home, envelope: env), "\(step.name)")
+ } else {
+ #expect(try transport.encrypt(frame).hex == step.message, "\(step.name)")
+ }
+ }
+ // The phone's own frames are what encodeUnsigned writes.
+ let away = try CloudWire.encodeFrame(CloudWire.encodeUnsigned(t: CloudWire.msgWatch, body: .map(["hz": .int(CloudWire.hzAway)])))
+ let suspend = try CloudWire.encodeFrame(CloudWire.encodeUnsigned(t: CloudWire.msgSuspend, body: .map(["deviceId": .text("Zf09xQ2a")])))
+ #expect(v.transport.filter { $0.from == "phone" }.map(\.frame) == [away.hex, suspend.hex])
+ }
+
+ @Test func framesRefuseWhatThisProtocolNeverSends() throws {
+ let good: Bytes = [1, 1, 0, 0, 0, 2, 0xa0, 0xa0]
+ let cases: [String: Bytes] = [
+ "short": [1, 1, 0],
+ "version": [2, 1, 0, 0, 0, 2, 0xa0, 0xa0],
+ "lane 0": [1, 0, 0, 0, 0, 2, 0xa0, 0xa0],
+ "flag": [1, 1, 2, 0, 0, 2, 0xa0, 0xa0],
+ "reserved": [1, 1, 0, 1, 0, 2, 0xa0, 0xa0],
+ "padding": good + [0],
+ "overrun": [1, 1, 0, 0, 0, 9, 0xa0],
+ "short length": [1, 1, 0, 0, 0, 1, 0xa0, 0xa0],
+ ]
+ for (name, msg) in cases {
+ #expect(throws: CloudWire.WireError.self, "\(name)") { try CloudWire.decodeFrame(msg) }
+ }
+ #expect(try CloudWire.decodeFrame(good) == [0xa0, 0xa0])
+ #expect(try CloudWire.encodeFrame([0xa3, 1, 2, 3]) == [1, 1, 0, 0, 0, 4, 0xa3, 1, 2, 3])
+ #expect(throws: CloudWire.WireError.self) { try CloudWire.encodeFrame(Bytes(repeating: 0, count: CloudWire.maxPayload + 1)) }
+ }
+
+ @Test func anEmptySignatureIsAnEmptyByteString() throws {
+ let raw = CloudWire.Envelope(t: CloudWire.msgWatch, b: [0xa1, 0x62, 0x68, 0x7a, 1]).encode()
+ // {"b": h'a162687a01', "t": "watch", "sig": h''}
+ #expect(raw.hex == "a3616245a162687a0161746577617463686373696740")
+ let back = try CloudWire.Envelope.decode(raw)
+ #expect(back.t == CloudWire.msgWatch && back.sig.isEmpty && back.b == [0xa1, 0x62, 0x68, 0x7a, 1])
+ }
+
+ @Test func envelopesRefuseTheWrongShapes() {
+ func mk(_ pairs: [(String, CBOR)]) -> Bytes { encodeCBOR(.map(pairs)) }
+ let cases: [String: Bytes] = [
+ "not a map": encodeCBOR(.array([.int(1)])),
+ "no type": mk([("b", .bytes([1])), ("sig", .bytes([]))]),
+ "no body": mk([("t", .text("hb")), ("sig", .bytes([]))]),
+ "no sig": mk([("t", .text("hb")), ("b", .bytes([1]))]),
+ "text body": mk([("t", .text("hb")), ("b", .text("a0")), ("sig", .bytes([]))]),
+ "bytes type": mk([("t", .bytes(Bytes("hb".utf8))), ("b", .bytes([1])), ("sig", .bytes([]))]),
+ "empty type": mk([("t", .text("")), ("b", .bytes([1])), ("sig", .bytes([]))]),
+ "text sig": mk([("t", .text("hb")), ("b", .bytes([1])), ("sig", .text(""))]),
+ "null sig": mk([("t", .text("hb")), ("b", .bytes([1])), ("sig", .null)]),
+ "integer body": mk([("t", .text("hb")), ("b", .int(5)), ("sig", .bytes([]))]),
+ "integer key": encodeCBOR(.map([CBOR.Entry(.int(1), .int(1)), CBOR.Entry(.text("t"), .text("hb")),
+ CBOR.Entry(.text("b"), .bytes([1])), CBOR.Entry(.text("sig"), .bytes([]))])),
+ "garbage": [0xff, 0x00],
+ "duplicate t": [0xa4, 0x61, 0x62, 0x41, 0, 0x61, 0x74, 0x61, 0x78, 0x61, 0x74, 0x61, 0x79, 0x63, 0x73, 0x69, 0x67, 0x40],
+ "truncated": [0xa3, 0x61, 0x62],
+ "trailing": mk([("t", .text("hb")), ("b", .bytes([1])), ("sig", .bytes([]))]) + [0],
+ ]
+ for (name, raw) in cases {
+ #expect(throws: CloudWire.WireError.self, "\(name)") { try CloudWire.Envelope.decode(raw) }
+ }
+ // Unknown keys are ignored.
+ let extra = mk([("b", .bytes([1])), ("t", .text("hb")), ("sig", .bytes([])), ("zz", .int(9))])
+ #expect((try? CloudWire.Envelope.decode(extra)) == CloudWire.Envelope(t: "hb", b: [1]))
+ }
+
+ @Test func theSignatureCoversHomeTypeAndEveryBodyByte() throws {
+ let v = try CloudVectors.load()
+ let boxPK = Bytes(hex: v.boxKey.pk)
+ let env = try v.envelope("hb")
+ #expect(CloudWire.verify(boxKey: boxPK, home: v.home, envelope: env))
+ for i in env.b.indices {
+ var changed = env
+ changed.b[i] ^= 0x01
+ #expect(!CloudWire.verify(boxKey: boxPK, home: v.home, envelope: changed), "byte \(i)")
+ }
+ var other = env
+ other.t = CloudWire.msgLive
+ #expect(!CloudWire.verify(boxKey: boxPK, home: v.home, envelope: other))
+ #expect(!CloudWire.verify(boxKey: boxPK, home: "aaaaaaaaaaaaaaaaaaaaaaaaaa", envelope: env))
+ var short = env
+ short.sig.removeLast()
+ #expect(!CloudWire.verify(boxKey: boxPK, home: v.home, envelope: short))
+ #expect(!CloudWire.verify(boxKey: Array(boxPK.dropLast()), home: v.home, envelope: env))
+ var offCurve = boxPK
+ offCurve[64] ^= 1
+ #expect(!CloudWire.verify(boxKey: offCurve, home: v.home, envelope: env))
+ // A signature this file makes verifies as Go's do.
+ let mine = CloudWire.Envelope(t: env.t, b: env.b, sig: try cloudSign(v.boxSigner(), home: v.home, t: env.t, b: env.b))
+ #expect(CloudWire.verify(boxKey: boxPK, home: v.home, envelope: mine))
+ }
+
+ @Test func aHomeIDIsTwentySixLowercaseCharacters() throws {
+ let home = CloudWire.homeID(pk: Bytes(hex: try CloudVectors.load().boxKey.pk))
+ #expect(home.count == 26)
+ #expect(home.utf8.allSatisfy { (0x61...0x7a).contains($0) || (0x32...0x37).contains($0) })
+ #expect(String(decoding: CloudWire.appPrologue(home: "fjzyr67buzpyncqmjliwgezvde"), as: UTF8.self) == "ftw.cloud.v1:fjzyr67buzpyncqmjliwgezvde")
+ }
+
+ @Test func aSnapshotIsTheOnlyMessageTwo() throws {
+ let now = CloudWire.encodeUnsigned(t: CloudWire.msgNow, body: .map(["hz": .int(1)]))
+ #expect(throws: CloudWire.WireError.self) { try CloudWire.parseSnapshot(payload: now) }
+ let notEnvelopes = CloudWire.encodeUnsigned(t: CloudWire.msgSnapshot, body: .map(["envelopes": .array([.emptyMap])]))
+ #expect(throws: CloudWire.WireError.self) { try CloudWire.parseSnapshot(payload: notEnvelopes) }
+ }
+
+ @Test func bodiesIgnoreUnknownKeysAndRefuseTheWrongTypes() throws {
+ func body(_ pairs: [(String, CBOR)]) -> Bytes { encodeCBOR(.map(pairs)) }
+ let hb = try CloudWire.HB(body: body([("seq", .int(4)), ("future", .text("x")), ("wallMs", .null), ("liveSeq", .int(3))]))
+ #expect(hb.header.seq == 4 && hb.header.wallMs == nil && hb.liveSeq == 3 && hb.ages.isEmpty && hb.clock.isEmpty)
+
+ let refused: [String: Bytes] = [
+ "empty": [],
+ "not a map": encodeCBOR(.array([])),
+ "negative seq": body([("seq", .int(-1))]),
+ "text seq": body([("seq", .text("1"))]),
+ "float uptime": body([("seq", .int(1)), ("uptimeMs", .double(1.5))]),
+ "text boot": body([("seq", .int(1)), ("boot", .text("b"))]),
+ "too large": body([("seq", .int(1)), ("uptimeMs", .unsigned(UInt64(Int64.max) + 1))]),
+ ]
+ for (name, raw) in refused {
+ #expect(throws: CloudWire.WireError.self, "\(name)") { try CloudWire.Header(body: raw) }
+ }
+ #expect(throws: CloudWire.WireError.self) { try CloudWire.Live(body: body([("fields", .map(["2": .text("1")]))])) }
+ #expect(throws: CloudWire.WireError.self) { try CloudWire.Live(body: body([("sources", .array([]))])) }
+ #expect(throws: CloudWire.WireError.self) { try CloudWire.LP(body: body([("loadpoints", .array([.map(["plugged": .int(1)])]))])) }
+
+ // A state this build does not know ranks as never.
+ let live = try CloudWire.Live(body: body([("sources", .map(["m": .map(["state": .text("melting")])]))]))
+ #expect(live.sources?["m"]?.state == .never)
+ }
+}
diff --git a/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift
index e4ae48f..bb39c64 100644
--- a/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift
+++ b/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift
@@ -183,7 +183,7 @@ struct IdentityVectors: Decodable {
@Test func restoreRefusesToOverwriteAnotherIdentity() async throws {
let vault = Vault(store: MemoryStore())
- let local = vault.localWrappingKey()
+ let local = try vault.localWrappingKey()
_ = try vault.deviceKey(local)
#expect(throws: Vault.VaultError.self) { try vault.restoreDeviceKey(local, scalar: randomBytes(32)) }
}
diff --git a/appleApp/FTWKit/Tests/FTWKitTests/LiveCloudTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/LiveCloudTests.swift
new file mode 100644
index 0000000..2ef0c77
--- /dev/null
+++ b/appleApp/FTWKit/Tests/FTWKitTests/LiveCloudTests.swift
@@ -0,0 +1,337 @@
+import Foundation
+import Security
+import Testing
+@testable import FTWKit
+
+/// Against a real FTW box and a real `ftwcloudd`, through the production
+/// relay. Off unless the environment names them:
+///
+/// FTW_LIVE_BOX=http://127.0.0.1:18080 the box's API, on loopback
+/// FTW_LIVE_CLOUD_URL=wss://127.0.0.1:18443/v1/app
+/// FTW_LIVE_CLOUD_KEY=
+/// FTW_LIVE_CLOUD_CA=/path/to/ca.pem the local certificate's root
+/// FTW_LIVE_CLOUD_STOP='kill …' FTW_LIVE_CLOUD_START='…' optional
+///
+/// It pairs two phones, turns FTW Cloud on as the consent screen does, and
+/// times the three layers of a cold start against the relay's path, five
+/// times each. It then replays a snapshot an hour late, revokes a phone with
+/// its stream open, and closes the cloud under a running home.
+@MainActor
+@Suite(.enabled(if: ProcessInfo.processInfo.environment["FTW_LIVE_CLOUD_URL"] != nil), .serialized)
+struct LiveCloudTests {
+ let env = ProcessInfo.processInfo.environment
+ let scheduler = LiveScheduler()
+
+ var box: URL { URL(string: env["FTW_LIVE_BOX"] ?? "http://127.0.0.1:18080")! }
+
+ var endpoint: CloudEndpoint {
+ CloudEndpoint(url: URL(string: env["FTW_LIVE_CLOUD_URL"]!)!, serviceKeys: [try! Base64url.decode(env["FTW_LIVE_CLOUD_KEY"]!)])
+ }
+
+ var cloudSockets: WebSocketFactory {
+ let pem = try! String(contentsOfFile: env["FTW_LIVE_CLOUD_CA"]!, encoding: .utf8)
+ let der = Data(base64Encoded: pem.split(separator: "\n").filter { !$0.hasPrefix("-----") }.joined())!
+ let anchor = SecCertificateCreateWithData(nil, der as CFData)!
+ return { url, events in PinnedWebSocket(url: url, events: events, anchor: anchor) }
+ }
+
+ /// One phone, as the app holds it after pairing.
+ struct Phone {
+ let enrollment: Enrollment
+ let key: Primitives.KeyPair
+ var id: String { String(Base64url.encode(key.publicKey).prefix(8)) }
+ }
+
+ func pair(_ role: String) async throws -> Phone {
+ var req = URLRequest(url: box.appendingPathComponent("api/app-link/pairing"))
+ req.httpMethod = "POST"
+ req.httpBody = Data(#"{"role":"\#(role)"}"#.utf8)
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ let (data, _) = try await URLSession.shared.data(for: req)
+ let json = try JSON(parsing: Bytes(data))
+ let url = try #require(json["url"]?.string ?? json["link"]?.string)
+ let vault = Vault(store: MemoryStore())
+ return Phone(enrollment: try Enrollment.parse(scanned: url), key: try vault.deviceKey(vault.localWrappingKey()))
+ }
+
+ func relay(_ phone: Phone) -> NoiseCarrier {
+ let e = phone.enrollment
+ return NoiseCarrier(
+ inner: RelayCarrier(secret: e.rendezvousSecret, scheduler: scheduler),
+ staticKey: phone.key, remoteStatic: e.boxStaticPublic,
+ prologue: NoiseCarrier.prologue(boxStaticKey: e.boxStaticPublic),
+ handshakePayload: e.pairingCode, scheduler: scheduler
+ )
+ }
+
+ func files() -> SealedFiles {
+ SealedFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-live-\(UUID().uuidString)"), store: MemoryStore())
+ }
+
+ func site(_ files: SealedFiles) -> SiteModel {
+ let s = SiteModel(siteId: "live", build: "live-cloud-test", ua: "test", scheduler: scheduler, files: files)
+ s.start()
+ return s
+ }
+
+ /// Wait for `ok`, polling every 2 ms, and return how long it took.
+ func until(_ seconds: Double = 30, _ ok: () -> Bool) async throws -> Double {
+ let start = Date()
+ while !ok() {
+ if Date().timeIntervalSince(start) > seconds { throw Timeout() }
+ try await Task.sleep(nanoseconds: 2_000_000)
+ }
+ return Date().timeIntervalSince(start) * 1000
+ }
+
+ struct Timeout: Error {}
+
+ func line(_ name: String, _ ms: [Double]) {
+ let sorted = ms.sorted()
+ let text = ms.map { String(Int($0.rounded())) }.joined(separator: ", ")
+ print("live-cloud: \(name): \(text) ms; median \(Int(sorted[sorted.count / 2].rounded())) ms")
+ }
+
+ func shell(_ command: String?) throws {
+ guard let command else { return }
+ let p = Process()
+ p.executableURL = URL(fileURLWithPath: "/bin/sh")
+ p.arguments = ["-c", command]
+ try p.run()
+ p.waitUntilExit()
+ }
+
+ @Test func threeLayersAgainstTheRelayAndTheProofs() async throws {
+ // An owner pairs and turns FTW Cloud on over the box session, as the
+ // consent screen does.
+ let owner = try await pair(Contract.roleOwner)
+ let setupFiles = files()
+ var home = site(setupFiles)
+ home.connect(relay(owner))
+ _ = try await until { home.session.phase == .streaming }
+ #expect(home.session.caps.contains(Contract.capCloudLink))
+ let consent = CloudConsent(defaults: UserDefaults(suiteName: "live-\(UUID().uuidString)")!)
+ await consent.accept(home)
+ print("live-cloud: grant \(consent.box?.state ?? (home.cloudOn ? "already on" : "?")) \(consent.help ?? "")")
+ #expect(consent.box?.on == true || home.cloudOn)
+ home.destroy()
+
+ // The next hello names the box's key; the cache keeps it.
+ try await Task.sleep(nanoseconds: 2_000_000_000)
+ home = site(setupFiles)
+ home.connectCloud(deviceKey: owner.key, makeSocket: cloudSockets, endpoint: endpoint)
+ home.connect(relay(owner))
+ _ = try await until { home.cloudOn && home.carrier == .cloud }
+ let identity = try #require(home.cloudIdentity)
+ print("live-cloud: home \(identity.home), sources \(identity.sources.values.sorted())")
+ home.destroy()
+
+ // Five cold starts of each path, each on new sockets.
+ var relayFirst: [Double] = [], cloudSnapshot: [Double] = [], cloudLive: [Double] = [], cachePaint: [Double] = []
+ for _ in 0..<5 {
+ try await Task.sleep(nanoseconds: 1_000_000_000)
+ let r = site(files())
+ let t0 = Date()
+ r.connect(relay(owner))
+ _ = try await until { r.session.phase == .streaming && r.lastFrameAtMs != nil }
+ relayFirst.append(Date().timeIntervalSince(t0) * 1000)
+ r.destroy()
+ }
+ for _ in 0..<5 {
+ try await Task.sleep(nanoseconds: 1_000_000_000)
+ let f = files()
+ f.writeJSON("cloud-live", CloudCache(identity: identity, offset: nil))
+ f.writeJSON("snapshot-live", CachedSnapshot(siteId: "live", savedAtMs: Date().timeIntervalSince1970 * 1000 - 60_000, state: home.session))
+ let t0 = Date()
+ let c = site(f)
+ cachePaint.append(Date().timeIntervalSince(t0) * 1000)
+ #expect(c.carrier == .cache && !c.session.fields.isEmpty)
+ c.connectCloud(deviceKey: owner.key, makeSocket: cloudSockets, endpoint: endpoint)
+ _ = try await until { c.carrier == .cloud }
+ cloudSnapshot.append(Date().timeIntervalSince(t0) * 1000)
+ let snapFrame = c.lastFrameAtMs
+ _ = try await until { c.lastFrameAtMs != snapFrame && c.cloudView?.live != nil }
+ cloudLive.append(Date().timeIntervalSince(t0) * 1000)
+ #expect(c.isLive)
+ c.destroy()
+ }
+ // How often the box sends while a phone looks: the cloud sends on
+ // change, at most once a second while watched.
+ var arrivals: [(String, Double)] = []
+ let watcher = CloudCarrier(endpoint: endpoint, home: identity.home, deviceKey: owner.key, looking: true, scheduler: scheduler, makeSocket: cloudSockets)
+ watcher.setHandlers(onSnapshot: { _, _ in }, onEnvelope: { e, at in arrivals.append((e.t, at)) }, onStatus: { _ in })
+ watcher.start()
+ try await Task.sleep(nanoseconds: 20_000_000_000)
+ watcher.close()
+ for t in ["live", "lp", "hb", "prices"] {
+ let at = arrivals.filter { $0.0 == t }.map(\.1)
+ let gaps = zip(at.dropFirst(), at).map { Int(($0 - $1).rounded()) }
+ print("live-cloud: watched 20 s: \(t) \(at.count)×, gaps \(gaps) ms")
+ }
+
+ line("first frame from the phone's cache", cachePaint)
+ line("cloud snapshot (layer 2)", cloudSnapshot)
+ line("first cloud update after it (layer 3)", cloudLive)
+ line("relay, first reading (today's path)", relayFirst)
+
+ // A replayed snapshot: the real bytes, an hour late, never live.
+ var snapshots: [CloudWire.Snapshot] = []
+ let raw = CloudCarrier(endpoint: endpoint, home: identity.home, deviceKey: owner.key, looking: true, scheduler: scheduler, makeSocket: cloudSockets)
+ raw.setHandlers(onSnapshot: { s, _ in snapshots.append(s) }, onEnvelope: { _, _ in }, onStatus: { _ in })
+ raw.start()
+ _ = try await until { !snapshots.isEmpty }
+ raw.close()
+ let now = Int64(Date().timeIntervalSince1970 * 1000)
+ var fresh = CloudView(home: identity.home, boxKey: identity.pk)
+ var late = CloudView(home: identity.home, boxKey: identity.pk)
+ for e in snapshots[0].envelopes {
+ try? fresh.accept(e, arrivalMs: now)
+ try? late.accept(e, arrivalMs: now + 3_600_000)
+ }
+ let g1 = fresh.grade(nowMs: now), g2 = late.grade(nowMs: now + 3_600_000)
+ print("live-cloud: replay: now sources \(g1.sources.values.map(\.rawValue).sorted()), an hour late \(g2.sources.values.map(\.rawValue).sorted()), fields \(Set(g2.fields.values.map(\.rawValue)).sorted())")
+ #expect(!g2.sources.values.contains(.live) && !g2.fields.values.contains(.live))
+
+ // Revoke a phone with its stream open: closed within a second.
+ let viewer = try await pair(Contract.roleViewer)
+ let v = site(files())
+ v.connect(relay(viewer))
+ _ = try await until { v.session.phase == .streaming }
+ v.destroy()
+ try await Task.sleep(nanoseconds: 1_500_000_000)
+ var status: CloudCarrier.Status = .connecting
+ let vc = CloudCarrier(endpoint: endpoint, home: identity.home, deviceKey: viewer.key, looking: true, scheduler: scheduler, makeSocket: cloudSockets)
+ vc.setHandlers(onSnapshot: { _, _ in }, onEnvelope: { _, _ in }, onStatus: { status = $0 })
+ vc.start()
+ _ = try await until { status.isOpen }
+ var del = URLRequest(url: box.appendingPathComponent("api/app-link/devices/\(viewer.id)"))
+ del.httpMethod = "DELETE"
+ let t0 = Date()
+ let (_, resp) = try await URLSession.shared.data(for: del)
+ let cut = try await until(5) { !status.isOpen }
+ print("live-cloud: revoke: HTTP \((resp as? HTTPURLResponse)?.statusCode ?? 0), stream closed \(Int(Date().timeIntervalSince(t0) * 1000)) ms after the request (\(Int(cut)) ms after the answer), \(status)")
+ #expect(Date().timeIntervalSince(t0) < 1)
+ vc.close()
+
+ // The cloud goes away under a running home: the relay takes over
+ // under its own name, and the cloud takes back over when it returns.
+ guard env["FTW_LIVE_CLOUD_STOP"] != nil else { return }
+ let f = files()
+ f.writeJSON("cloud-live", CloudCache(identity: identity, offset: nil))
+ let both = site(f)
+ both.connectCloud(deviceKey: owner.key, makeSocket: cloudSockets, endpoint: endpoint)
+ both.connect(relay(owner))
+ _ = try await until { both.carrier == .cloud && both.session.phase == .streaming }
+ try shell(env["FTW_LIVE_CLOUD_STOP"])
+ let down = try await until { both.carrier == .relay }
+ print("live-cloud: cloud stopped: carrier \(both.carrier.rawValue) after \(Int(down)) ms, band \"\(both.freshness(noCarrier: false).message)\"")
+ try shell(env["FTW_LIVE_CLOUD_START"])
+ both.networkOnline()
+ let up = try await until(70) { both.carrier == .cloud }
+ print("live-cloud: cloud back: carrier \(both.carrier.rawValue) after \(Int(up)) ms, band \"\(both.freshness(noCarrier: false).message)\"")
+ both.destroy()
+ }
+}
+
+/// URLSession's WebSocket, trusting one local root and nothing else, so a
+/// test reaches a local `ftwcloudd` without touching the Mac's keychain.
+@MainActor
+final class PinnedWebSocket: NSObject, WebSocketConnection {
+ private var task: URLSessionWebSocketTask?
+ private var session: URLSession?
+ private let events: WebSocketEvents
+ private var closed = false
+ private var opened = false
+
+ init(url: URL, events: WebSocketEvents, anchor: SecCertificate) {
+ self.events = events
+ super.init()
+ let session = URLSession(configuration: .ephemeral, delegate: Delegate(owner: self, anchor: anchor), delegateQueue: nil)
+ self.session = session
+ let task = session.webSocketTask(with: url)
+ self.task = task
+ task.resume()
+ receive()
+ }
+
+ func send(_ data: Bytes) {
+ guard !closed, let task else { return }
+ task.send(.data(Data(data))) { _ in }
+ }
+
+ func close() {
+ guard !closed else { return }
+ closed = true
+ task?.cancel(with: .normalClosure, reason: nil)
+ session?.invalidateAndCancel()
+ }
+
+ private func receive() {
+ task?.receive { [weak self] result in
+ Task { @MainActor [weak self] in
+ guard let self, !self.closed else { return }
+ switch result {
+ case .success(.data(let d)):
+ self.didOpen()
+ self.events.onBinary(d.byteArray)
+ self.receive()
+ case .success:
+ self.receive()
+ case .failure:
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ let code = self.task?.closeCode.rawValue ?? 0
+ self.didClose(code == 0 ? 1006 : code)
+ }
+ }
+ }
+ }
+
+ fileprivate func didOpen() {
+ guard !opened, !closed else { return }
+ opened = true
+ events.onOpen()
+ }
+
+ fileprivate func didClose(_ code: Int) {
+ guard !closed else { return }
+ closed = true
+ session?.invalidateAndCancel()
+ events.onClose(code, "")
+ }
+
+ private final class Delegate: NSObject, URLSessionWebSocketDelegate, @unchecked Sendable {
+ weak var owner: PinnedWebSocket?
+ let anchor: SecCertificate
+
+ init(owner: PinnedWebSocket, anchor: SecCertificate) {
+ self.owner = owner
+ self.anchor = anchor
+ }
+
+ func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
+ guard let trust = challenge.protectionSpace.serverTrust else {
+ completionHandler(.cancelAuthenticationChallenge, nil)
+ return
+ }
+ SecTrustSetAnchorCertificates(trust, [anchor] as CFArray)
+ SecTrustSetAnchorCertificatesOnly(trust, true)
+ if SecTrustEvaluateWithError(trust, nil) {
+ completionHandler(.useCredential, URLCredential(trust: trust))
+ } else {
+ completionHandler(.cancelAuthenticationChallenge, nil)
+ }
+ }
+
+ func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
+ Task { @MainActor [weak owner] in owner?.didOpen() }
+ }
+
+ func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
+ Task { @MainActor [weak owner] in owner?.didClose(closeCode.rawValue) }
+ }
+
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
+ Task { @MainActor [weak owner] in owner?.didClose(1006) }
+ }
+ }
+}
diff --git a/appleApp/FTWKit/Tests/FTWKitTests/PushTokensTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/PushTokensTests.swift
new file mode 100644
index 0000000..1c92941
--- /dev/null
+++ b/appleApp/FTWKit/Tests/FTWKitTests/PushTokensTests.swift
@@ -0,0 +1,41 @@
+import Foundation
+import Synchronization
+import Testing
+@testable import FTWKit
+
+/// The APNs token's way to FTW Cloud: whoever wants it gets the latest, once
+/// per change, and the person is asked only when something wants pushes.
+@Suite struct PushTokensTests {
+ final class Sink: PushTokenSink {
+ let got = Mutex<[PushTokens.Token]>([])
+
+ func register(apnsToken: Data, environment: PushEnvironment) async {
+ got.withLock { $0.append(PushTokens.Token(data: apnsToken, environment: environment)) }
+ }
+ }
+
+ @Test func theSinkGetsEachTokenOnce() async {
+ let asked = Mutex(0)
+ PushTokens.setAsk { asked.withLock { $0 += 1 } }
+ defer { PushTokens.uninstall() }
+ let a = Data([1, 2, 3])
+ let b = Data([4, 5, 6])
+
+ // A token from an earlier yes, before anything wants it.
+ await PushTokens.received(a, environment: .sandbox)
+ #expect(asked.withLock { $0 } == 0)
+ #expect(PushTokens.current == PushTokens.Token(data: a, environment: .sandbox))
+
+ let sink = Sink()
+ await PushTokens.install(sink)
+ #expect(asked.withLock { $0 } == 1)
+ // Apple hands the same token again after the ask: not repeated.
+ await PushTokens.received(a, environment: .sandbox)
+ await PushTokens.received(b, environment: .sandbox)
+ #expect(sink.got.withLock { $0 } == [.init(data: a, environment: .sandbox), .init(data: b, environment: .sandbox)])
+
+ PushTokens.uninstall()
+ await PushTokens.received(a, environment: .production)
+ #expect(sink.got.withLock { $0 }.count == 2)
+ }
+}
diff --git a/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift
index 6c2be65..97d7760 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
}
@@ -201,6 +201,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 {
@@ -507,11 +529,12 @@ final class RecordingCarrier: Carrier {
var kind: CarrierKind { inner.kind }
var status: CarrierStatus { inner.status }
- func send(_ frame: Bytes) {
+ @discardableResult
+ func send(_ frame: Bytes) -> Bool {
if let d = try? Frame.decode(frame), d.envelope.t == "cmd", let b = d.envelope.b {
commands.append(Command(op: b["op"]?.string ?? "", args: b["args"] ?? CBOR.emptyMap, notValidAfterMs: b["notValidAfterMs"]?.double, uptimeMs: uptimeMs))
}
- inner.send(frame)
+ return inner.send(frame)
}
func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void) {
diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-app-vectors.json b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-app-vectors.json
new file mode 100644
index 0000000..b49c1b3
--- /dev/null
+++ b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-app-vectors.json
@@ -0,0 +1,53 @@
+{
+ "serviceStatic": "02d86370739892ce589fb0dac0416128cb716a667f6b3baf2979ed03a39be145",
+ "servicePublic": "4bcf8ca8663797d826934ad341de6e223114105a1f513b78da30ef3f32cd8557",
+ "serviceEphemeral": "f4e90c05a93a8b3e2e3741a3c3df44681e9829b95332b4a406e3325d3cce6bd3",
+ "phoneStatic": "ea39659c38ef7cf936ca64fc18edccb9889785bf85e202593bd192680b425f37",
+ "phonePublic": "f7d081a5e8884ade9cf55aeb6cf767afabf70d3c0b195a013651055908128f08",
+ "phoneEphemeral": "df36c6700bc874c62773997c6e95704050bac93a3b649e0883a7f5791a358ebc",
+ "home": "fjzyr67buzpyncqmjliwgezvde",
+ "prologue": "6674772e636c6f75642e76313a666a7a7972363762757a70796e63716d6a6c697767657a766465",
+ "msg1Payload": "a3616245a162687a016174636e6f776373696740",
+ "msg1": "38869cb4f47eb17ca6a251b657a79943bca4b3a22813caac1578296e837a0273dd35e67fe141738f375ec80128d8e3c18dae31dcc5fe42a0273b9cbe300164e9e903d823a7c1bab23d562d1bf2879379e0a974150000f0b93b6e0c0fe2d0f58b1d814d5427606b9ab58d548696d231eab9369a86",
+ "msg2Payload": "a36162590771a4646d6f726500666f6e6c696e65f569656e76656c6f70657385a361625901fdaa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b65644279806174646c69766563736967584002a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95a3616258a1a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b65644279806174646c6976656373696758400dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79a36162588caa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847617466707269636573637369675840e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd86a6c6173745365656e4d731b000001a130d8ad88617468736e617073686f746373696740",
+ "msg2": "a0719d669e99f8ba82522bf6f2e6614d99bccb5616ae3e2af7e4635267531c148d96093e3a658c9843d6ba1321e2fc10ea6f9dc9a4ff98256ab2edf4dd8f9d8d29fc50ed8ce63ab3d409e5740f425a84b8331523b6aebb7bc37080cf41080144beee9311a8089e341d3fb4886247e9114f2990333e5e29e7f3d91f338d0fdb89bb0ff5424c533b8a6cb800dd263eb08c5995540efdad9b729ff8d63e1c15df4cfe7079a8354fee4141d7ac43a0d44cc6b820444920c760983413caefbd1a31037003228381c291226f4cead6db97fe6023e8c0c1a503e8467c6726d1a045b456971a2ba19df457439481c218b95a5a94628ca3a813ee741501e475b6039f5614ab8a5e71ffe8377d3e06fc253f106a4133709fbfdccb6c6ca754ba375e12c924cb5c52351cf78ee6645f007a439ef55774f69b8e458d93cec2a1b369759ba67772b8c4f45737a82aef39f434c956b34a8a1978e261999904b80d39cd619cda27cbc983b45385b8c2569677f76d888439e7dae4df77a4e1d05ee11526f1a6276de5a53266d13fcc620e77594115f90d1b9e802ee942c5705aecb8bcd7ca2d410474dcd89d368f5411645a69bf364e67abc04e7638028117f577c016d008ba9eea84215a921b67dab3e16ec3c11b716574c332a5047e824b0232b97a1bdd210056b4a936cf70221aef0e40fd7ae1341abf7ab9b09a4424203c2f821ca2e9360b2f2130b28fa7d28cf7ac89d7013dd086f4f7aa88f3667cd25d279b0697224504f05bb5dd15098759a86cf046521fddf4010323fa2bfd88bd58b3e6babaf32d77a607b220aa613b4a0dacc1141556d8aae189d218f9f402e57b3ededb3f8b46d5d18267566fa0c309ef988333c416218579247abd47634d6631a4c6e81ecb0c5946370b1686b08509ff35aee5a6b4aed4ff56bb1c4fd7742377f8da760867a8c8e482abd062326085f62b2343957c6826e15d8a5025a584ca3b1eb31d2f48c5c0234ec9f63b68d589d4b7ad4727bb5141e2e2d3a3a7cfbe15eae129a6cdaaab7df28eb05d8ce4cf5d65761c4f5132dc717cae2c7045860224743780aca762c3e77fec18db5c7bb73bfd66f90df934afaf0b6f4f7d6102bae17b083e47a4aa63db614aa0441c0fd45193d7bc02194d9d93241f6ce83feda0f6f16d79cd13a3aadfb1fad0e8db8a40a1434f85634e923759363a71b37579f28c3795fba377a59c719937b56d44aed8bad1fd3a7be959b80ae0e12fcf1d8f2a016a64663a9f197eb35c1d1948dea05b88ff7cf25aa794a98e01383ab4dd5e4de1aceaa0dae29b90c776b4b8c97ef95201f1b466b5a19841a427a4b74123341f763770661c4fb8b01837a785c86f065d258801fac607d886d0775b32c8856e47f05be7c0f672c886c218371104c237c95c9e149bf2a5ab68882737919351429a0982da7304bb3d2643a11f12105ac393a5167fdf30a163b175c3bfe2aea7cb89cf13a60fac27fc447e98c530726d5ed4ec0d2355488a5c0ee8b265c95a3ec9381a2e1f156740bfe1346143e227d4ed6a78adbd1447f9e2690fb878f3c7bdbaefa366edbdb4c2b99c4735847beaaeada651140497d4dc82133797dd1f3333579b93aeef3a485dc3a658a20d22584b54bd5efac1d1542e37212465b51c0ccebb55d81f68729e77db8ea5eb001abd46fd4ea13d2e67114be82976a748368948bd9f3bb44784830eb8723d37e1bb12785fdcd2b02ce60d7ad655000d5abbdbbae9db3f215308e56f378b736cd101236221dbfb5c00a15c97161b53c144de4fdfac0f1e349536a37065259b86630b2f7351568878832c2a8c8c5669f70d8430b6a22c80c898da82ccd6e0b0cb902eda4b5294981a3d037cdf4da8ea31e511fdd142546e4c01e1c0154d1c098f5b36dee0d4732ad19032e9416eed93addea024bc422e5dad3783af218534b255d5dbde9202d0931230b7c6678c66165e05f0e98099b9ebe45ae7d83a1e0f4bc15845cbe4012a807632819d8d5762286c2868056b0670c26b7faa765047b42f89d7fe6e012a262c354a485419b0fb236a4332130707728e25b323f76dfcd6d89fcff4ffa274f988bcd09207f0e24f12f69a83f448b814c5eb0d7d87f993593f27ea3189a61f111d89c9d9e6668abd495253340741b31b34234e3d89106cda2a4dd06e3bc96ad3ed9dede19f1d387b1c7e094733b9e85571d9393fa23efdef0e64af8a53d4ab314b0c0f2a0f15d16bb2db9f8e57816b10d0e8a3d3406a08a68d24d0fdb1589ef951c7e98f77f9ad558da0a9a927ad9d85779bec3376f51188d12accf7fe20a019861a3c8bf0cf5135a093a7ab2cb7b2db0ca548b5e677a3bf8e0add303e3567d71826c4fd38a65a95aeeb8c166846090ee9a173fe0a753041f9df779f693ae686b75fe9854f76d0405bd2e4e5d98f3d0ee74747229c73f2964e72861a4b8df60fe638e8c450b80de929b1d612afc9c8fe537ffad8eed8f176c4d53cd1149e9566da2ff321905ea640938df4f972cb887c66c1a7828da36a014b09f111c1464ffe85f74d6e50a8428c4384579d0180ecd00209012545755e9b1c584817446d5a930e775460bb6edb1f19f6185e63685debd622b293657d3a560ef47c53aff422488f8cb09013290f08433e0f8284fa0e5105f84a2aa2882e75b7b846f19bf6dc89894a3fd773f1804f8cfa5e8a134ba15003472bacb2e5ccae391e8f08ae52a05ee1d4c2f850f94f096087bb4d26c5a6dacde4189ce9b76aeb738021092857b36c84037356d7f3431ac041c41072c57377ea",
+ "handshakeHash": "572f4062a19964d6417d27e070137f6e012ac705c073841669685158a36a0de7",
+ "snapshot": [
+ "live full",
+ "live delta",
+ "lp",
+ "prices",
+ "hb"
+ ],
+ "transport": [
+ {
+ "from": "service",
+ "name": "hb",
+ "frame": "0101000000d0a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8",
+ "message": "00000000000000009bc932e703b7e50790aabe40832bc82ce30862451e6dbe237412298bdaec184f5bce9309f5e4823f6dc191ab5ae312c7308284bda5b4c01cebc376c65fde035767f581515047a7d463eda5e9849ee2278db80301aa88813981e80f9f3f4988de7cf8869719a5160e41e2cca4c4b0deedcff9c5706ca531452abcee947966643389f57790b7c899cf6fa4945d70b455d3270a6484d978440b91d3e23ce3055f1ef260a148ba4570bb51cb58ef3190066f2b1443d6ecd11a015b96966e0caf32236a4df15140e6ae6e131ede59ff51403400bcd325f075f7d2a5000f847a6a7996480bbbc63d08"
+ },
+ {
+ "from": "service",
+ "name": "lp",
+ "frame": "010100000250a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79",
+ "message": "000000000000000148cf8f560097201c885cbfb4654f3007e1146c3b6c39ce8a4b98af3c76347a1def28cbf0bf20db92705fcc9bff3ee7bd78220f315ebbfebc2b4371edd2b9823910351899a8b3ef8e2a26c61eaa56fd4d71fff10af0aef235390740ad8357756cbe3ccbbf667c02d3c36d056d8f2ea29dcf4dd3be678e9d19abc308d6f940c45053b6633a336e0e86ba1accd5ac2ac60a7b7471115b9c539ee973acc104a71b71c42724ed43335f9eb3eff4d447b52d989605ab2a0e3250fbf19ec53367f11f1fbaa633b1bbd7422bc0740f7d44ffc6e7fc1b1e6594fae2a3027eb58b5a63089a328878c377ef69d5b1e194606836b5db97e9da4cd5e6bac5214f17b7ef161d0a04605251de29bfab442788332d5dcfc716bc04f604544368a87cfb77d7d57b89dae49c8378b2cf2372b6fe1108076edd6adbec3fa25df23d210bb22281f91564367ec7bfb17d5b6c218eda569d65bf5a99a9a242ff72190419e2ebaa34919457c9163458d6eec3e42b67e395b6d96b771a82ca8e4cf5faf3db7493c02d1695e2b2a6e40d3156cb570f5d4880ca294fd8ac70d8deddd605cdc9fbf5cb4c0b3de38a66ff3e1ef0d35c01dfb576eda170a545682498c035afac02f29ce0e3b7c0c3d0ad1536d04481cbc90e18376c57348ff1f38b1b83000e343966b52cf82f0d243c0d225360c6c1034f0dfbc2ed08f09ff2844c578ac10f27e5fa85e77fe84b98e6fff0609e82409f9b32b5809f50355007f7284bc8d60d612d60b46627668de3d4e7966b6c1f40435bd4d0fbba103a13c29db8fb74aa1c6ca06ea20979e3093d3a88f8fbef1bba2e15cfd81822a573820f3ca87e9f9519fbb8c536f23831f69e7be75b869835"
+ },
+ {
+ "from": "phone",
+ "name": "watch away",
+ "frame": "010100000016a3616245a162687a0061746577617463686373696740",
+ "message": "000000000000000018d9d2f74604f3ec3b405b61c46ab22f3f16d31530953cc94eec8cade0d64f32ba349e2f7835559709c008f3"
+ },
+ {
+ "from": "phone",
+ "name": "suspend",
+ "frame": "010100000026a3616253a1686465766963654964685a6630397851326161746773757370656e646373696740",
+ "message": "00000000000000018ef98c7df1fa903675ab1bf0e8aff5cd342be0cdc21b8ead3d04a153cf0fe6ce0d28f5fdf88e409e68eadc1827bfc61039294263085fc27f2b9b980b"
+ }
+ ],
+ "notes": {
+ "keys": "Secrets are SHA-256 of the labels in app_vectors_test.go. X25519 clamps them.",
+ "snapshot": "Message 2 holds the cloud-vectors.json envelopes named here, in this order.",
+ "transport": "Each message is appwire's: u64 BE seq, then ChaCha20-Poly1305 over the frame with the seq as associated data. Each direction counts from 0."
+ }
+}
diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-vectors.json b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-vectors.json
new file mode 100644
index 0000000..d578b86
--- /dev/null
+++ b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-vectors.json
@@ -0,0 +1,166 @@
+{
+ "boxKey": {
+ "d": "6c50811aa47e688ecbb01b2c1e91aca4a2ad7c879526d1c9679ef3fd25560074",
+ "pk": "04cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b8",
+ "home": "fjzyr67buzpyncqmjliwgezvde"
+ },
+ "serviceKey": {
+ "d": "f8d258ed839f4602dcfc2c139237d740e960310da68140e23bf88bf4c3c6ffa5",
+ "pk": "043cb883f267f64e085082b9025d318d83a1b36bc9fa474091074498c6417100da767b737fc59344ddb1b3ae5ddd4b6446f330315d79a0fb7dc88e96a5ec827c9e"
+ },
+ "grantKey": "7777777777777777777777777777777777777777777777777777777777777777",
+ "ids": [
+ {
+ "kind": "src",
+ "name": "p1meter",
+ "id": "ACfHt4DWL7g"
+ },
+ {
+ "kind": "src",
+ "name": "sungrow",
+ "id": "f-oHMDqV-mA"
+ },
+ {
+ "kind": "src",
+ "name": "easee",
+ "id": "DUYWWQmn-Xg"
+ },
+ {
+ "kind": "lp",
+ "name": "garage",
+ "id": "j3HZORNCWHs"
+ }
+ ],
+ "messages": [
+ {
+ "name": "challenge",
+ "signer": "",
+ "t": "challenge",
+ "body": "a2617601656e6f6e636558201111111111111111111111111111111111111111111111111111111111111111",
+ "signedInputSha256": "b8baed8e00b3e02409d01f18a83294c626a1ee5d43a3e38efd071fb2a89e92f0",
+ "sig": "",
+ "envelope": "a36162582ca2617601656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116174696368616c6c656e67656373696740",
+ "frame": "010100000042a36162582ca2617601656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116174696368616c6c656e67656373696740"
+ },
+ {
+ "name": "hello",
+ "signer": "box",
+ "t": "hello",
+ "body": "ae61760162706b584104cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b863656b6d58202222222222222222222222222222222222222222222222222222222222222222637365710164626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361707385646c697665626c706670726963657362686266726f73746572656275696c6467302e3134322e3065636c6f636b636e7470656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116677616c6c4d731b000001a130d7a000676772616e744964504444444444444444444444444444444468626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333368757074696d654d7319ea6069726f7374657253657104",
+ "signedInputSha256": "85ba027484b1e3ca0d10cca2602d07413d2dac2850e410e468274ee17ac43068",
+ "sig": "32b835d4f0c887e458a7af2f52f53368a7c188d85ec502bcec39fd43e6f0fd00a0205eb354a8ea6351acedecf5b3c8c5c663bd2c66d507e282f65794a87f540d",
+ "envelope": "a36162590155ae61760162706b584104cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b863656b6d58202222222222222222222222222222222222222222222222222222222222222222637365710164626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361707385646c697665626c706670726963657362686266726f73746572656275696c6467302e3134322e3065636c6f636b636e7470656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116677616c6c4d731b000001a130d7a000676772616e744964504444444444444444444444444444444468626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333368757074696d654d7319ea6069726f737465725365710461746568656c6c6f63736967584032b835d4f0c887e458a7af2f52f53368a7c188d85ec502bcec39fd43e6f0fd00a0205eb354a8ea6351acedecf5b3c8c5c663bd2c66d507e282f65794a87f540d",
+ "frame": "0101000001a9a36162590155ae61760162706b584104cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b863656b6d58202222222222222222222222222222222222222222222222222222222222222222637365710164626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361707385646c697665626c706670726963657362686266726f73746572656275696c6467302e3134322e3065636c6f636b636e7470656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116677616c6c4d731b000001a130d7a000676772616e744964504444444444444444444444444444444468626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333368757074696d654d7319ea6069726f737465725365710461746568656c6c6f63736967584032b835d4f0c887e458a7af2f52f53368a7c188d85ec502bcec39fd43e6f0fd00a0205eb354a8ea6351acedecf5b3c8c5c663bd2c66d507e282f65794a87f540d"
+ },
+ {
+ "name": "hello_ok",
+ "signer": "service",
+ "t": "hello_ok",
+ "body": "a463656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f7374657253657103",
+ "signedInputSha256": "499bf81555f32a94ce6e39a3e1eb43880773b33005ae5efe459a8227aee4d855",
+ "sig": "230af93c21c9b3b977d9c89cfdd637ab53d0be86fee865f5b3bd0f931993476fd439a631ce64cd949df8047b48ce083610b71316cd20cd9ca12c49242c7d283d",
+ "envelope": "a361625863a463656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f737465725365710361746868656c6c6f5f6f6b637369675840230af93c21c9b3b977d9c89cfdd637ab53d0be86fee865f5b3bd0f931993476fd439a631ce64cd949df8047b48ce083610b71316cd20cd9ca12c49242c7d283d",
+ "frame": "0101000000b9a361625863a463656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f737465725365710361746868656c6c6f5f6f6b637369675840230af93c21c9b3b977d9c89cfdd637ab53d0be86fee865f5b3bd0f931993476fd439a631ce64cd949df8047b48ce083610b71316cd20cd9ca12c49242c7d283d"
+ },
+ {
+ "name": "roster",
+ "signer": "box",
+ "t": "roster",
+ "body": "a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104",
+ "signedInputSha256": "f0926ee704ac9558f742f604315ef8ea260a9edef63a210172d9c299d5b171cf",
+ "sig": "cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124",
+ "envelope": "a361625901b2a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104617466726f73746572637369675840cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124",
+ "frame": "010100000207a361625901b2a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104617466726f73746572637369675840cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124"
+ },
+ {
+ "name": "live full",
+ "signer": "box",
+ "t": "live",
+ "body": "aa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b6564427980",
+ "signedInputSha256": "e45fdca79363137b374011804fb5905c1452b533cb275c89de0aa61562886393",
+ "sig": "02a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95",
+ "envelope": "a361625901fdaa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b65644279806174646c69766563736967584002a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95",
+ "frame": "010100000250a361625901fdaa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b65644279806174646c69766563736967584002a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95"
+ },
+ {
+ "name": "live delta",
+ "signer": "box",
+ "t": "live",
+ "body": "a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b6564427980",
+ "signedInputSha256": "6881cdc040c9d5e58e85e7bfb9e3b15ae13e6a9c235a06070c43d870df0976fd",
+ "sig": "0dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6",
+ "envelope": "a3616258a1a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b65644279806174646c6976656373696758400dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6",
+ "frame": "0101000000f3a3616258a1a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b65644279806174646c6976656373696758400dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6"
+ },
+ {
+ "name": "lp",
+ "signer": "box",
+ "t": "lp",
+ "body": "a7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e",
+ "signedInputSha256": "4774dda3175a23d752f2b1aea4e1959d50edec99f4882caf007779daa829eb8f",
+ "sig": "b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79",
+ "envelope": "a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79",
+ "frame": "010100000250a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79"
+ },
+ {
+ "name": "prices",
+ "signer": "box",
+ "t": "prices",
+ "body": "aa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847",
+ "signedInputSha256": "022b3d2ae277cd3caee80775e0a2fe1c8ecebcce3263f586c0ee5e4af96a00e7",
+ "sig": "e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3",
+ "envelope": "a36162588caa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847617466707269636573637369675840e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3",
+ "frame": "0101000000e0a36162588caa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847617466707269636573637369675840e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3"
+ },
+ {
+ "name": "hb",
+ "signer": "box",
+ "t": "hb",
+ "body": "a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c",
+ "signedInputSha256": "6961171385681257aeb2a889a6c33b5854b95e85cef4d9a8fd3b6dfb7d709541",
+ "sig": "e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8",
+ "envelope": "a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8",
+ "frame": "0101000000d0a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8"
+ },
+ {
+ "name": "watch",
+ "signer": "",
+ "t": "watch",
+ "body": "a162687a01",
+ "signedInputSha256": "64808965667acfc5db8cd769b2c18654ff94b593dd47363d30b0835f438bde81",
+ "sig": "",
+ "envelope": "a3616245a162687a0161746577617463686373696740",
+ "frame": "010100000016a3616245a162687a0161746577617463686373696740"
+ },
+ {
+ "name": "resync",
+ "signer": "",
+ "t": "resync",
+ "body": "a16477686174816466756c6c",
+ "signedInputSha256": "778a30a84492300eb2b2b14555a35e0ac73d2f145d5ef5c0b9aa45e0be3f093f",
+ "sig": "",
+ "envelope": "a361624ca16477686174816466756c6c617466726573796e636373696740",
+ "frame": "01010000001ea361624ca16477686174816466756c6c617466726573796e636373696740"
+ },
+ {
+ "name": "bye",
+ "signer": "box",
+ "t": "bye",
+ "body": "a6637365710863776879677265766f6b656464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0657075726765f56677616c6c4d731b000001a130d7c71068757074696d654d731a00011170",
+ "signedInputSha256": "0499c5251783c9826fdc39a6a09e8f0f35f20244dd100b07e9978dced9475389",
+ "sig": "19b5ea48e5c3b113712f5815840c2339d654a3438a516dff0b13c444c604d94f5f25b72689a8dd9e02de38740f3b420836d0e40e12e8d236ac21e8685e781b04",
+ "envelope": "a36162584da6637365710863776879677265766f6b656464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0657075726765f56677616c6c4d731b000001a130d7c71068757074696d654d731a0001117061746362796563736967584019b5ea48e5c3b113712f5815840c2339d654a3438a516dff0b13c444c604d94f5f25b72689a8dd9e02de38740f3b420836d0e40e12e8d236ac21e8685e781b04",
+ "frame": "01010000009ea36162584da6637365710863776879677265766f6b656464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0657075726765f56677616c6c4d731b000001a130d7c71068757074696d654d731a0001117061746362796563736967584019b5ea48e5c3b113712f5815840c2339d654a3438a516dff0b13c444c604d94f5f25b72689a8dd9e02de38740f3b420836d0e40e12e8d236ac21e8685e781b04"
+ },
+ {
+ "name": "purged",
+ "signer": "service",
+ "t": "purged",
+ "body": "a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444",
+ "signedInputSha256": "8789b7655a3f732582f55de4e6e1dbb9d9a60dadc501b535560566e8c879c9b6",
+ "sig": "910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32",
+ "envelope": "a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32",
+ "frame": "010100000086a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32"
+ }
+ ]
+}
diff --git a/appleApp/FTWKit/Tests/FTWKitTests/ReviewFixTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/ReviewFixTests.swift
new file mode 100644
index 0000000..d818608
--- /dev/null
+++ b/appleApp/FTWKit/Tests/FTWKitTests/ReviewFixTests.swift
@@ -0,0 +1,238 @@
+import Foundation
+import Testing
+@testable import FTWKit
+import FTWShared
+
+/// A store that cannot answer, as the keychain before the first unlock: every
+/// read throws errSecInteractionNotAllowed, and every write is counted.
+@MainActor
+final class LockedStore: SecureStore {
+ private(set) var writes: [String] = []
+
+ func get(_ key: String) -> Bytes? { nil }
+ func read(_ key: String) throws -> Bytes? { throw SecureStoreUnavailable(status: -25308) }
+ func put(_ key: String, _ value: Bytes) { writes.append(key) }
+ func remove(_ key: String) {}
+}
+
+@MainActor
+@Suite struct ReviewFixTests {
+ /// Every open used to say "Reconnecting to your box", because a carrier
+ /// made a moment earlier was woken at once. A cold start says it is
+ /// connecting, and says reconnecting only once the carrier really drops.
+ @Test func aColdStartSaysConnectingNeverReconnecting() async throws {
+ let world = ShellTests.World()
+ let first = world.app()
+ let pair = PairModel(app: first)
+ let task = Task { await pair.pair(world.pairingURL) }
+ await world.run(500)
+ await task.value
+ first.suspend()
+
+ let app = world.app()
+ app.launch()
+ let site = try #require(app.site)
+ var said: [String] = []
+ for _ in 0..<40 {
+ await world.run(20, step: 10)
+ let message = site.freshness(noCarrier: false).message
+ if said.last != message { said.append(message) }
+ }
+ #expect(site.session.phase == .streaming)
+ #expect(said.contains("Connecting to your box"))
+ #expect(!said.contains("Reconnecting to your box"), "\(said)")
+
+ world.relay.closeCurrent(code: 1006, reason: "")
+ await world.run(40, step: 10)
+ #expect(site.freshness(noCarrier: false).message == "Reconnecting to your box")
+ }
+
+ /// A command on a carrier that is not open fails at once, not after the
+ /// five seconds an ack would get.
+ @Test func aCommandOnAClosedCarrierFailsAtOnce() async throws {
+ let scheduler = ManualScheduler()
+ let site = SiteModel(siteId: "s1", build: "t", ua: "t", scheduler: scheduler, files: nil)
+ site.start()
+ let relay = ScriptedBox()
+ site.connect(relay)
+ let started = scheduler.nowMs
+ do {
+ _ = try await site.command(Contract.opSetMode, args: [("mode", .text("self_consumption"))])
+ Issue.record("a command went out on a carrier that was not open")
+ } catch let e as CommandError {
+ #expect(e.code == "E_NO_ACK")
+ }
+ #expect(scheduler.nowMs - started < 100)
+ #expect(relay.sent.isEmpty)
+ }
+
+ /// Before the first unlock the keychain cannot answer. Nothing is then
+ /// written: no new wrap key over the old one, no cache key, no list of
+ /// homes with the others dropped, and no pairing.
+ @Test func aLockedKeychainLeadsToNoWrite() async throws {
+ let store = LockedStore()
+ let vault = Vault(store: store)
+ #expect(throws: SecureStoreUnavailable.self) { try vault.localWrappingKey() }
+ let files = SealedFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-locked-\(UUID().uuidString)"), store: store)
+ files.write("snapshot-s1", [1, 2, 3])
+ #expect(files.read("snapshot-s1") == nil)
+ SiteList(store: store).put(StoredSite(siteId: "s1", label: "Home", boxStaticKey: Data(repeating: 1, count: 32), rendezvousSecret: nil, pairingCode: nil, lanHint: nil, addedAtMs: 0, lastSeenAtMs: 0))
+
+ let app = AppModel(store: store, files: files, passkeys: nil, scheduler: ManualScheduler(), build: "t", ua: "t")
+ app.launch()
+ await app.connect()
+ #expect(store.writes.isEmpty, "\(store.writes)")
+ }
+
+ /// After 20 s without a connection the band stops counting and says when
+ /// the last reading was.
+ @Test func aLongWaitSaysCantReachWithTheLastReading() async throws {
+ let scheduler = ManualScheduler()
+ let site = SiteModel(siteId: "s1", build: "t", ua: "t", scheduler: scheduler, files: nil)
+ site.start()
+ site.connect(ScriptedBox())
+ var band = site.freshness(noCarrier: false)
+ #expect(band.wait == "0s")
+ for _ in 0..<19 { scheduler.advance(1_000) }
+ band = site.freshness(noCarrier: false)
+ #expect(band.wait == "19s")
+ for _ in 0..<2 { scheduler.advance(1_000) }
+ band = site.freshness(noCarrier: false)
+ #expect(band.message == "Can't reach your box")
+ #expect(band.wait == nil)
+ #expect(band.tone == .lost)
+
+ let withReading = Freshness.band(carrier: .cache, transport: .none, srcState: .stale, ageMs: 120_000, phase: .failed,
+ waitMs: 25_000, bootPct: nil, noCarrier: false, lastReading: "14:02")
+ #expect(withReading.message == "Can't reach your box · last reading 14:02")
+ #expect(withReading.wait == nil)
+ let starting = Freshness.band(carrier: .cache, transport: .none, srcState: .stale, ageMs: 120_000, phase: .booting,
+ waitMs: 25_000, bootPct: 40, noCarrier: false, lastReading: "14:02")
+ #expect(starting.message == "Your box is starting")
+ }
+
+ /// While live, the clock moves with the frames: one change a second, not
+ /// a frame and a tick.
+ @Test func theClockMovesWithTheFrames() async throws {
+ let scheduler = ManualScheduler()
+ let site = SiteModel(siteId: "s1", build: "t", ua: "t", scheduler: scheduler, files: nil)
+ site.start()
+ let relay = ScriptedBox()
+ site.connect(relay)
+ relay.open(scheduler.nowMs)
+ relay.helloOk(uptimeMs: 60_000, cloud: nil)
+ relay.snap(uptimeMs: 60_000, gridW: 500)
+ scheduler.advance(20)
+ var changes = 0
+ site.observe { changes += 1 }
+ for i in 1...10 {
+ scheduler.advance(1_000)
+ relay.say("delta", .map([("seq", .int(i)), ("uptimeMs", .int(60_000 + i * 1_000)), ("fields", .map(["2": .int(500 + i)]))]))
+ }
+ scheduler.advance(20)
+ #expect(site.isLive)
+ #expect(changes <= 12, "\(changes) changes in 10 s of 1 Hz frames")
+ #expect(site.nowMs == scheduler.nowMs - 20)
+
+ // Silence: the clock wakes once to say the stream went quiet.
+ scheduler.advance(SiteModel.streamQuietAfterMs + 10)
+ #expect(site.srcState == .lagging)
+ }
+
+ /// Found in the simulator: while a burst waited for its frame, the band
+ /// read the newest relay state and said "Live via encrypted relay" as
+ /// the card still said "Not live right now". Both read one snapshot now.
+ @Test func theBandAndTheCardsNeverDisagree() async throws {
+ let scheduler = ManualScheduler()
+ let site = SiteModel(siteId: "s1", build: "t", ua: "t", scheduler: scheduler, files: nil)
+ site.start()
+ let relay = ScriptedBox()
+ site.connect(relay)
+ relay.open(scheduler.nowMs)
+ relay.helloOk(uptimeMs: 60_000, cloud: nil)
+ relay.snap(uptimeMs: 60_000, gridW: 500)
+ // Within the frame: whatever is shown agrees with itself.
+ #expect((site.carrier == .relay) == (site.session.phase == .streaming))
+ #expect(!site.isLive || site.carrier == .relay)
+ scheduler.advance(20)
+ #expect(site.carrier == .relay && site.session.phase == .streaming && site.isLive)
+ }
+
+ /// The same home is not written twice, and the background writes once.
+ @Test func theSameSnapshotIsNotWrittenTwice() async throws {
+ let w = try CloudSite()
+ w.open()
+ w.site.connect(w.relay)
+ w.relay.open(w.scheduler.nowMs)
+ w.relay.helloOk(uptimeMs: w.uptime, cloud: nil)
+ w.relay.snap(uptimeMs: w.uptime, gridW: 700)
+ await w.run(40)
+ w.site.persistNow(wait: true)
+ // Each write draws a new nonce, so equal bytes prove no second write.
+ let first = w.files.read("snapshot-s1")
+ w.site.persistNow(wait: true)
+ w.site.suspend()
+ #expect(first != nil)
+ #expect(w.files.read("snapshot-s1") == first)
+ }
+
+ /// Numbers never step back: a cloud copy older than the cache on screen
+ /// waits until the cloud has something newer.
+ @Test func anOlderCopyNeverReplacesANewerOne() async throws {
+ let w = try CloudSite()
+ w.keepFromEarlierLaunch(gridW: 111)
+ // The cache was written a minute ago; the cloud's copy is from five
+ // minutes ago and has gone stale.
+ let old = try w.live(gridW: 900)
+ w.scheduler.advance(300_000)
+ w.files.writeJSON("snapshot-s1", CachedSnapshot(siteId: "s1", savedAtMs: w.scheduler.nowMs - 60_000, state: {
+ var s = SessionState()
+ s.fields = [Contract.FID.gridW: 111]
+ return s
+ }()))
+ w.service.held = [old]
+ w.open()
+ w.connectCloud()
+ await w.run(60)
+ #expect(w.site.session.fields[Contract.FID.gridW] == 111)
+ #expect(w.site.carrier == .cache)
+
+ // A fresh live from the box is newer than the cache, and takes over.
+ w.service.push(try w.live(gridW: 1200))
+ await w.run(60)
+ #expect(w.site.session.fields[Contract.FID.gridW] == 1200)
+ #expect(w.site.carrier == .cloud)
+ let at = try #require(w.site.fieldAtMs[Contract.FID.gridW])
+ #expect(abs(at - w.scheduler.nowMs) < 200)
+ }
+
+ /// Envelopes that land in one burst reach the screens as one change.
+ @Test func aBurstIsOneChange() async throws {
+ let w = try CloudSite()
+ w.keepFromEarlierLaunch(gridW: nil)
+ w.service.held = [try w.live(gridW: 1000)]
+ w.open()
+ w.connectCloud()
+ await w.run(60)
+ var changes = 0
+ w.site.observe { changes += 1 }
+ w.service.push(try w.live(gridW: 1100))
+ w.service.push(try w.live(gridW: 1150))
+ w.service.push(try w.live(gridW: 1200))
+ await w.run(60)
+ #expect(w.site.session.fields[Contract.FID.gridW] == 1200)
+ #expect(changes <= 2, "\(changes) changes for one burst")
+ }
+
+ /// The cloud's next grade change is when a reading's age passes S.
+ @Test func theCloudSaysWhenItsGradeNextChanges() throws {
+ var box = try FakeCloudBox(boot: 1)
+ let at: Int64 = 2_000_000
+ var view = box.view()
+ try view.accept(box.fullLive(at, age: 1_000), arrivalMs: at + 100)
+ // Rule N: sent at `at`. S = 10 s, age 1 s: live until at + 9 s.
+ #expect(view.nextGradeChange(nowMs: at + 100) == at + 9_001)
+ #expect(view.grade(nowMs: at + 9_001).sources["m"] == .lagging)
+ #expect(view.grade(nowMs: at + 9_000).sources["m"] == .live)
+ }
+}
diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift
new file mode 100644
index 0000000..a838b08
--- /dev/null
+++ b/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift
@@ -0,0 +1,386 @@
+import Foundation
+import Testing
+@testable import FTWKit
+import FTWShared
+
+/// A box session the test speaks for frame by frame: hello_ok with or
+/// without FTW Cloud, then a snapshot of readings.
+@MainActor
+final class ScriptedBox: Carrier {
+ let kind: CarrierKind = .relay
+ private(set) var status: CarrierStatus = .connecting
+ private var onFrame: @MainActor (Bytes) -> Void = { _ in }
+ private var onStatus: @MainActor (CarrierStatus) -> Void = { _ in }
+ private(set) var sent: [String] = []
+ /// The box's HTTP API through the passthrough: method, path and body in,
+ /// status and JSON out.
+ var api: ((String, String, String) -> (Int, String))?
+ private(set) var calls: [(method: String, path: String, body: String)] = []
+
+ @discardableResult
+ func send(_ frame: Bytes) -> Bool {
+ guard status.isOpen else { return false }
+ guard let f = try? Frame.decode(frame) else { return true }
+ sent.append(f.envelope.t)
+ guard f.envelope.t == "api.req", let id = f.envelope.id, let b = f.envelope.b else { return true }
+ let method = b["method"]?.string ?? "", path = b["path"]?.string ?? ""
+ let body = b["body"]?.byteString.map { String(decoding: $0, as: UTF8.self) } ?? ""
+ calls.append((method, path, body))
+ let (status, json) = api?(method, path, body) ?? (404, "{}")
+ reply("api.head", id, .map([("status", .int(status)), ("headers", .map([("content-type", .text("application/json"))]))]))
+ reply("api.chunk", id, .map([("seq", .int(0)), ("data", .bytes(Bytes(json.utf8)))]))
+ reply("api.end", id, CBOR.emptyMap)
+ return true
+ }
+
+ private func reply(_ t: String, _ id: UInt32, _ body: CBOR) {
+ guard let frame = try? Frame.encodeBulk(envelope: Envelope(t: t, id: id, b: body)) else { return }
+ onFrame(frame)
+ }
+
+ func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void) {
+ self.onFrame = onFrame
+ self.onStatus = onStatus
+ }
+
+ func wake() {}
+
+ func close(reason: String) {
+ status = .closed(reason: reason, retryable: false)
+ }
+
+ func open(_ at: Double) {
+ status = .open(sinceMs: at)
+ onStatus(status)
+ }
+
+ func say(_ t: String, _ body: CBOR) {
+ guard let frame = try? Frame.encodeBulk(envelope: Envelope(t: t, b: body)) else { return }
+ onFrame(frame)
+ }
+
+ func helloOk(uptimeMs: Int64, cloud: CloudIdentity?, role: String = Contract.roleOwner, caps: [String] = [Contract.capCloudLink, Contract.capAPIPassthrough]) {
+ var body: [(String, CBOR)] = [
+ ("proto", .int(Proto.max)), ("mode", .text("full")),
+ ("box", .map([("id", .text("box")), ("build", .text("t")), ("tz", .text("Europe/Stockholm"))])),
+ ("clock", .map([("source", .text("ntp")), ("syncedAtMs", .int(0)), ("uptimeMs", .int(uptimeMs))])),
+ ("caps", .array(caps.map { .text($0) })), ("role", .text(role)), ("modes", .array([])),
+ ("subscribed", .bool(true)),
+ ]
+ if let cloud {
+ body.append(("cloud", .map([
+ ("pk", .bytes(cloud.pk)), ("home", .text(cloud.home)), ("boot", .bytes(cloud.boot)),
+ ("sources", .map(cloud.sources.sorted { $0.key < $1.key }.map { (k, v) -> (String, CBOR) in (k, .text(v)) })),
+ ("loadpoints", .map([(String, CBOR)]())),
+ ])))
+ }
+ say("hello_ok", .map(body))
+ }
+
+ func snap(uptimeMs: Int64, gridW: Int) {
+ say("snap", .map([
+ ("uptimeMs", .int(uptimeMs)), ("controlRev", .int(1)),
+ ("dict", .map(["2": .map([("name", .text("grid")), ("unit", .text("W")), ("srcId", .text("p1meter"))])])),
+ ("fields", .map(["2": .int(gridW)])),
+ ("sources", .map(["p1meter": .map([("kind", .text("driver")), ("name", .text("P1 meter")), ("lastOkMs", .int(uptimeMs - 200)),
+ ("staleAfterMs", .int(10_000)), ("state", .text("live"))])])),
+ ("dispatchBlockedBy", .array([])),
+ ]))
+ }
+}
+
+/// A home with FTW Cloud on: a box that signs, the service, and a site model
+/// that reads them.
+@MainActor
+final class CloudSite {
+ let scheduler = ManualScheduler()
+ let store = MemoryStore()
+ let files: SealedFiles
+ let shared = AppGroupFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-shared-\(UUID().uuidString)"))
+ let service: FakeCloudService
+ let device = Primitives.generateKeyPair()
+ var box: FakeCloudBox
+ let identity: CloudIdentity
+ var site: SiteModel!
+ let relay = ScriptedBox()
+ let tokens = FakeTokens()
+
+ init(noClock: Bool = false) throws {
+ files = SealedFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-test-\(UUID().uuidString)"), store: store)
+ service = FakeCloudService(scheduler: scheduler)
+ box = try FakeCloudBox(boot: 1)
+ box.start = Int64(scheduler.nowMs) - 60_000
+ box.noClock = noClock
+ identity = CloudIdentity(pk: box.pk, boot: box.boot, sources: ["m": "p1meter"])
+ service.roster = [device.publicKey]
+ }
+
+ var at: Int64 { Int64(scheduler.nowMs) }
+ var uptime: Int64 { at - box.start }
+
+ /// What an earlier launch left: the box's word on FTW Cloud, and a home
+ /// on screen a minute ago.
+ func keepFromEarlierLaunch(gridW: Int? = 111) {
+ files.writeJSON("cloud-s1", CloudCache(identity: identity, offset: nil))
+ guard let gridW else { return }
+ var s = SessionState()
+ s.fields = [Contract.FID.gridW: Double(gridW)]
+ s.uptimeMs = 50_000
+ s.sources = ["p1meter": Source(kind: "driver", name: "P1 meter", lastOkMs: 49_800, staleAfterMs: 10_000, state: .live)]
+ s.dict = [Contract.FID.gridW: FieldDef(name: "grid", unit: "W", srcId: "p1meter")]
+ files.writeJSON("snapshot-s1", CachedSnapshot(siteId: "s1", savedAtMs: scheduler.nowMs - 60_000, state: s))
+ }
+
+ func open() {
+ site = SiteModel(siteId: "s1", build: "test", ua: "test", scheduler: scheduler, files: files, shared: shared)
+ site.pushTokens = tokens.source
+ site.start()
+ }
+
+ func connectCloud() {
+ site.connectCloud(deviceKey: device, makeSocket: service.factory, endpoint: service.endpoint)
+ }
+
+ func live(gridW: Int, age: Int64 = 400) throws -> CloudWire.Envelope {
+ let h = box.header(at)
+ let m = CBOR.map([("kind", .text("driver")), ("state", .text("live")), ("lastOkMs", .int(uptime - age)), ("staleAfterMs", .int(10_000))])
+ return try box.seal(CloudWire.msgLive, h + [
+ ("fields", .map(["2": .int(Int64(gridW))])), ("fieldSources", .map(["2": .array([.text("m")])])),
+ ("sources", .map(["m": m])), ("srcRev", .unsigned(1)), ("ages", .map(["m": .int(age)])), ("dispatchBlockedBy", .array([])),
+ ])
+ }
+
+ func run(_ ms: Double) async {
+ var left = ms
+ while left > 0 {
+ for _ in 0..<5 { await Task.yield() }
+ scheduler.advance(min(10, left))
+ left -= 10
+ }
+ for _ in 0..<20 { await Task.yield() }
+ }
+}
+
+@MainActor
+@Suite struct SiteModelCloudTests {
+ /// The three layers: the phone's cache at once, the cloud's copy one
+ /// round trip later, then each change on the same socket.
+ @Test func cacheThenTheCloudsCopyThenEachChange() async throws {
+ let w = try CloudSite()
+ w.keepFromEarlierLaunch(gridW: 111)
+ w.service.held = [try w.live(gridW: 1000)]
+ w.open()
+ #expect(w.site.carrier == .cache)
+ #expect(w.site.session.fields[Contract.FID.gridW] == 111)
+
+ w.connectCloud()
+ await w.run(60)
+ #expect(w.site.carrier == .cloud)
+ #expect(w.site.session.fields[Contract.FID.gridW] == 1000)
+ #expect(w.site.isLive)
+ #expect(w.site.srcState == .live)
+ #expect(w.site.freshness(noCarrier: false).message == "Live via FTW Cloud")
+ // Names come from the box's table, never from the service.
+ #expect(w.site.session.dict[Contract.FID.gridW]?.srcId == "p1meter")
+ #expect(w.site.session.sources["p1meter"]?.state == .live)
+
+ let firstFrame = w.site.lastFrameAtMs
+ w.service.push(try w.live(gridW: 1200))
+ await w.run(50)
+ #expect(w.site.session.fields[Contract.FID.gridW] == 1200)
+ #expect(w.site.lastFrameAtMs != firstFrame)
+ }
+
+ /// The doc's replay case on the phone: a snapshot the service held back
+ /// an hour arrives, signed and intact, and shows its age, never live.
+ @Test func aReplayedSnapshotIsNotLive() async throws {
+ let w = try CloudSite()
+ w.keepFromEarlierLaunch(gridW: nil)
+ // The box signed it an hour before the phone opens.
+ let old = try w.live(gridW: 900)
+ w.scheduler.advance(3_600_000)
+ w.service.held = [old]
+ w.open()
+ w.connectCloud()
+ await w.run(60)
+ #expect(w.site.carrier == .cloud)
+ #expect(w.site.session.fields[Contract.FID.gridW] == 900)
+ #expect(w.site.srcState == .down)
+ #expect(!w.site.isLive)
+ #expect((w.site.ageMs ?? 0) > 3_500_000)
+ }
+
+ /// No wall clock on the box and no rule U yet: shown, unverified, never
+ /// live.
+ @Test func noAnchorIsNeverLive() async throws {
+ let w = try CloudSite(noClock: true)
+ w.keepFromEarlierLaunch(gridW: nil)
+ w.service.held = [try w.live(gridW: 1000)]
+ w.open()
+ w.connectCloud()
+ await w.run(60)
+ #expect(w.site.carrier == .cloud)
+ #expect(w.site.srcState == .stale)
+ #expect(w.site.ageMs == nil)
+ #expect(w.site.freshness(noCarrier: false).message == "FTW Cloud · age unverified")
+ }
+
+ /// Rule U from the box session gives the same box a trusted clock.
+ @Test func theBoxSessionAnchorsABoxWithoutAClock() async throws {
+ let w = try CloudSite(noClock: true)
+ w.open()
+ w.connectCloud()
+ w.site.connect(w.relay)
+ w.relay.open(w.scheduler.nowMs)
+ w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity)
+ w.service.held = [try w.live(gridW: 1000)]
+ await w.run(60)
+ #expect(w.site.carrier == .cloud)
+ #expect(w.site.srcState == .live)
+ #expect(w.site.ageMs != nil)
+ }
+
+ /// The cloud fails or is out of reach: the box session's readings take
+ /// over under its own name, and the cloud takes back over when it heals.
+ @Test func theRelayTakesOverWhileTheCloudIsDown() async throws {
+ let w = try CloudSite()
+ w.open()
+ w.connectCloud()
+ w.site.connect(w.relay)
+ w.relay.open(w.scheduler.nowMs)
+ w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity)
+ w.relay.snap(uptimeMs: w.uptime, gridW: 700)
+ w.service.held = [try w.live(gridW: 1000)]
+ await w.run(60)
+ #expect(w.site.carrier == .cloud)
+ #expect(w.site.session.fields[Contract.FID.gridW] == 1000)
+
+ w.service.close(code: 1006)
+ await w.run(20)
+ #expect(w.site.carrier == .relay)
+ #expect(w.site.session.fields[Contract.FID.gridW] == 700)
+ #expect(w.site.freshness(noCarrier: false).message == "Live via encrypted relay")
+
+ await w.run(600)
+ #expect(w.site.carrier == .cloud)
+ }
+
+ /// A box session that streams live wins over a cloud copy that is not
+ /// live: never show older data than the phone has.
+ @Test func aLiveBoxSessionBeatsAStaleCloud() async throws {
+ let w = try CloudSite()
+ w.open()
+ w.connectCloud()
+ w.site.connect(w.relay)
+ w.relay.open(w.scheduler.nowMs)
+ w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity)
+ let old = try w.live(gridW: 900, age: 120_000)
+ w.service.held = [old]
+ w.relay.snap(uptimeMs: w.uptime, gridW: 700)
+ await w.run(60)
+ #expect(w.site.carrier == .relay)
+ #expect(w.site.session.fields[Contract.FID.gridW] == 700)
+ }
+
+ /// The box said FTW Cloud is off: the session closes and nothing of it
+ /// is kept.
+ @Test func aGrantTurnedOffForgetsTheCloud() async throws {
+ let w = try CloudSite()
+ w.keepFromEarlierLaunch(gridW: nil)
+ w.service.held = [try w.live(gridW: 1000)]
+ w.open()
+ w.connectCloud()
+ await w.run(60)
+ #expect(w.site.cloudOn)
+ w.site.connect(w.relay)
+ w.relay.open(w.scheduler.nowMs)
+ w.relay.helloOk(uptimeMs: w.uptime, cloud: nil)
+ await w.run(30)
+ #expect(!w.site.cloudOn)
+ #expect(w.site.carrier != .cloud)
+ #expect(w.service.current == nil)
+ #expect(w.files.readJSON(CloudCache.self, "cloud-s1") == nil)
+ }
+
+ /// A first launch learns FTW Cloud from hello and keeps it for the next.
+ @Test func helloStartsTheCloudAndIsKept() async throws {
+ let w = try CloudSite()
+ w.service.held = [try w.live(gridW: 1000)]
+ w.open()
+ w.connectCloud()
+ await w.run(60)
+ #expect(w.service.dialled.isEmpty)
+ w.site.connect(w.relay)
+ w.relay.open(w.scheduler.nowMs)
+ w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity)
+ await w.run(60)
+ #expect(w.service.dialled.count == 1)
+ #expect(w.site.carrier == .cloud)
+ #expect(w.files.readJSON(CloudCache.self, "cloud-s1")?.identity == w.identity)
+ }
+
+ /// An envelope that does not verify never reaches a screen.
+ @Test func anEnvelopeTheBoxDidNotSignIsDropped() async throws {
+ let w = try CloudSite()
+ w.keepFromEarlierLaunch(gridW: nil)
+ w.service.held = [try w.live(gridW: 1000)]
+ w.open()
+ w.connectCloud()
+ await w.run(60)
+ var forged = try w.live(gridW: 5)
+ forged.b[forged.b.count - 3] ^= 1
+ w.service.push(forged)
+ await w.run(50)
+ #expect(w.site.session.fields[Contract.FID.gridW] == 1000)
+ }
+
+ /// Prices and cars from the cloud go to the widgets, labelled as read
+ /// through FTW Cloud.
+ @Test func theWidgetsGetTheCloudsPricesAndCars() async throws {
+ let w = try CloudSite()
+ w.keepFromEarlierLaunch(gridW: nil)
+ let t0 = w.at - w.at % 86_400_000
+ let prices = try w.box.seal(CloudWire.msgPrices, w.box.header(w.at) + [
+ ("zone", .text("SE3")), ("currency", .text("SEK")), ("t0Ms", .int(t0)), ("slotMs", .int(900_000)),
+ ("spotMinor", .array([.int(42), .null, .int(40)])), ("totalMinor", .array([.int(118), .null, .int(115)])),
+ ])
+ let lp = try w.box.seal(CloudWire.msgLP, w.box.header(w.at) + [
+ ("controlRev", .int(3)),
+ ("loadpoints", .array([.map([("id", .text("j3HZ")), ("src", .text("m")), ("plugged", .bool(true)),
+ ("socPermille", .int(430)), ("socSource", .text("vehicle")), ("socAgeS", .int(60)),
+ ("targetPermille", .int(800)), ("powerW", .int(2100))])])),
+ ("feedback", .array([])),
+ ])
+ w.service.held = [try w.live(gridW: 1000), prices, lp]
+ w.open()
+ w.connectCloud()
+ await w.run(60)
+ for _ in 0..<200 where w.shared.read(CarEntry.self, siteId: "s1") == nil {
+ try await Task.sleep(nanoseconds: 2_000_000)
+ }
+ let p = try #require(w.shared.read(PriceEntry.self, siteId: "s1"))
+ #expect(p.carrier == .cloud)
+ #expect(p.value.prices.slots.count == 2)
+ #expect(p.value.prices.slots.first?.totalMinor == 118)
+ let car = try #require(w.shared.read(CarEntry.self, siteId: "s1"))
+ #expect(car.carrier == .cloud)
+ #expect(car.value.cars.first?.socPct == 43)
+ #expect(car.value.cars.first?.pluggedIn == true)
+ }
+
+ /// The background closes the session, as it closes the relay's.
+ @Test func theBackgroundClosesTheCloud() async throws {
+ let w = try CloudSite()
+ w.keepFromEarlierLaunch(gridW: nil)
+ w.service.held = [try w.live(gridW: 1000)]
+ w.open()
+ w.connectCloud()
+ await w.run(60)
+ w.site.suspend()
+ #expect(w.service.current == nil)
+ #expect(w.site.carrier != .cloud)
+ #expect(w.site.cloudStatus == nil)
+ // The readings stay, with their age.
+ #expect(w.site.session.fields[Contract.FID.gridW] == 1000)
+ }
+}
diff --git a/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift
index b0c4667..0c93240 100644
--- a/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift
+++ b/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift
@@ -39,6 +39,9 @@ import Testing
let read = try #require(CacheReader(sources: [shared]).read(PriceEntry.self, siteId: "home"))
#expect(read.value.prices == written)
+ // The widget reads the clock the way the home does.
+ #expect(read.value.timeZone != nil)
+ #expect(read.value.timeZone == site.session.box?.tz)
#expect(read.carrier == .cache)
#expect(read.record.readAtMs >= asked && read.record.readAtMs <= scheduler.nowMs)
#expect(wakes.count == 1)
diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PushAlertTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PushAlertTests.swift
new file mode 100644
index 0000000..3e2574f
--- /dev/null
+++ b/appleApp/FTWKit/Tests/FTWSharedTests/PushAlertTests.swift
@@ -0,0 +1,316 @@
+import CryptoKit
+import Foundation
+import Testing
+@testable import FTWShared
+
+/// The push check against srcfl/ftw's own vectors: every case in
+/// push-vectors.json, as the Go reference reads it, and every message in
+/// cloud-vectors.json for the envelope and the bytes a signature covers.
+/// Then the edges the vectors do not reach, signed here with the vector box
+/// key.
+@Suite struct PushAlertTests {
+ struct PushFile: Decodable {
+ struct Key: Decodable { var home: String; var pk: String }
+ struct Want: Decodable {
+ var verdict: String
+ var home: String
+ var kind: String?
+ var args: [String: String]?
+ var src: String?
+ var lp: String?
+ var boot: String?
+ var seq: UInt64?
+ var atMs: Int64?
+ }
+ struct Offset: Decodable {
+ var home: String
+ var boot: String
+ var offsetMs: Int64
+ var learnedAtMs: Int64
+ }
+ struct Case: Decodable {
+ var name: String
+ var payload: String
+ var offset: Offset?
+ var nowMs: Int64
+ var want: Want
+ }
+ var keys: [Key]
+ var cases: [Case]
+ }
+
+ struct CloudFile: Decodable {
+ struct Key: Decodable { var d: String?; var pk: String; var home: String? }
+ struct Message: Decodable {
+ var name: String
+ var signer: String
+ var t: String
+ var body: String
+ var signedInputSha256: String
+ var sig: String
+ var envelope: String
+ }
+ var boxKey: Key
+ var serviceKey: Key
+ var messages: [Message]
+ }
+
+ static func load(_ 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 == 18)
+ let keys = Dictionary(uniqueKeysWithValues: file.keys.map { ($0.home, Data(hex: $0.pk)) })
+ for c in file.cases {
+ let payload = Data(c.payload.utf8)
+ // Rule U's anchor, held for one home, as CloudKeys holds it.
+ let offset = { (home: String) -> CloudHome.Offset? in
+ guard let o = c.offset, o.home == home else { return nil }
+ return CloudHome.Offset(boot: Array(Data(hex: o.boot)), offsetMs: o.offsetMs, learnedAtMs: o.learnedAtMs)
+ }
+ let fromJSON = PushAlert.read(payload: payload, boxKey: { keys[$0] }, offset: offset, nowMs: c.nowMs)
+ // The extension gets userInfo, not the bytes: both roads agree.
+ let userInfo = try #require(JSONSerialization.jsonObject(with: payload) as? [AnyHashable: Any])
+ let fromUserInfo = PushAlert.read(userInfo: userInfo, boxKey: { keys[$0] }, offset: offset, nowMs: c.nowMs)
+ #expect(fromJSON == fromUserInfo, "\(c.name)")
+
+ let a = fromJSON
+ let w = c.want
+ #expect(a.verdict.rawValue == w.verdict, "\(c.name)")
+ #expect(a.home == w.home, "\(c.name)")
+ #expect(a.kind == (w.kind ?? ""), "\(c.name)")
+ #expect(a.args == (w.args ?? [:]), "\(c.name)")
+ #expect((a.src ?? "") == (w.src ?? ""), "\(c.name)")
+ #expect((a.lp ?? "") == (w.lp ?? ""), "\(c.name)")
+ #expect((a.boot?.hex ?? "") == (w.boot ?? ""), "\(c.name)")
+ #expect(a.seq == (w.seq ?? 0), "\(c.name)")
+ #expect(a.atMs == (w.atMs ?? 0), "\(c.name)")
+ }
+ }
+
+ /// Every envelope parses, its body is the bytes carried, the signed
+ /// input hashes as Go's does, and each signature verifies with its
+ /// signer's key and with no other.
+ @Test func everyCloudMessageVerifiesOverTheBytesItCarries() throws {
+ let file = try Self.load("cloud-vectors", as: CloudFile.self)
+ let home = try #require(file.boxKey.home)
+ let keys = ["box": Data(hex: file.boxKey.pk), "service": Data(hex: file.serviceKey.pk)]
+ #expect(file.messages.count == 16)
+ for m in file.messages {
+ let env = try #require(PushAlert.Envelope(Array(Data(hex: m.envelope))), "\(m.name)")
+ #expect(env.t == m.t, "\(m.name)")
+ #expect(Data(env.b) == Data(hex: m.body), "\(m.name)")
+ #expect(Data(env.sig) == Data(hex: m.sig), "\(m.name)")
+ let input = PushCBOR.signedInput(home: home, t: env.t, b: env.b)
+ #expect(Data(SHA256.hash(data: input)) == Data(hex: m.signedInputSha256), "\(m.name)")
+ for (signer, pk) in keys {
+ #expect(PushAlert.verify(pk, home: home, env) == (signer == m.signer), "\(m.name) with the \(signer) key")
+ }
+ // Signed for another home, never valid.
+ if let pk = keys[m.signer] {
+ #expect(!PushAlert.verify(pk, home: "aaaaaaaaaaaaaaaaaaaaaaaaaa", env), "\(m.name)")
+ }
+ // The strict reader takes every body the box sends.
+ #expect((try? PushCBOR.decode(env.b)) != nil, "\(m.name)")
+ }
+ }
+
+ /// The three events of the shared vectors, delivered fresh, read as
+ /// events, and session complete carries its energy.
+ @Test func theSharedEventsReadAsEvents() throws {
+ let file = try Self.load("cloud-vectors", as: CloudFile.self)
+ let home = try #require(file.boxKey.home)
+ let pk = Data(hex: file.boxKey.pk)
+ for m in file.messages where m.t == "event" {
+ let env = try #require(PushAlert.Envelope(Array(Data(hex: m.envelope))))
+ let ev = try #require(PushAlert.Event(env.b))
+ let now = try #require(ev.wallMs) + 1_200
+ let a = PushAlert.read(payload: Self.payload(home: home, event: Data(hex: m.envelope)), boxKey: { $0 == home ? pk : nil }, nowMs: now)
+ #expect(a.verdict == .event, "\(m.name)")
+ #expect(a.kind == ev.kind)
+ #expect(a.lp == "j3HZORNCWHs")
+ #expect(a.src == "DUYWWQmn-Xg")
+ #expect(a.boot == Data(repeating: 0xb0, count: 16))
+ if ev.kind == PushKind.chargingSessionComplete {
+ #expect(a.args == ["kwh": "18.4"])
+ #expect(PushText.content(a, language: .en, decimalSeparator: ".").body == "18.4 kWh delivered — ready to go.")
+ #expect(PushText.content(a, language: .sv, decimalSeparator: ",").body == "18,4 kWh laddat – redo att åka.")
+ }
+ }
+ }
+
+ // MARK: Edges, signed here with the vector box
+
+ static let boxKey: P256.Signing.PrivateKey = {
+ let file = try! load("cloud-vectors", as: CloudFile.self)
+ return try! P256.Signing.PrivateKey(rawRepresentation: Data(hex: file.boxKey.d!))
+ }()
+ static var home: String { "fjzyr67buzpyncqmjliwgezvde" }
+ static var pk: Data { boxKey.publicKey.x963Representation }
+ static let wall: Int64 = 1_791_820_800_000
+
+ static func body(_ extra: [(String, TestCBOR)] = [], drop: Set = [], kind: String = PushKind.chargingConnected) -> [UInt8] {
+ var fields: [(String, TestCBOR)] = [
+ ("seq", .u(9)), ("boot", .b(Array(repeating: 0xb0, count: 16))), ("uptimeMs", .u(71_000)),
+ ("wallMs", .u(UInt64(wall))), ("eventId", .t("Q2ZrMjR0dzVh")), ("kind", .t(kind)),
+ ("severity", .t("info")), ("args", .m([])), ("lp", .t("j3HZORNCWHs")),
+ ]
+ fields.removeAll { drop.contains($0.0) || extra.map(\.0).contains($0.0) }
+ return TestCBOR.m(fields + extra).encoded
+ }
+
+ static func envelope(_ body: [UInt8], t: String = "event", home: String = home) -> [UInt8] {
+ let sig = try! boxKey.signature(for: PushCBOR.signedInput(home: home, t: t, b: body)).rawRepresentation
+ return TestCBOR.m([("b", .b(body)), ("t", .t(t)), ("sig", .b(Array(sig)))]).encoded
+ }
+
+ static func payload(home: String = home, event: Data? = nil, kind: String? = nil) -> Data {
+ var ftw: [String: Any] = ["v": 1, "home": home]
+ if let event { ftw["event"] = event.base64EncodedString() }
+ if let kind { ftw["kind"] = kind }
+ let aps: [String: Any] = ["alert": ["title-loc-key": "push.title", "loc-key": "push.generic"], "mutable-content": 1, "sound": "default"]
+ return try! JSONSerialization.data(withJSONObject: ["aps": aps, "ftw": ftw])
+ }
+
+ static func read(_ env: [UInt8], nowMs: Int64 = wall + 1_200, kind: String? = nil) -> PushAlert {
+ PushAlert.read(payload: payload(event: Data(env), kind: kind), boxKey: { $0 == home ? pk : nil }, nowMs: nowMs)
+ }
+
+ @Test func aFreshSignedEventReads() {
+ let a = Self.read(Self.envelope(Self.body()))
+ #expect(a.verdict == .event)
+ #expect(a.atMs == Self.wall)
+ #expect(a.seq == 9)
+ #expect(a.src == nil)
+ }
+
+ /// Unknown keys are a newer box, not a fault.
+ @Test func unknownKeysAreIgnored() {
+ #expect(Self.read(Self.envelope(Self.body([("later", .m([("x", .u(1))]))]))).verdict == .event)
+ }
+
+ /// A field of the wrong type is not the event that was meant.
+ @Test func aFieldOfTheWrongTypeIsGeneric() {
+ for extra: (String, TestCBOR) in [("seq", .t("9")), ("wallMs", .t("now")), ("kind", .b([1])), ("args", .m([("kwh", .u(18))])), ("lp", .u(1)), ("ts", .neg(UInt64.max))] {
+ #expect(Self.read(Self.envelope(Self.body([extra]))).verdict == .generic, "\(extra.0)")
+ }
+ }
+
+ /// The box clock anchors the event; the phone's bounds it.
+ @Test func theClockRules() {
+ let b = Self.body()
+ #expect(Self.read(Self.envelope(b), nowMs: Self.wall - 2_000).verdict == .event)
+ #expect(Self.read(Self.envelope(b), nowMs: Self.wall - 2_001).verdict == .generic)
+ #expect(Self.read(Self.envelope(b), nowMs: Self.wall - 2_000).atMs == Self.wall - 2_000)
+ #expect(Self.read(Self.envelope(b), nowMs: Self.wall + 600_000).verdict == .event)
+ #expect(Self.read(Self.envelope(b), nowMs: Self.wall + 600_001).verdict == .generic)
+ // The event's own time, when earlier, is when it happened.
+ let held = Self.body([("ts", .u(UInt64(Self.wall - 30_000)))])
+ #expect(Self.read(Self.envelope(held)).atMs == Self.wall - 30_000)
+ #expect(Self.read(Self.envelope(held), nowMs: Self.wall + 570_001).verdict == .generic)
+ // No wall clock, no anchor.
+ #expect(Self.read(Self.envelope(Self.body(drop: ["wallMs"]))).verdict == .generic)
+ }
+
+ /// The service's kind beside an event, or the box signing the one kind
+ /// only the service may send, is generic.
+ @Test func onlyTheServiceSaysUnreachableAndOnlyAlone() {
+ #expect(Self.read(Self.envelope(Self.body()), kind: PushKind.boxUnreachable).verdict == .generic)
+ #expect(Self.read(Self.envelope(Self.body(kind: PushKind.boxUnreachable))).verdict == .generic)
+ let alone = PushAlert.read(payload: Self.payload(kind: PushKind.boxUnreachable), boxKey: { _ in nil }, nowMs: Self.wall)
+ #expect(alone.verdict == .unreachable)
+ #expect(alone.kind.isEmpty && alone.args.isEmpty)
+ }
+
+ /// Another type of message, signed by the same box, is not an event.
+ @Test func onlyAnEventIsAnEvent() {
+ #expect(Self.read(Self.envelope(Self.body(), t: "hb")).verdict == .generic)
+ }
+
+ @Test func aPayloadThatIsNotOursIsGenericWithoutAHome() {
+ for json in [#"{"aps":{}}"#, #"{"ftw":{"v":2,"home":"x"}}"#, #"{"ftw":{"v":true,"home":"x"}}"#, #"{"ftw":{"v":1,"home":7}}"#, #"{"ftw":{"v":1,"home":"x","event":"not base64!"}}"#, #"{"ftw":null}"#, "[]", "nonsense"] {
+ let a = PushAlert.read(payload: Data(json.utf8), boxKey: { _ in Self.pk }, nowMs: Self.wall)
+ #expect(a == PushAlert(.generic, home: ""), "\(json)")
+ }
+ }
+
+ /// The envelope's shape is checked byte by byte.
+ @Test func theEnvelopeIsStrict() throws {
+ let b = Self.body()
+ let sig = Array(try Self.boxKey.signature(for: PushCBOR.signedInput(home: Self.home, t: "event", b: b)).rawRepresentation)
+ let good = TestCBOR.m([("b", .b(b)), ("t", .t("event")), ("sig", .b(sig))]).encoded
+ #expect(PushAlert.Envelope(good) != nil)
+ // t as bytes, b as text, no sig, an empty t.
+ #expect(PushAlert.Envelope(TestCBOR.m([("b", .b(b)), ("t", .b(Array("event".utf8))), ("sig", .b(sig))]).encoded) == nil)
+ #expect(PushAlert.Envelope(TestCBOR.m([("b", .t("x")), ("t", .t("event")), ("sig", .b(sig))]).encoded) == nil)
+ #expect(PushAlert.Envelope(TestCBOR.m([("b", .b(b)), ("t", .t("event"))]).encoded) == nil)
+ #expect(PushAlert.Envelope(TestCBOR.m([("b", .b(b)), ("t", .t("")), ("sig", .b(sig))]).encoded) == nil)
+ // A byte after the value, a key twice, an indefinite length.
+ #expect(PushAlert.Envelope(good + [0x00]) == nil)
+ #expect(PushAlert.Envelope(TestCBOR.m([("b", .b(b)), ("t", .t("event")), ("t", .t("event")), ("sig", .b(sig))]).encoded) == nil)
+ var indefinite = good
+ indefinite[0] = 0xbf
+ #expect(PushAlert.Envelope(indefinite + [0xff]) == nil)
+ // Truncated anywhere.
+ for n in 0.. [UInt8] {
+ let m = major << 5
+ switch n {
+ case 0..<24: return [m | UInt8(n)]
+ case 24...0xff: return [m | 24, UInt8(n)]
+ case 0x100...0xffff: return [m | 25, UInt8(n >> 8), UInt8(n & 0xff)]
+ case 0x1_0000...0xffff_ffff: return [m | 26] + (0..<4).reversed().map { UInt8(truncatingIfNeeded: n >> ($0 * 8)) }
+ default: return [m | 27] + (0..<8).reversed().map { UInt8(truncatingIfNeeded: n >> ($0 * 8)) }
+ }
+ }
+ switch self {
+ case .u(let n): return head(0, n)
+ case .neg(let n): return head(1, n)
+ case .b(let b): return head(2, UInt64(b.count)) + b
+ case .t(let s): return head(3, UInt64(s.utf8.count)) + Array(s.utf8)
+ case .m(let pairs): return head(5, UInt64(pairs.count)) + pairs.flatMap { TestCBOR.t($0.0).encoded + $0.1.encoded }
+ }
+ }
+}
+
+extension Data {
+ init(hex: String) {
+ var out = Data()
+ var it = hex.makeIterator()
+ while let a = it.next(), let b = it.next() {
+ out.append(UInt8(String([a, b]), radix: 16)!)
+ }
+ self = out
+ }
+
+ var hex: String { map { String(format: "%02x", $0) }.joined() }
+}
diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PushStoreTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PushStoreTests.swift
new file mode 100644
index 0000000..af563aa
--- /dev/null
+++ b/appleApp/FTWKit/Tests/FTWSharedTests/PushStoreTests.swift
@@ -0,0 +1,98 @@
+import Foundation
+import Testing
+@testable import FTWShared
+
+/// What the app leaves for the notification extension, and what the
+/// extension remembers between pushes.
+@Suite struct PushStoreTests {
+ static let home = "fjzyr67buzpyncqmjliwgezvde"
+ static let key = Data([0x04] + Array(repeating: 0x11, count: 64))
+
+ static func temp() -> URL {
+ FileManager.default.temporaryDirectory.appendingPathComponent("ftw-push-\(UUID().uuidString)", isDirectory: true)
+ }
+
+ static func event(home: String = home, boot: UInt8 = 0xb0, seq: UInt64) -> PushAlert {
+ PushAlert(.event, home: home, kind: PushKind.chargingConnected, boot: Data(repeating: boot, count: 16), seq: seq, atMs: 1)
+ }
+
+ // MARK: CloudKeys
+
+ @Test func aHomeReadsBackAsWritten() throws {
+ let dir = Self.temp()
+ defer { try? FileManager.default.removeItem(at: dir) }
+ let keys = CloudKeys(directory: dir)
+ let row = CloudHome(home: Self.home, siteId: "site", boxKey: Self.key, loadpoints: ["j3HZORNCWHs": "garage"], sources: ["DUYWWQmn-Xg": "Easee"])
+ try keys.write(row)
+ #expect(keys.read(home: Self.home) == row)
+ #expect(keys.boxKey(home: Self.home) == Self.key)
+ #expect(keys.read(home: "t3w5bfotxeygcjxjdhjaeo6pvq") == nil)
+ keys.remove(home: Self.home)
+ #expect(keys.read(home: Self.home) == nil)
+ }
+
+ /// The home id comes from a push. It never names a file outside the
+ /// store, and a key that is not a point is refused at the door.
+ @Test func onlyAHomeIDAndAPointAreKept() throws {
+ let dir = Self.temp()
+ defer { try? FileManager.default.removeItem(at: dir) }
+ let keys = CloudKeys(directory: dir)
+ for bad in ["../../etc/passwd", "FJZYR67BUZPYNCQMJLIWGEZVDE", "fjzyr67buzpyncqmjliwgezvd", "fjzyr67buzpyncqmjliwgezvd1", ""] {
+ #expect(!CloudKeys.isHomeID(bad), "\(bad)")
+ #expect(throws: CloudKeys.Refused.self) { try keys.write(CloudHome(home: bad, siteId: "s", boxKey: Self.key)) }
+ #expect(keys.read(home: bad) == nil)
+ }
+ #expect(CloudKeys.isHomeID(Self.home))
+ #expect(throws: CloudKeys.Refused.self) { try keys.write(CloudHome(home: Self.home, siteId: "s", boxKey: Data(Self.key.dropFirst()))) }
+ #expect(throws: CloudKeys.Refused.self) { try keys.write(CloudHome(home: Self.home, siteId: "s", boxKey: Data([0x02]) + Self.key.dropFirst())) }
+ }
+
+ /// The event's charger, through the box's table. A home with one
+ /// charger needs no name for it; an unknown name is not guessed.
+ @Test func theChargerAnEventMeans() {
+ let one = CloudHome(home: Self.home, siteId: "s", boxKey: Self.key, loadpoints: ["j3HZORNCWHs": "garage"])
+ #expect(one.loadpointID(opaque: "j3HZORNCWHs") == "garage")
+ #expect(one.loadpointID(opaque: nil) == "garage")
+ #expect(one.loadpointID(opaque: "unknown") == nil)
+ let two = CloudHome(home: Self.home, siteId: "s", boxKey: Self.key, loadpoints: ["a": "garage", "b": "street"])
+ #expect(two.loadpointID(opaque: nil) == nil)
+ #expect(two.loadpointID(opaque: "b") == "street")
+ #expect(CloudHome(home: Self.home, siteId: "s", boxKey: Self.key).loadpointID(opaque: nil) == nil)
+ }
+
+ // MARK: PushSeen
+
+ @Test func anEventIsShownOnce() {
+ let dir = Self.temp()
+ defer { try? FileManager.default.removeItem(at: dir) }
+ let seen = PushSeen(file: dir.appendingPathComponent("push-seen"))
+ #expect(seen.accept(Self.event(seq: 9), nowMs: 1))
+ #expect(!seen.accept(Self.event(seq: 9), nowMs: 2))
+ // Older than one shown.
+ #expect(!seen.accept(Self.event(seq: 8), nowMs: 3))
+ #expect(seen.accept(Self.event(seq: 10), nowMs: 4))
+ // A restart counts again; another home counts on its own.
+ #expect(seen.accept(Self.event(boot: 0xc0, seq: 1), nowMs: 5))
+ #expect(seen.accept(Self.event(home: "t3w5bfotxeygcjxjdhjaeo6pvq", seq: 9), nowMs: 6))
+ // Remembered across processes: a fresh reader of the same file.
+ #expect(!PushSeen(file: seen.file).accept(Self.event(seq: 10), nowMs: 7))
+ // Only events carry a number.
+ #expect(seen.accept(PushAlert(.unreachable, home: Self.home), nowMs: 8))
+ #expect(seen.accept(PushAlert(.unreachable, home: Self.home), nowMs: 9))
+ #expect(seen.accept(PushAlert(.generic, home: Self.home), nowMs: 10))
+ }
+
+ @Test func theFileStaysSmall() {
+ let dir = Self.temp()
+ defer { try? FileManager.default.removeItem(at: dir) }
+ let seen = PushSeen(file: dir.appendingPathComponent("push-seen"))
+ for boot in 0.. [Entry] {
+ let yaml = try String(contentsOf: repo.appendingPathComponent("protocol/push-catalogue.yaml"), encoding: .utf8)
+ var out = [Entry]()
+ for line in yaml.split(separator: "\n") {
+ let t = line.trimmingCharacters(in: .whitespaces)
+ if t.hasPrefix("- kind: ") {
+ out.append(Entry(kind: String(t.dropFirst(8)), title: "", body: ""))
+ } else if t.hasPrefix("title: "), !out.isEmpty {
+ out[out.count - 1].title = String(t.dropFirst(7))
+ } else if t.hasPrefix("body: "), !out.isEmpty {
+ var body = String(t.dropFirst(6))
+ if body.hasPrefix("\""), body.hasSuffix("\"") { body = String(body.dropFirst().dropLast()) }
+ out[out.count - 1].body = body
+ }
+ }
+ return out
+ }
+
+ static func event(_ kind: String, args: [String: String] = [:], src: String? = nil, lp: String? = nil) -> PushAlert {
+ PushAlert(.event, home: "fjzyr67buzpyncqmjliwgezvde", kind: kind, args: args, src: src, lp: lp, boot: Data(repeating: 0xb0, count: 16), seq: 9, atMs: 1)
+ }
+
+ // MARK: The catalogue
+
+ @Test func theCatalogueIsTheOneTheBoxHas() throws {
+ let entries = try Self.catalogue()
+ #expect(entries.map(\.kind) == PushKind.all)
+ #expect(entries.allSatisfy { !$0.title.isEmpty && !$0.body.isEmpty })
+ }
+
+ /// Every kind has both languages, and the English is the catalogue's,
+ /// placeholders and all.
+ @Test func everyKindHasSwedishAndTheCataloguesEnglish() throws {
+ for e in try Self.catalogue() {
+ let en = try #require(PushText.en[e.kind], "\(e.kind)")
+ let sv = try #require(PushText.sv[e.kind], "\(e.kind)")
+ #expect(en.title == e.title, "\(e.kind)")
+ #expect(en.body == e.body, "\(e.kind)")
+ // Swedish asks for the same arguments, and has the same way out.
+ #expect(Self.placeholders(sv.body) == Self.placeholders(en.body), "\(e.kind)")
+ #expect((sv.without == nil) == (en.without == nil), "\(e.kind)")
+ #expect(Self.placeholders(en.body).isEmpty == (en.without == nil), "\(e.kind)")
+ #expect(sv.title != en.title, "\(e.kind)")
+ }
+ #expect(Set(PushText.en.keys) == Set(PushKind.all))
+ #expect(Set(PushText.sv.keys) == Set(PushKind.all))
+ }
+
+ static func placeholders(_ s: String) -> [String] {
+ s.matches(of: /\{([a-z]+)\}/).map { String($0.1) }.sorted()
+ }
+
+ /// Filled, the English reads exactly as the catalogue with its
+ /// placeholders filled.
+ @Test func filledEnglishIsTheCataloguesSentence() throws {
+ let args = ["kwh": "18.4", "target": "80", "by": "07:00", "version": "0.142.0", "phase": "L2"]
+ for e in try Self.catalogue() where e.kind != PushKind.boxUnreachable {
+ var want = e.body
+ for (k, v) in args { want = want.replacingOccurrences(of: "{\(k)}", with: v) }
+ want = want.replacingOccurrences(of: "{name}", with: "Easee")
+ let home = CloudHome(home: "fjzyr67buzpyncqmjliwgezvde", siteId: "s", boxKey: Data(), sources: ["DUYWWQmn-Xg": "Easee"])
+ let got = PushText.content(Self.event(e.kind, args: args, src: "DUYWWQmn-Xg"), sourceName: home.sourceName(opaque: "DUYWWQmn-Xg"), language: .en, decimalSeparator: ".")
+ #expect(got == PushText.Content(title: e.title, body: want), "\(e.kind)")
+ }
+ }
+
+ @Test func theSwedishReadsNaturally() {
+ let sv = { (kind: String, args: [String: String], name: String?) in
+ PushText.content(Self.event(kind, args: args), sourceName: name, language: .sv, decimalSeparator: ",")
+ }
+ #expect(sv(PushKind.chargingConnected, [:], nil) == .init(title: "Bilen är inkopplad", body: "Öppna för att se laddningen, kolla bilens nivå och välja klartid."))
+ #expect(sv(PushKind.chargingSessionComplete, ["kwh": "18.4"], nil) == .init(title: "Bilen är laddad", body: "18,4 kWh laddat – redo att åka."))
+ #expect(sv(PushKind.chargingGoalAtRisk, ["target": "80", "by": "07:00"], nil).body == "Den når inte 80 % till 07:00 i den här takten.")
+ #expect(sv(PushKind.updateInstalled, ["version": "0.142.0"], nil).body == "Nu kör den 0.142.0. Allt kom igång igen av sig självt.")
+ #expect(sv(PushKind.driverOffline, [:], "Easee").body == "Easee har slutat svara.")
+ #expect(sv(PushKind.fuseOverLimit, ["phase": "L2"], nil).body == "L2 drar mer än huvudsäkringen klarar.")
+ }
+
+ /// Energy is kWh with the reader's decimal mark. Never a percentage.
+ @Test func energyIsKilowattHours() {
+ for (sep, want) in [(".", "18.4 kWh delivered — ready to go."), (",", "18,4 kWh delivered — ready to go.")] {
+ #expect(PushText.content(Self.event(PushKind.chargingSessionComplete, args: ["kwh": "18.4"]), language: .en, decimalSeparator: sep).body == want)
+ }
+ #expect(PushText.content(Self.event(PushKind.chargingSessionComplete, args: ["kwh": "7"]), language: .sv, decimalSeparator: ",").body == "7 kWh laddat – redo att åka.")
+ for language in PushText.Language.allCases {
+ #expect(!PushText.sentences(language)[PushKind.chargingSessionComplete]!.body.contains("%"))
+ }
+ }
+
+ /// An argument that is missing or will not do gives the sentence
+ /// without it, never a half-filled one.
+ @Test func aBadArgumentGivesTheSentenceWithoutIt() {
+ let cases: [(String, [String: String], String)] = [
+ (PushKind.chargingSessionComplete, [:], "Ready to go."),
+ (PushKind.chargingSessionComplete, ["kwh": "18.4%"], "Ready to go."),
+ (PushKind.chargingSessionComplete, ["kwh": "1e3"], "Ready to go."),
+ (PushKind.chargingSessionComplete, ["kwh": "١٨"], "Ready to go."),
+ (PushKind.chargingGoalAtRisk, ["target": "180", "by": "07:00"], "It will not reach its goal in time at this pace."),
+ (PushKind.chargingGoalAtRisk, ["target": "80", "by": "7:00"], "It will not reach its goal in time at this pace."),
+ (PushKind.chargingGoalAtRisk, ["target": "80", "by": "25:00"], "It will not reach its goal in time at this pace."),
+ (PushKind.updateInstalled, ["version": "1.0\nCall this number"], "Everything came back on its own."),
+ (PushKind.updateInstalled, ["version": String(repeating: "x", count: 41)], "Everything came back on its own."),
+ (PushKind.fuseOverLimit, ["phase": ""], "One phase is over the fuse rating."),
+ (PushKind.driverOffline, [:], "A device stopped answering."),
+ ]
+ for (kind, args, body) in cases {
+ #expect(PushText.content(Self.event(kind, args: args), language: .en, decimalSeparator: ".").body == body, "\(kind) \(args)")
+ }
+ // A name comes only from the box's table, never from the event.
+ #expect(PushText.content(Self.event(PushKind.driverOffline, args: ["name": "Mallory"]), language: .en).body == "A device stopped answering.")
+ }
+
+ @Test func theGenericLineSaysNothingThePushClaimed() {
+ #expect(PushText.generic(.sv) == .init(title: "Något hände hemma", body: "Öppna FTW för att se vad."))
+ #expect(PushText.generic(.en) == .init(title: "Something happened at home", body: "Open FTW to see what."))
+ #expect(PushText.content(PushAlert(.generic, home: "x"), language: .sv) == PushText.generic(.sv))
+ #expect(PushText.content(PushAlert(.unreachable, home: "x"), language: .en).title == "Your box is out of reach")
+ #expect(PushText.content(PushAlert(.unreachable, home: "x"), language: .sv).title == "Din box går inte att nå")
+ }
+
+ /// What Apple shows if the extension never runs: the app's own strings
+ /// for the payload's keys, which are the generic line.
+ @Test func theAppsFallbackStringsAreTheGenericLine() throws {
+ for language in PushText.Language.allCases {
+ let file = Self.repo.appendingPathComponent("appleApp/FTW/Intents/\(language.rawValue).lproj/Localizable.strings")
+ let strings = try #require(NSDictionary(contentsOf: file) as? [String: String], "\(language)")
+ #expect(strings["push.title"] == PushText.generic(language).title, "\(language)")
+ #expect(strings["push.generic"] == PushText.generic(language).body, "\(language)")
+ }
+ }
+
+ @Test func theLanguageIsThePersonsFirst() {
+ #expect(PushText.Language.preferred(["sv-SE", "en-US"]) == .sv)
+ #expect(PushText.Language.preferred(["SV"]) == .sv)
+ #expect(PushText.Language.preferred(["en-SE", "sv-SE"]) == .en)
+ #expect(PushText.Language.preferred(["de-DE"]) == .en)
+ #expect(PushText.Language.preferred([]) == .en)
+ }
+
+ // MARK: Buttons
+
+ @Test func onlyAVerifiedKindPicksButtons() {
+ #expect(PushActions.category(for: Self.event(PushKind.chargingConnected)) == .level)
+ #expect(PushActions.category(for: Self.event(PushKind.chargingInterrupted)) == .chargeNow)
+ #expect(PushActions.category(for: Self.event(PushKind.chargingGoalAtRisk)) == .chargeNow)
+ for kind in PushKind.all where ![PushKind.chargingConnected, PushKind.chargingInterrupted, PushKind.chargingGoalAtRisk].contains(kind) {
+ #expect(PushActions.category(for: Self.event(kind)) == nil, "\(kind)")
+ }
+ #expect(PushActions.category(for: PushAlert(.unreachable, home: "x")) == nil)
+ // A generic alert carries no kind it could be tricked by.
+ #expect(PushActions.category(for: PushAlert(.generic, home: "x", kind: PushKind.chargingConnected)) == nil)
+ #expect(PushActions.Category.level.actions == [.level20, .level50, .level80, .open])
+ #expect(PushActions.Category.chargeNow.actions == [.chargeNow, .open])
+ }
+
+ @Test func aButtonRunsOnlyForTheKindThatOffersIt() {
+ let connected = Self.event(PushKind.chargingConnected)
+ let atRisk = Self.event(PushKind.chargingGoalAtRisk)
+ #expect(PushActions.allows(.level50, for: connected))
+ #expect(!PushActions.allows(.chargeNow, for: connected))
+ #expect(PushActions.allows(.chargeNow, for: atRisk))
+ #expect(!PushActions.allows(.level80, for: atRisk))
+ for action in PushActions.Action.allCases {
+ #expect(!PushActions.allows(action, for: Self.event(PushKind.cloudGranted)))
+ #expect(!PushActions.allows(action, for: PushAlert(.generic, home: "x", kind: PushKind.chargingConnected)))
+ }
+ }
+
+ /// The levels are the car's level, any whole percent; Charge now is the
+ /// Control's command. Open asks the box nothing.
+ @Test func whatEachButtonAsks() {
+ #expect(PushActions.command(.level20) == .setLevel(pct: 20))
+ #expect(PushActions.command(.level50) == .setLevel(pct: 50))
+ #expect(PushActions.command(.level80) == .setLevel(pct: 80))
+ #expect(PushActions.command(.chargeNow) == .chargeNow)
+ #expect(PushActions.command(.open) == nil)
+ #expect(PushActions.Action.allCases.filter(PushActions.opensApp) == [.open])
+ #expect(PushActions.title(.chargeNow, .sv) == "Ladda nu")
+ #expect(PushActions.title(.open, .sv) == "Öppna")
+ #expect(PushActions.title(.open, .en) == "Open")
+ #expect(PushActions.title(.level50, .sv) == "50 %")
+ }
+
+ // MARK: The notice
+
+ @Test func theNoticeSetsEverythingItself() {
+ let home = CloudHome(home: "fjzyr67buzpyncqmjliwgezvde", siteId: "site", boxKey: Data(), loadpoints: ["j3HZORNCWHs": "garage"])
+ let connected = PushNotice(Self.event(PushKind.chargingConnected, lp: "j3HZORNCWHs"), home: home, language: .en)
+ #expect(connected.category == "ftw.level")
+ #expect(connected.level == .active)
+ #expect(connected.thread == "fjzyr67buzpyncqmjliwgezvde")
+ #expect(connected.verified == ["verdict": "event", "home": "fjzyr67buzpyncqmjliwgezvde", "kind": PushKind.chargingConnected, "seq": "9", "atMs": "1", "loadpointID": "garage"])
+
+ let interrupted = PushNotice(Self.event(PushKind.chargingInterrupted), home: home, language: .sv)
+ #expect(interrupted.level == .timeSensitive)
+ #expect(interrupted.category == "ftw.chargeNow")
+ #expect(interrupted.title == "Laddningen avbröts")
+
+ let unreachable = PushNotice(PushAlert(.unreachable, home: "fjzyr67buzpyncqmjliwgezvde"), home: nil, language: .en)
+ #expect(unreachable.level == .timeSensitive)
+ #expect(unreachable.category == "")
+ #expect(unreachable.verified == ["verdict": "unreachable", "home": "fjzyr67buzpyncqmjliwgezvde"])
+
+ let generic = PushNotice(PushAlert(.generic, home: "fjzyr67buzpyncqmjliwgezvde"), home: home, language: .sv)
+ #expect(generic.category == "")
+ #expect(generic.level == .active)
+ #expect(generic.verified.isEmpty)
+ #expect(generic.title == "Något hände hemma")
+ #expect(PushNotice.generic(.en).category == "")
+
+ // 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/FTWKit/Tests/FTWSharedTests/Resources/cloud-vectors.json b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/cloud-vectors.json
new file mode 100644
index 0000000..9ad2244
--- /dev/null
+++ b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/cloud-vectors.json
@@ -0,0 +1,196 @@
+{
+ "boxKey": {
+ "d": "6c50811aa47e688ecbb01b2c1e91aca4a2ad7c879526d1c9679ef3fd25560074",
+ "pk": "04cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b8",
+ "home": "fjzyr67buzpyncqmjliwgezvde"
+ },
+ "serviceKey": {
+ "d": "f8d258ed839f4602dcfc2c139237d740e960310da68140e23bf88bf4c3c6ffa5",
+ "pk": "043cb883f267f64e085082b9025d318d83a1b36bc9fa474091074498c6417100da767b737fc59344ddb1b3ae5ddd4b6446f330315d79a0fb7dc88e96a5ec827c9e"
+ },
+ "grantKey": "7777777777777777777777777777777777777777777777777777777777777777",
+ "ids": [
+ {
+ "kind": "src",
+ "name": "p1meter",
+ "id": "ACfHt4DWL7g"
+ },
+ {
+ "kind": "src",
+ "name": "sungrow",
+ "id": "f-oHMDqV-mA"
+ },
+ {
+ "kind": "src",
+ "name": "easee",
+ "id": "DUYWWQmn-Xg"
+ },
+ {
+ "kind": "lp",
+ "name": "garage",
+ "id": "j3HZORNCWHs"
+ }
+ ],
+ "messages": [
+ {
+ "name": "challenge",
+ "signer": "",
+ "t": "challenge",
+ "body": "a2617601656e6f6e636558201111111111111111111111111111111111111111111111111111111111111111",
+ "signedInputSha256": "b8baed8e00b3e02409d01f18a83294c626a1ee5d43a3e38efd071fb2a89e92f0",
+ "sig": "",
+ "envelope": "a36162582ca2617601656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116174696368616c6c656e67656373696740",
+ "frame": "010100000042a36162582ca2617601656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116174696368616c6c656e67656373696740"
+ },
+ {
+ "name": "hello",
+ "signer": "box",
+ "t": "hello",
+ "body": "ae61760162706b584104cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b863656b6d58202222222222222222222222222222222222222222222222222222222222222222637365710164626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361707385646c697665626c706670726963657362686266726f73746572656275696c6467302e3134322e3065636c6f636b636e7470656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116677616c6c4d731b000001a130d7a000676772616e744964504444444444444444444444444444444468626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333368757074696d654d7319ea6069726f7374657253657104",
+ "signedInputSha256": "85ba027484b1e3ca0d10cca2602d07413d2dac2850e410e468274ee17ac43068",
+ "sig": "32b835d4f0c887e458a7af2f52f53368a7c188d85ec502bcec39fd43e6f0fd00a0205eb354a8ea6351acedecf5b3c8c5c663bd2c66d507e282f65794a87f540d",
+ "envelope": "a36162590155ae61760162706b584104cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b863656b6d58202222222222222222222222222222222222222222222222222222222222222222637365710164626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361707385646c697665626c706670726963657362686266726f73746572656275696c6467302e3134322e3065636c6f636b636e7470656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116677616c6c4d731b000001a130d7a000676772616e744964504444444444444444444444444444444468626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333368757074696d654d7319ea6069726f737465725365710461746568656c6c6f63736967584032b835d4f0c887e458a7af2f52f53368a7c188d85ec502bcec39fd43e6f0fd00a0205eb354a8ea6351acedecf5b3c8c5c663bd2c66d507e282f65794a87f540d",
+ "frame": "0101000001a9a36162590155ae61760162706b584104cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b863656b6d58202222222222222222222222222222222222222222222222222222222222222222637365710164626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361707385646c697665626c706670726963657362686266726f73746572656275696c6467302e3134322e3065636c6f636b636e7470656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116677616c6c4d731b000001a130d7a000676772616e744964504444444444444444444444444444444468626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333368757074696d654d7319ea6069726f737465725365710461746568656c6c6f63736967584032b835d4f0c887e458a7af2f52f53368a7c188d85ec502bcec39fd43e6f0fd00a0205eb354a8ea6351acedecf5b3c8c5c663bd2c66d507e282f65794a87f540d"
+ },
+ {
+ "name": "hello_ok",
+ "signer": "service",
+ "t": "hello_ok",
+ "body": "a463656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f7374657253657103",
+ "signedInputSha256": "499bf81555f32a94ce6e39a3e1eb43880773b33005ae5efe459a8227aee4d855",
+ "sig": "230af93c21c9b3b977d9c89cfdd637ab53d0be86fee865f5b3bd0f931993476fd439a631ce64cd949df8047b48ce083610b71316cd20cd9ca12c49242c7d283d",
+ "envelope": "a361625863a463656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f737465725365710361746868656c6c6f5f6f6b637369675840230af93c21c9b3b977d9c89cfdd637ab53d0be86fee865f5b3bd0f931993476fd439a631ce64cd949df8047b48ce083610b71316cd20cd9ca12c49242c7d283d",
+ "frame": "0101000000b9a361625863a463656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f737465725365710361746868656c6c6f5f6f6b637369675840230af93c21c9b3b977d9c89cfdd637ab53d0be86fee865f5b3bd0f931993476fd439a631ce64cd949df8047b48ce083610b71316cd20cd9ca12c49242c7d283d"
+ },
+ {
+ "name": "roster",
+ "signer": "box",
+ "t": "roster",
+ "body": "a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104",
+ "signedInputSha256": "f0926ee704ac9558f742f604315ef8ea260a9edef63a210172d9c299d5b171cf",
+ "sig": "cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124",
+ "envelope": "a361625901b2a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104617466726f73746572637369675840cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124",
+ "frame": "010100000207a361625901b2a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104617466726f73746572637369675840cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124"
+ },
+ {
+ "name": "live full",
+ "signer": "box",
+ "t": "live",
+ "body": "aa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b6564427980",
+ "signedInputSha256": "e45fdca79363137b374011804fb5905c1452b533cb275c89de0aa61562886393",
+ "sig": "02a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95",
+ "envelope": "a361625901fdaa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b65644279806174646c69766563736967584002a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95",
+ "frame": "010100000250a361625901fdaa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b65644279806174646c69766563736967584002a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95"
+ },
+ {
+ "name": "live delta",
+ "signer": "box",
+ "t": "live",
+ "body": "a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b6564427980",
+ "signedInputSha256": "6881cdc040c9d5e58e85e7bfb9e3b15ae13e6a9c235a06070c43d870df0976fd",
+ "sig": "0dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6",
+ "envelope": "a3616258a1a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b65644279806174646c6976656373696758400dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6",
+ "frame": "0101000000f3a3616258a1a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b65644279806174646c6976656373696758400dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6"
+ },
+ {
+ "name": "lp",
+ "signer": "box",
+ "t": "lp",
+ "body": "a7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e",
+ "signedInputSha256": "4774dda3175a23d752f2b1aea4e1959d50edec99f4882caf007779daa829eb8f",
+ "sig": "b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79",
+ "envelope": "a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79",
+ "frame": "010100000250a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79"
+ },
+ {
+ "name": "prices",
+ "signer": "box",
+ "t": "prices",
+ "body": "aa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847",
+ "signedInputSha256": "022b3d2ae277cd3caee80775e0a2fe1c8ecebcce3263f586c0ee5e4af96a00e7",
+ "sig": "e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3",
+ "envelope": "a36162588caa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847617466707269636573637369675840e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3",
+ "frame": "0101000000e0a36162588caa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847617466707269636573637369675840e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3"
+ },
+ {
+ "name": "hb",
+ "signer": "box",
+ "t": "hb",
+ "body": "a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c",
+ "signedInputSha256": "6961171385681257aeb2a889a6c33b5854b95e85cef4d9a8fd3b6dfb7d709541",
+ "sig": "e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8",
+ "envelope": "a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8",
+ "frame": "0101000000d0a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8"
+ },
+ {
+ "name": "watch",
+ "signer": "",
+ "t": "watch",
+ "body": "a162687a01",
+ "signedInputSha256": "64808965667acfc5db8cd769b2c18654ff94b593dd47363d30b0835f438bde81",
+ "sig": "",
+ "envelope": "a3616245a162687a0161746577617463686373696740",
+ "frame": "010100000016a3616245a162687a0161746577617463686373696740"
+ },
+ {
+ "name": "resync",
+ "signer": "",
+ "t": "resync",
+ "body": "a16477686174816466756c6c",
+ "signedInputSha256": "778a30a84492300eb2b2b14555a35e0ac73d2f145d5ef5c0b9aa45e0be3f093f",
+ "sig": "",
+ "envelope": "a361624ca16477686174816466756c6c617466726573796e636373696740",
+ "frame": "01010000001ea361624ca16477686174816466756c6c617466726573796e636373696740"
+ },
+ {
+ "name": "bye",
+ "signer": "box",
+ "t": "bye",
+ "body": "a6637365710863776879677265766f6b656464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0657075726765f56677616c6c4d731b000001a130d7c71068757074696d654d731a00011170",
+ "signedInputSha256": "0499c5251783c9826fdc39a6a09e8f0f35f20244dd100b07e9978dced9475389",
+ "sig": "19b5ea48e5c3b113712f5815840c2339d654a3438a516dff0b13c444c604d94f5f25b72689a8dd9e02de38740f3b420836d0e40e12e8d236ac21e8685e781b04",
+ "envelope": "a36162584da6637365710863776879677265766f6b656464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0657075726765f56677616c6c4d731b000001a130d7c71068757074696d654d731a0001117061746362796563736967584019b5ea48e5c3b113712f5815840c2339d654a3438a516dff0b13c444c604d94f5f25b72689a8dd9e02de38740f3b420836d0e40e12e8d236ac21e8685e781b04",
+ "frame": "01010000009ea36162584da6637365710863776879677265766f6b656464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0657075726765f56677616c6c4d731b000001a130d7c71068757074696d654d731a0001117061746362796563736967584019b5ea48e5c3b113712f5815840c2339d654a3438a516dff0b13c444c604d94f5f25b72689a8dd9e02de38740f3b420836d0e40e12e8d236ac21e8685e781b04"
+ },
+ {
+ "name": "purged",
+ "signer": "service",
+ "t": "purged",
+ "body": "a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444",
+ "signedInputSha256": "8789b7655a3f732582f55de4e6e1dbb9d9a60dadc501b535560566e8c879c9b6",
+ "sig": "910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32",
+ "envelope": "a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32",
+ "frame": "010100000086a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32"
+ },
+ {
+ "name": "event connected",
+ "signer": "box",
+ "t": "event",
+ "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a00011558",
+ "signedInputSha256": "6e13d5c342e20d31aef8530bd5d8d89b08540809278bd1aa93d2d156fe2b6d29",
+ "sig": "490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23",
+ "envelope": "a3616258a6ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a000115586174656576656e74637369675840490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23",
+ "frame": "0101000000f9a3616258a6ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a000115586174656576656e74637369675840490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23"
+ },
+ {
+ "name": "event interrupted",
+ "signer": "box",
+ "t": "event",
+ "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a00011940",
+ "signedInputSha256": "b29e1deef680d5b19f631a1ee29965bf70774e690afe1dbab5e7e25c4738ddbb",
+ "sig": "ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25",
+ "envelope": "a3616258abab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a000119406174656576656e74637369675840ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25",
+ "frame": "0101000000fea3616258abab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a000119406174656576656e74637369675840ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25"
+ },
+ {
+ "name": "event session complete",
+ "signer": "box",
+ "t": "event",
+ "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d28",
+ "signedInputSha256": "37a352e6f700b18e8dfec643cc783a67bf284209ec5ef16956266dfe84ed0b0a",
+ "sig": "e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb",
+ "envelope": "a3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb",
+ "frame": "01010000010aa3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb"
+ }
+ ]
+}
diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/Resources/push-vectors.json b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/push-vectors.json
new file mode 100644
index 0000000..e6b4ac6
--- /dev/null
+++ b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/push-vectors.json
@@ -0,0 +1,257 @@
+{
+ "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"
+ }
+ },
+ {
+ "name": "box clock not synced, anchored by rule U",
+ "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQFNotNXPkQUfb07DO7RwspScyA+HtdtuE/R6EAOdZFzmMBTAeJfa+x5KFmmfsdQNg7Q3ffV+WLE0KNonhgL6OXY=\"}}",
+ "sig": "5368b4d5cf91051f6f4ec33bb470b2949cc80f87b5db6e13f47a10039d645ce63014c07897dafb1e4a16699fb1d40d83b4377df57e58b13428da278602fa3976",
+ "offset": {
+ "home": "fjzyr67buzpyncqmjliwgezvde",
+ "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0",
+ "offsetMs": 1791820740000,
+ "learnedAtMs": 1791817211000
+ },
+ "nowMs": 1791820812200,
+ "want": {
+ "verdict": "event",
+ "home": "fjzyr67buzpyncqmjliwgezvde",
+ "kind": "charging.connected",
+ "src": "DUYWWQmn-Xg",
+ "lp": "j3HZORNCWHs",
+ "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0",
+ "seq": 12,
+ "atMs": 1791820810640
+ }
+ },
+ {
+ "name": "box clock ahead, anchored by rule U",
+ "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWJqqYmxwa2ozSFpPUk5DV0hzY3NlcQ1jc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDX7iBnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEAT0WQabV0OknG7SmU38U/KFBl7E6LdxOZZMNh9lOK/hi2WqRrVsSfQKUrKe8k65zfTUYHCQjuGSyi03B3vwg4W\"}}",
+ "sig": "13d1641a6d5d0e9271bb4a6537f14fca14197b13a2ddc4e65930d87d94e2bf862d96a91ad5b127d0294aca7bc93ae737d35181c2423b864b28b4dc1defc20e16",
+ "offset": {
+ "home": "fjzyr67buzpyncqmjliwgezvde",
+ "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0",
+ "offsetMs": 1791820740000,
+ "learnedAtMs": 1791817211000
+ },
+ "nowMs": 1791820812200,
+ "want": {
+ "verdict": "event",
+ "home": "fjzyr67buzpyncqmjliwgezvde",
+ "kind": "charging.connected",
+ "src": "DUYWWQmn-Xg",
+ "lp": "j3HZORNCWHs",
+ "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0",
+ "seq": 13,
+ "atMs": 1791820810640
+ }
+ },
+ {
+ "name": "rule U from another boot",
+ "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQGk/DH/IlyDThxxoaZe5v0fNthUnu8QnNIlvDjReA36nwi4S1d0vPiDfNV2JwE4iyT57heDhaU27bERXLSNGRnc=\"}}",
+ "sig": "693f0c7fc89720d3871c686997b9bf47cdb61527bbc42734896f0e345e037ea7c22e12d5dd2f3e20df355d89c04e22c93e7b85e0e1694dbb6c44572d23464677",
+ "offset": {
+ "home": "fjzyr67buzpyncqmjliwgezvde",
+ "boot": "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1",
+ "offsetMs": 1791820740000,
+ "learnedAtMs": 1791817211000
+ },
+ "nowMs": 1791820812200,
+ "want": {
+ "verdict": "generic",
+ "home": "fjzyr67buzpyncqmjliwgezvde"
+ }
+ },
+ {
+ "name": "rule U learned over a day ago",
+ "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQJYY1B25q3N9kJEsHpndMcA9zIZD70klDsFbQdtklHF3eIWxYd1qbRN1eQaszxJWNQc8zatRazb3KwxDyiFMj/0=\"}}",
+ "sig": "9618d41db9ab737d90912c1e99dd31c03dcc8643ef49250ec15b41db649471777885b161dd6a6d13757906accf125635073ccdab516b36f72b0c43ca214c8ffd",
+ "offset": {
+ "home": "fjzyr67buzpyncqmjliwgezvde",
+ "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0",
+ "offsetMs": 1791820740000,
+ "learnedAtMs": 1791734412199
+ },
+ "nowMs": 1791820812200,
+ "want": {
+ "verdict": "generic",
+ "home": "fjzyr67buzpyncqmjliwgezvde"
+ }
+ }
+ ]
+}
diff --git a/appleApp/FTWNotify-Info.plist b/appleApp/FTWNotify-Info.plist
new file mode 100644
index 0000000..041c793
--- /dev/null
+++ b/appleApp/FTWNotify-Info.plist
@@ -0,0 +1,20 @@
+
+
+
+
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.usernotifications.service
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).NotificationService
+
+ FTWAppGroup
+ $(FTW_APP_GROUP)
+ CFBundleLocalizations
+
+ en
+ sv
+
+
+
diff --git a/appleApp/FTWNotify.entitlements b/appleApp/FTWNotify.entitlements
new file mode 100644
index 0000000..04c6e8a
--- /dev/null
+++ b/appleApp/FTWNotify.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ $(FTW_APP_GROUP)
+
+
+
diff --git a/appleApp/FTWNotify/NotificationService.swift b/appleApp/FTWNotify/NotificationService.swift
new file mode 100644
index 0000000..3da6c7b
--- /dev/null
+++ b/appleApp/FTWNotify/NotificationService.swift
@@ -0,0 +1,61 @@
+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) }, offset: { keys?.offset(home: $0) }, nowMs: Int64(nowMs))
+ log.info("push: \(alert.verdict.rawValue, privacy: .public) \(alert.kind, privacy: .public)")
+ if let seen = PushSeen.shared(), !seen.accept(alert, nowMs: nowMs) {
+ log.info("push: seen before, seq \(alert.seq, privacy: .public)")
+ finish(content(.repeated(alert, language: .preferred())))
+ 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 = notice.sound ? .default : nil
+ c.categoryIdentifier = notice.category
+ c.threadIdentifier = notice.thread
+ c.interruptionLevel = notice.level
+ var info = userInfo
+ info[PushNotice.userInfoKey] = notice.verified
+ c.userInfo = info
+ return c
+ }
+}
diff --git a/appleApp/FTWNotify/PrivacyInfo.xcprivacy b/appleApp/FTWNotify/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..64e785c
--- /dev/null
+++ b/appleApp/FTWNotify/PrivacyInfo.xcprivacy
@@ -0,0 +1,23 @@
+
+
+
+
+ NSPrivacyTracking
+
+ NSPrivacyTrackingDomains
+
+ NSPrivacyCollectedDataTypes
+
+ NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryFileTimestamp
+ NSPrivacyAccessedAPITypeReasons
+
+ C617.1
+
+
+
+
+
diff --git a/protocol/push-catalogue.yaml b/protocol/push-catalogue.yaml
new file mode 100644
index 0000000..8473df4
--- /dev/null
+++ b/protocol/push-catalogue.yaml
@@ -0,0 +1,62 @@
+# The push notification catalogue — every sentence a phone's lock screen
+# may carry, and the one place they are written.
+#
+# The app owns all prose; the box sends codes. Push is the sole exception,
+# stated in CLAUDE.md since the beginning: a push must render text when the
+# app is not running, so the box renders it — FROM THIS FILE, never from
+# words of its own. The box's copy is byte-identical and CI on both sides
+# compares them, exactly as contract/registry.yaml is paired.
+#
+# `{arg}` placeholders are filled by the box from the event's own facts.
+# Every event here must be sparse by design: a notification is an
+# interruption, and a household that mutes the app because it nagged has
+# lost every notification that mattered.
+version: 1
+events:
+ - kind: charging.connected
+ title: Car plugged in
+ body: "Open to see charging status, check the battery level and set a ready time."
+ - kind: charging.session_complete
+ title: Car charged
+ body: "{kwh} kWh delivered — ready to go."
+ - kind: charging.goal_at_risk
+ # Only when the box knows the car's level, from the car or from someone
+ # who set it, and the plan cannot reach the goal by the ready time. An
+ # assumed level would make this a guess. Once per plug-in.
+ title: The car may not be ready
+ body: "It will not reach {target} % by {by} at this pace."
+ - kind: charging.interrupted
+ # Only after the hysteresis window the box applies: a charge that had
+ # been running steadily and then stopped without finishing. A flapping
+ # cable must not become a night of lock-screen noise.
+ title: Charging stopped early
+ body: "The car stopped charging before it was done."
+ - kind: update.installed
+ title: Your box updated itself
+ body: "Now running {version}. Everything came back on its own."
+ - kind: driver.offline
+ # After the box's own threshold: ten minutes of silence from a driver
+ # that had been reporting. A blip must not become a lock-screen.
+ title: A device went quiet
+ body: "{name} stopped answering."
+ - kind: fuse.over_limit
+ # After the box's own threshold: thirty seconds over the rating. A
+ # kettle must not page anyone.
+ title: The house is drawing too much
+ body: "{phase} is over the fuse rating."
+ - kind: box.unreachable
+ # The one sentence the box cannot send about itself. The relay holds
+ # this pre-encrypted and posts it only when the box has missed its
+ # deadline — the dead man's switch, and the only stored row the relay
+ # has. The box encrypts it before it ever leaves home.
+ title: Your box is out of reach
+ body: "It stopped answering. Power cut, or the internet at home is down."
+ - kind: cloud.granted
+ # To every owner, when an owner turns FTW Cloud on. Sharing a readable
+ # copy of the house is a choice every owner should hear about.
+ title: FTW Cloud is on
+ body: "Your box now shares a copy of its readings with FTW Cloud. You can turn it off in the app or on the box."
+ - kind: cloud.revoked
+ # To every owner, when FTW Cloud is turned off at either door.
+ title: FTW Cloud is off
+ body: "Your box stopped sharing with FTW Cloud and asked it to delete its copy."