Integrating AdMob Rewarded Ads in Tauri 2.x iOS Apps: Swift Plugin Development Guide
7 min read

Integrating AdMob Rewarded Ads in Tauri 2.x iOS Apps: Swift Plugin Development Guide

A complete guide to integrating Google AdMob rewarded ads into Tauri v2 iOS apps. Learn how to develop a custom Swift plugin since no official plugin exists.

Starting With No Official Plugin

Once Shadow Dash was live on the App Store, monetization became the next chore. I went looking for a way to add rewarded ads and searched the Tauri plugin ecosystem, only to find that no official plugin covers AdMob at all.

So I had to build a Swift plugin that wraps the GoogleMobileAds SDK myself. What follows is an ordered account of where I got stuck and the code that got me unstuck. It walks through the actual sequence I hit, from upgrading the Tauri CLI to permission setup and linker errors.

This post is a follow-up to iOS Game Development with Tauri + PixiJS. It’s based on real experience integrating ads into the Shadow Dash game.

Shadow Dash game over screen

Why AdMob?

Comparing Mobile Game Monetization Options

MethodProsCons
Rewarded AdsGreat UX, high eCPMComplex implementation
Banner AdsSimple implementationLow eCPM, hurts UX
In-App PurchasesHigh revenueComplex implementation, payment review

Why I Chose Rewarded Ads

In Shadow Dash, I applied rewarded ads to the continue system:

graph LR
    A[Game Over] --> B["Watch Ad to Continue" button]
    B --> C[Watch Ad]
    C --> D[Resume Game]

Users voluntarily watch ads and receive rewards (continuing the game), which doesn’t hurt the user experience.

When Rewarded Ads Fit, and When They Don’t

Before wiring up an ad SDK, decide whether rewarded ads even suit your game. Drop them in blindly and you get no revenue and worse reviews.

Where rewarded ads work well:

  • Arcade and casual games built around short, repeating sessions. A structure like Shadow Dash, where game-over happens often and the “one more run” urge is strong, is the sweet spot.
  • Cases where the reward for watching is clear and immediate, such as a continue, doubled coins, or a revealed hint.
  • Solo-developer apps with a high share of non-paying users, where in-app purchases alone won’t sustain revenue. As an individual developer’s AI SaaS monetization journey shows, the smaller the app, the more a low-barrier revenue source matters.

Where you’re better off avoiding them:

  • Immersive narrative games or core games with high willingness to pay. Whether interstitial or rewarded, an ad that breaks immersion tends to drive churn instead.
  • Early-stage apps with only a few hundred DAU. However high the eCPM, too few impressions won’t produce meaningful revenue. Here, acquiring users comes before integrating ads.
  • Minimal projects where the SDK’s added build size and complexity are a burden. The maintenance cost can outweigh the ad income. If your project leans on AI tooling, the cost-versus-benefit lens from the reality of AI agent costs applies directly to this call.

In short, rewarded ads fit best in games with short sessions and a strong retry motive. If you’re building something similar to Shadow Dash, start with the game-loop design in iOS game development with Tauri + PixiJS.

Ad SDK Selection Process

I evaluated available ad SDKs for Tauri apps:

SDKProsConsDecision
AdMobHigh eCPM, various ad formatsRequires native plugin✅ Selected
AppLixirJS SDK only integrationRequires 5,000+ DAU❌ Not selected
H5 Game AdsHTML5 game specializedBeta service (unstable)❌ Not selected

Why not AppLixir:

  • Requires a minimum of 5,000 daily active users (DAU) to apply
  • Difficult to meet this requirement for new apps

Why not H5 Game Ads:

  • Still in beta service stage
  • Stability and profitability not verified

AdMob vs AppLixir Detailed Comparison

Comparing AdMob and AppLixir performance:

FeatureAdMobAppLixir
eCPM (Rewarded)$20-30$15-25
Fill Rate95%+80-90%
Supported FormatsRewarded, Interstitial, BannerRewarded only
MediationSupportedNot supported
Integration DifficultyHigh (native required)Low (JS SDK)
Entry BarrierNone5,000+ DAU required

