Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/apple.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Apple

on:
push:
branches: [main]
paths: ['appleApp/**', 'protocol/**', '.github/workflows/apple.yml']
pull_request:
paths: ['appleApp/**', 'protocol/**', '.github/workflows/apple.yml']

concurrency:
group: apple-${{ github.ref }}
cancel-in-progress: true

jobs:
apple:
name: FTWKit and the iPhone app
runs-on: macos-26
steps:
- uses: actions/checkout@v4
- name: Use the newest Xcode on the runner
run: |
latest=$(ls -d /Applications/Xcode_*.app | sort -V | tail -1)
sudo xcode-select -s "$latest"
xcodebuild -version
- name: Test FTWKit, FTWShared and FTWDemo
working-directory: appleApp/FTWKit
run: swift test
# Signed ad hoc, as FTW.xcconfig sets for the simulator, so the App
# Group entitlement travels with the app and the widget. Device
# builds need a team, which the project does not have yet.
- name: Build the app and its widgets for the iOS Simulator
working-directory: appleApp
run: >-
xcodebuild build -quiet -project FTW.xcodeproj -scheme FTW
-destination 'generic/platform=iOS Simulator'
-derivedDataPath build/ios
# For whoever reviews the screens: the demo, photographed in the
# simulator. Evidence, not a gate, so a flaky simulator does not turn
# the build red.
- name: Photograph the demo in the iOS Simulator
continue-on-error: true
working-directory: appleApp
run: scripts/demo-screenshots.sh build/screenshots
- name: Keep the screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: demo-screenshots
path: appleApp/build/screenshots
if-no-files-found: ignore
10 changes: 8 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,16 @@ DerivedData/
*.aab
captures/

# iOS
iosApp/Pods/
# Xcode
*.xcworkspace/xcuserdata/

# Secrets
*.jks
keystore.properties

# Swift package
appleApp/FTWKit/.build/
appleApp/FTWKit/.swiftpm/
appleApp/build/
appleApp/FTW.xcodeproj/project.xcworkspace/xcuserdata/
appleApp/FTW.xcodeproj/xcuserdata/
88 changes: 68 additions & 20 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# FTW native app — project guide

Kotlin Multiplatform shared logic. SwiftUI on iOS. Jetpack Compose on Android.
Two native apps. iPhone: pure Swift in `appleApp/` (FTWKit, FTWShared,
FTWDemo, SwiftUI, a WidgetKit extension). Android: Jetpack Compose on Kotlin
Multiplatform shared logic in `shared/`.
Talks to an FTW box over an encrypted session; the box is the authority and
this app is a cached projection of it.

Expand All @@ -17,15 +19,33 @@ and useful notifications. Those goals do not remove the current native release
gates below or claim features are present on either phone. Reuse Core's
contracts and authority as native scope expands.

## Current v1
## Current scope

Pair + Now only. Shipped on `main` as of 2026-08-22. Persist vault, site and
last readings on the phone. Cold start paints the cache, then reconnects
without a passkey. README Status lists what was proven and what is still open.
**iPhone (`appleApp/`).** On 2026-10-12 Fredrik approved M1 for an
iPhone-only app on iOS 26: pairing, a lean Now, a car view and price
widgets. The car view sets the level with 20/40/60/80 and ±5 through
`loadpoint.soc.set`, the goal through the schedule PUT, and has Charge now,
Pause and Back to the plan. M1 needs no server and no change in Core.
His decisions:

Do not add Energy / History / Plan / EV, commands, escrow, LAN, push, or store
listing until Pair + Now is solid on both phones, including wrap-key parity
with the web app.
- iOS 26 is the minimum. iPhone only, no macOS.
- Notification text is written on the phone, in Swedish and English.
- Commands from the lock screen, Siri and notifications require unlock.
- Charge now runs with no time limit.

Do not add Plan, History, Box admin, escrow, LAN, push or the store listing
in M1. FTW Cloud (an opt-in state cache, push and statistics, readable by
Sourceful rather than end-to-end encrypted) is designed separately; nothing
in M1 may depend on it.

**Android (`androidApp/`, `shared/`).** Pair + Now, shipped on `main` as of
2026-08-22. Do not add Energy / History / Plan / EV, commands, escrow, LAN,
push, or store listing on Android until Pair + Now is solid there, including
wrap-key parity with the web app.

