mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: iOS TestFlight release pipeline
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
name: Release iOS (TestFlight)
|
||||
|
||||
# The iOS project is NOT committed - unlike android/, which is hand-maintained,
|
||||
# ios/ is gitignored and generated fresh by `expo prebuild` on every run. Keep
|
||||
# any native iOS change in app.config.js (or a config plugin), never in ios/.
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upload_to_testflight:
|
||||
description: "Upload the build to TestFlight (off = build the IPA only)"
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
concurrency:
|
||||
group: release-ios-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
EXPO_NO_TELEMETRY: "1"
|
||||
# App Store Connect refuses a CFBundleVersion it has already seen for this
|
||||
# marketing version, so every run needs a fresh, increasing number.
|
||||
IOS_BUILD_NUMBER: ${{ github.run_number }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Show toolchain versions
|
||||
run: |
|
||||
xcodebuild -version
|
||||
pod --version
|
||||
|
||||
- name: Cache CocoaPods
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/Library/Caches/CocoaPods
|
||||
~/.cocoapods/repos
|
||||
key: pods-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: pods-${{ runner.os }}-
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate the iOS project
|
||||
run: npx expo prebuild --platform ios --clean
|
||||
|
||||
- name: Check for signing credentials
|
||||
id: creds
|
||||
env:
|
||||
CERT: ${{ secrets.IOS_DIST_CERT_BASE64 }}
|
||||
ASC_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||
TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
if [ -n "$CERT" ] && [ -n "$ASC_KEY" ] && [ -n "$TEAM" ]; then
|
||||
echo "signed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "signed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Signing secrets are not configured - compiling without signing. No IPA will be produced."
|
||||
fi
|
||||
|
||||
- name: Resolve workspace and scheme
|
||||
id: xcode
|
||||
run: |
|
||||
WORKSPACE=$(ls -d ios/*.xcworkspace | head -1)
|
||||
SCHEME=$(basename "$WORKSPACE" .xcworkspace)
|
||||
echo "workspace=$WORKSPACE" >> "$GITHUB_OUTPUT"
|
||||
echo "scheme=$SCHEME" >> "$GITHUB_OUTPUT"
|
||||
echo "Using workspace $WORKSPACE, scheme $SCHEME"
|
||||
|
||||
# ── Unsigned path: a compile check only, so the workflow is still useful
|
||||
# on forks and before the Apple secrets exist.
|
||||
- name: Build without signing
|
||||
if: steps.creds.outputs.signed != 'true'
|
||||
run: |
|
||||
xcodebuild build \
|
||||
-workspace "${{ steps.xcode.outputs.workspace }}" \
|
||||
-scheme "${{ steps.xcode.outputs.scheme }}" \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGN_IDENTITY=""
|
||||
|
||||
# ── Signed path ────────────────────────────────────────────
|
||||
- name: Import the distribution certificate
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
env:
|
||||
CERT_BASE64: ${{ secrets.IOS_DIST_CERT_BASE64 }}
|
||||
CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
|
||||
run: |
|
||||
KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
|
||||
# Ephemeral runner, ephemeral keychain - this password protects nothing
|
||||
# beyond the life of the job.
|
||||
KEYCHAIN_PASSWORD=$(uuidgen)
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
|
||||
|
||||
echo "$CERT_BASE64" | base64 -d > "$RUNNER_TEMP/dist.p12"
|
||||
security import "$RUNNER_TEMP/dist.p12" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \
|
||||
-T /usr/bin/codesign -T /usr/bin/security -f pkcs12
|
||||
rm -f "$RUNNER_TEMP/dist.p12"
|
||||
|
||||
# Without this, codesign triggers an interactive keychain prompt that
|
||||
# nothing can answer on a headless runner.
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: \
|
||||
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null
|
||||
|
||||
# Put our keychain first in the search list, keeping the login one so
|
||||
# Xcode can still reach Apple's intermediate certificates.
|
||||
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | sed 's/"//g')
|
||||
security find-identity -v -p codesigning "$KEYCHAIN"
|
||||
|
||||
- name: Install the App Store Connect API key
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
env:
|
||||
ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
run: |
|
||||
# Both xcodebuild -allowProvisioningUpdates and altool look the key up
|
||||
# by ID in this directory.
|
||||
mkdir -p ~/private_keys
|
||||
echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
||||
|
||||
- name: Archive
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
# -allowProvisioningUpdates lets Xcode create/refresh the App Store
|
||||
# provisioning profile through the API key, so no .mobileprovision has
|
||||
# to be stored as a secret.
|
||||
xcodebuild archive \
|
||||
-workspace "${{ steps.xcode.outputs.workspace }}" \
|
||||
-scheme "${{ steps.xcode.outputs.scheme }}" \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath ~/private_keys/AuthKey_${ASC_KEY_ID}.p8 \
|
||||
-authenticationKeyID "$ASC_KEY_ID" \
|
||||
-authenticationKeyIssuerID "$ASC_ISSUER_ID" \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
|
||||
- name: Export the IPA
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
id: export
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key><string>app-store</string>
|
||||
<key>teamID</key><string>${TEAM_ID}</string>
|
||||
<key>signingStyle</key><string>automatic</string>
|
||||
<key>uploadSymbols</key><true/>
|
||||
<key>destination</key><string>export</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$RUNNER_TEMP/Bulwark.xcarchive" \
|
||||
-exportPath "$RUNNER_TEMP/export" \
|
||||
-exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" \
|
||||
-allowProvisioningUpdates \
|
||||
-authenticationKeyPath ~/private_keys/AuthKey_${ASC_KEY_ID}.p8 \
|
||||
-authenticationKeyID "$ASC_KEY_ID" \
|
||||
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
|
||||
|
||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
SRC=$(ls "$RUNNER_TEMP"/export/*.ipa | head -1)
|
||||
DEST="bulwark-mobile-${VERSION}-${GITHUB_SHA::7}.ipa"
|
||||
cp "$SRC" "$DEST"
|
||||
echo "path=$DEST" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: steps.creds.outputs.signed == 'true'
|
||||
with:
|
||||
name: ${{ steps.export.outputs.path }}
|
||||
path: ${{ steps.export.outputs.path }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to TestFlight
|
||||
# The IPA is not attached to the GitHub release: without an Apple device
|
||||
# provisioned for it, it is not installable, and TestFlight is the only
|
||||
# distribution channel for it.
|
||||
if: >-
|
||||
steps.creds.outputs.signed == 'true' &&
|
||||
(github.event_name == 'release' || inputs.upload_to_testflight)
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
||||
run: |
|
||||
IPA="${{ steps.export.outputs.path }}"
|
||||
# Validate first - it catches missing Info.plist keys and entitlement
|
||||
# mistakes in seconds, instead of via a rejection email 20 minutes later.
|
||||
xcrun altool --validate-app --type ios --file "$IPA" \
|
||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
||||
xcrun altool --upload-app --type ios --file "$IPA" \
|
||||
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
|
||||
echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight."
|
||||
@@ -26,10 +26,10 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa
|
||||
|
||||
## What's missing or rough
|
||||
|
||||
- iOS build is untested
|
||||
- iOS builds, but push notifications and client certificates are Android-only so far
|
||||
- Filters & rules, S/MIME, plugins, themes, file storage - UI stubs only
|
||||
- Calendar editing is partial; contacts editing is basic
|
||||
- No Play Store distribution yet (sideload APK from Releases)
|
||||
- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight
|
||||
|
||||
## Run locally
|
||||
|
||||
@@ -41,6 +41,7 @@ npx expo start
|
||||
Then press `a` for Android, `i` for iOS, or scan the QR with Expo Go.
|
||||
|
||||
For a release APK build see [.github/workflows/release-android.yml](.github/workflows/release-android.yml).
|
||||
For iOS builds and TestFlight distribution see [docs/ios-release.md](docs/ios-release.md).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@ if (!COMMIT) {
|
||||
}
|
||||
COMMIT = COMMIT.slice(0, 7);
|
||||
|
||||
// App Store Connect rejects a build whose CFBundleVersion it has already seen
|
||||
// for this CFBundleShortVersionString, so this has to advance on every upload
|
||||
// even when VERSION does not. CI passes the workflow run number; local builds
|
||||
// fall back to 1 (never uploaded).
|
||||
const IOS_BUILD_NUMBER = process.env.IOS_BUILD_NUMBER || '1';
|
||||
|
||||
module.exports = {
|
||||
expo: {
|
||||
name: 'Bulwark Mobile',
|
||||
@@ -33,6 +39,14 @@ module.exports = {
|
||||
},
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
bundleIdentifier: 'org.bulwarkmail.mobile',
|
||||
buildNumber: IOS_BUILD_NUMBER,
|
||||
config: {
|
||||
// The app only speaks HTTPS/TLS and uses platform crypto, which is
|
||||
// exempt. Declaring it here skips the manual export-compliance
|
||||
// questionnaire that otherwise blocks every TestFlight build.
|
||||
usesNonExemptEncryption: false,
|
||||
},
|
||||
},
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
@@ -55,6 +69,19 @@ module.exports = {
|
||||
{
|
||||
cameraPermission:
|
||||
'Bulwark Mail uses the camera to scan sign-in QR codes shown in webmail.',
|
||||
// QR scanning never records audio; leaving the mic entry in would be
|
||||
// an unexplained permission in App Review.
|
||||
microphonePermission: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
'expo-image-picker',
|
||||
{
|
||||
photosPermission:
|
||||
'Bulwark Mail needs access to your photos so you can attach them to emails and set contact photos.',
|
||||
// Only launchImageLibraryAsync is used - no in-app capture.
|
||||
cameraPermission: false,
|
||||
microphonePermission: false,
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# iOS builds and TestFlight
|
||||
|
||||
The iOS app is built by [.github/workflows/release-ios.yml](../.github/workflows/release-ios.yml)
|
||||
on a GitHub-hosted macOS runner and uploaded straight to TestFlight. macOS runner
|
||||
minutes are free for this repository because it is public.
|
||||
|
||||
Unlike `android/`, which is committed and hand-maintained, **`ios/` is gitignored
|
||||
and regenerated by `expo prebuild` on every run.** Never commit it, and never fix
|
||||
anything by editing it - native iOS changes belong in `app.config.js` or a config
|
||||
plugin, or they will be silently thrown away on the next build.
|
||||
|
||||
Without the secrets below the workflow still runs, but only as an unsigned
|
||||
compile check; no IPA is produced.
|
||||
|
||||
## One-time Apple setup
|
||||
|
||||
You need an active Apple Developer Program membership ($99/yr). Everything here
|
||||
can be done from Windows - no Mac required.
|
||||
|
||||
### 1. Team ID
|
||||
|
||||
developer.apple.com → Account → Membership details. A 10-character string like
|
||||
`A1B2C3D4E5`.
|
||||
|
||||
### 2. Distribution certificate
|
||||
|
||||
Apple issues certificates against a certificate signing request. Keychain Access
|
||||
is the usual way to make one, but OpenSSL works and does not need a Mac:
|
||||
|
||||
```bash
|
||||
openssl genrsa -out dist.key 2048
|
||||
openssl req -new -key dist.key -out dist.csr \
|
||||
-subj "/emailAddress=you@bulwarkmail.org/CN=Bulwark Mail/C=DE"
|
||||
```
|
||||
|
||||
Upload `dist.csr` at developer.apple.com → Certificates → **+** → **Apple
|
||||
Distribution**, download the resulting `distribution.cer`, then bundle the cert
|
||||
and its private key into a `.p12`:
|
||||
|
||||
```bash
|
||||
openssl x509 -inform DER -in distribution.cer -out dist.pem
|
||||
openssl pkcs12 -export -inkey dist.key -in dist.pem -out dist.p12 \
|
||||
-passout pass:CHOOSE_A_PASSWORD
|
||||
```
|
||||
|
||||
**On OpenSSL 3.x, add `-legacy` to that last command.** OpenSSL 3 defaults to
|
||||
AES-256-CBC, which macOS `security import` refuses to read, and the workflow then
|
||||
fails at the import step. OpenSSL 1.x has no `-legacy` flag and already writes
|
||||
the format macOS wants - passing it there is an error. Check with `openssl
|
||||
version` first; Git Bash on Windows currently ships 1.1.1.
|
||||
|
||||
Keep `dist.key` somewhere safe - you cannot re-download it, and losing it means
|
||||
revoking the certificate and starting over.
|
||||
|
||||
### 3. App Store Connect API key
|
||||
|
||||
App Store Connect → Users and Access → Integrations → App Store Connect API →
|
||||
**+**, with the **App Manager** role. Download the `.p8` (**one download only**)
|
||||
and note the **Key ID** and the **Issuer ID** shown above the key list.
|
||||
|
||||
This one key does two jobs: it lets `xcodebuild -allowProvisioningUpdates` create
|
||||
and refresh the provisioning profile, which is why no `.mobileprovision` has to
|
||||
be stored anywhere, and it authenticates the TestFlight upload.
|
||||
|
||||
### 4. App ID, then app record
|
||||
|
||||
These live on two different Apple sites and have to be done in this order.
|
||||
|
||||
**developer.apple.com** → Certificates, Identifiers & Profiles → Identifiers →
|
||||
**+** → App IDs → App. Explicit bundle ID `org.bulwarkmail.mobile`, matching
|
||||
`ios.bundleIdentifier` in [app.config.js](../app.config.js). Leave every
|
||||
capability off: push is not implemented on iOS, and sign-in uses the custom
|
||||
`bulwarkmobile://` scheme rather than universal links, so no Associated Domains.
|
||||
|
||||
**appstoreconnect.apple.com** → Apps → **+** → New App. Platform iOS, any SKU.
|
||||
The bundle ID here is a dropdown populated from the identifiers above, which is
|
||||
why the App ID has to be registered first. The app name must be unique across
|
||||
the entire App Store.
|
||||
|
||||
The TestFlight upload is rejected if the app record does not exist - and that
|
||||
failure lands at the very end of a ~30 minute build.
|
||||
|
||||
## Repository secrets
|
||||
|
||||
Settings → Secrets and variables → Actions:
|
||||
|
||||
| Secret | Value |
|
||||
| --- | --- |
|
||||
| `APPLE_TEAM_ID` | The 10-character team ID from step 1 |
|
||||
| `IOS_DIST_CERT_BASE64` | `dist.p12` from step 2, base64-encoded |
|
||||
| `IOS_DIST_CERT_PASSWORD` | The password you chose for `dist.p12` |
|
||||
| `APP_STORE_CONNECT_KEY_ID` | Key ID from step 3 |
|
||||
| `APP_STORE_CONNECT_ISSUER_ID` | Issuer ID from step 3 |
|
||||
| `APP_STORE_CONNECT_PRIVATE_KEY` | The `.p8` from step 3, base64-encoded |
|
||||
|
||||
To base64-encode a file:
|
||||
|
||||
```powershell
|
||||
# PowerShell
|
||||
[Convert]::ToBase64String([IO.File]::ReadAllBytes('dist.p12')) | Set-Clipboard
|
||||
```
|
||||
|
||||
```bash
|
||||
# bash
|
||||
base64 -w0 dist.p12
|
||||
```
|
||||
|
||||
## Running a build
|
||||
|
||||
Publishing a GitHub release triggers the iOS workflow alongside the Android one.
|
||||
You can also run it by hand from the Actions tab, with an
|
||||
`upload_to_testflight` toggle if you only want the IPA as a workflow artifact.
|
||||
|
||||
The IPA is deliberately not attached to the GitHub release: unlike the APK, it is
|
||||
not installable outside TestFlight.
|
||||
|
||||
`CFBundleVersion` comes from the workflow run number via the `IOS_BUILD_NUMBER`
|
||||
env var. App Store Connect rejects a build number it has already seen for a given
|
||||
version, so never re-run a workflow expecting a re-upload to succeed - the run
|
||||
number advances on its own, so just start a new run.
|
||||
|
||||
## After the upload
|
||||
|
||||
Processing takes roughly 5-30 minutes before the build shows up in TestFlight.
|
||||
Internal testers (up to 100, must be on your App Store Connect team) get it
|
||||
immediately. External testers (up to 10,000) need a Beta App Review, which takes
|
||||
about a day the first time and is usually waved through afterwards.
|
||||
|
||||
## What does not work on iOS yet
|
||||
|
||||
- **Push notifications.** The push path is a hand-written Android module talking
|
||||
to FCM (`BulwarkFcmModule`, `BulwarkMessagingService`). iOS needs an APNs key
|
||||
and an equivalent native module; until then `push-notifications.ts` short-
|
||||
circuits and the app polls like a device with push disabled.
|
||||
- **Client certificates.** `BulwarkClientCertModule` is Android-only.
|
||||
`secureFetch` falls back to plain `fetch`, so cert-authenticated servers will
|
||||
not connect.
|
||||
- **In-app updates.** Hidden on purpose - see `supportsSideloadUpdates` in
|
||||
[src/lib/platform-capabilities.ts](../src/lib/platform-capabilities.ts).
|
||||
Downloading and installing a binary is forbidden by App Store Review guideline
|
||||
2.5.2, so iOS updates come through TestFlight instead. The Updates settings
|
||||
pane is removed from the list on iOS; the version and commit are still shown
|
||||
under About & Data.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.54",
|
||||
"version": "0.1.55",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Single source of truth for "this feature only exists on some platforms", so
|
||||
// the UI, the stores and the native bridges all agree on what is reachable.
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
/**
|
||||
* In-app sideload updates: download the release APK from GitHub and hand it to
|
||||
* the system package installer (see `lib/install-update`).
|
||||
*
|
||||
* Android only. App Store Review guideline 2.5.2 forbids an iOS app from
|
||||
* downloading and installing executable code, and there is no iOS equivalent of
|
||||
* the installer intent anyway - iOS builds are updated through TestFlight or
|
||||
* the App Store. The whole updater surface (banner, settings pane, launch-time
|
||||
* check) is hidden when this is false.
|
||||
*/
|
||||
export const supportsSideloadUpdates = Platform.OS === 'android';
|
||||
@@ -38,6 +38,7 @@ import { LayoutSettings } from '../components/settings/LayoutSettings';
|
||||
import { DownloadsSettings } from '../components/settings/DownloadsSettings';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useHasCalendar, useHasContacts, useHasFiles } from '../lib/capabilities';
|
||||
import { supportsSideloadUpdates } from '../lib/platform-capabilities';
|
||||
|
||||
type Tab =
|
||||
| 'account' | 'language' | 'notifications'
|
||||
@@ -138,11 +139,18 @@ const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
|
||||
updates: UpdatesSettings,
|
||||
};
|
||||
|
||||
// Tabs whose feature does not exist on this platform never appear in the list.
|
||||
// The "Updates" pane drives the sideload installer, which is Android-only - the
|
||||
// current version and build are still shown under "About & Data".
|
||||
const AVAILABLE_TABS: TabDef[] = TABS.filter(
|
||||
(t) => t.id !== 'updates' || supportsSideloadUpdates,
|
||||
);
|
||||
|
||||
function groupTabs() {
|
||||
return GROUP_ORDER.map(group => ({
|
||||
group,
|
||||
label: GROUP_LABELS[group],
|
||||
items: TABS.filter(t => t.group === group),
|
||||
items: AVAILABLE_TABS.filter(t => t.group === group),
|
||||
})).filter(g => g.items.length > 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Constants from 'expo-constants';
|
||||
import { fetchLatestRelease, type LatestRelease } from '../api/updates';
|
||||
import { isNewer } from '../lib/version-compare';
|
||||
import { downloadAndInstallApk, type InstallProgress } from '../lib/install-update';
|
||||
import { supportsSideloadUpdates } from '../lib/platform-capabilities';
|
||||
|
||||
const STORAGE_KEY = 'webmail:updates:v1';
|
||||
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
@@ -80,6 +81,10 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
||||
},
|
||||
|
||||
checkNow: async (opts) => {
|
||||
// On platforms without sideloading there is nothing actionable to report,
|
||||
// so skip the GitHub round-trip entirely rather than caching a release the
|
||||
// user can never install.
|
||||
if (!supportsSideloadUpdates) return;
|
||||
const s = get();
|
||||
if (s.checking) return;
|
||||
const now = Date.now();
|
||||
@@ -104,6 +109,7 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
||||
},
|
||||
|
||||
installLatest: async () => {
|
||||
if (!supportsSideloadUpdates) return;
|
||||
const s = get();
|
||||
if (s.installing || !s.cachedLatest?.apkAsset) return;
|
||||
set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } });
|
||||
@@ -142,6 +148,7 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
||||
currentVersion: () => Constants.expoConfig?.version ?? '0.0.0',
|
||||
|
||||
hasUpdate: () => {
|
||||
if (!supportsSideloadUpdates) return false;
|
||||
const s = get();
|
||||
if (!s.cachedLatest) return false;
|
||||
const current = Constants.expoConfig?.version ?? '0.0.0';
|
||||
|
||||
Reference in New Issue
Block a user