Conclusion: While native plugin development is required, AdMob has no entry barrier and offers higher eCPM and fill rate.


Upgrading Tauri CLI

XCFramework Support Required

To develop iOS plugins in Tauri v2, you need to use Swift Package Manager (SPM). For plugins that require external framework dependencies like the GoogleMobileAds SDK, XCFramework support is necessary.

Upgrading to Tauri CLI 2.9.6+

# Upgrade Tauri CLI via Cargo
cargo install tauri-cli --force

# Verify version
cargo tauri --version
# Output: tauri-cli 2.9.6 (or higher)

The —ios-framework Option

You can specify the iOS framework type when creating a plugin:

# Xcode project method (easy to add SPM dependencies) - Recommended
cargo tauri plugin new admob --ios --ios-framework xcode

# XCFramework method
cargo tauri plugin new admob --ios --ios-framework xcframework
OptionDescriptionProsCons
xcodeCreates Xcode projectEasy to add SPM dependenciesProject file management required
xcframeworkCreates XCFrameworkEasy distributionComplex external dependencies

For AdMob plugin, xcode option is recommended: It allows easy addition of GoogleMobileAds SDK via SPM.


Understanding Tauri Plugin Structure

Directory Structure

tauri-plugin-admob/
├── src/                    # Rust code
│   ├── lib.rs             # Plugin entry point
│   ├── mobile.rs          # iOS/Android bridge
│   ├── desktop.rs         # Desktop stub
│   ├── commands.rs        # Tauri commands
│   └── models.rs          # Request/Response types
├── ios/                    # iOS native code
│   └── tauri-plugin-admob/
│       └── AdmobPlugin.swift
├── guest-js/              # TypeScript API
│   └── index.ts
├── permissions/           # Tauri permission settings
│   └── default.toml
├── build.rs               # Build script
└── Cargo.toml

Data Flow

graph TD
    A[TypeScript] -->|invoke| B[Rust Command]
    B -->|run_mobile_plugin| C[Swift Plugin]
    C -->|API Call| D[GoogleMobileAds SDK]
    D -->|Response| C
    C -->|Result| B
    B -->|JSON| A

Starting Plugin Development

Plugin Scaffolding

# Create with Xcode project method
cargo tauri plugin new admob --ios --ios-framework xcode

Adding GoogleMobileAds SDK

Open the plugin project in Xcode and add the SDK via Swift Package Manager:

  1. Open tauri-plugin-admob/ios/tauri-plugin-admob.xcodeproj
  2. File → Add Package Dependencies
  3. Enter URL: https://github.com/googleads/swift-package-manager-google-mobile-ads
  4. Select GoogleMobileAds and add

Swift Plugin Implementation

AdmobPlugin.swift

import SwiftRs
import Tauri
import UIKit
import WebKit
import GoogleMobileAds

// MARK: - Argument Types
class InitializeArgs: Decodable {}

class LoadRewardedArgs: Decodable {
    let adUnitId: String
}

class ShowRewardedArgs: Decodable {}

// MARK: - AdMob Plugin
class AdmobPlugin: Plugin {
    private var rewardedAd: GADRewardedAd?
    private var isInitialized = false
    private var pendingInvoke: Invoke?

    // Test ad ID (use actual ID in production)
    private let testAdUnitId = "ca-app-pub-3940256099942544/1712485313"

    @objc public override func load(webview: WKWebView) {
        NSLog("[AdMob Plugin] Loaded")
    }

    // Initialize SDK
    @objc public func initialize(_ invoke: Invoke) {
        if isInitialized {
            invoke.resolve(["success": true, "message": "Already initialized"])
            return
        }

        GADMobileAds.sharedInstance().start { status in
            self.isInitialized = true
            NSLog("[AdMob Plugin] SDK Initialized")
            invoke.resolve(["success": true, "message": "SDK initialized"])
        }
    }

