Repository navigation
Conversation
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>
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>
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>
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>
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>
- 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>
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. |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 77dba308ff
ℹ️ 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 shown.source == .cloud { | ||
| if shown.cloudOpen { return .cloud } | ||
| return cachedAtMs != nil ? .cache : .none |
There was a problem hiding this comment.
Treat the cloud carrier as live in node freshness
Whenever a valid cloud snapshot is selected, this exposes .cloud to every SiteModel.freshness(...) call, but NodeFreshness.live in State/EnergyNode.swift:93-95 recognizes only .relay and .webrtc. Consequently every cloud-fed flow and node is rendered muted/non-live even while SiteModel.isLive and the freshness band report “Live via FTW Cloud”; include .cloud in the node-level live-carrier predicate.
Useful? React with 👍 / 👎.
| 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 |
There was a problem hiding this comment.
Stop aging cloud readings from the relay timestamp
When the relay has delivered at least one frame and then drops while cloud updates continue, shown.relayFrameAtMs remains fixed, so this value crosses the quiet threshold and is passed to NodeFreshness.of, which downgrades otherwise-live cloud sources to lagging and keeps increasing their displayed age. Cloud delivery never refreshes this relay timestamp, so the calculation must use the selected source’s timestamp or omit relay silence while shown.source == .cloud.
Useful? React with 👍 / 👎.
| private func composed() -> SessionState { | ||
| var s = relay | ||
| if source == .cloud, let view = cloudView, let identity = cloudIdentity { | ||
| compose(&s, view: view, identity: identity) |
There was a problem hiding this comment.
Record cloud updates while the relay is unavailable
When FTW Cloud is carrying the home during a relay outage, composing from relay preserves its .failed phase even though publishNow advances lastFrameAtMs for each cloud reading. LiveTrace.take in State/LiveTrace.swift:98-101 rejects every such update unless session.phase == .streaming, so the live charts stop or remain empty precisely when cloud is the only working path; make trace recording depend on the selected reading carrier rather than the relay phase.
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 cloud capability becomes available
If consent is accepted while the box is .ownerLater, a later hello after the box updates can add capCloudLink without changing any component of this task ID: siteId, heardFromBox (already true), cloudOn (still false), and answered (already true). SwiftUI therefore does not rerun consent.ensure, and the promised automatic grant is never sent until the view or app is recreated; include the relevant capability or derived audience in the task identity.
Useful? React with 👍 / 👎.
…d first (#18, #19, #21, #20) One squash for four stacked PRs: - #18: M3/M4, notifications you can act on, delivered through FTW Cloud, with the FTWNotify extension that checks the box's signature. - #19: C3, the app reads its home from FTW Cloud first and falls back to the relay. It also passes the box's time zone to the shared prices and gives a cancelled unlock its own text, in English and Swedish. - #21: release builds dial FTW Cloud with its production Noise key. - #20: C3 + C4, the FTW Cloud session carries the push token. A live run of a release build against cloud.ftw.energy showed "Live via FTW Cloud" at a median of 262 ms from a cold start, against 611 ms through the relay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FTW Cloud C3, the app's part: the iPhone app reads its home from FTW Cloud first. Draft.
Pairs:
/v1/app.Based on #15, which has the registry names (
cloud,cloud.link), so the diff here is C3 alone.A cold start in three layers
CloudCarrieropens one WebSocket towss://cloud.ftw.energy/v1/app?home=<id>and runs FTWKit's Noise IK as initiator to the service's key. Message 1 says the phone is looking; message 2 is the snapshot.The phone checks every envelope.
CloudWireandCloudViewportgo/protocol/cloudwire(frames, envelope, ES256 with CryptoKit P-256, home id) and its freshness rules.hello_ok.cloudover Noise, never from the service.wallMsand arrival, and off for a boot whose clock leads by more than 2 s;SiteModel: the cloud first, the relay as backup
SiteModeltakes readings from the cloud. The box session over the relay stays open beside it for commands, prices and chargers, as today.sessionkeeps its shape, so no screen changed. The cloud's readings arrive under the box session's names, from the box's table, with their ages.cloud.cloud.ftw.energy, and only a debug build may point elsewhere:-FTWCloudURL wss://127.0.0.1:18443/v1/app -FTWCloudKey <base64url>. Release builds carry no service key yet (CloudEndpoint.releaseKeysis empty), so TestFlight reads through the relay alone until the key exists.Consent at first start, on one screen (
FTW/Cloud/**)POST /api/cloud/grant, all five cloud scopes, policyftw-cloud-2026-10), once per home. Statistics go on only when ticked.Files outside this session's list
The line in RootView, as asked:
These were also needed. Each change is small:
AboutView.swift: the FTW Cloud row and the privacy footer.AppModel.swift: one line inconnect,site.connectCloud(deviceKey: device, makeSocket: makeSocket).Session.swift:SessionState.cloud, set inonHelloOk.Messages.swift:HelloOk.cloud.Contract.swift:capCloudLink,cloudScopes,cloudPurposes; a test checks them against the registry.Freshness.swift,sv.lproj/Localizable.strings: the band'scloudwords.Readings.swift: one comment.#16 touches
RootView,Session.swift,AppModel.swiftandContract.swift, and #17 touches the Swedish strings. Neither shares a hunk with this PR.Measured, locally
The setup was a Core from srcfl/ftw#1603,
ftwclouddfrom srcfl/ftw-cloud#5 on loopback, a simulated house and the production relay, with the laptop on a phone's hotspot.The app's code in-process on the Mac (
LiveCloudTests), five cold starts each, new sockets each time:The app in the iOS 26.5 simulator (debug build), five cold launches. The first frame is counted from process start; the rest from
SiteModel.start.cloud.ftw.energyruns C2 without/v1/app, and deploying C3 is not this PR's to do. From the same laptop it answered TLS in 70–132 ms, so layer 2 over mobile data would be about four round trips, near 150 ms.Proofs:
downand never live.DELETE /api/app-link/devices/{id}.ftwclouddstopped, the carrier moved torelaywithin 11 ms, and the band said "Live via encrypted relay" in the simulator.DELETE /api/cloud/grantover Noise, and the box held the purge receipt 7 ms later.The simulator also showed one bug, fixed and tested here: consent given while FTW Cloud was already on did not record that the home had been asked, so turning it off in About let the next start turn it on again.
I reviewed the screens in the simulator in English and Swedish, light and dark, and at the largest text size.
Review fixes (f3c2ff6, 329dfce)
A review of the app found faults in the parts this PR owns. Each fix has a test in
ReviewFixTests.What the band says
carrier,srcState,ageMsandisLivenow come from a snapshot taken in the same change assession.Commands
Carrier.sendreturns whether the frame left. A command on a carrier that is not open throwsE_NO_ACKat once, not after the 5 s ack wait. The same holds when a send fails. This covers every carrier: relay, Noise, cloud and the simulated box.Readings that move smoothly
fieldAtMs,readingAtMs).The clock and the disk
A locked Keychain writes nothing
errSecInteractionNotAllowed.SecureStore.readnow throwsSecureStoreUnavailablefor that case.Vault,SealedFilesandSiteListwrite nothing: no new wrap key over the old one, no cache key, no home list without the other homes, and no new pairing.Time to live
LaunchClock.mark("live")marks the first live reading after process start.Measured after the fixes, in the iOS 26.5 simulator, five launches each:
Left for others
session, the reads at start, and the part of point 7 that lives in the session.FTW/UI/**): the band's fade between states. The model gives the states; the fade belongs to the view.Merging with #16 and #17. This branch merges with each of them without a text conflict.
LaunchClock.markkeeps its name, so iPhone Now: tap a node for live readings, history, plan and mode; the plan ahead on Now and as a widget #16's calls still work.RecordingCarrierinRemoteTests.swiftwithfunc send(_:)that returns nothing. Whichever lands second must make it-> Booland returninner.send(frame).Files outside this session's list, all small:
Carrier.swift,RelayCarrier.swift,NoiseCarrier.swift,FTWDemo/SimulatedBox.swift:sendreturnsBool.Session.swift:commandfails at once on a closed carrier.SecureStore.swift,FTW/Platform/KeychainStore.swift,Sites.swift,SealedFiles.swift: the locked-Keychain case.Freshness.swift,sv.lproj/Localizable.strings: the long-wait text.FTW/UI/LaunchClock.swifthands its marks to the newSupport/ProcessClock.swift. This file sits in iPhone Now: tap a node for live readings, history, plan and mode; the plan ahead on Now and as a widget #16's area.Tests
swift test: FTWKitTests 193 tests, FTWSharedTests 35, all passing. The 33 cloud wire and view tests are included.CloudCarrierTests,CloudEndpointTests,SiteModelCloudTests,CloudConsentTests,ReviewFixTests.LiveCloudTestsruns only when its environment names a box and a service.For Fredrik before a release turns this on
CloudEndpoint.releaseKeysand onto the host.claude/ios-m3-notify) expects FTWSharedCloudKeysto be written fromhello_ok.cloud, and this socket installed as itsPushTokenSink.SiteModel.cloudIdentityandCloudCarrierare the places.🤖 Generated with Claude Code