iOS Project Structure
What cargo tauri ios init generates, the gen/apple directory, Info.ios.plist, and bundle.iOS config
This page covers what gets added to your Tauri project when you enable iOS, where the iOS-specific files live, and which configuration fields in tauri.conf.json actually drive Xcode's behavior.
Running cargo tauri ios init
From the directory containing your tauri.conf.json (the src-tauri/ directory in a typical layout):
cargo tauri ios initThis runs cargo-mobile2 under the hood and scaffolds an Xcode project. First run takes a few minutes because it compiles dependencies, sets up CocoaPods, and generates the Xcode project.
What Gets Generated
After ios init, your project picks up a gen/ directory:
src- tauri/
Cargo. toml
tauri. conf. json
Info. ios. plist # (you create this yourself when needed)
src/
lib. rs # must export `run` for mobile entry point
gen/
apple/ # iOS- specific output
Podfile
project. yml # XcodeGen project definition
<AppName>. xcodeproj/ # generated Xcode project
<AppName>_ iOS/
<AppName>_ iOS. entitlements # capability entitlements
Assets. xcassets/
LaunchScreen. storyboard
Sources/
main. mm # Objective- C++ entry point bridging to RustThe gen/apple/ directory is generated from project.yml. XcodeGen reads project.yml and produces the .xcodeproj whenever you run cargo tauri ios init or cargo tauri ios dev/build. Anything you edit directly inside .xcodeproj risks being overwritten.
Tip
Check gen/apple/ into git only if you need reproducible builds in CI without re-running ios init. For typical development, gitignore gen/ and rely on the CLI to regenerate. See the .gitignore example below.
lib.rs Must Export run
The mobile entry point calls into tauri_lib::run(). Your src/ needs to look roughly like:
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|_app| Ok(()))
.invoke_handler(tauri::generate_handler![])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}And main.rs just calls the library:
fn main() {
app_lib::run()
}Replace app_lib with your actual crate's lib name (it's configured via the [lib] section in Cargo.toml). If you don't have a lib.rs yet, add one and update Cargo.toml:
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]The staticlib crate type is what the iOS build actually links against. Without it, cargo tauri ios build fails.
tauri.conf.json > bundle.iOS
The iOS-specific bundle configuration lives under bundle.iOS:
{
"bundle": {
"active": true,
"targets": "all",
"iOS": {
"developmentTeam": "ABCD123456",
"minimumSystemVersion": "14.0",
"frameworks": ["CoreHaptics", "WebKit"],
"bundleVersion": "1"
}
}
}| Field | Purpose |
|---|---|
developmentTeam | 10-character Apple Team ID. Found in Xcode > Settings > Accounts > your team > "Team ID" |
minimumSystemVersion | Maps to IPHONEOS_DEPLOYMENT_TARGET. Default 13.0. Bump to 14.0+ if you need modern Web APIs |
frameworks | Extra Apple frameworks to link. Changing this requires re-running cargo tauri ios init |
bundleVersion | Maps to CFBundleVersion. Defaults to the top-level version. Override when you need a build number |
Note
developmentTeam can also be set via the APPLE_DEVELOPMENT_TEAM environment variable -- useful for CI where you don't want to commit the team ID. The env var takes precedence over the config field.
The identifier Rule
The top-level identifier field in tauri.conf.json is the bundle identifier on iOS:
{
"identifier": "com.takazudo.myapp"
}It must match the App ID you register on the Apple Developer portal (or the one auto-generated by Xcode for a personal team). Reverse-DNS style is the convention: com.<org>.<app>.
Warning
Stick to alphanumeric characters and dots. Older Tauri versions had bugs with hyphens and underscores in the bundle identifier. com.takazudo.my-app has caused breakage in the past -- com.takazudo.myapp is the safe choice.
Info.ios.plist: Merging Extra Keys
Tauri auto-generates an in-memory Info.plist for you with CFBundleShortVersionString, CFBundleVersion, and the basics. When you need additional keys (camera permission prompt, ATS exceptions, URL scheme handlers), create an Info.ios.plist next to tauri.conf.json:
<?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>NSCameraUsageDescription</key>
<string>This app uses the camera to scan QR codes.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to your photos to attach images.</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>Keys defined here are merged on top of the generated Info.plist. Shared keys between desktop and iOS can go in a plain Info.plist instead -- Tauri reads both.
Entitlements File
Capabilities that require entitlements (push notifications, App Groups, associated domains) are controlled by gen/. That file is regenerated, so edits live there with care:
<?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>aps-environment</key>
<string>development</string>
</dict>
</plist>In practice, because a free Personal Team does not grant these capabilities, you won't touch this file until you're on a paid Apple Developer Program membership.
.gitignore Additions
For a Tauri project with iOS enabled, extend the existing .gitignore:
/ target/
/ gen/ apple/ Pods/
/ gen/ apple/ Externals/
/ gen/ apple/ build/Whether to ignore the rest of gen/apple/ is a judgement call. Committing the Xcode project can make it easier to tweak build settings in the GUI without XcodeGen regenerating them, but then you lose the "edit project.yml, re-init" workflow. The safe default is to gitignore all of gen/ and regenerate on CI.
iOS Version Matrix
minimumSystemVersion | Device reach | Notes |
|---|---|---|
13.0 (Tauri default) | iPhone 6s and newer | Lowest bar; some modern Web APIs may be missing |
14.0 | iPhone 6s and newer | Gets you better WKWebView features, container queries |
15.0 | iPhone 6s and newer | Safer bet for modern web apps in 2025+ |
16.0 | iPhone 8 and newer | Drops older devices, gains Developer Mode unified flow |
Pick the lowest version that supports the Web APIs your frontend actually uses. Check caniuse.com filtered to Safari-iOS for specifics.
iOS Sandbox Path Resolution
On iOS, your app cannot write wherever it likes. Everything must live inside the app's sandbox container. The standard writable home for your app's working files is the container's Documents directory, which dirs::document_dir() resolves to on iOS (the sandbox also exposes Library/ and tmp/, but Documents is the right place for working files). Route all of your app's working files there:
let workspace = dirs::document_dir()
.expect("could not determine iOS documents directory")
.join("my-app");
std::fs::create_dir_all(&workspace).ok();The resulting path is inside the sandbox. Anything you try to anchor outside it (a repo path, a ~/Documents path computed for desktop) will silently fail to be writable on a real device.
The cfg-ordering trap
When a single binary serves both desktop and iOS, you typically branch on the platform and on debug vs release. The order of those branches matters, and getting it wrong costs you an afternoon.
fn resolve_project_root() -> String {
// iOS (debug OR release): always use the app sandbox documents directory.
// Must be checked BEFORE the debug_assertions branch. On a simulator build
// both `cfg!(target_os = "ios")` and `cfg!(debug_assertions)` are true, and
// the repo-root path from CARGO_MANIFEST_DIR is outside the iOS container.
#[cfg(target_os = "ios")]
{
let workspace = dirs::document_dir()
.expect("could not determine iOS documents directory")
.join("my-app");
std::fs::create_dir_all(&workspace).ok();
return workspace.to_string_lossy().to_string();
}
// Dev mode (non-iOS): the repo root is the parent of src-tauri/
if cfg!(debug_assertions) {
return std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_string_lossy()
.to_string();
}
// Production (macOS and other non-iOS targets)
#[cfg(not(target_os = "ios"))]
{
let home = dirs::home_dir().expect("could not determine home directory");
home.join("Documents/my-app").to_string_lossy().to_string()
}
}The subtlety: on the iOS simulator, a debug build has both cfg!(target_os = "ios") and cfg!(debug_assertions) evaluating to true. If you check debug_assertions first, the simulator falls into the dev branch and returns the repo root from CARGO_MANIFEST_DIR — a path outside the iOS app container. The app then fails to read or write its files, with no error pointing at the cause. Because the simulator can read your Mac's filesystem, it may even appear to half-work, which makes the bug worse to track down.
The fix is purely about ordering: the #[cfg(target_os = "ios")] block with its early return must come first, before the debug_assertions check. The same pattern applies to resolve_app_config_dir() and any other path resolver that branches on debug mode — put the iOS early-return at the top.
Warning
This is not something the type system or compiler catches. Both branches compile and both are reachable on the simulator; only the runtime path is wrong. Always test sandbox path resolution on a real device or at least confirm the resolved path is under the app container, not your repo.