    // Load rewarded ad
    @objc public func loadRewardedAd(_ invoke: Invoke) {
        do {
            let args = try invoke.parseArgs(LoadRewardedArgs.self)
            let adUnitId = args.adUnitId.isEmpty ? testAdUnitId : args.adUnitId

            let request = GADRequest()
            GADRewardedAd.load(withAdUnitID: adUnitId, request: request) { [weak self] ad, error in
                if let error = error {
                    invoke.resolve(["success": false, "error": error.localizedDescription])
                    return
                }

                self?.rewardedAd = ad
                self?.rewardedAd?.fullScreenContentDelegate = self
                invoke.resolve(["success": true])
            }
        } catch {
            invoke.reject(error.localizedDescription)
        }
    }

    // Check if ad is ready
    @objc public func isRewardedAdReady(_ invoke: Invoke) {
        let isReady = rewardedAd != nil
        invoke.resolve(["ready": isReady])
    }

    // Show ad
    @objc public func showRewardedAd(_ invoke: Invoke) {
        guard let rewardedAd = rewardedAd else {
            invoke.resolve(["success": false, "rewarded": false, "error": "No ad loaded"])
            return
        }

        guard let rootViewController = getRootViewController() else {
            invoke.resolve(["success": false, "rewarded": false, "error": "No root view controller"])
            return
        }

        pendingInvoke = invoke

        DispatchQueue.main.async {
            rewardedAd.present(fromRootViewController: rootViewController) { [weak self] in
                let reward = rewardedAd.adReward
                if let pending = self?.pendingInvoke {
                    pending.resolve([
                        "success": true,
                        "rewarded": true,
                        "rewardAmount": reward.amount.intValue,
                        "rewardType": reward.type
                    ])
                    self?.pendingInvoke = nil
                }
            }
        }
    }

    // Get Root View Controller
    private func getRootViewController() -> UIViewController? {
        if let windowScene = UIApplication.shared.connectedScenes
            .compactMap({ $0 as? UIWindowScene })
            .first(where: { $0.activationState == .foregroundActive }),
           let keyWindow = windowScene.windows.first(where: { $0.isKeyWindow }),
           let rootVC = keyWindow.rootViewController {
            var topController = rootVC
            while let presented = topController.presentedViewController {
                topController = presented
            }
            return topController
        }
        return nil
    }
}

// MARK: - GADFullScreenContentDelegate
extension AdmobPlugin: GADFullScreenContentDelegate {
    func adDidDismissFullScreenContent(_ ad: GADFullScreenPresentingAd) {
        rewardedAd = nil
        if let pending = pendingInvoke {
            pending.resolve(["success": true, "rewarded": false])
            pendingInvoke = nil
        }
    }

    func ad(_ ad: GADFullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) {
        rewardedAd = nil
        if let pending = pendingInvoke {
            pending.resolve(["success": false, "rewarded": false, "error": error.localizedDescription])
            pendingInvoke = nil
        }
    }
}

// MARK: - Plugin Export
@_cdecl("init_plugin_admob")
func initPlugin() -> Plugin {
    return AdmobPlugin()
}

Key Points

  1. @objc annotation: Required to expose to Rust via Objective-C runtime
  2. pendingInvoke pattern: Used to bridge async ad callbacks to Tauri’s sync invoke pattern
  3. GADFullScreenContentDelegate: Handles ad dismiss/error events
  4. @_cdecl("init_plugin_admob"): C function export for Rust plugin loading

Rust Bridge Implementation

models.rs

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeResponse {
    pub success: bool,
    pub message: Option<String>,
}

