diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml new file mode 100644 index 0000000..a5ec938 --- /dev/null +++ b/.github/workflows/apple.yml @@ -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 diff --git a/.gitignore b/.gitignore index 6bfa9df..00894b1 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md index a941e9e..15f07f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. @@ -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 @@ -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. @@ -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-`, `car-`), 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 @@ -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. diff --git a/README.md b/README.md index 5ae3564..a7e5175 100644 --- a/README.md +++ b/README.md @@ -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. +Two native apps. The iPhone app is pure Swift in `appleApp/`. The Android app +is Jetpack Compose on Kotlin Multiplatform (`shared/`, `androidApp/`). 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`). @@ -25,33 +26,99 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). ## Shape ``` -SwiftUI / Compose - │ - ▼ - shared (KMP) — enrollment, vault, Noise IK, frames, session, relay - │ - ▼ - wss://relay.ftw.energy (encrypted) - │ - ▼ - FTW box + iPhone (appleApp/) Android + SwiftUI app ── widgets Compose + │ │ │ + ▼ ▼ ▼ + FTWKit ──► FTWShared ◄── App Group shared (KMP) + session, cache records, enrollment, vault, + Noise, vault formatters Noise IK, frames, + │ 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: iPhone (2026-10-12) + +M1 is pairing, a lean Now, a car view and price widgets. This is its +foundation, taken from the Swift draft in #5 and cut down to that scope. +iOS 26 is the minimum. iPhone only. + +**In the app** + +- Pair: camera QR, a link from outside shown before it is trusted (also a + pairing QR scanned with the iOS camera, as a universal link), and the live + demo against a simulated box. In the simulator, which has no camera, a + picture of the QR from Photos takes the same path. +- Now: one sentence, the energy flow, the price card with the cheapest two + hours, today's totals and the fuse. The freshness band above it keeps + carrier and source state apart. +- A price widget that reads the App Group cache and nothing else. +- The app writes `prices-` and `car-` into the App Group after + each read, with where each reading came from and when. +- The session closes when the app goes to the background and opens again + on return, without a passkey. +- A relay that turns the phone away with 4429 shows "FTW is busy" ("FTW är + upptaget") instead of an error. + +**Proven** + +| Check | Result | +|---|---| +| `swift test` in `appleApp/FTWKit` (Xcode 27, Swift 6.4) | 94 tests green, 1 live test skipped | +| Cross-implementation vectors from the web app and the box | Noise, frames, rendezvous handles and the vault copy match | +| `ContractTests` against `protocol/registry.yaml` | Scopes, ops, capabilities, modes, errors, states and frozen field ids match | +| App and widget for the iOS Simulator, signed ad hoc | Builds; both carry the App Group entitlement | +| Demo in the iOS Simulator (iPhone 17, iOS 26.5) | Launches and streams | +| A picture of a pairing QR, picked from Photos in that simulator | Read and paired; the home then dials the relay | + +**Next in M1** + +The car view (level presets 20/40/60/80 and ±5 through `loadpoint.soc.set`, +the goal through the schedule PUT, Charge now, Pause and Back to the plan), +the four price widget families, then tests and screenshots. + +**Manual steps before a phone build** + +The team is `L66M368PZ6`, set once in `appleApp/FTW.xcconfig` for the app and +the widget, with automatic signing. Nothing for signing is in the repository. +Someone in that team still has to: + +- Register the App IDs `energy.ftw.app` and `energy.ftw.app.widgets`, the + App Group `group.energy.ftw.app` on both, and Associated Domains on the + app. Signing in to Xcode with the team and building for a phone does this + through automatic signing; the developer portal works too. +- Publish `apple-app-site-association` on `app.ftw.energy` with + `L66M368PZ6.energy.ftw.app` under `webcredentials` and `/p` under + `applinks` (in ftw-webapp). Passkeys and the camera QR link need it. + +Simulator builds sign ad hoc and need no Apple account. + +**Not in M1 (do not start these next)** + +Plan, History, Box admin, escrow and recovery, spoken box codes, macOS, +LAN, WebRTC, push and the store listing. + +## Status: Android (2026-08-22) 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. +app. Not Flutter, not React Native. Android's KMP core stays as it is until +Android is back in scope. -**In the apps today** +**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 +- Vault, site and last readings live in Android + EncryptedSharedPreferences. Cold start paints from cache, then reconnects without Face ID. Forget wipes the store. **Proven here** @@ -60,24 +127,24 @@ app. Not Flutter, not React Native. |---|---| | `./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. +- 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. The iPhone app uses the + web app's HKDF. - `PasskeyHost.enroll` from Kotlin still blocks. The UIs call the async ceremony and skip that path. - Field ids in `Explanation.kt` are still hand-written; they should come from @@ -85,14 +152,20 @@ WebRTC, push, App Store / Play listing. ## Tests -JDK 21. +iPhone, on a Mac with Xcode 27: + +```bash +cd appleApp/FTWKit && swift test +``` + +Android, JDK 21: ```bash export JAVA_HOME="$(brew --prefix openjdk@21)/libexec/openjdk.jdk/Contents/Home" ./gradlew :shared:jvmTest ``` -That suite covers enrollment URLs, Cacophony Noise IK, hello/sub CBOR matching +The Android suite covers enrollment URLs, Cacophony Noise IK, hello/sub CBOR matching the box's interop hex, 512-byte lane 0 frames, the vault, and a session that turns hello_ok + snap into Now state. @@ -116,10 +189,18 @@ 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`. +iPhone: open `appleApp/FTW.xcodeproj` in Xcode 27 and run the FTW scheme on +a simulator. The demo on the pairing screen runs without a box, a passkey or +a network; `-FTWDemo YES` opens it at launch in a debug build. From the +command line: + +```bash +cd appleApp +xcodebuild build -project FTW.xcodeproj -scheme FTW -destination 'generic/platform=iOS Simulator' +``` + +Simulator builds sign ad hoc (`FTW.xcconfig`), so the App Group entitlement +travels with the app and its widget. Android: `./gradlew :androidApp:assembleDebug` (minSdk 28). Pair uses CameraX + ML Kit for the QR. Passkeys go through Credential Manager. @@ -140,11 +221,20 @@ wrapping copy so Now paints without a passkey prompt. | Path | What | |---|---| -| `shared/` | KMP: identity, crypto, protocol, relay, session | +| `appleApp/FTWKit/Sources/FTWKit` | Swift: identity, crypto, protocol, relay, session, screen state. The app alone links it. | +| `appleApp/FTWKit/Sources/FTWShared` | `Sendable` values for the app and its extensions: the App Group cache and the price and freshness formatters | +| `appleApp/FTWKit/Sources/FTWDemo` | The simulated box, for the demo, App Review and previews | +| `appleApp/FTW/` | SwiftUI for iPhone, Keychain, passkeys, camera | +| `appleApp/FTWWidgets/` | WidgetKit extension. Reads the cache; no key, no relay. | +| `appleApp/FTW.xcconfig` | Settings app and widget share: the App Group, versions, signing | +| `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 | +| `scripts/e2e-ftw.sh` | Live box e2e (Android shared logic) | + +The Xcode project uses folders that Xcode keeps in step with the disk. A new +file under `FTW/`, `FTWWidgets/` or a package target needs no change to +`project.pbxproj`; only a new target does. ## Licence diff --git a/appleApp/FTW-Info.plist b/appleApp/FTW-Info.plist new file mode 100644 index 0000000..03bab63 --- /dev/null +++ b/appleApp/FTW-Info.plist @@ -0,0 +1,13 @@ + + + + + FTWAppGroup + $(FTW_APP_GROUP) + CFBundleLocalizations + + en + sv + + + diff --git a/iosApp/iosApp/iosApp.entitlements b/appleApp/FTW.entitlements similarity index 76% rename from iosApp/iosApp/iosApp.entitlements rename to appleApp/FTW.entitlements index fbe5ddb..1411e58 100644 --- a/iosApp/iosApp/iosApp.entitlements +++ b/appleApp/FTW.entitlements @@ -7,5 +7,9 @@ webcredentials:app.ftw.energy applinks:app.ftw.energy + com.apple.security.application-groups + + $(FTW_APP_GROUP) + diff --git a/appleApp/FTW.xcconfig b/appleApp/FTW.xcconfig new file mode 100644 index 0000000..8a90e57 --- /dev/null +++ b/appleApp/FTW.xcconfig @@ -0,0 +1,24 @@ +// Settings the app and its widget extension must agree on, in one place. +// Both targets build on this file through the project. + +// The App Group the app and widgets share. Written here once: both +// entitlements files and both Info.plists read it as $(FTW_APP_GROUP), and +// Swift reads it back from Info.plist (AppGroupFiles.infoKey). +FTW_APP_GROUP = group.energy.ftw.app + +// An extension's version must match its app's. +MARKETING_VERSION = 0.1.0 +CURRENT_PROJECT_VERSION = 1 + +IPHONEOS_DEPLOYMENT_TARGET = 26.0 + +// Sourceful's Apple team, for the app and the widget alike. Signing is +// automatic: Xcode, signed in to that team, makes the profiles. Nothing for +// signing is committed here: no certificate, profile or key. +DEVELOPMENT_TEAM = L66M368PZ6 +CODE_SIGN_STYLE = Automatic + +// Simulator builds sign ad hoc and need no Apple account, for CI and for +// anyone without one. The entitlements, and with them the App Group, still +// travel with the app. +CODE_SIGN_IDENTITY[sdk=iphonesimulator*] = - diff --git a/appleApp/FTW.xcodeproj/project.pbxproj b/appleApp/FTW.xcodeproj/project.pbxproj new file mode 100644 index 0000000..9a2ab24 --- /dev/null +++ b/appleApp/FTW.xcodeproj/project.pbxproj @@ -0,0 +1,469 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + F7A000000000000000000014 /* FTWKit in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000016 /* FTWKit */; }; + F7A000000000000000000030 /* FTWDemo in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000031 /* FTWDemo */; }; + F7A000000000000000000032 /* FTWShared in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000033 /* FTWShared */; }; + F7A000000000000000000034 /* FTWWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000040 /* FTWWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + F7A000000000000000000035 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F7A000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F7A000000000000000000050; + remoteInfo = FTWWidgets; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + F7A000000000000000000036 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + F7A000000000000000000034 /* FTWWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + F7A000000000000000000005 /* FTW.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FTW.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F7A000000000000000000006 /* FTW.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FTW.entitlements; sourceTree = ""; }; + F7A000000000000000000007 /* FTWWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FTWWidgets.entitlements; sourceTree = ""; }; + F7A000000000000000000008 /* FTW.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = FTW.xcconfig; sourceTree = ""; }; + F7A000000000000000000009 /* FTW-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "FTW-Info.plist"; sourceTree = ""; }; + F7A00000000000000000000A /* FTWWidgets-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "FTWWidgets-Info.plist"; sourceTree = ""; }; + F7A000000000000000000040 /* FTWWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FTWWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + F7A000000000000000000003 /* FTW */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FTW; + sourceTree = ""; + }; + F7A000000000000000000041 /* FTWWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FTWWidgets; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + F7A000000000000000000012 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F7A000000000000000000014 /* FTWKit in Frameworks */, + F7A000000000000000000030 /* FTWDemo in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7A000000000000000000052 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F7A000000000000000000032 /* FTWShared in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + F7A000000000000000000002 = { + isa = PBXGroup; + children = ( + F7A000000000000000000003 /* FTW */, + F7A000000000000000000041 /* FTWWidgets */, + F7A000000000000000000008 /* FTW.xcconfig */, + F7A000000000000000000006 /* FTW.entitlements */, + F7A000000000000000000009 /* FTW-Info.plist */, + F7A000000000000000000007 /* FTWWidgets.entitlements */, + F7A00000000000000000000A /* FTWWidgets-Info.plist */, + F7A000000000000000000004 /* Products */, + ); + sourceTree = ""; + }; + F7A000000000000000000004 /* Products */ = { + isa = PBXGroup; + children = ( + F7A000000000000000000005 /* FTW.app */, + F7A000000000000000000040 /* FTWWidgets.appex */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + F7A000000000000000000010 /* FTW */ = { + isa = PBXNativeTarget; + buildConfigurationList = F7A000000000000000000023 /* Build configuration list for PBXNativeTarget "FTW" */; + buildPhases = ( + F7A000000000000000000011 /* Sources */, + F7A000000000000000000012 /* Frameworks */, + F7A000000000000000000013 /* Resources */, + F7A000000000000000000036 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + F7A000000000000000000037 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + F7A000000000000000000003 /* FTW */, + ); + name = FTW; + packageProductDependencies = ( + F7A000000000000000000016 /* FTWKit */, + F7A000000000000000000031 /* FTWDemo */, + ); + productName = FTW; + productReference = F7A000000000000000000005 /* FTW.app */; + productType = "com.apple.product-type.application"; + }; + F7A000000000000000000050 /* FTWWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = F7A000000000000000000056 /* Build configuration list for PBXNativeTarget "FTWWidgets" */; + buildPhases = ( + F7A000000000000000000051 /* Sources */, + F7A000000000000000000052 /* Frameworks */, + F7A000000000000000000053 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F7A000000000000000000041 /* FTWWidgets */, + ); + name = FTWWidgets; + packageProductDependencies = ( + F7A000000000000000000033 /* FTWShared */, + ); + productName = FTWWidgets; + productReference = F7A000000000000000000040 /* FTWWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F7A000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + F7A000000000000000000010 = { + CreatedOnToolsVersion = 16.0; + }; + F7A000000000000000000050 = { + CreatedOnToolsVersion = 27.0; + }; + }; + }; + buildConfigurationList = F7A000000000000000000020 /* Build configuration list for PBXProject "FTW" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + sv, + Base, + ); + mainGroup = F7A000000000000000000002; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = F7A000000000000000000004 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F7A000000000000000000010 /* FTW */, + F7A000000000000000000050 /* FTWWidgets */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + F7A000000000000000000013 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7A000000000000000000053 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + F7A000000000000000000011 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7A000000000000000000051 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + F7A000000000000000000037 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F7A000000000000000000050 /* FTWWidgets */; + targetProxy = F7A000000000000000000035 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + F7A000000000000000000021 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F7A000000000000000000008 /* FTW.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + F7A000000000000000000022 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F7A000000000000000000008 /* FTW.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + F7A000000000000000000024 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = FTW.entitlements; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTW-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + INFOPLIST_KEY_NSCameraUsageDescription = "FTW uses the camera to read the pairing code from your box."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app; + PRODUCT_NAME = FTW; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + F7A000000000000000000025 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = FTW.entitlements; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTW-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + INFOPLIST_KEY_NSCameraUsageDescription = "FTW uses the camera to read the pairing code from your box."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app; + PRODUCT_NAME = FTW; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + F7A000000000000000000054 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = FTWWidgets.entitlements; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTWWidgets-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app.widgets; + PRODUCT_NAME = FTWWidgets; + 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; + }; + F7A000000000000000000055 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = FTWWidgets.entitlements; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTWWidgets-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app.widgets; + PRODUCT_NAME = FTWWidgets; + 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 */ + F7A000000000000000000020 /* Build configuration list for PBXProject "FTW" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7A000000000000000000021 /* Debug */, + F7A000000000000000000022 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F7A000000000000000000023 /* Build configuration list for PBXNativeTarget "FTW" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7A000000000000000000024 /* Debug */, + F7A000000000000000000025 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F7A000000000000000000056 /* Build configuration list for PBXNativeTarget "FTWWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7A000000000000000000054 /* Debug */, + F7A000000000000000000055 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = FTWKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + F7A000000000000000000016 /* FTWKit */ = { + isa = XCSwiftPackageProductDependency; + package = F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */; + productName = FTWKit; + }; + F7A000000000000000000031 /* FTWDemo */ = { + isa = XCSwiftPackageProductDependency; + package = F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */; + productName = FTWDemo; + }; + F7A000000000000000000033 /* FTWShared */ = { + isa = XCSwiftPackageProductDependency; + package = F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */; + productName = FTWShared; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = F7A000000000000000000001 /* Project object */; +} diff --git a/appleApp/FTW.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/appleApp/FTW.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/appleApp/FTW.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/appleApp/FTW.xcodeproj/xcshareddata/xcschemes/FTW.xcscheme similarity index 82% rename from iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme rename to appleApp/FTW.xcodeproj/xcshareddata/xcschemes/FTW.xcscheme index 3657485..a66be0e 100644 --- a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme +++ b/appleApp/FTW.xcodeproj/xcshareddata/xcschemes/FTW.xcscheme @@ -14,10 +14,10 @@ buildForAnalyzing = "YES"> + BlueprintName = "FTW" + ReferencedContainer = "container:FTW.xcodeproj"> @@ -44,10 +44,10 @@ runnableDebuggingMode = "0"> + BlueprintName = "FTW" + ReferencedContainer = "container:FTW.xcodeproj"> @@ -61,10 +61,10 @@ runnableDebuggingMode = "0"> + BlueprintName = "FTW" + ReferencedContainer = "container:FTW.xcodeproj"> diff --git a/appleApp/FTW/Assets.xcassets/AccentColor.colorset/Contents.json b/appleApp/FTW/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..fdca332 --- /dev/null +++ b/appleApp/FTW/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "red": "0xDA", + "green": "0x7F", + "blue": "0x00", + "alpha": "1.000" + } + } + }, + { + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "red": "0xFF", + "green": "0xAC", + "blue": "0x41", + "alpha": "1.000" + } + } + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/Contents.json b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..592f5b9 --- /dev/null +++ b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,74 @@ +{ + "images": [ + { + "filename": "icon-ios-1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + }, + { + "filename": "icon-mac-16@1x.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16" + }, + { + "filename": "icon-mac-16@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16" + }, + { + "filename": "icon-mac-32@1x.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32" + }, + { + "filename": "icon-mac-32@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32" + }, + { + "filename": "icon-mac-128@1x.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128" + }, + { + "filename": "icon-mac-128@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128" + }, + { + "filename": "icon-mac-256@1x.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256" + }, + { + "filename": "icon-mac-256@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256" + }, + { + "filename": "icon-mac-512@1x.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "filename": "icon-mac-512@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png new file mode 100644 index 0000000..91a152a Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@1x.png new file mode 100644 index 0000000..e548c7e Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@1x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png new file mode 100644 index 0000000..5bd332f Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-128@2x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@1x.png new file mode 100644 index 0000000..dd8790b Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@1x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png new file mode 100644 index 0000000..e8a13a2 Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-16@2x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@1x.png new file mode 100644 index 0000000..5bd332f Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@1x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png new file mode 100644 index 0000000..42decea Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-256@2x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@1x.png new file mode 100644 index 0000000..e8a13a2 Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@1x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png new file mode 100644 index 0000000..652376e Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-32@2x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@1x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@1x.png new file mode 100644 index 0000000..42decea Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@1x.png differ diff --git a/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png new file mode 100644 index 0000000..0b759c2 Binary files /dev/null and b/appleApp/FTW/Assets.xcassets/AppIcon.appiconset/icon-mac-512@2x.png differ diff --git a/appleApp/FTW/Assets.xcassets/Contents.json b/appleApp/FTW/Assets.xcassets/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/appleApp/FTW/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift new file mode 100644 index 0000000..b7fc0de --- /dev/null +++ b/appleApp/FTW/FTWApp.swift @@ -0,0 +1,72 @@ +import FTWDemo +import FTWKit +import SwiftUI +import WidgetKit + +@main +struct FTWApp: App { + @State private var app: AppModel + @Environment(\.scenePhase) private var scenePhase + private let network: NetworkWatch + + init() { + let store = KeychainStore() + let files = SealedFiles(directory: SealedFiles.defaultDirectory(), store: store) + let model = AppModel( + store: store, + files: files, + // What the widgets read. Nil only in a build without the App + // Group entitlement, and then the app works without widgets. + shared: AppGroupFiles.shared(), + sharedWritten: { WidgetCenter.shared.reloadAllTimelines() }, + passkeys: Passkeys(), + build: AppInfo.build, + ua: AppInfo.userAgent, + // App Review and anyone without a box can try a simulated home. + demo: SimulatedBox.loopback + ) + // A paired phone paints its cached home before anything else runs. + model.launch() + #if DEBUG + // For screenshots: `-FTWDemo YES` opens the simulated home. + if UserDefaults.standard.bool(forKey: "FTWDemo") { model.startDemo() } + #endif + _app = State(initialValue: model) + network = NetworkWatch { [weak model] in model?.networkOnline() } + } + + var body: some Scene { + WindowGroup { + RootView(app: app) + // A pairing QR scanned with the iOS camera arrives as a + // universal link to https://app.ftw.energy/p#…, and takes the + // same path as any other pairing link. + .onOpenURL { app.offer($0.absoluteString) } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + if let url = activity.webpageURL { app.offer(url.absoluteString) } + } + .onChange(of: scenePhase, initial: true) { _, phase in + // Inactive is a glance at Control Center or the app + // switcher: slow the stream, keep the session. Only the + // background closes it. + app.setVisible(phase == .active) + if phase == .background { app.suspend() } + } + } + } +} + +enum AppInfo { + static var version: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0" + } + + static var buildNumber: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0" + } + + /// What the box records about this app in its hello. + static var build: String { "\(version)+\(buildNumber)" } + + static var userAgent: String { "FTW iOS \(version)" } +} diff --git a/appleApp/FTW/Platform/KeychainStore.swift b/appleApp/FTW/Platform/KeychainStore.swift new file mode 100644 index 0000000..16f5611 --- /dev/null +++ b/appleApp/FTW/Platform/KeychainStore.swift @@ -0,0 +1,49 @@ +import Foundation +import FTWKit +import Security + +/// The secure store: generic passwords, readable after first unlock, never +/// synced and never migrated to another device. Cold start reads it without +/// Face ID, which is what lets the cache paint first. +/// +/// No access group is named, so every item lives in the app's own default +/// group. The widget extension has a different one and can read none of it: +/// widgets get the App Group cache and no key. +@MainActor +final class KeychainStore: SecureStore { + private let service = "energy.ftw.app" + + func get(_ key: String) -> 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 + } + + func put(_ key: String, _ value: Bytes) { + let query = base(key) + let update: [String: Any] = [kSecValueData as String: Data(value)] + let status = SecItemUpdate(query as CFDictionary, update as CFDictionary) + if status == errSecItemNotFound { + var item = query + item[kSecValueData as String] = Data(value) + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + SecItemAdd(item as CFDictionary, nil) + } + } + + func remove(_ key: String) { + SecItemDelete(base(key) as CFDictionary) + } + + private func base(_ key: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + } +} diff --git a/appleApp/FTW/Platform/NetworkWatch.swift b/appleApp/FTW/Platform/NetworkWatch.swift new file mode 100644 index 0000000..471a600 --- /dev/null +++ b/appleApp/FTW/Platform/NetworkWatch.swift @@ -0,0 +1,29 @@ +import Foundation +import Network + +/// Tells the app when a network path comes back, so a stream replaces its +/// dead socket at once instead of waiting out a timeout. Lives as long as +/// the app does. +@MainActor +final class NetworkWatch { + private let monitor = NWPathMonitor() + private let onOnline: @MainActor () -> Void + private var wasOnline = true + + init(onOnline: @escaping @MainActor () -> Void) { + self.onOnline = onOnline + // Sendable on purpose: the monitor calls this on its own queue, and a + // closure left to infer main-actor isolation would trap there. + monitor.pathUpdateHandler = { @Sendable [weak self] path in + guard let self else { return } + let online = path.status == .satisfied + Task { @MainActor in self.update(online) } + } + monitor.start(queue: DispatchQueue(label: "energy.ftw.network")) + } + + private func update(_ online: Bool) { + if online, !wasOnline { onOnline() } + wasOnline = online + } +} diff --git a/appleApp/FTW/Platform/Passkeys.swift b/appleApp/FTW/Platform/Passkeys.swift new file mode 100644 index 0000000..beb876c --- /dev/null +++ b/appleApp/FTW/Platform/Passkeys.swift @@ -0,0 +1,136 @@ +import AuthenticationServices +import CryptoKit +import FTWKit +import Foundation + +/// The passkey ceremony, with PRF, against the web app's relying party. The +/// same passkey and the same salt give the same PRF output on the web and +/// here, which is what lets one passkey open a home in both apps. +@MainActor +final class Passkeys: PasskeyAuthenticator { + private var pending: Ceremony? + + var isAvailable: Bool { true } + + func register(label: String, userHandle: Bytes, excludeCredentialIDs: [String]) async throws -> PasskeyOutcome { + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: Origin.rpID) + let request = provider.createCredentialRegistrationRequest(challenge: Data(randomBytes(32)), name: label, userID: Data(userHandle)) + request.userVerificationPreference = .required + request.excludedCredentials = descriptors(excludeCredentialIDs) + request.prf = .inputValues(.init(saltInput1: Data(PRF.vaultSalt))) + let answer = try await perform(request) + return PasskeyOutcome(credentialID: Base64url.encode(answer.credentialID.byteArray), prfOutput: answer.prf?.byteArray, prfEnabled: answer.prfSupported) + } + + func assert(credentialIDs: [String]) async throws -> PasskeyOutcome { + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: Origin.rpID) + let request = provider.createCredentialAssertionRequest(challenge: Data(randomBytes(32))) + request.userVerificationPreference = .required + request.allowedCredentials = descriptors(credentialIDs) + request.prf = .inputValues(.init(saltInput1: Data(PRF.vaultSalt))) + let answer = try await perform(request) + return PasskeyOutcome(credentialID: Base64url.encode(answer.credentialID.byteArray), prfOutput: answer.prf?.byteArray) + } + + private func descriptors(_ ids: [String]) -> [ASAuthorizationPlatformPublicKeyCredentialDescriptor] { + ids.compactMap { try? Base64url.decode($0) }.map { ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: Data($0)) } + } + + private func perform(_ request: ASAuthorizationRequest) async throws -> CeremonyAnswer { + // One sheet at a time; a second ask while one is up is the same ask. + pending?.cancel() + let ceremony = Ceremony(request) + pending = ceremony + defer { if pending === ceremony { pending = nil } } + return try await ceremony.run() + } +} + +/// The platform answered with a credential of a kind this app never asked for. +private struct UnexpectedCredential: Error {} + +/// What a ceremony produced, copied out of the platform's credential on the +/// main actor so only plain values cross back to the caller. +private struct CeremonyAnswer: Sendable { + let credentialID: Data + let prf: Data? + /// At registration: the platform will evaluate PRF on an assertion even + /// when it gave no output yet. + let prfSupported: Bool + + init(_ credential: ASAuthorizationCredential) throws { + if let created = credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration { + credentialID = created.credentialID + prf = created.prf?.first.map(Self.data) + prfSupported = created.prf?.isSupported ?? false + } else if let asserted = credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion { + credentialID = asserted.credentialID + prf = asserted.prf.map { Self.data($0.first) } + prfSupported = asserted.prf != nil + } else { + throw UnexpectedCredential() + } + } + + private static func data(_ key: SymmetricKey) -> Data { + key.withUnsafeBytes { Data($0) } + } +} + +/// One authorization controller and the continuation it answers. +@MainActor +private final class Ceremony: NSObject { + private let controller: ASAuthorizationController + private var continuation: CheckedContinuation? + + init(_ request: ASAuthorizationRequest) { + controller = ASAuthorizationController(authorizationRequests: [request]) + super.init() + controller.delegate = self + controller.presentationContextProvider = self + } + + func run() async throws -> CeremonyAnswer { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + controller.performRequests() + } + } + + func cancel() { + controller.cancel() + } + + fileprivate func finish(_ result: Result) { + continuation?.resume(with: result) + continuation = nil + } +} + +extension Ceremony: ASAuthorizationControllerDelegate { + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + finish(Result { try CeremonyAnswer(authorization.credential) }) + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + // A dismissed sheet is an answer, not a fault, and gets its own type. + if let e = error as? ASAuthorizationError, e.code == .canceled { + finish(.failure(PasskeyCancelled())) + } else { + finish(.failure(error)) + } + } +} + +extension Ceremony: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + let windows = scenes.flatMap(\.windows) + if let window = windows.first(where: \.isKeyWindow) ?? windows.first { return window } + guard let scene = scenes.first else { + // A ceremony starts from a button on screen, so a scene exists. + preconditionFailure("passkey ceremony with no window scene") + } + return ASPresentationAnchor(windowScene: scene) + } +} diff --git a/appleApp/FTW/Platform/QRScanner.swift b/appleApp/FTW/Platform/QRScanner.swift new file mode 100644 index 0000000..8074d26 --- /dev/null +++ b/appleApp/FTW/Platform/QRScanner.swift @@ -0,0 +1,114 @@ +import AVFoundation +import SwiftUI + +/// Reads a pairing QR through the camera. The first code that looks like an +/// FTW link is handed over once; the camera stops at that moment. +struct QRScanner: View { + var onCode: (String) -> Void + @State private var allowed: Bool? + + static func looksLikePairing(_ text: String) -> Bool { + text.contains("ftw.energy") || text.contains("/p#") + } + + var body: some View { + Group { + switch allowed { + case .some(true): + CameraView(onCode: onCode) + case .some(false): + Text("FTW needs the camera to read the pairing code. Allow it in Settings, then try again.") + .font(.footnote) + .foregroundStyle(Theme.fgDim) + .padding() + case .none: + Color.black + } + } + .task { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: allowed = true + case .notDetermined: allowed = await AVCaptureDevice.requestAccess(for: .video) + default: allowed = false + } + } + } +} + +/// The capture session, started and stopped off the main thread as +/// AVFoundation asks. Held in a box so the closures that move it are honest +/// about crossing threads. +private final class CaptureBox: @unchecked Sendable { + let session = AVCaptureSession() + private let queue = DispatchQueue(label: "energy.ftw.camera") + + func start() { queue.async { self.session.startRunning() } } + func stop() { queue.async { self.session.stopRunning() } } +} + +private struct CameraView: UIViewRepresentable { + var onCode: (String) -> Void + + func makeUIView(context: Context) -> PreviewView { + let view = PreviewView() + context.coordinator.attach(view) + return view + } + + func updateUIView(_ view: PreviewView, context: Context) { + context.coordinator.onCode = onCode + } + + static func dismantleUIView(_ view: PreviewView, coordinator: Coordinator) { + coordinator.capture.stop() + } + + func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) } + + final class PreviewView: UIView { + override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } + var preview: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } + } + + @MainActor + final class Coordinator: NSObject { + let capture = CaptureBox() + var onCode: (String) -> Void + private var done = false + + init(onCode: @escaping (String) -> Void) { + self.onCode = onCode + } + + func attach(_ view: PreviewView) { + let session = capture.session + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { return } + session.addInput(input) + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { return } + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + view.preview.session = session + view.preview.videoGravity = .resizeAspectFill + capture.start() + } + + func found(_ text: String) { + guard !done, QRScanner.looksLikePairing(text) else { return } + done = true + capture.stop() + onCode(text) + } + } +} + +extension CameraView.Coordinator: @preconcurrency AVCaptureMetadataOutputObjectsDelegate { + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + for object in metadataObjects { + if let text = (object as? AVMetadataMachineReadableCodeObject)?.stringValue { found(text) } + } + } +} diff --git a/appleApp/FTW/UI/FlowDiagram.swift b/appleApp/FTW/UI/FlowDiagram.swift new file mode 100644 index 0000000..0c24500 --- /dev/null +++ b/appleApp/FTW/UI/FlowDiagram.swift @@ -0,0 +1,174 @@ +import FTWKit +import SwiftUI + +/// The house and what flows through it, drawn from the same nodes the box's +/// own dashboard draws: solar top left, battery top right, grid bottom left, +/// the car bottom right, the house in the middle. +/// +/// Dots move along a line only while readings are live. A cached or quiet +/// view holds still, because motion says power is flowing right now. +struct FlowDiagram: View { + let readings: Flow.Readings + let moving: Bool + let tap: (Flow.Role) -> Void + + private static let height: CGFloat = 340 + + var body: some View { + GeometryReader { geo in + let layout = Layout(size: geo.size, nodes: readings.nodes) + ZStack { + TimelineView(.animation(minimumInterval: 1.0 / 30, paused: !moving)) { context in + Canvas { canvas, _ in + draw(canvas, layout: layout, at: context.date.timeIntervalSinceReferenceDate) + } + } + .accessibilityHidden(true) + + Hub(loadKw: readings.loadKw, selfPoweredPct: readings.selfPoweredPctToday) + .position(layout.hub) + .onTapGesture { tap(.load) } + + ForEach(readings.nodes) { node in + Bubble(node: node) + .position(layout.points[node.id] ?? layout.hub) + .onTapGesture { if node.tappable { tap(node.role) } } + } + } + } + .frame(height: Self.height) + .frame(maxWidth: 520) + .frame(maxWidth: .infinity) + } + + /// Where each node sits. Several nodes in one corner stack toward the + /// middle, the way a site with two inverters shows two suns. + struct Layout { + let hub: CGPoint + let points: [String: CGPoint] + + init(size: CGSize, nodes: [Flow.Node]) { + let w = size.width, h = size.height + hub = CGPoint(x: w / 2, y: h / 2) + var points = [String: CGPoint]() + var counts = [String: Int]() + for node in nodes { + let key = "\(node.corner)" + let n = counts[key, default: 0] + counts[key] = n + 1 + let x: CGFloat = (node.corner == .topLeft || node.corner == .bottomLeft) ? w * 0.17 : w * 0.83 + let top = node.corner == .topLeft || node.corner == .topRight + let y: CGFloat = top ? h * 0.16 + CGFloat(n) * 96 : h * 0.84 - CGFloat(n) * 96 + points[node.id] = CGPoint(x: x, y: y) + } + self.points = points + } + } + + private func draw(_ canvas: GraphicsContext, layout: Layout, at time: TimeInterval) { + for node in readings.nodes { + guard let from = layout.points[node.id] else { continue } + var line = Path() + line.move(to: from) + line.addLine(to: layout.hub) + let active = node.kw * 1000 > Flow.idleW + let color = Theme.color(node.tone) + canvas.stroke(line, with: .color(active ? color.opacity(0.45) : Theme.line), style: StrokeStyle(lineWidth: active ? 2 : 1.5, dash: active ? [] : [3, 5])) + guard active, moving else { continue } + // Speed grows with power but never races: a kettle and a car + // charger are both readable at a glance. + let speed = 0.25 + min(1.2, log10(1 + node.kw) * 0.9) + let dots = 4 + for i in 0..