Skip to content
Draft
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
65 changes: 65 additions & 0 deletions .github/workflows/apple.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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:
kit-linux:
name: FTWKit on Linux
runs-on: ubuntu-latest
container: swift:6.1
steps:
- uses: actions/checkout@v4
- name: Test
working-directory: appleApp/FTWKit
run: swift test

apple:
name: FTWKit and the app on macOS
runs-on: macos-15
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 with CryptoKit
working-directory: appleApp/FTWKit
run: swift test
# Unsigned builds: they prove the app compiles for both platforms.
# Signing, passkeys and the camera need a team and a device.
- name: Build the app for the iOS Simulator
working-directory: appleApp
run: >-
xcodebuild build -quiet -project FTW.xcodeproj -scheme FTW
-destination 'generic/platform=iOS Simulator'
-derivedDataPath build/ios CODE_SIGNING_ALLOWED=NO
- name: Build the app for macOS
working-directory: appleApp
run: >-
xcodebuild build -quiet -project FTW.xcodeproj -scheme FTW
-destination 'generic/platform=macOS'
-derivedDataPath build/macos CODE_SIGNING_ALLOWED=NO
# For whoever reviews the screens: the demo, photographed per tab 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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,10 @@ iosApp/Pods/
# Secrets
*.jks
keystore.properties

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

Kotlin Multiplatform shared logic. SwiftUI on iOS. Jetpack Compose on Android.
Pure Swift on iPhone, iPad and Mac: FTWKit plus SwiftUI in `appleApp/`.
Kotlin Multiplatform shared logic with Jetpack Compose on Android.
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 +18,22 @@ 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.
**Apple (`appleApp/`).** On 2026-09-25 Fredrik chose a pure Swift app for
iOS and macOS that covers every screen of the web app: Pair, Now, Plan,
History with daily energy, the charger sheet, and Box (access,
notifications, restart, the sealed copy, sign out). It derives the wrap key
with HKDF exactly as the web app does, so one passkey opens a home in both.
README Status lists what is proven and what still needs a device.

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.
**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.

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 Down Expand Up @@ -62,21 +70,24 @@ The protocol, the QR, the relay and the identity model are specified in

## 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.
On Apple, `appleApp/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, escrow, freshness, explanations and the
state each screen reads. It builds and tests on Linux too. `appleApp/FTW`
owns the camera, the passkey ceremony, the Keychain and every pixel. Keep
logic out of the views: if a sentence or a rule can be tested, it belongs
in FTWKit with a test.

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 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 on Apple platforms and swift-crypto on Linux.

## RP ID

Expand All @@ -86,8 +97,13 @@ strands every passkey.
## Tests

```bash
./gradlew :shared:jvmTest
./gradlew :shared:jvmTest # Android shared logic
cd appleApp/FTWKit && swift test # Apple logic, on macOS or Linux
```

The Apple workflow also builds the app, unsigned, for the iOS Simulator and
for macOS. 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.
123 changes: 89 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

Your home's energy, on the phone.

Native iOS (SwiftUI) and Android (Jetpack Compose). Shared logic is Kotlin
Multiplatform: pairing, passkeys, Noise, the relay, the session. The box at
home is the record. This app is a cached projection of it. The cloud is blind.
Pure Swift on iPhone, iPad and Mac (FTWKit and SwiftUI). Jetpack Compose on
Android, with its logic in Kotlin Multiplatform. Both carry pairing,
passkeys, Noise, the relay and the session. The box at home is the record.
This app is a cached projection of it. The cloud is blind.

Not a wrap of the [web app](https://github.com/srcfl/ftw-webapp). Same protocol,
same QR, same relay, same RP ID (`app.ftw.energy`).
Expand All @@ -25,67 +26,120 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
## Shape

```
SwiftUI / Compose
│
▼
shared (KMP) — enrollment, vault, Noise IK, frames, session, relay
│
▼
wss://relay.ftw.energy (encrypted)
│
▼
FTW box
SwiftUI (iOS, macOS) Compose (Android)
│ │
▼ ▼
FTWKit (Swift) shared (KMP)
enrollment, vault, escrow, enrollment, vault,
Noise IK, frames, session, Noise IK, frames,
relay, screen state session, relay
│ │
└─────────────┬──────────────┘
▼
wss://relay.ftw.energy (encrypted)
│
▼
FTW box
```

Two taps: scan the QR on the box, Face ID / biometrics, the house.

## Status (2026-08-22)
## Status: Apple (2026-09-25)

The Apple app is pure Swift and covers every screen of the web app. The
wrap key comes from HKDF over the PRF output, exactly as in the web app, so
one passkey opens a home in both. Native pairings made before this change
scan the QR again.

**In the app**

- Pair: camera QR, a picture of the QR on a Mac, passkey recovery from the
sealed copy, a link arriving from outside shown before it is trusted, and
the live demo against a simulated box.
- Now: one sentence, the energy flow, the price card with the cheapest two
hours, what FTW does next, today's totals and savings, the fuse, a live
line per part of the house, and the charger sheet (charge now, pause,
battery level, goal, spare solar only, home battery boost, car battery
size, charging windows).
- Plan: the headline, how the home is run, prices for today and tomorrow,
and the next twelve hours.
- History: energy per day for today, 7 and 30 days, and power over 24 h to
a year from cached tiles.
- Box: identity, who can see this home and viewer invites, notification
rules and history, restart, the sealed copy, sign out.
- The freshness band above every screen, with carrier and source state kept
apart. A Mac also gets a menu bar glance.

**Proven**

V1 is Pair + Now. That is on `main` as of 2026-08-22. Not a wrap of the web
app. Not Flutter, not React Native.
| Check | Result |
|---|---|
| `swift test` in `appleApp/FTWKit`, Linux (Swift 6.3) and macOS (CryptoKit) | 95 tests green |
| Cross implementation vectors from the web app and the box | Noise, frames, rendezvous handles, recovery blob, escrow ids and write keys, vault copy all match |
| Live box through the production relay | `hello_ok`, snapshot, `streaming`, history tiles |
| Unsigned app build in CI | iOS Simulator and macOS |

**Needs a device or an owner decision**

- Passkeys on a real phone or Mac need a signing team and an
`apple-app-site-association` file on `app.ftw.energy` that names the app
(`<TEAM>.energy.ftw.app` under `webcredentials` and `applinks`).
- Nobody has reviewed the screens in a simulator or on a device yet.
- Notifications: the box reaches phones through web push and ntfy. This app
manages the box's rules and shows what was sent, but cannot receive them
until the box and relay learn to send to APNs.

## Status: Android (2026-08-22)

**In the apps today**
V1 is Pair + Now. Not a wrap of the web app. Not Flutter, not React Native.

**In the app today**

- Scan or paste a v2 pairing QR (`https://app.ftw.energy/p#v2.…`).
- One passkey prompt at enroll. RP ID `app.ftw.energy`. PRF salt `ftw.prf.v1.vault`.
- Noise_IK_25519_ChaChaPoly_SHA256 to the box through `wss://relay.ftw.energy`.
- Now shows headline plus grid / solar / battery / house from frozen field ids.
- Vault, site and last readings live in iOS Keychain /
Android EncryptedSharedPreferences. Cold start paints from cache, then
reconnects without Face ID. Forget wipes the store.
- Vault, site and last readings live in Android EncryptedSharedPreferences.
Cold start paints from cache, then reconnects without biometrics. Forget
wipes the store.