#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoadRewardedAdRequest {
    pub ad_unit_id: String,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoadRewardedAdResponse {
    pub success: bool,
    pub error: Option<String>,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IsRewardedAdReadyResponse {
    pub ready: bool,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ShowRewardedAdResponse {
    pub success: bool,
    pub rewarded: bool,
    pub reward_amount: Option<i32>,
    pub reward_type: Option<String>,
    pub error: Option<String>,
}

mobile.rs

use serde::de::DeserializeOwned;
use tauri::{
    plugin::{PluginApi, PluginHandle},
    AppHandle, Runtime,
};
use crate::models::*;

#[cfg(target_os = "ios")]
tauri::ios_plugin_binding!(init_plugin_admob);

pub fn init<R: Runtime, C: DeserializeOwned>(
    _app: &AppHandle<R>,
    api: PluginApi<R, C>,
) -> crate::Result<Admob<R>> {
    #[cfg(target_os = "ios")]
    let handle = api.register_ios_plugin(init_plugin_admob)?;
    Ok(Admob(handle))
}

pub struct Admob<R: Runtime>(PluginHandle<R>);

impl<R: Runtime> Admob<R> {
    pub fn initialize(&self) -> crate::Result<InitializeResponse> {
        self.0.run_mobile_plugin("initialize", ()).map_err(Into::into)
    }

    pub fn load_rewarded_ad(&self, ad_unit_id: String) -> crate::Result<LoadRewardedAdResponse> {
        self.0.run_mobile_plugin("loadRewardedAd",
            LoadRewardedAdRequest { ad_unit_id }).map_err(Into::into)
    }

    pub fn is_rewarded_ad_ready(&self) -> crate::Result<IsRewardedAdReadyResponse> {
        self.0.run_mobile_plugin("isRewardedAdReady", ()).map_err(Into::into)
    }

    pub fn show_rewarded_ad(&self) -> crate::Result<ShowRewardedAdResponse> {
        self.0.run_mobile_plugin("showRewardedAd", ()).map_err(Into::into)
    }
}

build.rs (Important!)

const COMMANDS: &[&str] = &[
    "initialize",
    "load_rewarded_ad",
    "is_rewarded_ad_ready",
    "show_rewarded_ad"
];

fn main() {
    // Link frameworks for iOS builds
    let target = std::env::var("TARGET").unwrap_or_default();
    if target.contains("ios") {
        println!("cargo:rustc-link-lib=framework=GoogleMobileAds");
        println!("cargo:rustc-link-lib=framework=UserMessagingPlatform");
    }

    tauri_plugin::Builder::new(COMMANDS)
        .android_path("android")
        .ios_path("ios")
        .build();
}

Note: #[cfg(target_os = "ios")] doesn’t work in build.rs. Since build.rs runs on the host machine (macOS), you need to check the target using std::env::var("TARGET").


TypeScript API Implementation

guest-js/index.ts

import { invoke } from '@tauri-apps/api/core'

export interface InitializeResponse {
  success: boolean;
  message?: string;
}

export interface LoadRewardedAdResponse {
  success: boolean;
  error?: string;
}

export interface IsRewardedAdReadyResponse {
  ready: boolean;
}

export interface ShowRewardedAdResponse {
  success: boolean;
  rewarded: boolean;
  rewardAmount?: number;
  rewardType?: string;
  error?: string;
}

export async function initialize(): Promise<InitializeResponse> {
  return await invoke<InitializeResponse>('plugin:admob|initialize');
}

export async function loadRewardedAd(adUnitId: string = ''): Promise<LoadRewardedAdResponse> {
  return await invoke<LoadRewardedAdResponse>('plugin:admob|load_rewarded_ad', {
    adUnitId,
  });
}

export async function isRewardedAdReady(): Promise<IsRewardedAdReadyResponse> {
  return await invoke<IsRewardedAdReadyResponse>('plugin:admob|is_rewarded_ad_ready');
}

export async function showRewardedAd(): Promise<ShowRewardedAdResponse> {
  return await invoke<ShowRewardedAdResponse>('plugin:admob|show_rewarded_ad');
}

Integrating Plugin with Your App

Add Plugin to Cargo.toml

[dependencies]
tauri-plugin-admob = { path = "../tauri-plugin-admob" }

Register Plugin in lib.rs

pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_admob::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Permission Configuration

src-tauri/capabilities/default.json:

{
  "permissions": [
    "core:default",
    "admob:default"
  ]
}

tauri-plugin-admob/permissions/default.toml:

[default]
description = "Default permissions for the AdMob plugin"
permissions = [
    "allow-initialize",
    "allow-load-rewarded-ad",
    "allow-is-rewarded-ad-ready",
    "allow-show-rewarded-ad"
]

Add App ID to Info.plist

src-tauri/gen/apple/AppName_iOS/Info.plist:

<key>GADApplicationIdentifier</key>
<string>ca-app-pub-XXXXXXXXXXXXXXXX~XXXXXXXXXX</string>
<key>SKAdNetworkItems</key>
<array>
    <dict>
        <key>SKAdNetworkIdentifier</key>
        <string>cstr6suwn9.skadnetwork</string>
    </dict>
</array>

Using Ads in Your Game

Ad Configuration File

src/lib/config/admob.ts:

// Ad enabled flag (set to false until approval)
export const ADS_ENABLED = false;

const TEST_AD_UNITS = {
  rewardedAd: 'ca-app-pub-3940256099942544/1712485313',
  appId: 'ca-app-pub-3940256099942544~1458002511',
};

const PRODUCTION_AD_UNITS = {
  rewardedAd: 'ca-app-pub-XXXXXXXX/XXXXXXXXXX',  // Actual ad unit ID
  appId: 'ca-app-pub-XXXXXXXX~XXXXXXXXXX',       // Actual app ID
};

const isDevelopment = import.meta.env.DEV;

export const AD_UNITS = isDevelopment ? TEST_AD_UNITS : PRODUCTION_AD_UNITS;
export const isTestMode = isDevelopment;

Usage in Game Over Screen

import { AD_UNITS, ADS_ENABLED } from '$lib/config/admob';
import * as admob from 'tauri-plugin-admob-api';

let adLoading = false;
let adError = '';

async function handleWatchAd() {
  if (!ADS_ENABLED) {
    // Continue directly if ads disabled
    game.startContinue();
    return;
  }

  adLoading = true;
  adError = '';

  try {
    // Check if ad is ready
    const readyCheck = await admob.isRewardedAdReady();

    if (!readyCheck.ready) {
      // Load ad if not ready
      const loadResult = await admob.loadRewardedAd(AD_UNITS.rewardedAd);
      if (!loadResult.success) {
        adError = loadResult.error || 'Failed to load ad';
        adLoading = false;
        return;
      }
    }

    // Show ad
    const showResult = await admob.showRewardedAd();

    if (showResult.rewarded) {
      // Rewarded - continue game
      game.startContinue();
    } else if (showResult.error) {
      adError = showResult.error;
    }
  } catch (error) {
    adError = String(error);
  } finally {
    adLoading = false;
  }
}

Troubleshooting

Swift Type Error: “cannot find type ‘RewardedAd’ in scope”

Cause: GoogleMobileAds SDK is Objective-C based, requiring GAD prefix in Swift.

// ❌ Wrong
private var rewardedAd: RewardedAd?

// ✅ Correct
private var rewardedAd: GADRewardedAd?

Linker Error: “Undefined symbols for architecture arm64”

Cause: GoogleMobileAds framework not linked

Solution 1: Add SDK to main app Xcode project too

  1. Open src-tauri/gen/apple/AppName.xcodeproj
  2. File → Add Package Dependencies
  3. Add GoogleMobileAds SDK

Solution 2: Link framework in build.rs

let target = std::env::var("TARGET").unwrap_or_default();
if target.contains("ios") {
    println!("cargo:rustc-link-lib=framework=GoogleMobileAds");
}

Tauri Permission Error

Error message:

admob.is_rewarded_ad_ready not allowed.
Permissions associated with this command: admob:allow-is-rewarded-ad-ready

Solution: Add permissions to permissions/default.toml and capabilities/default.json

Ads Not Showing

Symptom: Button click doesn’t show ad

Check:

  1. Verify you’re using test ad ID
  2. Confirm GADMobileAds.sharedInstance().start() is called
  3. Test on real device (simulator support is limited)

Monetization Strategy Considerations

Phased Implementation Plan

graph TD
    subgraph Phase1["Phase 1 (Current)"]
        A[Rewarded Continue Ads Only]
    end

    subgraph Phase2["Phase 2 (1-2 months later)"]
        B[Add Interstitial Ads]
        C[Double Coins Ad]
    end

    subgraph Phase3["Phase 3 (3 months later)"]
        D[IAP: Remove Ads]
        E[IAP: Coin Packs]
    end

    Phase1 --> Phase2
    Phase2 --> Phase3

Three Monetization Principles

  1. Protect Game Balance: Cosmetics only, no pay-to-win
  2. Respect Free Players: All content achievable without paying
  3. Value First: Products users feel are “worth buying”

Expected Revenue (10,000 DAU basis)

PhaseAd TypesDaily RevenueMonthly Revenue
1Rewarded Only~$130~$3,900
2Rewarded + Interstitial~$425~$12,750
3Ads + IAP~$600~$18,000

What Building It Myself Taught Me

Key Lessons

  1. Keep Tauri CLI Updated: Important features like XCFramework support are continuously added
  2. Understand Tauri Plugin Structure: Essential Rust ↔ Swift bridge knowledge
  3. Cross-Compilation: HOST vs TARGET distinction in build.rs is crucial
  4. Tauri v2 Permission System: capabilities and permissions are required

Checklist

  • Upgrade to latest Tauri CLI (cargo install tauri-cli --force)
  • Create plugin with --ios-framework xcode option
  • Add GoogleMobileAds SDK to both main app and plugin
  • Detect iOS in build.rs using std::env::var("TARGET")
  • Define all command permissions in permissions/default.toml
  • Add admob:default to capabilities/default.json
  • Add GADApplicationIdentifier to Info.plist
  • Separate test/production ad ID management

Next Steps

  • Add Android support
  • Implement interstitial ads
  • Implement banner ads
  • StoreKit 2 integration for IAP

Download Shadow Dash

Try Shadow Dash, developed with the technologies covered in this article!

Download on the App Store

Feedback welcome! If you find any improvements or bugs while playing, please let us know via App Store review or email.

📱 Shadow Dash Details: Check out the portfolio page for core game mechanics and more screenshots.

References

Frequently Asked Questions

Do I really have to build my own AdMob plugin for Tauri?
For now, yes. There is no official plugin, so you need a custom Swift plugin that wraps the GoogleMobileAds SDK. You can use this article's AdmobPlugin.swift and Rust bridge code as a working starting point.
Why use the xcode option instead of xcframework when creating the plugin?
When you have an external SDK dependency like GoogleMobileAds, the xcode option makes it far easier to add that dependency through Swift Package Manager. The xcframework option is simpler to distribute but more awkward for wiring up external dependencies.
Why does #[cfg(target_os = ios)] not work in build.rs?
Because build.rs runs on the host machine (macOS), not the build target. To detect an iOS build you have to check whether std::env::var(TARGET) contains ios, otherwise the framework linking will not fire.
What should I check first when a rewarded ad fails to appear?
Confirm in order that you are using the test ad ID, that GADMobileAds.sharedInstance().start() was called, and that you are testing on a real device rather than the simulator. Simulator support for ads is limited.

Read in Other Languages

Was this helpful?

Your support helps me create better content. Buy me a coffee.

About the Author

jw

Kim Jangwook

Full-Stack Developer specializing in AI/LLM

Building AI agent systems, LLM applications, and automation solutions with 10+ years of web development experience. Sharing practical insights on Claude Code, MCP, and RAG systems.

Back to Blog