# iOS
GeoLibre runs as a native iOS app built from the same React codebase via **Tauri
v2 mobile** — no separate app. The webview UI (WKWebView) is bundled in the app,
so the shell works offline; map tiles and the heavier engines are fetched on
demand (same as the desktop and Android builds).
> **Status: builds and signs, locally and in CI; not yet shipped.** The pipeline
> has been run end to end both ways and produces a **submittable** `.ipa` —
> bundle id `org.geolibre.app`, signed by *Apple Distribution*,
> `get-task-allow=false`, an embedded App Store provisioning profile, the merged
> location usage string, and the web assets embedded in the binary.
>
> - **Locally** on a Mac (Xcode 26.6 / iOS SDK 26.5): `tauri ios init` → unsigned
> archive → `xcodebuild -exportArchive`.
> - **In CI**, the signed job has run green on a `macos-15` runner (Xcode 26.3):
> it imported the certificate and profile from the `APPLE_IOS_*` secrets,
> archived, exported under manual signing, verified the signature, and
> published the `geolibre-ios-ipa` artifact.
>
> A first build (version 2.4.0, build 2.4.0) has been uploaded to App Store
> Connect by hand via Transporter. What has **not** happened: any submission for
> review. Everything below still has to be run on a Mac (iOS cannot be
> cross-compiled from Linux).
## What works on iOS vs desktop
Same split as Android. The iOS build ships the full map workspace, Add Data, the
Vector tools (Turf.js / in-browser GeoPandas via Pyodide), the SQL Workspace
(DuckDB-WASM and PGlite/PostGIS), the Python Console (Pyodide), geocoding,
statistics, the AI assistant, story maps, and plugins.
Tools that depend on a **local desktop process** are hidden on mobile because
iOS has no Python sidecar or local helper binaries and its sandbox forbids
spawning subprocesses:
- Processing → **Whitebox**, **Raster**, **Conversion**, **AI Segmentation**
(all need the Python sidecar)
- Add Data → **PostgreSQL** (served by the local Martin tile server)
These are gated by a user-agent `isMobile()` check (which already matches
iPhone/iPad) so they never appear and then fail. Everything else runs
client-side.
## Location permission (required)
This is the one place iOS differs sharply from Android. Android's geolocation
plugin declares the runtime permission and the OS shows a generic dialog; **iOS
terminates the app the instant it requests location if no usage-description
string is present.** GeoLibre supplies it in
`src-tauri/Info.ios.plist`:
```xml
NSLocationWhenInUseUsageDescription
GeoLibre uses your location to center the map, capture GPS points during Field Collection, and record GPS tracks.
```
Tauri merges `Info.ios.plist` into the generated
`gen/apple/geolibre-desktop_iOS/Info.plist` at build time. `gen/apple` is git-ignored, so
this file is the durable home for the string — the same reason Android's manifest
permissions come from the plugin rather than a hand-edited, regenerated manifest.
It covers all three location consumers: Field Collection, GPS Tracking, and the
Controls → GeoLocate map control. After a build, confirm the key survived the
merge (see *Build* below).
## Toolchain setup (one time)
You need a **Mac** with **Xcode** (from the App Store; open it once to accept the
license and install the iOS platform), the command-line tools, CocoaPods, and the
Rust iOS targets.
```bash
xcode-select --install # command-line tools (if not already)
sudo xcodebuild -license accept
brew install cocoapods # or `gem install cocoapods`
# Rust device + simulator targets (install rustup first if needed)
rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
```
`aarch64-apple-ios` is real devices and the App Store `.ipa`;
`aarch64-apple-ios-sim` is the simulator on Apple Silicon Macs.
## Build
```bash
cd apps/geolibre-desktop
npx tauri ios init # generate src-tauri/gen/apple (once)
npx tauri ios dev # run in the simulator / on a tethered device
npx tauri ios build --no-sign # unsigned .ipa + .xcarchive (no Apple account needed)
```
> **`npx tauri ios build --export-method app-store-connect` does not work as-is.**
> The project `tauri ios init` generates bakes `CODE_SIGN_IDENTITY = "iPhone
> Developer"` into *both* the debug and release configurations, so Xcode resolves
> **development** signing even when the export method is App Store. Automatic
> development signing then demands a registered device and fails with:
>
> ```
> error: Your team has no devices from which to generate a provisioning profile.
> error: No profiles for 'org.geolibre.app' were found: Xcode couldn't find any
> iOS App Development provisioning profiles matching 'org.geolibre.app'.
> ```
>
> Overriding `CODE_SIGN_IDENTITY="Apple Distribution"` does not help either —
> Xcode rejects it as *"automatically signed for development, but a conflicting
> code signing identity … has been manually specified."*
>
> Split the archive from the export instead. Archive **unsigned**, then let the
> export step do the distribution signing — this needs no registered device, and
> Xcode creates the Apple Distribution certificate, registers the App ID, and
> generates the App Store profile on the fly:
>
> ```bash
> cd apps/geolibre-desktop
> npx tauri ios build --no-sign # → gen/apple/build/geolibre-desktop_iOS.xcarchive
>
> G=src-tauri/gen/apple
> cat > "$G/ExportOptions-appstore.plist" <<'PLIST'
>
>
>
> methodapp-store-connect
> teamIDYOUR_TEAM_ID
> signingStyleautomatic
> uploadSymbols
>
> PLIST
>
> xcodebuild -exportArchive -allowProvisioningUpdates \
> -archivePath "$G/build/geolibre-desktop_iOS.xcarchive" \
> -exportOptionsPlist "$G/ExportOptions-appstore.plist" \
> -exportPath "$G/build/appstore"
> ```
>
> This produces a genuinely submittable `build/appstore/GeoLibre.ipa`: signed by
> `Apple Distribution`, `get-task-allow=false`, entitlement
> `.org.geolibre.app`, and an embedded *iOS Team Store Provisioning
> Profile*. Verify with `codesign -dvvv` before uploading.
>
> Note the certificate Xcode creates this way is **Cloud Managed**, so it does
> *not* appear in `security find-identity -v -p codesigning`. An empty identity
> list is not evidence the signing failed — check the `.ipa` itself.
- `gen/apple` is generated (git-ignored) and regenerated on demand. `init` also
merges `tauri.ios.conf.json` (bundle id, drops the Python backend) and
`Info.ios.plist` (the location string).
- The app is named **GeoLibre** on iOS (the desktop build is "GeoLibre Desktop")
and uses the bundle id **`org.geolibre.app`**, both set via
`src-tauri/tauri.ios.conf.json` — the same override pattern and reasoning as
Android (`identifier` in `tauri.conf.json` stays `org.geolibre.desktop` so it
keeps keying desktop settings and the Linux/macOS packaging).
- Verify the location string landed after a build:
```bash
/usr/libexec/PlistBuddy -c 'Print :NSLocationWhenInUseUsageDescription' \
src-tauri/gen/apple/geolibre-desktop_iOS/Info.plist
```
The merge happens during `tauri ios build`/`dev`, **not** during `ios init` —
straight after `init` the generated plist does not contain the key yet, and
that is expected. Note also that the Xcode project, target, and plist
directory are named from the **Cargo package** (`geolibre-desktop` in
`src-tauri/Cargo.toml`), not from `productName`. The user-visible app name and
bundle id still come from `tauri.ios.conf.json` — they land as `PRODUCT_NAME:
GeoLibre` and `PRODUCT_BUNDLE_IDENTIFIER: org.geolibre.app` in the generated
`project.yml`.
## Signing
Every build that runs on a real device or reaches the App Store must be signed —
there is no debug-keystore shortcut like Android's. You need:
1. An **Apple Developer Program** membership ($99/year).
2. A **signing certificate** (Apple Distribution for App Store; Apple Development
for device testing), exported from Keychain as a `.p12`.
3. A **provisioning profile** for the `org.geolibre.app` app id.
4. Your **Team ID** (App Store Connect → Membership).
Locally, opening `gen/apple/geolibre-desktop.xcodeproj` in Xcode once and enabling
"Automatically manage signing" with your team is the simplest path. For CI, the
identity is imported from secrets (below).
> **The local and CI identities are not interchangeable.** The export recipe
> above lets Xcode create a **Cloud Managed** Apple Distribution certificate and
> an Xcode-managed profile. Neither can be fed to CI: a cloud-managed identity
> has no exportable private key, so there is no `.p12` to base64, and CI's
> manual-signing export rejects an Xcode-managed profile outright
> (*"is Xcode managed, but signing settings require a manually managed
> profile"*). For CI you must create a **separate, manually managed** pair in the
> Developer portal — an Apple Distribution certificate from a CSR you generate in
> Keychain Access (so you hold the private key and can export the `.p12`), plus
> an App Store provisioning profile for `org.geolibre.app` bound to it. Holding
> both a cloud-managed and a manual distribution certificate at once is fine and
> normal.
## Continuous integration
`.github/workflows/ios.yml` runs on `macos-15` on each published GitHub release
(and on demand via "Run workflow"). Because iOS can't be cross-compiled from
Linux, this is the only mobile workflow that needs a macOS runner. The image is
not free to choose — see the Xcode floor below.
- **With Apple signing secrets set**, it imports the identity into a throwaway
keychain, archives **unsigned**, exports a signed `.ipa` under manual signing,
verifies the bundle id and the signature, and uploads it as the
`geolibre-ios-ipa` artifact:
- `APPLE_IOS_CERTIFICATE_BASE64` — `base64 -i dist.p12`
- `APPLE_IOS_CERTIFICATE_PASSWORD`
- `APPLE_IOS_PROVISIONING_PROFILE_BASE64` — `base64 -i profile.mobileprovision`
- `APPLE_TEAM_ID` — **reused** from the existing macOS/Homebrew signing
secrets; the Team ID is account-wide.
- **Without them**, it falls back to a no-signing **compile check**
(`cargo build --lib --target aarch64-apple-ios`) so CI still catches iOS build
breakage; it just can't produce an installable `.ipa`.
### Reusing the macOS/Homebrew signing secrets?
Only the **Team ID**. The repo's release workflow already signs and notarizes the
macOS build for the Homebrew cask, but its `APPLE_CERTIFICATE` is a **Developer ID
Application** certificate — that type signs macOS apps distributed *outside* the
App Store and **cannot sign an iOS app**. iOS needs its own **Apple Distribution**
certificate and a **provisioning profile** (Developer ID distribution has
neither), so those are the `APPLE_IOS_*` secrets above, deliberately named apart
from the macOS ones to avoid feeding in the wrong cert/password. The good news:
they're created under the **same paid Apple Developer account** at no extra cost —
add an Apple Distribution certificate and an App Store provisioning profile for
`org.geolibre.app` in the Developer portal, and reuse the existing `APPLE_TEAM_ID`.
> **Why the job archives and exports as two steps.** It archives with
> `npx tauri ios build --no-sign` and then signs in a separate
> `xcodebuild -exportArchive` step using **manual** signing, for the reason given
> under *Build*: `tauri ios build --export-method` cannot sign this project,
> because the generated project bakes in `CODE_SIGN_IDENTITY = "iPhone
> Developer"` and automatic signing therefore hunts for a development profile no
> matter what the export method says.
>
> This has been verified on a runner — a `workflow_dispatch` run with
> `export_method: app-store-connect` completed green, reporting
> `IPA ... bundle id org.geolibre.app, signed by Apple Distribution` and
> publishing the `geolibre-ios-ipa` artifact. What remains unexercised is the
> **release-triggered** entry point (the job has only been started manually) and
> everything downstream of the artifact: no build has been uploaded to App Store
> Connect.
> **The runner's Xcode sets a hard floor.** `tauri ios init` generates an Xcode
> project in **object format 77**, which only **Xcode 16+** can open. On
> `macos-14` the archive failed immediately, before compiling anything:
>
> ```
> xcodebuild: error: Unable to load workspace '.../geolibre-desktop.xcodeproj/project.xcworkspace/'.
> Reason: The project 'geolibre-desktop' cannot be opened because it is in a
> future Xcode project file format (77).
> ```
>
> The job therefore runs on `macos-15` and explicitly selects the newest Xcode on
> the image rather than trusting its default, failing early with a clear message
> if that is older than 16. Note this floor comes from the **Tauri CLI's**
> generated project, not from anything in this repo — re-check it whenever the
> Tauri CLI is bumped.
>
> Separately, App Store Connect rejects uploads built against an SDK below its
> current floor. That failure is *silent* at build time — an older-but-openable
> Xcode still produces an `.ipa` and only fails at upload. The Xcode version and
> iOS SDK list are printed into the run log so a rejected upload can be diagnosed
> from the run instead of guessed at.
The `workflow_dispatch` `export_method` input picks the export path
(`app-store-connect` for TestFlight/App Store, `release-testing` for ad-hoc
registered devices, `debugging` for development).
## Install / test
- **Simulator:** `npx tauri ios dev` and pick a simulator, or open the Xcode
project and Run. No paid account needed for the simulator.
- **Your own device:** tether it, open `gen/apple/geolibre-desktop.xcodeproj` in Xcode,
select the device, and Run (a free Apple ID allows 7-day device signing).
- **Testers:** distribute a signed build through **TestFlight** (upload the `.ipa`
via Xcode Organizer or Transporter, then invite testers in App Store Connect).
## Publishing to the App Store
The build side is covered by the CI workflow; the rest is App Store Connect
onboarding.
1. **App record.** In App Store Connect, create a new app with the bundle id
`org.geolibre.app` (register the app id in the Developer portal first).
2. **Minimum Functionality (Guideline 4.2).** Apple rejects apps that are "a
repackaged website." GeoLibre passes because it's a bundled native app with
real device integration — GPS, offline-capable map workspace, local file
handling — not a wrapper that loads a remote URL. Keep it that way: ship the
web assets in the binary (the default here), don't point the webview at
`geolibre.app`.
3. **Upload** the `.ipa` from the `geolibre-ios-ipa` CI artifact (or via
Transporter / Xcode Organizer) to a TestFlight build, then submit that build
for App Store review.
**Build numbers.** App Store Connect consumes a `CFBundleVersion`
permanently per marketing version, and each upload must also sort above the
last one consumed, so every upload needs a fresh, higher value. CI stamps the
archive's `CFBundleVersion` with `$GITHUB_RUN_NUMBER` — an integer that only
ever rises — while `CFBundleShortVersionString` keeps tracking
`tauri.conf.json` and stays the version users see.
Re-running an existing run appends `$GITHUB_RUN_ATTEMPT` (`42` becomes
`42.2`), because the run number itself is fixed for a run's lifetime: without
the suffix, re-running a run whose IPA was already uploaded would regenerate
the same build number. The one hand-uploaded 2.4.0 build used `2.4.0`, which
a bare run number of 3 or more already beats under Apple's dotted-integer
comparison.
This is deliberately **not** done with `tauri ios build --build-number`:
that flag *appends* to the version, producing e.g. `2.4.0.42`. A
`CFBundleVersion` may hold at most **three** period-separated integers, so
App Store Connect rejects a four-component value at upload
(`ITMS-90060`) — after the whole build has run. The verify step asserts the
shape for that reason.
Uploading by hand (no CI) means setting the build number yourself if the
marketing version has been uploaded before.
4. **Store listing:** icon (already generated under `src-tauri/icons/ios`),
screenshots for the required device sizes (6.7" and 6.5" iPhone, plus 12.9"
iPad — a GIS workspace is genuinely iPad-appropriate), description, keywords.
5. **Privacy.** Fill the **App Privacy** questionnaire honestly — declare each
network destination (geocoding, the AI assistant, basemap/tile fetches, Google
OAuth for Earth Engine) and that location is used *when in use* and not
collected by a backend. Point the privacy policy URL at the published
[privacy policy](privacy.md).
6. **Age rating** questionnaire and category (Navigation or Productivity).
7. **Export compliance.** Already declared in `src-tauri/Info.ios.plist`:
```xml
ITSAppUsesNonExemptEncryption
```
App Store Connect asks about encryption on *every* upload, and a build with
the question unanswered cannot be submitted or sent to testers ("Missing
Compliance"). Declaring it in the plist answers it once, at build time.
`false` asserts that GeoLibre's use of encryption stays limited to what Apple
treats as exempt — HTTPS/TLS via the system libraries for tiles, geocoding,
the AI assistant, cloud catalogs, and the collaboration relay — and that the
app ships no cryptography of its own. That holds today, but it is a **legal
declaration**, not a build flag: revisit it if that ever changes.
Builds uploaded before this key was added (the first 2.4.0 upload) still
need the question answered by hand in App Store Connect.
## Known limitations / follow-ups
Most mirror Android:
- Local-file sources (MBTiles, local rasters, project files) assume real
filesystem paths; iOS hands apps sandboxed URLs via the document picker, so
those flows need adapting before they work natively.
- The **Download Offline Area** tool relies on a service worker, which the Tauri
builds don't use — it's a PWA feature. Native offline basemap caching is future
work.
- Earth Engine OAuth uses a desktop loopback/multi-window flow; a mobile
deep-link redirect (an iOS URL scheme / universal link) is future work.
- iPadOS multitasking (Split View / Stage Manager) hasn't been tuned; the
responsive layout should adapt, but verify on a real iPad before release.