**Proven here**

| Check | Result |
|---|---|
| `./gradlew :shared:jvmTest` | Green |
| Live box e2e (`127.0.0.1:18080` + production relay) | `hello_ok` + snapshot, phase `streaming` |
| iOS Simulator (iPhone 17, iOS 26.5) | Built and launched |
| Android emulator `FTW_Phone` (API 35 ARM64) | APK installed, Pair shown twice |

Passkey PRF cannot run on the JVM. Live e2e uses a local wrapping key for the
ceremony and the real Noise / relay / box path. The Android emulator has no
camera feed — paste the pairing link.

**Not v1 (do not start these next)**
**Not v1 on Android (do not start these next)**

Energy, History, Plan, EV, commands, escrow restore, spoken codes, LAN,
WebRTC, push, App Store / Play listing.
WebRTC, push, Play listing.

**Known holes**

- `srcState` should follow the Now fields' `srcId` in the dict, not every
driver on the site.
- Wrap key is raw PRF bytes, not the web app's HKDF. A native vault will not
open in the PWA, and the other way around.
- `PasskeyHost.enroll` from Kotlin still blocks. The UIs call the async
ceremony and skip that path.
- Wrap key is raw PRF bytes, not the web app's HKDF. An Android vault will
not open in the PWA, and the other way around.
- `PasskeyHost.enroll` from Kotlin still blocks. The UI calls the async
ceremony and skips that path.
- Field ids in `Explanation.kt` are still hand-written; they should come from
`protocol/registry.yaml`.

## Tests

JDK 21.
Apple, on macOS or Linux (Swift 6.1 or newer):

```bash
cd appleApp/FTWKit && swift test
```

Android, JDK 21.

```bash
export JAVA_HOME="$(brew --prefix openjdk@21)/libexec/openjdk.jdk/Contents/Home"
Expand Down Expand Up @@ -116,10 +170,10 @@ snapshot that includes the frozen field ids.

## Native apps

iOS: open `iosApp/iosApp.xcodeproj`. SwiftUI Pair (camera QR + paste) and Now.
Xcode 16+, iOS 18 for passkey PRF. A Run Script build phase compiles the
Shared framework with
`./gradlew :shared:embedAndSignAppleFrameworkForXcode`.
iOS and macOS: open `appleApp/FTW.xcodeproj`, pick your team under Signing,
and run the FTW scheme on a simulator, a device or My Mac. Xcode 16 or
newer; iOS 18 and macOS 15 for passkey PRF. The demo on the pairing screen
runs without a box, a passkey or a network.

Android: `./gradlew :androidApp:assembleDebug` (minSdk 28). Pair uses CameraX
+ ML Kit for the QR. Passkeys go through Credential Manager.
Expand All @@ -140,9 +194,10 @@ wrapping copy so Now paints without a passkey prompt.

| Path | What |
|---|---|
| `shared/` | KMP: identity, crypto, protocol, relay, session |
| `appleApp/FTWKit/` | Swift: identity, crypto, protocol, relay, session, screen state |
| `appleApp/FTW/` | SwiftUI for iOS and macOS |
| `shared/` | KMP for Android: identity, crypto, protocol, relay, session |
| `androidApp/` | Compose UI |
| `iosApp/` | SwiftUI UI |
| `protocol/registry.yaml` | Names shared with the box |
| `scripts/e2e-ftw.sh` | Live box e2e |

Expand Down
File renamed without changes.
19 changes: 19 additions & 0 deletions appleApp/FTW-macOS.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>webcredentials:app.ftw.energy</string>
<string>applinks:app.ftw.energy</string>
</array>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
Loading
Loading