zudo-tauri-wisdom
GitHub repository

Type to search...

to open search from anywhere

iOS Project Structure

Created Apr 16, 2026Updated May 28, 2026Takeshi Takatsudo

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 init

This 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 Rust

The 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/lib.rs 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"
    }
  }
}
FieldPurpose
developmentTeam10-character Apple Team ID. Found in Xcode > Settings > Accounts > your team > "Team ID"
minimumSystemVersionMaps to IPHONEOS_DEPLOYMENT_TARGET. Default 13.0. Bump to 14.0+ if you need modern Web APIs
frameworksExtra Apple frameworks to link. Changing this requires re-running cargo tauri ios init
bundleVersionMaps 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:

src-tauri/Info.ios.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>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/apple/<AppName>_iOS/<AppName>_iOS.entitlements. 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

minimumSystemVersionDevice reachNotes
13.0 (Tauri default)iPhone 6s and newerLowest bar; some modern Web APIs may be missing
14.0iPhone 6s and newerGets you better WKWebView features, container queries
15.0iPhone 6s and newerSafer bet for modern web apps in 2025+
16.0iPhone 8 and newerDrops 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.

Official Docs

Revision History

Takeshi TakatsudoCreated: 2026-04-17T08:50:05+09:00Updated: 2026-05-29T05:48:31+09:00