diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml new file mode 100644 index 0000000..adc3752 --- /dev/null +++ b/.github/workflows/ios-release.yml @@ -0,0 +1,207 @@ +name: iOS release + +# Archives the iPhone app and sends it to App Store Connect, where it lands +# in TestFlight. A tag `ios-v1.2.3` ships version 1.2.3, and so do +# `ios-v1.2.3-1`, `ios-v1.2.3-2`: anything after a dash only tells tags +# apart. A manual run ships the version in appleApp/FTW.xcconfig. The build +# number is this workflow's run number. appleApp/RELEASING.md has the steps +# around it. +# +# Without the three ASC_* secrets it builds an unsigned archive instead and +# says that the upload was skipped. A pull request that changes this file or +# the export options always takes that path, secrets or not. + +on: + workflow_dispatch: + push: + tags: ['ios-v*'] + pull_request: + paths: ['.github/workflows/ios-release.yml', 'appleApp/ExportOptions.plist'] + +permissions: + contents: read + +concurrency: + group: ios-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Archive the iPhone app for App Store Connect + # GitHub's image with Xcode 27 (public preview in October 2026). The + # macos-26 image stops at Xcode 26.6. + runs-on: xcode-27 + timeout-minutes: 60 + # The key's secrets may live in the repository or, safer, in this + # environment, which only main and ios-v* tags may use (RELEASING.md). + # A pull request needs no key. + environment: ${{ github.event_name != 'pull_request' && 'app-store' || '' }} + env: + # The release, by path. Picking the newest Xcode on this image would + # pick a beta. + DEVELOPER_DIR: /Applications/Xcode_27.0.app/Contents/Developer + steps: + - uses: actions/checkout@v4 + + - name: Show Xcode and the iOS SDK + run: | + xcodebuild -version + xcodebuild -version -sdk iphoneos SDKVersion + + - name: Test FTWKit, FTWShared and FTWDemo + working-directory: appleApp/FTWKit + run: swift test + + - name: Choose the version and build number + id: version + run: | + if [[ $GITHUB_REF_TYPE == tag && $GITHUB_REF_NAME == ios-v* ]]; then + version=${GITHUB_REF_NAME#ios-v} + version=${version%%-*} + else + version=$(sed -n 's/^MARKETING_VERSION *= *//p' appleApp/FTW.xcconfig) + fi + # What App Store Connect accepts: one to three whole numbers. + if ! [[ $version =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then + echo "::error::'$version' is not a version App Store Connect accepts. Tag as ios-v1.2.3." + exit 1 + fi + # A re-run keeps the run number, and App Store Connect refuses a + # build number twice. + build=$GITHUB_RUN_NUMBER + if (( GITHUB_RUN_ATTEMPT > 1 )); then build=$build.$GITHUB_RUN_ATTEMPT; fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "build=$build" >> "$GITHUB_OUTPUT" + echo "Version $version, build $build" + + # Secrets cannot appear in an `if`, so this step turns them into a + # yes or no. It prints nothing about them. ASC_KEY_P8 is the .p8 file's + # PEM text as Apple gives it; the other two are plain text. + - name: Check for the App Store Connect key + id: key + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} + run: | + if [[ $GITHUB_EVENT_NAME != pull_request && -n $ASC_KEY_ID && -n $ASC_ISSUER_ID && -n $ASC_KEY_P8 ]]; then + key=$RUNNER_TEMP/AuthKey.p8 + (umask 077; printf '%s\n' "$ASC_KEY_P8" > "$key") + echo "path=$key" >> "$GITHUB_OUTPUT" + echo "upload=true" >> "$GITHUB_OUTPUT" + else + echo "upload=false" >> "$GITHUB_OUTPUT" + fi + + # Xcode signs with Sourceful's team (FTW.xcconfig) and makes what it + # needs through the key: a development certificate on this runner for + # the archive, then the App Store profiles and a cloud-managed + # distribution certificate for the export. + - name: Archive, signed + if: steps.key.outputs.upload == 'true' + working-directory: appleApp + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + run: >- + xcodebuild archive -quiet -project FTW.xcodeproj -scheme FTW + -configuration Release -destination 'generic/platform=iOS' + -archivePath "$RUNNER_TEMP/FTW.xcarchive" + -allowProvisioningUpdates + -authenticationKeyPath "${{ steps.key.outputs.path }}" + -authenticationKeyID "$ASC_KEY_ID" + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + MARKETING_VERSION=${{ steps.version.outputs.version }} + CURRENT_PROJECT_VERSION=${{ steps.version.outputs.build }} + FTW_SOURCE_REVISION=$GITHUB_SHA + + - name: Archive, unsigned (dry run) + if: steps.key.outputs.upload != 'true' + working-directory: appleApp + run: >- + xcodebuild archive -quiet -project FTW.xcodeproj -scheme FTW + -configuration Release -destination 'generic/platform=iOS' + -archivePath "$RUNNER_TEMP/FTW.xcarchive" + CODE_SIGNING_ALLOWED=NO + MARKETING_VERSION=${{ steps.version.outputs.version }} + CURRENT_PROJECT_VERSION=${{ steps.version.outputs.build }} + FTW_SOURCE_REVISION=$GITHUB_SHA + + # App Store Connect refuses a widget whose version differs from its + # app's, so check both before sending anything. + - name: Check the version in the app and the widget + run: | + app=$RUNNER_TEMP/FTW.xcarchive/Products/Applications/FTW.app + for plist in "$app/Info.plist" "$app"/PlugIns/*.appex/Info.plist; do + got=$(plutil -extract CFBundleShortVersionString raw "$plist")/$(plutil -extract CFBundleVersion raw "$plist") + echo "$got ${plist#$app/} $(plutil -extract DTSDKName raw "$plist")" + [[ $got == ${{ steps.version.outputs.version }}/${{ steps.version.outputs.build }} ]] || { echo "::error::$plist has $got"; exit 1; } + done + + # Xcode signs and uploads in one step. If that fails, the next step + # tries the other way Apple documents. + - name: Upload to App Store Connect + id: upload + if: steps.key.outputs.upload == 'true' + continue-on-error: true + working-directory: appleApp + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + run: >- + xcodebuild -exportArchive + -archivePath "$RUNNER_TEMP/FTW.xcarchive" + -exportOptionsPlist ExportOptions.plist + -exportPath "$RUNNER_TEMP/export" + -allowProvisioningUpdates + -authenticationKeyPath "${{ steps.key.outputs.path }}" + -authenticationKeyID "$ASC_KEY_ID" + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + + # Apple's "Upload builds" help still lists altool beside Xcode and + # Transporter. Export the signed .ipa, then send it with the same key. + - name: Upload with altool instead + id: altool + if: steps.upload.outcome == 'failure' + working-directory: appleApp + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + run: | + echo "::warning title=Xcode upload failed::Exporting an .ipa and uploading it with altool instead." + plutil -replace destination -string export -o "$RUNNER_TEMP/ExportOptions-ipa.plist" ExportOptions.plist + xcodebuild -exportArchive \ + -archivePath "$RUNNER_TEMP/FTW.xcarchive" \ + -exportOptionsPlist "$RUNNER_TEMP/ExportOptions-ipa.plist" \ + -exportPath "$RUNNER_TEMP/ipa" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "${{ steps.key.outputs.path }}" \ + -authenticationKeyID "$ASC_KEY_ID" \ + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + xcrun altool --upload-package "$RUNNER_TEMP"/ipa/*.ipa \ + --api-key "$ASC_KEY_ID" --api-issuer "$ASC_ISSUER_ID" \ + --p8-file-path "${{ steps.key.outputs.path }}" + + - name: Report + run: | + v="${{ steps.version.outputs.version }} (${{ steps.version.outputs.build }})" + if [[ "${{ steps.key.outputs.upload }}" == true ]]; then + how=Xcode; [[ "${{ steps.altool.outcome }}" == success ]] && how=altool + echo "FTW $v went to App Store Connect through $how. It shows in TestFlight once Apple has processed it." >> "$GITHUB_STEP_SUMMARY" + else + echo "::warning title=Upload skipped::Dry run: FTW $v was archived unsigned and not sent to App Store Connect. A pull request never uploads; otherwise set ASC_KEY_ID, ASC_ISSUER_ID and ASC_KEY_P8 (appleApp/RELEASING.md)." + echo "**Upload skipped.** Dry run: FTW $v was archived unsigned and not sent to App Store Connect." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Remove the key + if: always() + run: rm -f "$RUNNER_TEMP/AuthKey.p8" + + # Xcode's own account of the export, for when App Store Connect says no. + - name: Keep the export logs + if: always() && steps.key.outputs.upload == 'true' && (failure() || steps.upload.outcome == 'failure') + uses: actions/upload-artifact@v4 + with: + name: export-logs + path: ${{ runner.temp }}/export + if-no-files-found: ignore diff --git a/appleApp/ExportOptions.plist b/appleApp/ExportOptions.plist new file mode 100644 index 0000000..62896d2 --- /dev/null +++ b/appleApp/ExportOptions.plist @@ -0,0 +1,24 @@ + + + + + + method + app-store-connect + destination + upload + teamID + L66M368PZ6 + signingStyle + automatic + manageAppVersionAndBuildNumber + + uploadSymbols + + + diff --git a/appleApp/RELEASING.md b/appleApp/RELEASING.md new file mode 100644 index 0000000..87a2c94 --- /dev/null +++ b/appleApp/RELEASING.md @@ -0,0 +1,445 @@ +# Releasing the iPhone app + +CI tests, archives, signs and uploads. A person sets up App Store Connect +once, then tags each release. This file lists that person's steps in order: +first the shortest way onto your own iPhone, then what external testers +and the App Store need. Checked against Apple's pages on 12 October 2026; +Apple moves menus, so trust the screen if it differs. + +| What | Value | +|---|---| +| Seller | Sourceful Labs AB (reg. no. 559382-0458), Kalmar, Sweden | +| Team ID | `L66M368PZ6` | +| Bundle IDs | `energy.ftw.app` (registered), `energy.ftw.app.widgets` | +| App Group | `group.energy.ftw.app` | +| Workflow | [`.github/workflows/ios-release.yml`](../.github/workflows/ios-release.yml) | +| Store texts | [`store/`](store/) | +| Privacy and support | `https://ftw.energy/privacy`, `https://ftw.energy/support` ([srcfl/ftw-web](https://github.com/srcfl/ftw-web)) | + +## How a build travels + +1. A tag `ios-v1.2.3` starts the job on GitHub's `xcode-27` runner. The job + uses the workflow file in the tagged commit, so any branch that contains + it can be tagged. Text after a dash only tells tags apart: + `ios-v0.1.0-1` and `ios-v0.1.0-2` both ship version 0.1.0. Once the + workflow is on `main`, **Actions → iOS release → Run workflow** also + works; it ships `MARKETING_VERSION` from `FTW.xcconfig`. +2. It runs `swift test`, then archives with Xcode 27.0 and the iOS 27 SDK. + The build number is the run number, with `.2`, `.3` on a re-run. +3. Xcode signs with the team through the API key and uploads with + [`ExportOptions.plist`](ExportOptions.plist) (`app-store-connect`, + `upload`). If that upload fails, the job exports the `.ipa` and sends it + with `xcrun altool --upload-package`, which Apple's "Upload builds" help + still lists. The build shows in TestFlight once Apple has processed it. + +Without the three secrets the job archives unsigned and reports +"Upload skipped". A pull request that changes the workflow or the export +options always takes that path, so it proves the archive without a key. + +Apple has required the iOS 26 SDK for uploads since 28 April 2026 and +requires the iOS 27 SDK from April 2027. Xcode 27 meets both. + +## Your own iPhone, from Xcode + +No App Store Connect, no key. The app is signed for development and runs +until the profile expires. + +1. On the iPhone, connect it to the Mac once, then **Settings → Privacy & + Security → Developer Mode → On**. The iPhone restarts; confirm. +2. In Xcode: **Settings → Accounts → (+) → Apple Account**, and sign in + with an Apple Account in team `L66M368PZ6`. +3. Unlock the iPhone and tap **Trust** when it asks. + +The simplest way: open `appleApp/FTW.xcodeproj`, pick the iPhone above the +editor, press **⌘R**. From the command line instead, find the iPhone's +UDID. Run these from the repository's root: + +```bash +xcrun devicectl list devices +``` + +Build for it. Automatic signing registers the iPhone and the widget's App +ID in the team: + +```bash +xcodebuild build -project appleApp/FTW.xcodeproj -scheme FTW -configuration Debug -destination 'platform=iOS,id=' -derivedDataPath appleApp/build/device -allowProvisioningUpdates +``` + +Install and open it: + +```bash +xcrun devicectl device install app --device appleApp/build/device/Build/Products/Debug-iphoneos/FTW.app +``` + +```bash +xcrun devicectl device process launch --device energy.ftw.app +``` + +## Internal TestFlight, the shortest way + +Internal testers are people in the App Store Connect team, up to 100. +Their builds need no Beta App Review. + +### 1. Accept the agreement + +The Account Holder accepts the latest Apple Developer Program License +Agreement under **Business**. Until then App Store Connect refuses new +apps, and the API key stops working. A free app needs no Paid Apps +Agreement. + +### 2. Create the app + +**App Store Connect → Apps → (+) → New App** + +| Field | Enter | +|---|---| +| Platforms | iOS | +| Name | `FTW` (see below) | +| Primary Language | English (U.S.) | +| Bundle ID | `energy.ftw.app` | +| SKU | `energy.ftw.app` (cannot change later) | +| User Access | Full Access | + +English is the primary language, so every country without its own text +shows English. Swedish comes later, under the localization menu in App +Information. + +**The name.** The iTunes Search API found no live app named exactly "FTW" +in the Swedish or US store on 12 October 2026 (nearest: "FTW Fly", "FTW - +Step Competition", "FTW Fitness", "FTW Trading"). The search sees only +live apps, and Apple also holds names for apps not yet released, so the New +App dialog gives the final answer. If "FTW" is taken, try in this order; +none was in use: + +1. `FTW Energy` (Swedish page: `FTW Energi`) +2. `FTW – Home Energy` (`FTW – Hemmets energi`) +3. `FTW by Sourceful` + +The name under the icon stays "FTW" whatever the store name. + +### 3. Make the API key and add the secrets + +**Users and Access → Integrations → App Store Connect API → Team Keys → +Generate API Key** + +- Name: `GitHub ftw-app release`. +- Access: **Admin**. With an App Manager key, cloud signing fails + ("Cloud signing permission error"; [Apple forum + thread](https://developer.apple.com/forums/thread/698117)). The reason: + the runner has no certificate, so the export signs with a cloud-managed + distribution certificate. Apple's [roles + table](https://developer.apple.com/support/roles/) gives those to Account + Holder and Admin; other roles need a per-user permission, and a team key + cannot carry one. Role and name cannot change after creation. +- Download the `.p8` file. Apple offers it once. +- The Key ID is in the key's row; the Issuer ID is at the top of the page. + +`ASC_KEY_ID` and `ASC_ISSUER_ID` are plain text; `gh` asks for each. +`ASC_KEY_P8` is the `.p8` file's PEM text as it is, not base64: + +```bash +gh secret set ASC_KEY_ID --repo srcfl/ftw-app +``` + +```bash +gh secret set ASC_ISSUER_ID --repo srcfl/ftw-app +``` + +```bash +gh secret set ASC_KEY_P8 --repo srcfl/ftw-app < AuthKey_XXXXXXXXXX.p8 +``` + +Then delete the `.p8` from your disk, or keep it in a password manager. + +**Safer, when there is time.** An Admin key can do much more than upload +builds, and any branch's workflow can read a repository secret. The job +also reads secrets from an environment `app-store`: + +1. **srcfl/ftw-app → Settings → Environments → New environment → + `app-store`.** +2. **Deployment branches and tags → Selected branches and tags**: add + branch `main` and tag `ios-v*`. Optionally add yourself as a required + reviewer. +3. Set the same three secrets with `--env app-store` added, then delete + the repository ones. + +### 4. Tag a build + +Tag a commit that contains the workflow, for example the head of the +branch you want to test, and push the tag: + +```bash +git tag ios-v0.1.0-1 && git push origin ios-v0.1.0-1 +``` + +Follow the run under **Actions → iOS release**. Each run on a clean runner +may create an "Apple Development" certificate marked "Created via API" for +the archive; if **Certificates** reports a limit, revoke the old ones. + +### 5. Answer the encryption question, if asked + +The store-ready branch ([#13](https://github.com/srcfl/ftw-app/pull/13)) +sets `ITSAppUsesNonExemptEncryption` to `NO`, so builds that carry it skip +this. If a build shows **Missing Compliance** in TestFlight, click +**Manage** next to it and choose **None of the algorithms mentioned +above** ([Encryption and export](#5-encryption-and-export) says why). + +### 6. Add yourself as a tester + +**Apps → FTW → TestFlight → Internal Testing → (+)**: group `Sourceful`. +Add yourself and others from the App Store Connect team, and turn on +automatic distribution so each new build reaches the group. On the iPhone, +install TestFlight from the App Store, signed in with the same Apple +Account, and accept the invitation. + +A build expires after 90 days. + +## Before external testers and the App Store + +### 1. Links + +- `https://app.ftw.energy/.well-known/apple-app-site-association` must + answer before review: passkeys and pairing links need it + ([srcfl/ftw-webapp#77](https://github.com/srcfl/ftw-webapp/pull/77)). + On 12 October 2026 it answered 404. +- `ftw.energy` must serve the website. On 12 October 2026 it had no + address record, and the Cloudflare Pages project `ftw` served only + `ftw.sourceful.energy`. In Cloudflare: **Workers & Pages → ftw → Custom + domains → Set up a custom domain → `ftw.energy`**. Then check: + + ```bash + curl -sI https://ftw.energy/privacy + ``` + + If `ftw.energy` stays off, replace it with `ftw.sourceful.energy` in + `store/*/privacy-url.txt`, `support-url.txt` and `marketing-url.txt`. + +### 2. App Information + +**Apps → FTW → App Information** (under General), for each language. Add +**Swedish** from the localization menu first. + +| Field | Value | +|---|---| +| Subtitle | `store//subtitle.txt` | +| Category | Primary **Utilities**, no secondary. Not Health & Fitness: that asks for a medical device declaration. | +| Content Rights | No third-party content: the app shows only the user's own box. | +| Age Rating | **Set Up Age Ratings**. Answer No to every capability (web access, user-generated content, social media, messaging, advertising) and None to every content item. Age Categories and Override: Not Applicable. Result: 4+. | +| Copyright | `2026 Sourceful Labs AB` | +| Privacy Policy URL (App Privacy) | `store//privacy-url.txt` | + +### 3. Trader status under the EU Digital Services Act + +EU users see the trader's address, phone and email on the product page. +Apple removes apps without trader status from the EU stores. + +1. **Business → Agreements → Compliance → Digital Services Act → Complete + Compliance Requirements → This is a trader account.** +2. The address comes from Sourceful Labs AB's D-U-N-S Number. Enter a + phone number and an email address, and confirm each with the code Apple + sends. +3. Upload a document that proves the company name and address, such as a + registration certificate from Bolagsverket. +4. For the app: **App Information → App Store Regulations and Permits → + Digital Services Act → Edit → trader**. + +Sourceful Labs AB already sells "Sourceful Energy" in the EU stores, so +the account may already be a trader account; check step 1 first. If that +app lives under another team, this team needs its own. + +**Decide:** which email and phone EU users see. The only Sourceful address +published today is `support@sourceful.energy` on +[sourceful.energy/contact](https://www.sourceful.energy/contact), which +ftw-web and FTW's SUPPORT.md link to. No phone number is published. + +### 4. App Privacy + +**Apps → FTW → App Privacy → Get Started → "No, we do not collect data +from this app" → Save → Publish.** + +Apple's test: data is collected when it leaves the device "in a way that +allows you and/or your third-party partners to access it for a period +longer than what is necessary to service the transmitted request in real +time". In M1: + +- The app's only network peer is `wss://relay.ftw.energy`. Everything it + sends is end-to-end encrypted to the user's box; the relay has no key. +- The relay keeps no traffic. Its access log drops addresses and + rendezvous handles (`relay/deploy/Caddyfile` in ftw-webapp). +- No analytics, ads, third-party code or tracking. + +One edge: when the relay fails to proxy a connection, Caddy's error log +may still hold the client address and request URI (rotated, 3 × 10 MB). +**Decide** before you publish the answer: + +- remove addresses from that log too, in ftw-webapp, so "Data Not + Collected" holds exactly; or +- declare **Other Diagnostic Data**: not linked to the user, not used for + tracking, purpose App Functionality. + +The privacy pages in srcfl/ftw-web#18 already mention the log. + +The privacy manifests from #13 match this answer. Both the app's and the +widget's declare no tracking, no tracking domains and no collected data. +Required-reason APIs: file timestamps (`C617.1`) in both, since the cache +sorts App Group files by date, and `UserDefaults` (`CA92.1`) in the app. + +Both answers change when FTW Cloud ships: it will hold readable data the +user turns on. + +### 5. Encryption and export + +#13 sets `ITSAppUsesNonExemptEncryption` to `NO` in `FTW-Info.plist`, so +App Store Connect asks nothing per build. A build without it asks under +**App Information → App Encryption Documentation (+)**, or **Manage** next +to the build in TestFlight; answer **None of the algorithms mentioned +above**. + +Why: all cryptography runs in CryptoKit, which ships with iOS (X25519, +ChaCha20-Poly1305, SHA-256 and HKDF, AES-GCM, Ed25519). The app implements +no algorithm itself; Noise IK is a published protocol built on those. +Apple's table says encryption "limited to that within the Apple operating +system" needs no documentation. No Apple page says outright that CryptoKit +counts; that is our reading. + +The rest is #13's reading, which no lawyer has reviewed; its sources are in +the PR. + +- **United States.** The source is public, so the encryption is not + subject to the EAR (15 CFR 742.15(b) and the note to 734.3(b)(2)–(3)): no + email to BIS and the NSA, and no year-end report. That holds only if every + store build comes from a published commit. The workflow passes + `FTW_SOURCE_REVISION=$GITHUB_SHA`; tag only commits pushed to this + public repository. +- **France.** Apple asks for nothing with "None". French law (Décret + 2007-663, art. 3–5) still requires a free declaration to ANSSI one month + before the app is offered in France. Fredrik decided on 12 October 2026 + that France waits: the first release leaves it out, and the declaration + comes later ([Before launching in France](#before-launching-in-france)). + +### 6. Price and countries + +**Monetization → Pricing and Availability** + +1. **Price Schedule → Add Pricing**: base country Sweden, price Free. +2. **App Availability → Set Up Availability → All Countries or Regions** + (175, and new ones as Apple adds them). +3. Remove **China mainland**. It needs an ICP Filing Number in App + Information that matches a filing with China's MIIT, which in practice + takes a Chinese entity or partner. Without it the version stops at + "ICP Filing Number Missing". (The iTunes lookup shows "Sourceful + Energy" in the Chinese store. If Sourceful holds a filing, enter it and + keep China.) +4. Remove **France** until the ANSSI declaration is filed and a month has + passed ([Before launching in France](#before-launching-in-france)). +5. Untick **Apple silicon Mac** and **Apple Vision Pro**. Apple publishes + iPhone apps there by default; M1 is iPhone only. + +Other countries, checked for a free 4+ utility that is not a game: + +| Where | Rule | For FTW | +|---|---|---| +| South Korea, Vietnam | Game ratings and licences | Games only. Nothing to do. | +| Brazil | Tax forms | Only for developers in Brazil. | +| Australia, Brazil, Singapore | 18+ download checks | Not for a 4+ app. | +| Texas, Utah, Louisiana | Age assurance for new accounts (Declared Age Range API) | **Decide with counsel.** M1 has no accounts and no age-dependent content. | +| France | ANSSI encryption declaration one month ahead (#13's reading) | Left out of the first release (step 4). | +| Russia | — | "Sourceful Energy" is not in the Russian store. **Decide** whether FTW follows. | + +### 7. External TestFlight + +**Apps → FTW → TestFlight** + +1. **Additional → Test Information**, for each language: + - Beta App Description: the first paragraph of `store//description.txt`. + - Feedback Email: an inbox someone reads (see the decision in step 3). + - Marketing URL and Privacy Policy URL: from `store//`. + - Beta App Review Information: contact, "Sign-in required" off, and + `store/review-notes.txt` as notes. +2. **External Testing → (+)**: group `Beta`. An internal group must exist + first. Add the build, then **Submit for Review**. Apple reviews the + first build; later builds of the same version usually go out at once. + Invite by email or turn on a public link (Open to Anyone, or Filter by + Criteria, with a tester limit). Up to 10,000 testers. +3. For each build, **What to Test**: `store//release-notes.txt`. + +### 8. The App Store version + +**Apps → FTW → App Store → iOS App → the version** + +| Field | Source | +|---|---| +| iPhone screenshots | `scripts/store-screenshots.sh` (6.9", 1320 × 2868) | +| Promotional Text | `store//promotional-text.txt` | +| Description | `store//description.txt` | +| Keywords | `store//keywords.txt` | +| Support URL | `store//support-url.txt` | +| Marketing URL | `store//marketing-url.txt` | +| What's New | `store//release-notes.txt` (not shown for the first version) | +| Build | The one TestFlight tested | +| App Review Information | Sign-in required: off. Contact: name, email, phone with `+46`. Notes: `store/review-notes.txt` | +| Version Release | Manually release this version | + +Then **Add for Review → Submit**. + +Guideline 2.1(a) allows a demo mode "in lieu of a demo account with prior +approval by Apple". FTW has no login; the demo stands in for the box. The +notes say so. If review asks for a video of a real box, record pairing +and the car view. + +`store/check.sh` checks every text against Apple's limits. + +## Each release + +1. Set `MARKETING_VERSION` in `FTW.xcconfig` to the new version on `main`. +2. Tag that commit and push the tag: + + ```bash + git tag ios-v1.0.0 && git push origin ios-v1.0.0 + ``` + +3. Watch the *iOS release* run. When it reports the upload, the build + appears in TestFlight, usually within an hour. +4. Write What to Test, then hand the build to the groups. + +## When the upload fails + +The run keeps Xcode's export logs as the `export-logs` artifact. The +summary says whether Xcode or altool uploaded the build. + +| Message | Cause | +|---|---| +| Cloud signing permission error | The key is not Admin. | +| No profiles for 'energy.ftw.app' | App ID not registered, or no app record yet. | +| Private key not installed for an Apple Development certificate | Old API certificates; revoke them under Certificates. | +| The bundle version must be higher | A build number was used; run again or tag again. | +| Agreement missing or expired | The Account Holder must accept it under Business. | +| Xcode upload failed, then altool succeeded | Nothing to do; Apple got the build. | + +## Before launching in France + +French law asks the supplier of an encryption product to declare it to +ANSSI, France's cybersecurity agency, one month before offering it there +(#13's reading of Décret 2007-663; no lawyer has reviewed it). The +declaration is free. + +1. Take the declaration form (Annex I of the decree) from ANSSI's [forms + page](https://cyber.gouv.fr/reglementation/reglementation-identite-confiance-numerique/controles-reglementaires-cryptographie/controle-moyen-de-cryptologie/controle-reglementaire-cryptographie-formulaires/). + [Contrôle relatif à un moyen de + cryptologie](https://cyber.gouv.fr/reglementation/reglementation-identite-confiance-numerique/controles-reglementaires-cryptographie/controle-moyen-de-cryptologie/) + explains what to send. Describe the app's cryptography as in + [Encryption and export](#5-encryption-and-export). ANSSI asks the + supplier to request a "grand public" (consumer) classification in the + same declaration. +2. Send it to ANSSI as those pages describe, and keep the receipt. +3. A month after filing: **Pricing and Availability → App Availability → + add France**. + +## Later + +- **M3, push:** an APNs key (**Certificates, Identifiers & Profiles → Keys + → (+) → Apple Push Notification service**). Not before. +- **FTW Cloud:** update the privacy pages, App Privacy, `review-notes.txt` + and the description before it ships. +- **April 2027:** apps on the iOS 27.1 SDK or later also need iPhone Duo + screenshots. diff --git a/appleApp/scripts/store-screenshots.sh b/appleApp/scripts/store-screenshots.sh new file mode 100755 index 0000000..ee9967f --- /dev/null +++ b/appleApp/scripts/store-screenshots.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# App Store screenshots: the demo on a 6.9" iPhone (1320 x 2868), in Swedish +# and English, ready for App Store Connect (RELEASING.md, step 10). +# +# Makes its own simulator and deletes it afterwards, so it leaves anyone +# else's alone. Needs a Debug build in build/ios, as for demo-screenshots.sh: +# xcodebuild build -project FTW.xcodeproj -scheme FTW \ +# -destination 'generic/platform=iOS Simulator' -derivedDataPath build/ios +# +# scripts/store-screenshots.sh [out] default build/store-screenshots +# FTW_STORE_DEVICE=iPhone-17-Pro another size (6.3", 1206 x 2622) +# FTW_STORE_LOOK=light light instead of dark +set -euo pipefail +cd "$(dirname "$0")/.." +app=build/ios/Build/Products/Debug-iphonesimulator/FTW.app +out=${1:-build/store-screenshots} +device=com.apple.CoreSimulator.SimDeviceType.${FTW_STORE_DEVICE:-iPhone-17-Pro-Max} +look=${FTW_STORE_LOOK:-dark} + +runtime=$(xcrun simctl list runtimes available --json | python3 -c ' +import json, sys +ios = [r for r in json.load(sys.stdin)["runtimes"] if r["platform"] == "iOS"] +print(max(ios, key=lambda r: [int(p) for p in r["version"].split(".")])["identifier"])') + +udid=$(xcrun simctl create "FTW store screenshots" "$device" "$runtime") +trap 'xcrun simctl shutdown "$udid" >/dev/null 2>&1 || true; xcrun simctl delete "$udid"' EXIT +xcrun simctl boot "$udid" +xcrun simctl bootstatus "$udid" -b >/dev/null +xcrun simctl ui "$udid" appearance "$look" +# The status bar Apple's own pictures show. +xcrun simctl status_bar "$udid" override --time 9:41 --dataNetwork wifi \ + --wifiMode active --wifiBars 3 --cellularMode active --cellularBars 4 \ + --batteryState charged --batteryLevel 100 +xcrun simctl install "$udid" "$app" + +# folder, language, name, then the app's launch arguments +shoot() { + local dir=$1 lang=$2 name=$3 + shift 3 + mkdir -p "$out/$dir" + xcrun simctl launch --terminate-running-process "$udid" energy.ftw.app \ + -AppleLanguages "($lang)" -AppleLocale "${dir/-/_}" "$@" >/dev/null + sleep 12 + xcrun simctl io "$udid" screenshot "$out/$dir/$name.png" >/dev/null 2>&1 + echo "$out/$dir/$name.png $(sips -g pixelWidth -g pixelHeight "$out/$dir/$name.png" | awk '/pixel/ {printf "%s ", $2}')" +} + +# -FTWTab and -FTWDemoCar only work in a Debug build with the car view. No +# pairing screen: in the simulator it shows a button a phone does not have. +for pair in sv-SE:sv en-US:en; do + dir=${pair%%:*} lang=${pair##*:} + shoot "$dir" "$lang" 1-now -FTWDemo YES + shoot "$dir" "$lang" 2-car -FTWDemo YES -FTWTab car + shoot "$dir" "$lang" 3-charging -FTWDemo YES -FTWTab car -FTWDemoCar charging +done diff --git a/appleApp/store/README.md b/appleApp/store/README.md new file mode 100644 index 0000000..bd33905 --- /dev/null +++ b/appleApp/store/README.md @@ -0,0 +1,27 @@ +# App Store texts + +What the App Store page and TestFlight say about FTW, one file per field, +pasted into App Store Connect by hand ([RELEASING.md](../RELEASING.md)). + +| File | App Store Connect field | Limit | +|---|---|---| +| `name.txt` | Name | 30 characters | +| `subtitle.txt` | Subtitle | 30 characters | +| `promotional-text.txt` | Promotional Text | 170 characters | +| `description.txt` | Description | 4000 characters | +| `keywords.txt` | Keywords, comma-separated, each three or more characters | 100 bytes | +| `release-notes.txt` | What's New, and What to Test in TestFlight | 4000 characters | +| `privacy-url.txt` | Privacy Policy URL | | +| `support-url.txt` | Support URL | | +| `marketing-url.txt` | Marketing URL | | +| `review-notes.txt` | App Review Information → Notes, one for all languages | 4000 bytes | + +`en-US` is the primary language, so countries without their own text show +it. `sv-SE` is Swedish. + +Claim only what the app on `main` does. Write plainly: short words, active +voice, no stock phrases, nothing left to cut. Then run: + +```bash +appleApp/store/check.sh +``` diff --git a/appleApp/store/check.sh b/appleApp/store/check.sh new file mode 100755 index 0000000..e1b0f10 --- /dev/null +++ b/appleApp/store/check.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Fails when a store text breaks one of Apple's limits for App Store Connect. +# Run it after editing anything in this folder. +set -euo pipefail +cd "$(dirname "$0")" +python3 - <<'PY' +import pathlib, sys + +# file: (limit, unit). Keywords and review notes count bytes; the rest count +# characters. +limits = { + "name.txt": (30, "chars"), + "subtitle.txt": (30, "chars"), + "promotional-text.txt": (170, "chars"), + "description.txt": (4000, "chars"), + "keywords.txt": (100, "bytes"), + "release-notes.txt": (4000, "chars"), + "review-notes.txt": (4000, "bytes"), +} +bad = 0 +for path in sorted(pathlib.Path(".").rglob("*.txt")): + text = path.read_text(encoding="utf-8").rstrip("\n") + if path.name.endswith("-url.txt"): + ok = text.startswith("https://") and "\n" not in text + print(f"{'ok ' if ok else 'BAD'} {path} {text}") + bad += not ok + continue + if path.name not in limits: + print(f"BAD {path} not a field this script knows") + bad += 1 + continue + limit, unit = limits[path.name] + size = len(text.encode()) if unit == "bytes" else len(text) + ok = 0 < size <= limit + if path.name == "keywords.txt": + words = text.split(",") + short = [w for w in words if len(w.strip()) <= 2] + if short: + ok = False + print(f" {path}: every keyword needs three or more characters: {short}") + print(f"{'ok ' if ok else 'BAD'} {path} {size}/{limit} {unit}") + bad += not ok +sys.exit(1 if bad else 0) +PY diff --git a/appleApp/store/en-US/description.txt b/appleApp/store/en-US/description.txt new file mode 100644 index 0000000..7453255 --- /dev/null +++ b/appleApp/store/en-US/description.txt @@ -0,0 +1,30 @@ +See your home's energy on your iPhone, straight from the FTW box at home. Steer the car's charging and check electricity prices. + +FTW is open-source home energy software from Sourceful. It runs on a box in your home, reads your meters and devices, plans the battery and the car, and controls them on site. This app shows you that box. You say what you want; the box decides how. + +NOW +• Is FTW in control? One line tells you, and why not when it isn't. +• Live power between solar, battery, home, grid and car. +• How much of today's power your home made itself. +• The electricity price now, the cheapest two hours and the hours ahead. +• Every reading shows its age. When the link drops, the app says so and reconnects on its own. + +YOUR CAR +• The car's level, and where it came from: the car, you or FTW's estimate. +• Correct it with 20, 40, 60 or 80 %, or by 5 % up or down. +• Set a goal: how full and by when, once or on chosen days. +• See whether the plan will reach it. +• Charge now, Pause or Back to the plan. Each command shows when it was sent, when the box got it and when it was done. + +PRICE WIDGETS +• The electricity price on your Home Screen, Lock Screen and in StandBy. +• Widgets show what the app last read from your box, and how old it is. + +PRIVACY +• No account and no password. Scan the pairing code your box shows and confirm with a passkey. +• Your data stays on your box and your phone. The app reaches the box through Sourceful's relay, which carries encrypted traffic it cannot read. +• No ads, no analytics, no tracking. + +WHAT YOU NEED +• An FTW box at home with the FTW app turned on in its settings. FTW runs on a Raspberry Pi or another Linux computer. Learn more at ftw.energy. +• No box? Try the demo, a simulated home that runs inside the app. diff --git a/appleApp/store/en-US/keywords.txt b/appleApp/store/en-US/keywords.txt new file mode 100644 index 0000000..7db399f --- /dev/null +++ b/appleApp/store/en-US/keywords.txt @@ -0,0 +1 @@ +solar,battery,charging,electricity,price,spot price,home energy,HEMS,grid,kWh,widget,smart home diff --git a/appleApp/store/en-US/marketing-url.txt b/appleApp/store/en-US/marketing-url.txt new file mode 100644 index 0000000..7c53c28 --- /dev/null +++ b/appleApp/store/en-US/marketing-url.txt @@ -0,0 +1 @@ +https://ftw.energy/ diff --git a/appleApp/store/en-US/name.txt b/appleApp/store/en-US/name.txt new file mode 100644 index 0000000..fbbe77b --- /dev/null +++ b/appleApp/store/en-US/name.txt @@ -0,0 +1 @@ +FTW diff --git a/appleApp/store/en-US/privacy-url.txt b/appleApp/store/en-US/privacy-url.txt new file mode 100644 index 0000000..851268c --- /dev/null +++ b/appleApp/store/en-US/privacy-url.txt @@ -0,0 +1 @@ +https://ftw.energy/privacy diff --git a/appleApp/store/en-US/promotional-text.txt b/appleApp/store/en-US/promotional-text.txt new file mode 100644 index 0000000..3659018 --- /dev/null +++ b/appleApp/store/en-US/promotional-text.txt @@ -0,0 +1 @@ +See what your FTW box sees, with the age of every reading. Set the car's level and goal, or charge now. Electricity prices on your Home Screen and Lock Screen. diff --git a/appleApp/store/en-US/release-notes.txt b/appleApp/store/en-US/release-notes.txt new file mode 100644 index 0000000..ba26095 --- /dev/null +++ b/appleApp/store/en-US/release-notes.txt @@ -0,0 +1 @@ +The first FTW app for iPhone. Pair with your box, see your home's energy now, steer the car's charging and put electricity prices on your Home Screen and Lock Screen. diff --git a/appleApp/store/en-US/subtitle.txt b/appleApp/store/en-US/subtitle.txt new file mode 100644 index 0000000..f3cee54 --- /dev/null +++ b/appleApp/store/en-US/subtitle.txt @@ -0,0 +1 @@ +Your home energy, on the phone diff --git a/appleApp/store/en-US/support-url.txt b/appleApp/store/en-US/support-url.txt new file mode 100644 index 0000000..464405d --- /dev/null +++ b/appleApp/store/en-US/support-url.txt @@ -0,0 +1 @@ +https://ftw.energy/support diff --git a/appleApp/store/review-notes.txt b/appleApp/store/review-notes.txt new file mode 100644 index 0000000..73fcbb4 --- /dev/null +++ b/appleApp/store/review-notes.txt @@ -0,0 +1,22 @@ +FTW is the phone app for FTW, open-source home energy software that runs on a box in the user's home (a Raspberry Pi or another Linux computer). The app talks only to that box, through an end-to-end encrypted relay. There is no account and nothing to sign in to. You do not have a box, so the app has a built-in demo for you. + +THE DEMO +1. Open the app. On the first screen, tap "Try the live demo". +2. The demo runs a simulated FTW box inside the app. It needs no network and no passkey, and it saves nothing. +3. Now shows "Is FTW in control?", live power between solar, battery, home, grid and car, and today's electricity prices. +4. Tap the car to open the car view. Try the level buttons (20/40/60/80 % and ±5), set a goal, then tap Charge now, Pause and Back to the plan. The simulated box answers each command, and the app shows when it was sent, received and done. +5. "Exit demo" at the top returns to the first screen. + +WITH A REAL BOX (needs the hardware) +The owner opens the box's local dashboard, chooses Settings → FTW app → Show pairing code and scans the QR code with this app or the iPhone camera (links to https://app.ftw.energy/p). The app then creates a passkey for app.ftw.energy and connects through wss://relay.ftw.energy. + +WIDGETS +The price widgets show prices the app saved from a real box. The demo saves nothing, so in review a widget says "Open FTW to see prices". + +OTHER +- Privacy policy: "Privacy" at the foot of Now and of the first screen, and in About. +- No account to delete. "Remove this phone" at the foot of Now unpairs the phone. +- Camera: used only to read the pairing QR code. +- No sign-in, no in-app purchases, no ads, no analytics, no tracking. +- Encryption: Noise protocol built on Apple's CryptoKit only. +- The demo stands in for the hardware, not for a login: the app has no login. diff --git a/appleApp/store/sv-SE/description.txt b/appleApp/store/sv-SE/description.txt new file mode 100644 index 0000000..5e11860 --- /dev/null +++ b/appleApp/store/sv-SE/description.txt @@ -0,0 +1,30 @@ +Se hemmets energi i din iPhone, direkt från FTW-boxen hemma. Styr bilens laddning och se elpriset. + +FTW är hemenergi med öppen källkod från Sourceful. Det körs på en box hemma hos dig, läser av mätare och enheter, planerar batteriet och bilen och styr dem på plats. Appen visar dig boxen. Du säger vad du vill, och boxen bestämmer hur. + +NU +• Styr FTW? En rad svarar, och säger varför när svaret är nej. +• Effekten just nu mellan sol, batteri, hem, elnät och bil. +• Hur stor del av dagens el hemmet gjorde själv. +• Elpriset nu, de två billigaste timmarna och timmarna framåt. +• Varje värde visar sin ålder. Bryts kontakten säger appen det och ansluter igen av sig själv. + +BILEN +• Bilens nivå, och var den kommer ifrån: bilen, du eller FTW:s uppskattning. +• Rätta den med 20, 40, 60 eller 80 %, eller 5 % upp eller ner. +• Sätt ett mål: hur full och till när, en gång eller vissa dagar. +• Se om planen hinner. +• Ladda nu, Pausa eller Tillbaka till planen. Varje kommando visar när det skickades, när boxen fick det och när det var utfört. + +PRISWIDGETAR +• Elpriset på hemskärmen, låsskärmen och i Vänteläge. +• Widgetarna visar det appen senast läste från boxen, och hur gammalt det är. + +INTEGRITET +• Inget konto och inget lösenord. Skanna parkopplingskoden som boxen visar och bekräfta med en lösenordsnyckel. +• Din data stannar i boxen och i telefonen. Appen når boxen genom Sourcefuls relä, som bär krypterad trafik det inte kan läsa. +• Ingen reklam, ingen analys, ingen spårning. + +DET HÄR BEHÖVER DU +• En FTW-box hemma med FTW-appen påslagen i boxens inställningar. FTW körs på en Raspberry Pi eller en annan Linuxdator. Läs mer på ftw.energy. +• Ingen box? Prova demon, ett simulerat hem som körs i appen. diff --git a/appleApp/store/sv-SE/keywords.txt b/appleApp/store/sv-SE/keywords.txt new file mode 100644 index 0000000..b163969 --- /dev/null +++ b/appleApp/store/sv-SE/keywords.txt @@ -0,0 +1 @@ +solceller,batteri,elbil,laddning,elpris,spotpris,energi,hemenergi,elnät,kWh,widget,smarta hem diff --git a/appleApp/store/sv-SE/marketing-url.txt b/appleApp/store/sv-SE/marketing-url.txt new file mode 100644 index 0000000..7c53c28 --- /dev/null +++ b/appleApp/store/sv-SE/marketing-url.txt @@ -0,0 +1 @@ +https://ftw.energy/ diff --git a/appleApp/store/sv-SE/name.txt b/appleApp/store/sv-SE/name.txt new file mode 100644 index 0000000..fbbe77b --- /dev/null +++ b/appleApp/store/sv-SE/name.txt @@ -0,0 +1 @@ +FTW diff --git a/appleApp/store/sv-SE/privacy-url.txt b/appleApp/store/sv-SE/privacy-url.txt new file mode 100644 index 0000000..dabb852 --- /dev/null +++ b/appleApp/store/sv-SE/privacy-url.txt @@ -0,0 +1 @@ +https://ftw.energy/sv/privacy diff --git a/appleApp/store/sv-SE/promotional-text.txt b/appleApp/store/sv-SE/promotional-text.txt new file mode 100644 index 0000000..9d31321 --- /dev/null +++ b/appleApp/store/sv-SE/promotional-text.txt @@ -0,0 +1 @@ +Se det din FTW-box ser, med åldern på varje värde. Ange bilens nivå och mål, eller ladda nu. Elpriset på hemskärmen och låsskärmen. diff --git a/appleApp/store/sv-SE/release-notes.txt b/appleApp/store/sv-SE/release-notes.txt new file mode 100644 index 0000000..cb6ef2e --- /dev/null +++ b/appleApp/store/sv-SE/release-notes.txt @@ -0,0 +1 @@ +Den första FTW-appen för iPhone. Parkoppla med din box, se hemmets energi just nu, styr bilens laddning och lägg elpriset på hemskärmen och låsskärmen. diff --git a/appleApp/store/sv-SE/subtitle.txt b/appleApp/store/sv-SE/subtitle.txt new file mode 100644 index 0000000..495aeda --- /dev/null +++ b/appleApp/store/sv-SE/subtitle.txt @@ -0,0 +1 @@ +Hemmets energi i telefonen diff --git a/appleApp/store/sv-SE/support-url.txt b/appleApp/store/sv-SE/support-url.txt new file mode 100644 index 0000000..7b6156c --- /dev/null +++ b/appleApp/store/sv-SE/support-url.txt @@ -0,0 +1 @@ +https://ftw.energy/sv/support