Both: persist vault, site and last readings on the phone. Cold start paints
the cache, then reconnects without a passkey. README Status lists what was
proven and what is still open.

The protocol, the QR, the relay and the identity model are specified in
[ftw-webapp](https://github.com/srcfl/ftw-webapp) `docs/architecture.md` and
Expand All @@ -39,7 +59,8 @@ The protocol, the QR, the relay and the identity model are specified in
- Few required choices. Hide transport setup; expose useful household goals
and expert controls when their flows are implemented.
- Failures heal themselves. A dropped connection reconnects on its own.
There is no reconnect button.
There is no reconnect button. A relay that is full (4429) says FTW is
busy, not that something broke.
- Errors say what happens now, not what broke inside.
- Least code that does the whole job.

Expand All @@ -56,27 +77,47 @@ The protocol, the QR, the relay and the identity model are specified in
- **The cache is a cache, never the original.**
- **The cache key is not PRF-wrapped.** Cold start paints before Face ID.
PRF gates enrollment and privileged commands, not reading.
- **Widgets read the App Group cache and nothing else.** No key from the
Keychain, no relay stream. Every record carries `carrier`, `srcState` and
the time it was read, and reads back as `cache`.
- **The session closes in the background.** iOS freezes the app anyway,
and an open socket holds one of the relay's four streams per home.
- **Never hand-write a name shared with the box.** Scopes, capabilities,
field ids come from `protocol/registry.yaml`, the same file as in
srcfl/ftw and srcfl/ftw-webapp.

## Shared vs UI

`shared/` owns enrollment parse, rendezvous handles, Noise IK, frames,
session, vault wrap/unwrap, freshness and explanations.

Platform UI owns the camera, the passkey ceremony, Keychain / Keystore,
and every pixel.

Inject `PasskeyHost`, `KeyValueStore` and `SocketFactory`. Do not call
AuthenticationServices or Credential Manager from commonMain. iOS uses
Keychain. Android uses EncryptedSharedPreferences + a Keystore master key.
On iPhone, one Swift package in `appleApp/FTWKit` holds three modules:

- `FTWKit` owns everything that is not a pixel: enrollment parse,
rendezvous handles, Noise IK, frames, the relay and Noise carriers, the
session, vault wrap/unwrap and the state each screen reads. The app alone
links it. It writes the App Group cache.
- `FTWShared` holds plain `Sendable` values for the app's extensions: the
App Group cache (`prices-<site>`, `car-<site>`), the price and freshness
formatters, and their Swedish and English words. Nothing on the main
actor, no keys. A copy from FTW Cloud would be one more
`CacheSource` here, not a new model.
- `FTWDemo` is the simulated box. The app links it for the demo and App
Review; FTWKit never does.

`appleApp/FTW` owns the camera, the passkey ceremony, the Keychain and every
pixel. `appleApp/FTWWidgets` owns the widgets. Keep logic out of views: if a
sentence or a rule can be tested, it belongs in a package module with a
test. The App Group name is written once, in `appleApp/FTW.xcconfig`; the
entitlements and Info.plists read it from there.

On Android, `shared/` owns the same logic in Kotlin. Inject `PasskeyHost`,
`KeyValueStore` and `SocketFactory`. Do not call Credential Manager from
commonMain. Android uses EncryptedSharedPreferences + a Keystore master key.

## Crypto

Noise_IK_25519_ChaChaPoly_SHA256, Cacophony-tested, must stay byte-identical
to the TypeScript client and the Go box. Do not swap the primitives for a
library that has not passed `NoiseTest`.
library that has not passed `NoiseTest` (Kotlin) or `NoiseTests` (Swift).
FTWKit uses CryptoKit.

## RP ID

Expand All @@ -86,8 +127,15 @@ strands every passkey.
## Tests

```bash
./gradlew :shared:jvmTest
./gradlew :shared:jvmTest # Android shared logic
cd appleApp/FTWKit && swift test # iPhone logic, on a Mac with Xcode 27
```

`ContractTests` reads `protocol/registry.yaml` and fails when `Contract.swift`
drifts from it. Update `Contract.swift` by hand, never with a name the
registry does not have. The Apple workflow also builds the app and widget
for the iOS Simulator. Review UI changes in the simulator or on a device;
reading the source is not enough.

Green before every handoff. New protocol code needs a vector, not only a
round-trip against itself.
Loading
Loading