Repository navigation
feat(cloud): the FTW Cloud session carries the push token (C3 + C4) - #20
Conversation
…e Activity Charge now is a Live Activity intent in FTWShared, so the Control in the widget extension and the Live Activity's Back to the plan run in the app, which holds the key. supportedModes is background; iOS 27 also names the app as the only execution target. Every command needs the unlocked phone. "Is the car ready?" answers a locked phone with the gist only, and its card offers Charge now or Back to the plan. LoadpointsModel starts the Live Activity on the box's cmd.result for Charge now and ends it on Back to the plan, Pause or a read without the hold. Static words and a timer, no power, stale after an hour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
protocol/registry.yaml is the byte-for-byte copy of srcfl/ftw-webapp contract/registry.yaml, which now holds scope ftw.events.read, capability cloud.link, carrier state cloud and the cloud_scopes and cloud_purposes lists. Contract and CarrierKind follow so ContractTests still reads both directions. Nothing produces the cloud carrier yet; that is C3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iOS 26.5's linkd treats an app whose metadata lists an included package (extract.packagedata) as having no App Intents at all (aggregateMetadataIsEmpty), so Siri, Shortcuts and the Control had nothing to run. Xcode 27 already copies the FTWShared intents into the app's and the extension's metadata without it, and iOS 27 reads both. The Live Activity's title stays on one line, and the expanded island puts it below the camera. -FTWDemoChargeNow presses Charge now in the demo for screenshots (Debug only). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hanged CarLevel is 20/40/60/80, as the car view's chips: each case is a tile in Shortcuts and Spotlight, and ten were clutter. The card under "Is the car ready?" no longer repeats the answer Siri just gave; it shows the headline again only once a button on it has changed the state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a watch's Smart Stack the card is narrow: the timer takes the top line next to the icon, and "Charging now since 14:02" the line below. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PR check builds with Xcode 26.6, whose SDK has no IntentExecutionTargets. The iOS 27 declaration now needs Swift 6.4, which ships with that SDK; the release build (Xcode 27.0) keeps it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CloudWire is the phone's half of the FTW Cloud wire: the unpadded lane 1
frame, the {t, b, sig} envelope, the signed input and its ES256 check
with CryptoKit, the home id, the app session's prologue and snapshot,
and the bodies of the classes a phone reads. Bodies decode as Go's
decoder does: unknown keys ignored, a known key of the wrong type
refused.
CloudView ports Go's cloudwire.View: the highest seq per class and boot,
boot replacement only by a later anchored message, rules N and U with
the drift margin and 24-hour lifetime, and the state rules, which can
only make the box's own grades worse. It is a plain Sendable value with
no clock, so an extension can run the same rules. It also keeps the
newest prices and hb for the screens, outside the grades.
The tests replay srcfl/ftw's cloud-vectors.json and
cloud-app-vectors.json, copied unchanged from cloud-c3-core at cc6b36cb,
and all 16 freshness cases in docs/protocol-cloud.md, with the box
signing by the vectors' key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FTWShared reads a push from FTW Cloud the way the Go reference (cloudwire.ReadAlert) does: a strict CBOR reader for the envelope and the event, ES256 over the bytes received with the box key the phone holds, the box clock checked against the phone's, and the catalogue's kinds only. The tests replay all 14 push vectors and the 16 messages of cloud-vectors.json from srcfl/ftw, then sign edge cases with the vector box key. PushText writes every kind in Swedish and English; the English is the push catalogue's, copied byte for byte to protocol/push-catalogue.yaml and held to it by a test. PushActions picks buttons from a verified kind alone. CloudKeys keeps each home's box key and id tables in the App Group, PushSeen drops a repeated or older seq per home and boot. The app's own strings carry the generic line under the payload's loc keys, for when the extension does not run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FTWNotify (energy.ftw.app.notify) links FTWShared only and holds the
App Group alone. It checks each push with PushAlert, drops one PushSeen
has shown, and delivers new content: the words from PushText in the
person's language, the category set every time ("" when the event is
not verified), the interruption level from the kind, one thread per
home, and the checked fields beside the original payload in userInfo.
Out of time, it shows the generic line with no buttons.
The target follows the widget's: a synchronized folder, an Info.plist
that reads the group from FTW_APP_GROUP, embedded in the app, so the
shared scheme builds it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CloudCarrier opens one WebSocket to wss://cloud.ftw.energy/v1/app and runs Noise IK as initiator to the service's key with the home id in the prologue. Message 2 brings the copy the service holds in memory; every change the box sends follows on the same socket. Each envelope is checked with the box's P-256 key, which the box gives every role in hello_ok over its own Noise session, and graded by CloudView: never live without an anchor, a replayed copy ages, a forged one is dropped. SiteModel reads the cloud first while the home has a grant and the box session over the relay stays open beside it for commands. The relay's readings take over when the cloud fails, is out of reach or has nothing better, under the carrier the readings came by. Rule U is learned from the box session and kept with the box's FTW Cloud identity for the next cold start. Prices and cars from the cloud go to the widgets as carrier cloud. The first start shows one screen: the FTW app uses FTW Cloud, with a separate statistics box that is off. An owner's phone turns the grant on at the box over Noise, once per home; a viewer is told to ask the owner. About gains an FTW Cloud row to turn it off and show the receipt, and its privacy line says what FTW Cloud holds when it is on. The address is fixed and only a debug build may point elsewhere. Release builds carry no service key yet, so they read through the relay alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The extension first handed iOS empty content for an event it had shown. In the simulator, `simctl push` never starts a service extension, so whether iOS then shows nothing could not be checked there. Without Apple's filtering entitlement, iOS is documented to show the payload's own alert instead, and that one could carry a category the service chose. So a repeat gets the generic line with the category set to none, no sound and the passive level: it lands in Notification Center and interrupts nobody. Dropping it outright needs the filtering entitlement, which Apple grants on request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At launch the app registers two categories in Swedish or English: 20 %, 50 % and 80 % with Open after "Car plugged in", and Charge now with Open after "Charging stopped early" or "The car may not be ready". The commands need the phone unlocked and run in the background; Open brings the app up. A pressed button checks the original payload again with PushAlert, at the time the notification arrived, and acts only for a verified event whose kind offers that button. CloudKeys maps the event's opaque loadpoint to the charger (the only one, if the event names none), and the command goes only to the home the app opens. It takes the path Siri and the Control take: open the session, wait for the stream, send, follow cmd.result. The box's answer is logged, nothing more. `perform` takes the charger a notification names and never swaps it for another; the level was already any whole percent. The push token: a tiny app delegate (the one UIKit piece besides the camera) hands it to PushTokens in FTWKit, which gives it to a PushTokenSink once one is installed. FTW Cloud's channel (C3) will be that sink, and installing it is when the person is asked; until then a release build asks nothing. The app gets aps-environment and the time-sensitive entitlement. Debug builds take launch arguments for the end-to-end test: -FTWPushRegister YES asks and registers at launch and logs the token, and -FTWCloudHome, -FTWCloudBoxKey, -FTWCloudLoadpoint, -FTWCloudSource and -FTWCloudSite seed one home's CloudKeys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`-FTWPairLink <url>` offers the link as a scanned QR would. The simulator has no camera, and the FTW Cloud C4 end-to-end test pairs a simulator with a local Core this way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A refusal from FTW Cloud is asked again slowly, from 7.5 s up to five minutes, and at once on wake: a service that just restarted refuses a phone that reaches it before the box does. A session another session of the same phone replaced stays closed. SiteModel logs, once per home, how long after start the relay's first reading, the cloud's snapshot and its first live arrived (category reading), beside LaunchClock's first frame. LiveCloudTests drives a real box and a real ftwcloudd through the production relay, off unless the environment names them: it times the three layers against the relay five times each, replays a snapshot an hour late, revokes a phone with its stream open and closes the cloud under a running home. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Consent given while FTW Cloud was already on never noted that the home had been asked, so turning it off in About let the next start turn it on again. Found in the simulator. ensure now notes an existing grant, and turning off in About notes it too. The consent sheet takes the accent tint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review points in SiteModel and the carriers: - A cold start no longer says "Reconnecting to your box". connect woke the carrier it had just made, which reported a reconnect that never happened; it now only arms the foreground deadline. - Carrier.send returns whether the frame went out, and a command on a carrier that is not open fails at once with E_NO_ACK instead of after the 5 s ack deadline. CloudCarrier's sends say so too. - The 1 Hz ticker is gone. The clock moves with each reading and, between readings, only when something on screen would change: the minute, a stream going quiet, a cloud grade lapsing (CloudView.nextGradeChange), or a counter the band shows in seconds. minuteMs is there for text that needs no finer clock. - After 20 s without a connection the band says "Can't reach your box · last reading HH:MM" and stops counting. - Snapshots are sealed and written off the main actor with the cache key kept in memory, are skipped when nothing changed, and the background writes once. - A keychain that cannot answer, as before the first unlock, never leads to a write: SecureStore.read tells absent from unavailable, and keys are made only when absent. - ProcessClock marks the first live reading beside LaunchClock's first frame, and signpost intervals time cold start to live and foreground to live. For flowing numbers: every value carries the time it was read (fieldAtMs, readingAtMs), a burst within one display frame reaches the screens as one change, and a source only takes over with a copy at least as new as the one on screen, so numbers never step back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…start Found in the simulator: while a burst waited for its display frame, the band read the newest relay state and said "Live via encrypted relay" while the card still said "Not live right now". carrier, srcState, ageMs and isLive now derive from a snapshot taken in the same change as session, and the cache's claim is retired in that change too. Closing the cloud publishes at once, and the background settles what the screens say before it writes. A cold start passes through inactive on its way to the front; it keeps its own cold-start-to-live interval instead of starting a foreground one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C3 made Carrier.send return whether the frame went out; M2's test carrier still returned nothing, so the merged tests did not build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Joins C3 (the app reads its home from FTW Cloud) and C4 (notifications through APNs). - When the box's hello says FTW Cloud is on, the site model writes the box key, the names and rule U's anchor to CloudKeys for the notification extension; the anchor follows at each snapshot write. Turning FTW Cloud off removes the entry. The demo writes none. - While FTW Cloud is on, the cloud session is PushTokens' sink. It sends push.register when the session opens, at every reopening and when Apple gives a new token. The service keeps one row per token. - PushAlert dates an event by rule U when the box sent no wallMs, or a wallMs too far ahead, as ReadAlert now does in srcfl/ftw. The four new push vectors replay here. - CloudWire.verify calls PushAlert's ES256 check, so a reading and a push are checked by one function. - The debug launch arguments that seeded CloudKeys are gone; the box fills them now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CloudEndpoint.releaseKeys carries the public half of cloud.ftw.energy's Noise key, /ftw-cloud/noise-key in Parameter Store. A release build now reads from FTW Cloud once the box says FTW Cloud is on; a debug build does the same unless launch arguments point it at a local service. Two tests: the release endpoint is cloud.ftw.energy with exactly that key, and a service that answers with any other key refuses message 1, so the phone opens no session and holds no snapshot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one conflict is a comment in Readings.swift: C3's "signed by the box" is now true, so it stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two fixes the merge needs: the comment on CarrierKind.cloud keeps C3's "signed by the box", which is now true, and M2's RecordingCarrier returns what send returns, as C3's Carrier asks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main has M2 (#17, squashed), Now detail (#16) and #22. Every conflict took main's side, then this branch's own changes since the M2 tip it was cut from (2c946bf) went back on top: - IntentRunner and LoadpointsModel keep main's guards for another home's card and the shared-record flush, and gain the notification's loadpointID. - The Swedish strings keep main's lines and add the push fallback. - project.pbxproj is main's (with FTWUITests and FTWCharts) plus the FTWNotify target's entries, placed at the end of each section. swift test: FTWShared 89 tests, FTWKit 179 tests, all pass. The simulator build embeds FTWNotify.appex and FTWWidgets.appex. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- AppModel.connect catches PasskeyCancelled. A closed sheet now says the unlock was cancelled and offers Try again, instead of saying the key is lost. Every help text of ConnectError and VaultError, and the one for a failure connect cannot name, goes through AppWords with Swedish. A test reads them from the source, so a new one needs Swedish too. - SiteModel.sharePrices passes the box's time zone to PriceEntry, so the price widget reads the clock the way the home does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 08557b3b22
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if wait { | ||
| write(name, data.byteArray) |
There was a problem hiding this comment.
Queue the final cache write behind pending writes
When the app is suspended while one or more writeLater tasks are pending, the wait path writes the newest snapshot synchronously without waiting for that queue. A previously queued older write can then finish afterward and atomically replace the final snapshot, so the next launch restores stale readings or cloud metadata despite persistNow(wait: true) intending to land the latest state before suspension.
Useful? React with 👍 / 👎.
| private var ensureKey: String { | ||
| guard let site = app.site else { return "" } | ||
| return "\(site.siteId ?? "")-\(site.heardFromBox)-\(site.cloudOn)-\(consent.answered)" |
There was a problem hiding this comment.
Retrigger consent when the cloud capability appears
If a consenting owner initially has an older box without cloud.link, ensure correctly defers the grant, but a later hello after the box updates changes only session.caps. Because this task identity omits the capability/audience and heardFromBox, cloudOn, and answered remain unchanged, SwiftUI does not rerun consent.ensure; FTW Cloud therefore stays off until another launch or manual action despite the UI promising activation after the update.
Useful? React with 👍 / 👎.
| 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) |
There was a problem hiding this comment.
Handle grant and statistics updates as separate operations
When statistics are selected, the cloud grant can succeed and the subsequent /api/cloud/stats request can fail. The shared act then reports failure, leaves the per-home asked marker unset, and displays that nothing changed even though the box has already enabled FTW Cloud; this can also trigger redundant grant attempts. Preserve the successful grant state and report or retry the statistics failure separately.
Useful? React with 👍 / 👎.
Joins C3 (#19, the app reads its home from FTW Cloud) and M3/C4 (#18, notifications through FTW Cloud): the FTW Cloud session now carries the push token, and the box's word over Noise fills what the notification extension reads.
What changes
SiteModel.cloudHello. AfteradoptCloud, it writesCloudHome(home:siteId:boxKey:loadpoints:sources:offset:)toCloudKeysin the App Group, from the K11 identity. The rule U offset follows with each snapshot write; the file is written only when something changed. Turning FTW Cloud off removes the entry. The demo has no App Group files, so it writes nothing.CloudHome.Offset. Rule U's anchor (boot,offsetMs,learnedAtMs) now lives in FTWShared.CloudCache.Offsetis the same type, and the cache file keeps the same JSON.PushAlertdates an event by rule U when the box sent nowallMs, or one more than 2 s ahead. This portsReadAlertfrom feat(cloudwire): join FTW Cloud C3 and C4; alerts dated by rule U ftw#1607. The four new push vectors replay here, 18 cases in all.CloudCarrier.pushRegister(apnsToken:environment:)sendspush.registerover the open session.PushTokens' sink. It sends the token when the session opens, at every reopening, and as soon as Apple gives a new one. The service keeps one row per token. Installing the sink is what asks the person to allow notifications. The sink hops to the main actor. A smallPushTokenSourcelets tests use their own token source instead of the process-wide one, soPushTokensTestsand the cloud tests cannot step on each other when they run in parallel.CloudWire.verifycallsPushAlert.verify(boxKey:home:t:b:sig:), so a reading and a push are checked by the same function.CloudKeys(-FTWCloudHome…) are gone: the box fills it now.RecordingCarrierinRemoteTestsreturnssend'sBool, as C3'sCarrierasks. Without that, the merged tests did not build.Commits: the merge (no conflicts), 1b79dfc (the test fix), 31c63d7 (the join).
Stack
Merged on top: #18 at 42071e4 (with
main9e391d5), then #21 at 5c4c08b, which carries #19 at 77dba30 and the release Noise key. Once #18, #19 and #21 land, the diff againstmainis the join alone: 1b79dfc (the test fix) and 31c63d7. Merge order: #18 → #19 → #21 → this. #16 and #17 are onmainalready.At 08557b3,
swift testpasses (265 tests in 43 suites, 89 in 9), and so does a simulator build withFTWNotifyandFTWWidgets.Checks
cd appleApp/FTWKit && swift test: 209 tests in 29 suites pass. New: the four rule U vectors;push.registerin the service's field names, only over an open session; the token goes out at the opening, at each reopening and when Apple gives a new one, and turning FTW Cloud off releases the sink; hello shares the key and names, the next write adds the offset, and turning FTW Cloud off removes them; the demo shares nothing.xcodebuild -scheme FTW -destination 'generic/platform=iOS Simulator'with its own derivedData: build succeeded.push.registerhas no shared vector, likewatchandsuspend. The end-to-end run shows that the Go service reads it.End to end (13 Oct, 10:15–10:21)
In its own iOS 26.5 simulator, against Core from srcfl/ftw#1607 and
ftwclouddfrom srcfl/ftw-cloud#7, built without development routes, and Apple's sandbox. I paired the simulator and turned FTW Cloud on with the consent screen. At the next hello the app read "Live via FTW Cloud", wrotecloud-<home>with the offset, and asked to allow notifications. After Allow, the service registered the token for this phone's roster entry in the same second. Acharging.connectedfrom Core reached the extension through Apple 703 ms after Core sent it. The extension verified it as an event and wrote its own words on the lock screen: "Car plugged in. Open to see charging status, check the battery level and set a ready time." The simulator is shut down.🤖 Generated with Claude Code