Skip to content

iOS SDK

The FleetML iOS SDK (a Swift package for iOS 15 and later, with its manifest at the repository root and its code in ios/FleetMLSDK) puts an iPhone or iPad app under FleetML's control: it registers with an enrollment key, receives model assignments over gRPC, downloads and verifies them, validates each model with Core ML before using it, keeps the last-known-good version, works offline, and reports telemetry. It does not run your models: your app does, with Core ML or anything else. It mirrors the Android SDK feature for feature.

This guide starts with the sample app on the simulator and then on your iPhone (Get started), then follows the path to your own app and to production: add the package, configure the app from build settings, run it on your iPhone with a model published and deployed to it, then the same on the simulator, and what production needs. The sample app and the tests come last.

Get started

The sample app on the simulator and on your iPhone (either first, or only one of them), against a server on your Mac: copy and paste, nothing to choose. Each step says what you should see; if you see something else, stop there and look it up in Troubleshooting. Use one terminal and keep it open until the end.

Before you start, once: Xcode with an iOS simulator (Xcode, Settings, Components), Homebrew and Docker Desktop.

  1. Check the tools. This installs XcodeGen, which generates the sample app's Xcode project:

    brew install -q xcodegen
    docker compose version && jq --version && xcodebuild -version && xcodegen --version
    

    You should see Xcode and Version:.

  2. Start FleetML. The first start takes a few minutes, to download and build the images:

    [ -f deploy/docker-compose.prod.yml ] || { [ -d fleetml ] || git clone https://github.com/fleetml/fleetml.git; cd fleetml; }
    [ -f ~/.fleetml.env ] || (umask 077; printf "export JWT_SECRET='%s' SIGNING_MASTER_KEY='%s' FLEETML_ADMIN_KEY='%s' FLEETML_REST_PORT=%s FLEETML_GRPC_PORT=%s FLEETML_DASHBOARD_PORT=%s FLEETML_S3_PUBLIC_PORT=%s S3_PUBLIC_ENDPOINT=localhost:%s\n" "$(openssl rand -base64 32)" "$(openssl rand -base64 32)" "$(openssl rand -hex 32)" "${FLEETML_REST_PORT:-18080}" "${FLEETML_GRPC_PORT:-19090}" "${FLEETML_DASHBOARD_PORT:-80}" "${FLEETML_S3_PUBLIC_PORT:-19100}" "${FLEETML_S3_PUBLIC_PORT:-19100}" > ~/.fleetml.env)
    source ~/.fleetml.env
    docker compose -f deploy/docker-compose.prod.yml up -d --build && for i in $(seq 90); do curl -sf localhost:${FLEETML_REST_PORT:-18080}/readyz && break; [ $i = 90 ] && echo "Not ready after 3 minutes: see Troubleshooting in docs/ADVANCED.md"; sleep 2; done || echo "Start failed: see Troubleshooting in docs/ADVANCED.md"; echo
    curl -sf -H "Authorization: Bearer $FLEETML_ADMIN_KEY" localhost:${FLEETML_REST_PORT:-18080}/api/v1/projects && echo && echo "Dashboard: http://localhost:${FLEETML_DASHBOARD_PORT:-80}  (log in with FLEETML_ADMIN_KEY from ~/.fleetml.env)"
    

    You should see {"db":"connected","nats":"connected","s3":"connected","status":"ok","version":"<version>"}. The block saved the server's secrets in ~/.fleetml.env: keep that file.

  3. Get the CLI, checked against the release's checksums:

    OS=$(uname -s | tr '[:upper:]' '[:lower:]'); ARCH=$(uname -m | sed -e 's/x86_64/amd64/' -e 's/aarch64/arm64/')
    curl -fsSLO "https://github.com/fleetml/fleetml/releases/latest/download/fleetml-$OS-$ARCH.tar.gz"
    curl -fsSLO https://github.com/fleetml/fleetml/releases/latest/download/SHA256SUMS
    grep " fleetml-$OS-$ARCH.tar.gz\$" SHA256SUMS | shasum -a 256 -c
    mkdir -p ~/.local/bin && tar -xzf "fleetml-$OS-$ARCH.tar.gz" -C ~/.local/bin fleetml
    rm "fleetml-$OS-$ARCH.tar.gz" SHA256SUMS
    export PATH="$HOME/.local/bin:$PATH"
    fleetml version
    

    You should see fleetml-<os>-<arch>.tar.gz: OK and ✓ fleetml v<version>.

  4. Publish the sample model and deploy it, the Core ML build of it. The project gets the time in its name, so running the block again makes a new one:

    export FLEETML_SERVER=http://localhost:${FLEETML_REST_PORT:-18080} FLEETML_API_KEY=$FLEETML_ADMIN_KEY
    P=$(fleetml project create --name "first-ios-$(date +%H%M%S)" -o json | jq -r .id)
    fleetml push models/iris/iris_v1.mlmodel --project $P --model iris --version 1.0.0 --framework COREML --quantization FP32 --metadata '{"fleetml_requirements":{"min_ram_mb":64,"min_score":0}}'
    V=$(fleetml ver list --model "$(fleetml model list --project $P -o json | jq -r '.[0].id')" -o json | jq -r '.[0].id')
    fleetml deploy start "$(fleetml deploy create --project $P --version $V -o json | jq -r .id)"
    export FLEETML_ENROLLMENT_KEY=$(fleetml enrollment-key create --project $P --name first-device -o json | jq -r .key)
    

    You should see ✓ Pushed models/iris/iris_v1.mlmodel → iris v1.0.0 (FP32) and ✓ Deployment start → COMPLETE.

  5. Put the simulator on it. This builds the sample app, starts an iPhone simulator, installs the app and launches it with the enrollment key in its environment, then waits until the SDK logs the model active (up to 15 minutes the first time, while Xcode builds without printing anything):

    (cd ios/SampleApp && xcodegen generate --quiet)
    SIM=$(xcrun simctl list devices available -j | jq -r '[.devices[][] | select(.name | startswith("iPhone"))][0].udid')
    xcodebuild -quiet -project ios/SampleApp/FleetMLSample.xcodeproj -scheme FleetMLSample -destination "platform=iOS Simulator,id=$SIM" -derivedDataPath ios/SampleApp/build build < /dev/null
    xcrun simctl boot $SIM 2>/dev/null; open -a Simulator
    xcrun simctl install $SIM ios/SampleApp/build/Build/Products/Debug-iphonesimulator/FleetMLSample.app
    SIMCTL_CHILD_FLEETML_PORT=${FLEETML_GRPC_PORT:-19090} SIMCTL_CHILD_FLEETML_ENROLLMENT_KEY=$FLEETML_ENROLLMENT_KEY xcrun simctl launch --terminate-running-process $SIM com.fleetml.sample
    for i in $(seq 60); do xcrun simctl spawn $SIM log show --last 2m --info --style compact --predicate 'subsystem == "com.fleetml.sdk"' 2>/dev/null | grep 'is active' && break; [ $i = 60 ] && echo "Not active after 2 minutes: see Troubleshooting"; sleep 2; done
    

    You should see [com.fleetml.sdk:FleetML] Model iris 1.0.0 is active, and the app's screen Active model: iris 1.0.0. Tap Classify sample flower to run it.

  6. Put your iPhone on it, in three blocks: pick the phone, build and install the app, then start it. This step needs steps 2 to 4 in this terminal; step 5 (the simulator) is not needed, so you can do this one first.

    Once per phone, before the blocks: - on the phone, Developer Mode on: Settings, Privacy & Security, Developer Mode, then restart it and tap Turn On; - connect it to the Mac with a cable, unlock it and tap Trust on "Trust This Computer?"; - while you test, keep it unlocked and on the charger: Settings, Display & Brightness, Auto-Lock, Never (a locked phone refuses the start with Locked); - in Xcode, your Apple ID (Xcode, Settings, Accounts, +) and an Apple Development certificate (Manage Certificates, +).

    The phone and the Mac must be on the same Wi-Fi (not a guest network that keeps devices apart). The cable can stay or go.

    6a. Pick the phone. This lists the iPhones paired with this Mac and takes the one, if there is only one:

    T=$(mktemp); xcrun devicectl list devices --quiet --json-output "$T" > /dev/null 2>&1
    jq -r '.result.devices[] | select(.hardwareProperties.platform == "iOS" and .connectionProperties.pairingState == "paired") | "\(.identifier)  \(.hardwareProperties.marketingName // .hardwareProperties.productType)"' "$T"
    PHONE=${PHONE:-$(jq -r '[.result.devices[] | select(.hardwareProperties.platform == "iOS" and .connectionProperties.pairingState == "paired")] | if length == 1 then .[0].identifier else empty end' "$T")}; rm -f "$T"
    echo "PHONE=${PHONE:-<more than one iPhone: copy yours from the list above and run export PHONE=<its identifier>>}"
    

    You should see one line per iPhone, its identifier and its model (such as iPhone 11 Pro Max, or its model code such as iPhone12,5 before the Mac has connected to the phone in this session), then PHONE= and that identifier. With more than one iPhone, run export PHONE= with yours, then go on.

    6b. Build and install the app on it. This points the server's downloads at the Mac's network address, which the phone reaches, and builds the sample app for that phone (Xcode adds the phone to your signing profile the first time):

    . ~/.fleetml.env && sed -i.bak "s/S3_PUBLIC_ENDPOINT=[^ ]*/S3_PUBLIC_ENDPOINT=$(ipconfig getifaddr en0):${FLEETML_S3_PUBLIC_PORT:-19100}/" ~/.fleetml.env && rm ~/.fleetml.env.bak
    source ~/.fleetml.env && docker compose -f deploy/docker-compose.prod.yml up -d
    TEAM=$(security find-certificate -c "Apple Development" -p | openssl x509 -noout -subject | grep -o 'OU=[A-Z0-9]*' | cut -d= -f2)
    (cd ios/SampleApp && xcodegen generate --quiet)
    xcodebuild -quiet -project ios/SampleApp/FleetMLSample.xcodeproj -scheme FleetMLSample -destination "id=$PHONE" -derivedDataPath ios/SampleApp/build -allowProvisioningUpdates DEVELOPMENT_TEAM=$TEAM build < /dev/null &&
      xcrun devicectl device install app --device $PHONE ios/SampleApp/build/Build/Products/Debug-iphoneos/FleetMLSample.app
    

    You should see the stack's containers (Container <stack>-server-1 Started the first time, Running after), then (after a few minutes the first time) App installed: and bundleID: com.fleetml.sample. A line IDERunDestination: Supported platforms for the buildables in the current scheme is empty is harmless.

    Trust the app's developer, once per phone: if no other app from this Mac is on the phone, on the phone open Settings, General, VPN & Device Management, the Apple Development entry with your Apple ID, Trust, and Trust again (the phone needs internet for it). iOS keeps that trust as long as any app from this Mac stays installed, so later installs and rebuilds need no tap; if you are not sure, go on: 6c says so if it is needed.

    6c. Start it and wait for the phone to check in:

    T=$(mktemp); xcrun devicectl device info details --device $PHONE --quiet --json-output "$T" > /dev/null 2>&1; MODEL=$(jq -r .result.hardwareProperties.productType "$T"); rm -f "$T"
    SINCE=$(date -u +%Y-%m-%dT%H:%M:%S)
    if OUT=$(DEVICECTL_CHILD_FLEETML_HOST=$(ipconfig getifaddr en0) DEVICECTL_CHILD_FLEETML_PORT=${FLEETML_GRPC_PORT:-19090} DEVICECTL_CHILD_FLEETML_ENROLLMENT_KEY=$FLEETML_ENROLLMENT_KEY xcrun devicectl device process launch --device $PHONE --terminate-existing com.fleetml.sample 2>&1); then
      echo "$OUT" | grep Launched
      for i in $(seq 60); do fleetml device list --project $P -o json | jq -e --arg m "$MODEL" --arg t "$SINCE" 'any(.[]; .chipset == $m and .last_seen_at >= $t)' > /dev/null && break; [ $i = 60 ] && echo "The iPhone has not checked in after 3 minutes: see the list below this step"; sleep 3; done
    elif echo "$OUT" | grep -q -i -E 'explicitly trusted|invalid code signature'; then
      echo "Trust the developer on the phone (once): Settings, General, VPN & Device Management, Apple Development, Trust; then run this block again"
    elif echo "$OUT" | grep -q -i -E 'expired|no longer valid'; then
      echo "The app's 7-day signing profile has expired: run 6b again, then this block"
    else
      echo "$OUT" | grep -E 'ERROR|Locked|reason' | head -3
    fi
    fleetml device list --project $P
    

    You should see Launched application with com.fleetml.sample bundle identifier and in the list a row whose Chipset is your phone's model identifier (such as iPhone12,5; the simulator's row, if you did step 5, has the simulator's). Running the block again later gives the same: it waits for the phone to check in, not for a new device. The first start asks on the phone to find devices on your local network: tap Allow (once for this app). The phone's screen then shows Active model: iris 1.0.0; tap Classify sample flower to run it. The check-in above passes for any project the key belongs to, but the screen shows iris only when a deployment of that project reaches the sample: one with no target, like step 4's, or one targeting {"app":"sample"}, the label the sample registers with; otherwise it shows Active model: bundled iris (no assignment yet): the sample runs the model built into the app, and FleetML has assigned it none.

    If it did not work, by what the block printed: - Trust the developer on the phone (once): no other app from this Mac is on the phone, so iOS asks for the trust; tap it as the line says, then run this block again. - The app's 7-day signing profile has expired: run 6b, then this block. - Locked: unlock the phone, then run this block again. - The specified device was not found: PHONE is not this phone's identifier (or it is not connected); run 6a again. - This provisioning profile cannot be installed on this device: 6b built for another phone; check PHONE, then run 6b again. - The iPhone has not checked in after 3 minutes: tap Allow if the phone shows the local network question, check that the phone is on the Mac's Wi-Fi, then run this block again. If the app shows Server unreachable, open http://<Mac LAN IP>:${FLEETML_REST_PORT:-18080}/readyz in Safari on the phone (ipconfig getifaddr en0 on the Mac prints the address): no answer means the network or the Mac's firewall keeps the phone out.

  7. See it in the dashboard. This puts the admin key on the clipboard (on a Mac, in WSL, or on Linux with xclip; elsewhere it prints it) and prints the dashboard's address: open it and paste the key into the login box:

    printf %s "$FLEETML_ADMIN_KEY" | { pbcopy || clip.exe || xclip -selection clipboard || cat; } 2> /dev/null; echo
    echo "Dashboard: http://localhost:${FLEETML_DASHBOARD_PORT:-80}"
    

    You should see Dashboard: http://localhost:<port>, and in the browser the Overview page (Dashboard in the menu); Devices lists the iPhone (and the simulator, if you did step 5: the Chipset column tells them apart), and Deployments one COMPLETE deployment of iris 1.0.0.

  8. Clean up, from the clone (cd back into it if you went to your own app's folder): the app on the simulator, the build, the server and all its data, and the saved secrets. The sample stays on the phone: iOS forgets the developer trust when the last app from your Mac is removed; leave the sample installed and the next install from this Mac needs no Trust tap:

    [ -z "${SIM:-}" ] || { xcrun simctl uninstall $SIM com.fleetml.sample; xcrun simctl shutdown $SIM; }
    rm -rf ios/SampleApp/build ios/SampleApp/FleetMLSample.xcodeproj
    source ~/.fleetml.env && docker compose -f deploy/docker-compose.prod.yml down -v && rm -f ~/.fleetml.env
    unset FLEETML_API_KEY FLEETML_ENROLLMENT_KEY
    

    You should see Network <stack>_default Removed.

    When you are done with FleetML for good, remove the sample from the phone with xcrun devicectl device uninstall app --device $PHONE com.fleetml.sample; the next install from this Mac then asks for the Trust again.

Then your own app: Install and Configure and start, and Your own app on an iPhone for its own project, signing and Info.plist keys.

Install

It needs Xcode 15 or later (16 recommended; the package manifest is swift-tools-version 5.9). The package comes from GitHub or from a clone of this repository, the latter to work against changes that are not released yet:

From GitHub From a clone
Xcode File → Add Package Dependencies, paste https://github.com/fleetml/fleetml, Dependency Rule Up to Next Major Version from 0.10.7, add the FleetMLSDK library to the app target File → Add Package Dependencies → Add Local…, pick the clone's root folder, add FleetMLSDK to the app target
Package.swift .package(url: "https://github.com/fleetml/fleetml", from: "0.10.7") .package(path: "path/to/fleetml")
XcodeGen project.yml url: https://github.com/fleetml/fleetml with from: 0.10.7 path: ../fleetml, relative to project.yml

In a Package.swift, the target depends on the FleetMLSDK product, and package: names the dependency by its identity, which Swift Package Manager takes from the URL or the folder, not from the manifest's own name: fleetml for the GitHub URL, and the clone folder's name, lowercased, for a path (fleetml for a plain git clone):

.product(name: "FleetMLSDK", package: "fleetml"),

With package: "FleetMLSDK" instead, the build stops with unknown package 'FleetMLSDK' in dependencies of target '<your target>'; valid packages are: 'fleetml'. With XcodeGen, the package's key in project.yml is a name of your choice, and the target depends on it (product: FleetMLSDK can be left out when the key is FleetMLSDK too); the own app runbook has a whole file:

packages:
  FleetMLSDK:
    url: https://github.com/fleetml/fleetml
    from: 0.10.7        # or path: ../fleetml for a clone
targets:
  MyApp:
    dependencies:
      - package: FleetMLSDK

The v0.3.0 and earlier tags have no manifest at the repository root, so 0.4.0 is the first version Swift Package Manager can install by URL. The package depends on grpc-swift, pinned to exactly 1.27.6 (the 1.x line, which supports iOS 15; 2.x needs iOS 18), and on swift-protobuf from 1.28. An app that already depends on grpc-swift must use that same version, or Swift Package Manager cannot resolve the graph. macOS 12 is supported too, for tests and tools.

Configure and start

Initialize once, for example in your App or app delegate. The server's address, the port, TLS and the enrollment key come from the app's Info.plist, where the build puts them from an .xcconfig file you do not commit, so moving between a phone, the simulator, a closed port for an offline test and production is an edit of that file, not of the code. This object starts the SDK, prints what it hears, and loads each model it delivers with Core ML:

import CoreML
import FleetMLSDK

final class AppModel: FleetMLDelegate {
    private(set) var mlModel: MLModel?

    private static func setting(_ key: String) -> String {
        Bundle.main.object(forInfoDictionaryKey: key) as? String ?? ""
    }

    init() {
        do {
            try FleetML.initialize(
                config: FleetMLConfig(serverHost: Self.setting("FleetMLServerHost"),
                                      serverPort: Int(Self.setting("FleetMLServerPort")) ?? 443,
                                      enrollmentKey: Self.setting("FleetMLEnrollmentKey"),
                                      useTLS: Self.setting("FleetMLUseTLS") == "YES",
                                      labels: ["app": "myapp", "tier": "beta"]),
                delegate: self)
        } catch {
            print("FleetML did not start: \(error)")
        }
    }

    func fleetML(registered deviceID: String) {
        print("FleetML registered as \(deviceID)")
    }

    func fleetML(connectionChanged connected: Bool) {
        print("FleetML connected: \(connected)")
    }

    func fleetML(connectionFailed error: Error) {
        print("FleetML cannot register: \(error)")
    }

    func fleetML(modelReady model: ActiveModel) {
        print("FleetML model ready: \(model.modelName) \(model.version)")
        let source = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".mlmodel")
        do {
            try FileManager.default.copyItem(at: model.fileURL, to: source)
            defer { try? FileManager.default.removeItem(at: source) }
            mlModel = try MLModel(contentsOf: try MLModel.compileModel(at: source))
        } catch {
            print("Core ML could not load \(model.modelName) \(model.version): \(error)")
        }
    }
}

The four values, in an .xcconfig file set as the target's configuration file and kept out of git (Secrets.xcconfig below), here for an iPhone and a server on the Mac:

FLEETML_SERVER_HOST = <Mac LAN IP>
FLEETML_SERVER_PORT = 19090
FLEETML_USE_TLS = NO
FLEETML_ENROLLMENT_KEY = <enrollment key>

and four entries in the target's Info.plist that take them over: FleetMLServerHost = $(FLEETML_SERVER_HOST), FleetMLServerPort = $(FLEETML_SERVER_PORT), FleetMLUseTLS = $(FLEETML_USE_TLS) and FleetMLEnrollmentKey = $(FLEETML_ENROLLMENT_KEY) (on the target's Info tab as Custom iOS Target Properties, or under info: properties: with XcodeGen; the INFOPLIST_KEY_ build settings of a generated Info.plist take only Apple's keys). The simulator uses localhost as the host, and production your server's name, port 443 and YES (Production). For a quick test only, the values can be literals in the code, but the key then sits in a file you commit.

print goes to Xcode's console, or to the terminal with simctl launch --console-pty on the simulator and devicectl device process launch --console on a phone. The SDK holds the delegate weakly: keep the object alive for as long as the app runs, for example as a property of your App or app delegate, or its callbacks stop. Delivered files keep the server's name ({variant}.bin), and Core ML compiles only from a path ending in .mlmodel, which is why fleetML(modelReady:) copies the file to one first (the sample app's IrisClassifier does the same).

Without the background task set up (optional, see Battery and data), the SDK logs one info line each time the app goes to the background, and at a launch that iOS starts in the background state (as the first launch after open -a Simulator can be): Background refresh not scheduled (BGTaskSchedulerErrorDomain error 3): the app does not permit com.fleetml.sdk.refresh; add BGTaskSchedulerPermittedIdentifiers and call registerBackgroundTask(), see docs/IOS_SDK.md "Battery and data". It is harmless: the SDK works in the foreground as described here (SDK 0.5.0 logged the same thing as an error).

The delegate's methods all have empty default implementations, so implement only what the app needs, with exactly these signatures: a method whose parameter types differ still compiles, as a method of your own, and the SDK never calls it. They are called on a background queue: switch to the main queue before touching the UI.

Method When
func fleetML(modelReady model: ActiveModel) A model was downloaded, verified and validated and is now active; also at start for models cached from an earlier run
func fleetML(modelUnloaded modelID: String, modelName: String, variantID: String) The server no longer assigns the model
func fleetML(modelLoadFailed modelName: String, version: String, reason: String) A new version could not be downloaded or failed validation; the last-known-good one stays
func fleetML(registered deviceID: String) The device registered (or re-registered)
func fleetML(connectionChanged connected: Bool) The server became reachable or unreachable
func fleetML(connectionFailed error: Error) Registration failed, with the error (When it does not connect)

ActiveModel has modelName and version (for example iris and 1.0.0), fileURL (the verified and validated file), modelID, versionID (telemetry is attributed to it), variantID and sha256.

The intervals and the batch size below are what the SDK uses until it first hears from the server; from the first registration on, the server's values apply (their defaults are in Battery and data).

FleetMLConfig field Default Purpose
serverHost, serverPort –, 19090 The FleetML gRPC endpoint
enrollmentKey "" Per-project key for the first registration (fleetml enrollment-key create)
useTLS false TLS for gRPC (true with the server behind a TLS proxy, as in production)
labels [:] Labels deployments and experiments target (target_selector)
heartbeatIntervalSeconds 30 Foreground heartbeat interval, until the server sends its own (server default 60)
backgroundHeartbeatIntervalSeconds 3600 Earliest interval of the background refresh, until the server sends its own (at least 15 minutes)
telemetryBatchSize, telemetryFlushIntervalSeconds 50, 30 Until the server's DeviceConfig overrides them (server defaults 100 and 30)
maxCacheSizeMB 500 Disk budget for models; the active and last-known-good models are never evicted
downloadPolicy .unmeteredOrCharging Which networks non-urgent downloads may use, until the server sends its own (Battery and data)
autoStart true Call FleetML.start() yourself if false

The parameters after serverHost all have defaults and are labelled, in the order of the table. FleetML.stop() stops background work so initialize can be called again (for example with a new server address). FleetML.reset() also deletes cached models and the registration.

When it does not connect

If the SDK cannot register, fleetML(connectionChanged:) reports false and fleetML(connectionFailed:) receives the error, on the first failure after start and again whenever the error changes (registration is retried every 10 seconds). connectionFailed is for registration only: a device that has registered before and cannot reach the server reports it with fleetML(connectionChanged: false) and the log line Heartbeat failed: ... (below). A new host, port or enrollment key in the app's configuration counts as a new server: the SDK registers again (with the same device ID), so an unreachable new address reports through connectionFailed like a first registration, and the cached model stays active meanwhile.

A wrong host or port, or a server that is not running, reads like deadlineExceeded (Connection errors: ... Connection refused); a wrong enrollment key is refused by the server as unauthenticated, on a device that has not registered with that server before. A device that has keeps its project whatever the key, by design (a rotated or revoked key must not lock devices out): its registration, in the Keychain, survives an uninstall, and the server accepts its saved token. A valid key of another project moves it there; to start it from nothing, remove the device (curl -fsS -X DELETE -H "Authorization: Bearer $FLEETML_ADMIN_KEY" "localhost:${FLEETML_REST_PORT:-18080}/api/v1/devices/<device ID>") or call FleetML.reset() in the app. The SDK also logs to the unified log with subsystem com.fleetml.sdk and category FleetML: at info level the registration, each download (model, version, bytes), each validation result and each activation, at warning level every failure, and at debug level every heartbeat. Xcode's console shows it while the app runs from Xcode; without a debugger, open Console.app on the Mac, select the phone (or the simulator) in the sidebar, press Start, and search for subsystem:com.fleetml.sdk (Action, Include Info Messages shows the info lines). The runbooks below show how to read it from a terminal.

A first launch that registers and receives iris 1.0.0 logs these lines (time stamps and the process column left out):

[com.fleetml.sdk:FleetML] Registered as device <device ID>
[com.fleetml.sdk:FleetML] Downloading iris 1.0.0 (555 bytes)
[com.fleetml.sdk:FleetML] Downloaded iris 1.0.0 (555 bytes, sha256 verified)
[com.fleetml.sdk:FleetML] Model iris 1.0.0 passed validation
[com.fleetml.sdk:FleetML] Model iris 1.0.0 is active

A registered app that cannot reach the server logs, at error level, about ten seconds after launch (here the server's port was closed):

[com.fleetml.sdk:FleetML] Heartbeat failed: The operation couldn’t be completed. (GRPC.GRPCConnectionPoolError error 1.)

At launch the delegate hears fleetML(connectionChanged: true) once the first heartbeat is answered. SDK 0.5.0 also reported a false right before it and logged Heartbeat failed ... GRPCStatus error 1 at every launch; that meant nothing (a heartbeat cancelled by the SDK itself) and is gone from 0.6.0.

Your own app on an iPhone

This runs an own app with the AppModel above on an iPhone, against a FleetML server on your Mac (the quickstart's, or one a colleague runs that the phone can reach), with XcodeGen (brew install xcodegen), jq, and the fleetml CLI on the PATH (Get the CLI). It is not verified on a phone by CI; the maintainers run it by hand. The same on the simulator follows as a shorter variant.

The app's folder holds project.yml, Secrets.xcconfig and a Sources folder with AppModel.swift (above, labels ["app": "myapp", ...]) and an App that keeps it alive:

import FleetMLSDK
import SwiftUI

@main
struct MyApp: App {
    private let model: AppModel

    init() {
        FleetML.registerBackgroundTask() // before launch finishes; needs step 4's background keys
        model = AppModel()
    }

    var body: some Scene { WindowGroup { Text("FleetML") } }
}
  1. The phone. Turn on Developer Mode (Settings, Privacy & Security, Developer Mode) and restart it when asked. In Xcode, add your Apple ID (Settings, Accounts); a free one gives a personal team. Your team's ID is on developer.apple.com under Membership, in Xcode's Signing & Capabilities tab once a project uses the team, or, from a terminal, in your development certificate (once Xcode has made one):

    security find-certificate -c "Apple Development" -p | openssl x509 -noout -subject | grep -o 'OU=[A-Z0-9]*'
    

    The team ID is the 10 characters after OU=. The ID in parentheses that security find-identity -v -p codesigning prints is another one, and with it xcodebuild stops with No Account for Team "<ID>". Put the phone and the Mac on the same Wi-Fi network.

    Everything from step 5 on needs the phone awake, unlocked and reachable, and some of it needs a hand on the phone once. Do these before you start, and the terminal steps can then run with the phone on the desk:

    • Developer Mode on, and the Mac trusted (connect the cable once and tap Trust).
    • Connect via network in Xcode's Devices and Simulators window (Shift-Command-2), if you want to work without the cable (step 5, Wirelessly).
    • Settings, Display & Brightness, Auto-Lock, Never, and the phone on its charger.
    • The developer trust: once per Mac per phone, and kept while any app from that Mac is installed (keep Get started's sample on the phone and step 5 needs none). Local Network access: once for each app, at its first launch. After that, launches need no hand at the phone.
  2. The server, with its object store on the Mac's LAN address. The phone reaches the server at the Mac's LAN address (ipconfig getifaddr en0, en1 on some Macs), and every model download is a presigned URL for the server's S3_PUBLIC_ENDPOINT, so that must be the same address. Save it in the quickstart's ~/.fleetml.env (so every later compose command keeps it) and restart the server, in the quickstart's clone:

    . ~/.fleetml.env && sed -i.bak "s/S3_PUBLIC_ENDPOINT=[^ ]*/S3_PUBLIC_ENDPOINT=$(ipconfig getifaddr en0):${FLEETML_S3_PUBLIC_PORT:-19100}/" ~/.fleetml.env && rm ~/.fleetml.env.bak
    source ~/.fleetml.env
    docker compose -f deploy/docker-compose.prod.yml up -d && for i in $(seq 90); do curl -sf localhost:${FLEETML_REST_PORT:-18080}/readyz > /dev/null && break; [ $i = 90 ] && echo "Not ready after 3 minutes: see Troubleshooting in docs/ADVANCED.md"; sleep 2; done; curl -sf localhost:${FLEETML_REST_PORT:-18080}/readyz > /dev/null && echo ready
    

    You should see Container <stack>-server-1 Started and ready. If someone else runs the server, ask them to start it this way. A phone that registers but never downloads: it is almost always the endpoint.

    Check the network path from the phone before the app: in Safari on the phone, http://<Mac LAN IP>:<REST port>/readyz must show "status":"ok", and http://<Mac LAN IP>:<model files port> must answer at all (an XML error page from the object store is fine). The stack's ports are in ~/.fleetml.env, which (. ~/.fleetml.env; env | grep -E '^(FLEETML_[A-Z0-9_]*PORT|S3_PUBLIC_ENDPOINT)=' | sort) prints (the REST port is FLEETML_REST_PORT, the model files' FLEETML_S3_PUBLIC_PORT). If Safari cannot load them on those ports, it is the Wi-Fi (a guest network that isolates devices) or the Mac's firewall, not the app or the SDK.

  3. The models, the enrollment key and the deployment, from the root of a clone (for the Core ML sample models; push your own .mlmodel instead), with the key your team gave you or the quickstart's $FLEETML_ADMIN_KEY (which key does what: Android's list applies here too). The first two lines make the block work in any terminal: they read the server's settings from ~/.fleetml.env (with no server started, the first line says so), find the CLI, and take the admin key (with your team's key, put it in place of $FLEETML_ADMIN_KEY). Project names are unique on a server: project already exists: slug "myapp-ios" means the project is there from an earlier run, so pick another name.

    source ~/.fleetml.env || echo "No server settings: start the server first (Get started step 2)"; export PATH="$HOME/.local/bin:$PATH"
    export FLEETML_SERVER=http://localhost:${FLEETML_REST_PORT:-18080} FLEETML_API_KEY=$FLEETML_ADMIN_KEY   # or your team's key
    REQ='{"fleetml_requirements":{"min_ram_mb":64,"min_score":0}}'
    P=$(fleetml project create --name myapp-ios -o json | jq -r .id)   # or P=<the project ID your team gave you>
    fleetml push models/iris/iris_v1.mlmodel --project $P --model iris --version 1.0.0 --framework COREML --quantization FP32 --metadata "$REQ"
    fleetml push models/iris/iris_v2.mlmodel --project $P --model iris --version 2.0.0 --framework COREML --quantization FP32 --metadata "$REQ"
    M=$(fleetml model list --project $P -o json | jq -r '.[] | select(.name == "iris") | .id')
    vid() { fleetml ver list --model $M -o json | jq -r --arg v "$1" '.[] | select(.version == $v) | .id'; }
    export FLEETML_ENROLLMENT_KEY=$(fleetml enrollment-key create --project $P --name myapp -o json | jq -r .key)
    D1=$(fleetml deploy create --project $P --version $(vid 1.0.0) --target '{"app":"myapp"}' -o json | jq -r .id)
    fleetml deploy start $D1
    

    Each push prints ✓ Pushed ... (FP32); the deployment starts as COMPLETE (✓ Deployment start → COMPLETE): it waits for the device.

  4. project.yml, in the app's folder: the package, the four settings from Secrets.xcconfig, what a phone needs in the Info.plist, and the signing team:

    name: MyApp
    options:
      deploymentTarget:
        iOS: "15.0"
    packages:
      FleetMLSDK:
        url: https://github.com/fleetml/fleetml
        from: 0.10.7        # or path: ../fleetml for a clone
    targets:
      MyApp:
        type: application
        platform: iOS
        sources: [Sources]
        dependencies:
          - package: FleetMLSDK
        configFiles:
          Debug: Secrets.xcconfig
          Release: Secrets.xcconfig
        info:
          path: Info.plist
          properties:
            FleetMLServerHost: $(FLEETML_SERVER_HOST)
            FleetMLServerPort: $(FLEETML_SERVER_PORT)
            FleetMLUseTLS: $(FLEETML_USE_TLS)
            FleetMLEnrollmentKey: $(FLEETML_ENROLLMENT_KEY)
            UILaunchScreen: {}
            UIRequiresFullScreen: true
            NSAppTransportSecurity:
              NSAllowsLocalNetworking: true
            NSLocalNetworkUsageDescription: Connects to the FleetML server on your network.
            BGTaskSchedulerPermittedIdentifiers: [com.fleetml.sdk.refresh]
            UIBackgroundModes: [fetch]
        settings:
          base:
            PRODUCT_BUNDLE_IDENTIFIER: com.example.myapp
            DEVELOPMENT_TEAM: <your team ID>
            CODE_SIGN_STYLE: Automatic
    
    • UIRequiresFullScreen keeps the build free of the warning All interface orientations must be supported unless the app requires full screen; list every orientation in UISupportedInterfaceOrientations instead if the app should run in iPad split view.
    • NSAllowsLocalNetworking lets the SDK download models over plain HTTP from the Mac, which App Transport Security blocks otherwise; it does not loosen ATS for any other host.
    • NSLocalNetworkUsageDescription is the reason iOS shows in the Local Network prompt on the first launch that connects to the Mac.
    • BGTaskSchedulerPermittedIdentifiers and UIBackgroundModes: fetch enable the background refresh, together with FleetML.registerBackgroundTask() before FleetML.initialize in the app's initializer, as in the App above (Battery and data). Leave out all three if you do not want it. With the keys but without the call, the SDK does not ask for the refresh and logs Background refresh not scheduled: the app permits com.fleetml.sdk.refresh but has not called FleetML.registerBackgroundTask(); call it before the app finishes launching, see docs/IOS_SDK.md "Battery and data" (SDK 0.6.0 and earlier submitted it, and iOS ended the app with No launch handler registered for task with identifier com.fleetml.sdk.refresh).

    XcodeGen writes Info.plist from info:; keep Secrets.xcconfig and the generated Info.plist and .xcodeproj out of git.

  5. Build and install over USB. Connect the phone with a cable and trust the Mac on it when asked. <device> is the phone's identifier from xcrun devicectl list devices (building for that phone, rather than for any iPhone, adds it to your signing profile the first time):

    (umask 077; printf 'FLEETML_SERVER_HOST = %s\nFLEETML_SERVER_PORT = %s\nFLEETML_USE_TLS = NO\nFLEETML_ENROLLMENT_KEY = %s\n' "$(ipconfig getifaddr en0)" "${FLEETML_GRPC_PORT:-19090}" "$FLEETML_ENROLLMENT_KEY" > Secrets.xcconfig)
    xcodegen generate
    xcodebuild -project MyApp.xcodeproj -scheme MyApp -destination 'id=<device>' -derivedDataPath build -allowProvisioningUpdates ENABLE_PREVIEWS=NO build
    xcrun devicectl device install app --device <device> build/Build/Products/Debug-iphoneos/MyApp.app
    xcrun devicectl device process launch --device <device> --console com.example.myapp
    

    Command-line builds for a device fail at codesign with errSecInternalComponent on __preview.dylib unless ENABLE_PREVIEWS=NO is passed. Secrets.xcconfig holds the enrollment key, so the subshell with umask 077 writes it readable only by you. The developer trust is asked once per Mac per phone and stays while any app from that Mac is installed: with the sample from Get started step 6 still on the phone, your app's first launch needs no Trust tap; without it, the first launch is refused with "has not been explicitly trusted" until you trust the developer in Settings, General, VPN & Device Management. iOS then asks, once for each app, whether it may find and connect to devices on the local network: allow it. A launch refused because the free team's 7-day profile expired needs a new build and install (the same three lines).

    Wirelessly: once the phone has been connected by cable, Xcode's Devices and Simulators window (Shift-Command-2) has Connect via network for it; after that xcrun devicectl list devices lists it as available without the cable, and the same install and launch lines work. A phone that is asleep or out of reach is listed as unavailable (or disconnected until the next command reaches it), and install fails with Connection interrupted, or with Xcode 26 CoreDeviceService was unable to locate a device matching the requested device identifier (com.apple.dt.CoreDeviceError error 1011): wake and unlock it, or use the cable. A locked phone that is reachable still takes the install; only the launch needs it unlocked.

    Keep the phone unlocked with the app in front from the launch to step 8 (step 1's list: Auto-Lock, Never). iOS refuses to launch an app on a locked phone (Unable to launch com.example.myapp because the device was not, or could not be, unlocked), and an app that nobody looks at is suspended within about a minute, which stops the SDK's timers until it is in front again (Battery and data): an update then waits for the next launch, which the launch line gives.

  6. What it prints. --console shows the app's print lines, for as long as the launch line runs: leave it running and type the CLI lines below in a second terminal, because Ctrl-C on it ends the app too (App terminated due to signal 2.). It prints FleetML registered as <device ID> (on the launch that registers; later launches start with the next two), FleetML connected: true and FleetML model ready: iris 1.0.0, which are enough to follow the steps below. The SDK's own lines (the five in When it does not connect) are in Console.app with the phone selected, without a password, or in a log archive from the phone, which log collect writes only as root (it asks for your administrator password; without sudo it stops with Must be root to collect logs from attached device). log is a built-in command in zsh, macOS's default shell, so call /usr/bin/log to read the archive:

    sudo log collect --device-name '<phone name>' --last 10m --output fleetml.logarchive
    /usr/bin/log show fleetml.logarchive --info --predicate 'subsystem == "com.fleetml.sdk"'
    

    --device-name takes the phone's name, as xcrun devicectl list devices lists it, not the identifier the step 5 lines accept (with one phone attached, --device alone picks it).

    When it does not get that far:

    • It does not register, FleetML cannot register: ... with a connection error, while the phone is online: the app has no Local Network permission (any request to the Mac fails with -1009, "The Internet connection appears to be offline"). Turn it on in Settings, Privacy & Security, Local Network; no relaunch is needed, the SDK registers on its next retry (within 10 seconds) and the model arrives with the next heartbeat.
    • It registers but never downloads, and the SDK logs Download of iris 1.0.0 failed: Could not connect to the server.; retried on the next heartbeat (-1004): nothing answers at the download address, so S3_PUBLIC_ENDPOINT is still localhost (on a phone, the phone itself) or the Mac's firewall blocks the model files' port. The line is the one the simulator logs with the endpoint on a closed port.

    fleetml deploy get $D1 | jq -c .devices prints {"loaded":1,"targeted":1} one heartbeat after the model is active, about a minute later: the SDK heartbeats right after start, which brings the assignment, and then at the interval the server sends in its first answer, 60 seconds by default (the SDK's own 30 seconds apply only until that answer arrives).

  7. An update. Roll out 2.0.0; the app gets it with its next heartbeat, 60 seconds by default (FleetML model ready: iris 2.0.0), and keeps 1.0.0 as last-known-good:

    fleetml deploy start $(fleetml deploy create --project $P --version $(vid 2.0.0) --target '{"app":"myapp"}' -o json | jq -r .id)
    

    On a phone nobody is looking at, iOS suspends the app within a minute or two and the heartbeat waits with it: run step 5's launch line again after the rollout, and the update comes within seconds.

  8. Offline. Turn on airplane mode (Wi-Fi off too, if it stays on), then swipe the app away and open it again from the home screen: the cached 2.0.0 is ready at once (fleetML(modelReady:)), and fleetML(connectionChanged: false) follows within about ten seconds. Turn airplane mode off: the next heartbeat reconnects. Without touching the phone, the closed port from the simulator's offline test works here too: FLEETML_SERVER_PORT = 19999 in Secrets.xcconfig, then the xcodebuild, install and launch lines of step 5; the cached 2.0.0 is ready at once (FleetML model ready: iris 2.0.0). Set the port back and rebuild to reconnect.

  9. Background refresh (with the three settings of step 4 and FleetML.registerBackgroundTask()). This is the one step that needs Xcode and someone at the phone; the steps before it run from a terminal. The simulator cannot run background tasks, so test it on the phone: run the app from Xcode, send it to the background, pause it in Xcode's debugger (the LLDB prompt needs the debugger attached, which a devicectl launch does not have), enter

    e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.fleetml.sdk.refresh"]
    

    and continue: the SDK runs one heartbeat and one telemetry flush, as the real refresh would.

The same on the simulator

The simulator shares the Mac's localhost, so the differences from the phone are few:

  • FLEETML_SERVER_HOST = localhost in Secrets.xcconfig, and the server needs no S3_PUBLIC_ENDPOINT change: its default, localhost on the model files' port, reaches the Mac, and so does the LAN address from step 2, so one server serves the phone and the simulator together. Downloads from localhost need no App Transport Security key and there is no Local Network prompt; the keys from step 4 are harmless on the simulator.
  • No team, trust prompt or Developer Mode: Xcode signs simulator builds without a team.

Steps 3 and 4 as above, then build, install and launch on the first available iPhone simulator (on a Mac with several, a simulator of your own, SIM=$(xcrun simctl create fleetml-test "iPhone 17 Pro"), keeps the test apart; xcrun simctl delete fleetml-test removes it):

(umask 077; printf 'FLEETML_SERVER_HOST = localhost\nFLEETML_SERVER_PORT = %s\nFLEETML_USE_TLS = NO\nFLEETML_ENROLLMENT_KEY = %s\n' "${FLEETML_GRPC_PORT:-19090}" "$FLEETML_ENROLLMENT_KEY" > Secrets.xcconfig)
xcodegen generate
SIM=$(xcrun simctl list devices available -j | jq -r '[.devices[][] | select(.name | startswith("iPhone"))][0].udid')
xcodebuild -project MyApp.xcodeproj -scheme MyApp -destination "platform=iOS Simulator,id=$SIM" -derivedDataPath build build
xcrun simctl boot $SIM; open -a Simulator
xcrun simctl install $SIM build/Build/Products/Debug-iphonesimulator/MyApp.app
xcrun simctl launch --console-pty $SIM com.example.myapp

simctl boot reports an error if that simulator is already booted, which does no harm. The console prints the app's three lines (FleetML registered as <device ID>, FleetML connected: true, FleetML model ready: iris 1.0.0). In a second terminal, this prints the SDK's own lines, the five above (--level debug adds the heartbeats; without --level only warnings appear):

xcrun simctl spawn booted log stream --level info --predicate 'subsystem == "com.fleetml.sdk"'

It first prints getpwuid_r did not find a match for uid <your uid> (the simulator has no user of that ID) and the filter line; both are harmless. fleetml deploy get $D1 | jq -c .devices turns to {"loaded":1,"targeted":1} one heartbeat after the model is active, about a minute later as on the phone (--level debug shows Heartbeat: 1 assignment(s), next in 60s at start and the next heartbeat 60 seconds after it), and step 7 rolls out 2.0.0 the same way.

Offline, without stopping a shared server: set FLEETML_SERVER_PORT = 19999, a port nothing listens on, in Secrets.xcconfig, then build, install and launch again with the xcodebuild and simctl lines. The cached 2.0.0 is ready at once (FleetML model ready: iris 2.0.0), and FleetML connected: false follows about ten seconds later. A new port counts as a new server, so the log shows Registration failed: deadlineExceeded (Connection errors: ... Connection refused) (the sample app: Cannot register: ...; your own app with the log lines of step 6: FleetML cannot register: ...), not Heartbeat failed, while the cached model keeps serving. Set the port back and rebuild to reconnect with the saved registration (same device ID). Stopping the server instead (docker compose -f deploy/docker-compose.prod.yml stop server, then start server) shows the Heartbeat failed line.

Production

  • TLS. FleetML does not terminate TLS: put the server behind a proxy that does and forwards gRPC over HTTP/2 (gRPC needs HTTP/2 end to end), see the trust boundaries. The app then uses the server's host name, port 443 and TLS, which is a separate .xcconfig for the Release configuration (configFiles: Release: in project.yml):
FLEETML_SERVER_HOST = fleetml.example.com
FLEETML_SERVER_PORT = 443
FLEETML_USE_TLS = YES
FLEETML_ENROLLMENT_KEY = <enrollment key>
  • Downloads over HTTPS. The server's S3_PUBLIC_ENDPOINT must be an HTTPS address phones reach from the internet, since every download URL is signed for it (CONFIG.md). Release builds then need no App Transport Security exception: NSAllowsLocalNetworking covers only local addresses, so keeping it does no harm, and the Local Network prompt appears only when the app connects to a local address.
  • The enrollment key is in every installed app, so it is not a secret in the way a password is, but it lets a device join the project: keep it in the uncommitted .xcconfig or the CI's secret store, give each app or release channel its own, and revoke one that leaks. The key is needed only to register: revoking it stops new installs from registering, and devices that already registered keep their token. A registered device (the Keychain keeps its identity across reinstalls) that starts with another project's valid key moves to that project, and back with the first key (When it does not connect): give each app its own project's key.

Enrollment and identity

The first registration needs a valid enrollment key; the device is enrolled into that key's project and receives a device token (30 days by default), stored in the Keychain (this device only, available after first unlock). The device ID is stable per app and device: the fingerprint is a SHA-256 of the bundle identifier and hardware properties (no identifierForVendor or advertising identifier), so two FleetML apps on one phone are separate devices. A device only ever receives deployments and experiments of its own project. After registering, the SDK:

  • re-registers only when it is new or its labels or SDK version changed (an app built without code signing, CODE_SIGNING_ALLOWED=NO, cannot keep the token in the simulator's Keychain and registers again, with the same device ID, at every launch; the SDK logs a warning then, and Xcode's default simulator builds are signed);
  • refreshes the token before it expires (6 days before, or at half its lifetime for short-lived tokens);
  • re-registers with the enrollment key if the server rejects the token.

Models: download, verify, validate, activate

For each assignment in a heartbeat the SDK:

  1. Checks the signature of the assignment with the project's signing keys (sent with registration and every heartbeat): an artifact whose signature does not verify is not downloaded, the current model stays, and SIGNATURE_INVALID is reported (artifact signing).
  2. Downloads the model from the presigned URL, writing each chunk to a .part file as it arrives. An interrupted download resumes with an HTTP Range request. When the server offers a delta from the version the device has, only the delta is downloaded and applied (delta updates).
  3. Verifies the sha256 before moving the file into place.
  4. Validates it with a ModelValidator.
  5. Activates it only if validation passed: the model becomes active, the previously active version becomes last-known-good, the device reports the new version as loaded and fleetML(modelReady:) is called.

If validation fails, the active (last-known-good) model stays in place, the SDK reports a MODEL_LOAD_FAILED event with the error (so a canary's health gate can roll the release back), calls fleetML(modelLoadFailed:version:reason:), and does not retry that variant for an hour.

A corrupt 3.0.0 is rejected and 2.0.0 stays active

Validators

ModelValidators.default() (used unless you pass validator: to initialize) compiles the model with Core ML, loads it and, when every input is a multi-array, runs one prediction on zeros. TensorFlow Lite files (identifier TFL3) are accepted as they are, because iOS has no TFLite runtime of its own. Write your own to check what matters to your app:

struct GoldenInputValidator: ModelValidator {
    func validate(fileURL: URL, model: ActiveModel) -> ValidationResult {
        runGoldenInput(fileURL) ? .valid : .invalid("golden input mismatch")
    }
}
try FleetML.initialize(config: config, delegate: self, validator: GoldenInputValidator())

Which variant a device gets

iOS devices register with OS IOS, and the server prefers COREML variants for them (then TFLITE, then GGUF). Requirements default by quantization (for example FP32 needs a device score of 60 and 2 GB of RAM, INT8 a score of 20), and an iPhone scores 54 at most, so an FP32 Core ML model reaches it only with a lower requirement in the version's metadata. fleetml push sets it on the version it creates:

fleetml push model.mlmodel --project <project ID> --model iris --version 1.0.0 --framework COREML --quantization FP32 --metadata '{"fleetml_requirements":{"min_ram_mb":64,"min_score":0}}'

If the device registers but never gets an assignment, compare the version's requirements with the device: fleetml device profile <device ID> prints its score and RAM (fleetml device list --project <project ID> gives the ID). How the server matches devices, labels and variants is in ROLLOUTS.md.

Battery and data

In the foreground In the background
Heartbeat every heartbeat_interval_seconds (server default 60 s) one per background refresh
Telemetry batches of telemetry_batch_size (100), every telemetry_flush_interval_seconds (30 s) and when a batch fills one flush when the app goes to the background, one per background refresh
Background refresh not scheduled a BGAppRefreshTask no earlier than background_heartbeat_interval_seconds (1 h, at least 15 min); iOS decides the exact time
Model downloads per download_policy per download_policy
  • Foreground only timers. The SDK stops its timers when the app enters the background and starts them when it returns. To get the background refresh, add com.fleetml.sdk.refresh (FleetML.backgroundTaskIdentifier) to BGTaskSchedulerPermittedIdentifiers in your Info.plist, enable the "Background fetch" mode (UIBackgroundModes: fetch), and call FleetML.registerBackgroundTask() and FleetML.initialize before the app finishes launching (the sample app does both in its App initializer). FleetML.syncNow() runs one heartbeat and one telemetry flush on demand. How to trigger the refresh on a phone is in step 9 of Your own app on an iPhone.
  • Downloads. .unmeteredOrCharging (the default): a network that is neither expensive (cellular, personal hotspot) nor constrained (Low Data Mode), or any network while the device charges. .unmeteredOnly and .anyNetwork are the alternatives. A waiting assignment is retried on the next heartbeat. Assignments of an urgent deployment, and every rollback, download on any connected network.
  • Telemetry is always batched; no event is sent on its own.
  • The server sets all of these for every device with the DEVICE_* variables (CONFIG.md); FleetMLConfig only provides the values used before the first response.
  • The iOS Simulator cannot run background tasks (BGTaskScheduler reports it unavailable, and an app that permits the task logs Background refresh not scheduled: background refresh is unavailable here (the simulator, or Background App Refresh is off in Settings) at info level).

Offline behaviour

Models activated in an earlier run are delivered through fleetML(modelReady:) (and FleetML.activeModels) as soon as the SDK starts, before the server is reached. Until the server answers, the app keeps using its cached models; fleetML(connectionChanged:) reports reachability within about ten seconds.

Offline start with the server down

Telemetry

if let model = FleetML.activeModel(named: "iris") {
    FleetML.telemetry?.reportInference(model: model, latencyMs: 3, confidence: 0.97, label: "virginica")
    // with input values, for feature-level drift (only features configured on the server are kept):
    FleetML.telemetry?.reportInference(model: model, latencyMs: 3, features: ["petal_length": [5.1]])
}

On the server, fleetml telemetry events --device <device ID> lists the device's events (type, latency, confidence, error) once the SDK has flushed them (30 seconds by default), and fleetml telemetry summary <version ID> sums up a version's.

Events go into a persistent queue (bounded to 10,000; oldest dropped first): they survive app termination and are kept while offline or before registration. They are sent in batches every flush interval, with exponential backoff (5 s up to 5 min) after a failure and an immediate flush when the connection returns. For a model in an A/B experiment the event carries the device's arm ID automatically. Feature values are binned on the device with the edges from the server (feature-level drift); only the counts are sent.

Sample app

Sample app

ios/SampleApp is the reference integration, generated with XcodeGen from project.yml: a SwiftUI config screen (host, port, enrollment key; the key field is masked, but crop the screen out of screenshots you share anyway), the SDK owned by an app-wide object, the Iris Core ML model from models/iris (Apache-2.0) bundled and used until the server assigns one, and inference telemetry. Its Info.plist sets NSAllowsLocalNetworking and NSLocalNetworkUsageDescription so model downloads from a server on the Mac work over plain HTTP, on the simulator and on a phone; production servers should serve presigned URLs over HTTPS. The config screen's host defaults to localhost, which works on the simulator; on a phone, use the Mac's LAN address. Unlike an own app, it takes its settings from the environment (FLEETML_HOST, FLEETML_PORT, FLEETML_ENROLLMENT_KEY, which SIMCTL_CHILD_ and DEVICECTL_CHILD_ in front of each name pass to the app without putting the key on a command line), as launch arguments (-fleetmlHost, -fleetmlPort) or on its config screen, and keeps them for later launches.

Simulator runbook

This is how the SDK and sample app are verified, against the server from the quickstart (the production compose stack, with the admin key in $FLEETML_ADMIN_KEY) and the fleetml CLI, from the root of a clone. The simulator shares the Mac's localhost, so the server needs no change. It needs Xcode and XcodeGen (brew install xcodegen). Contributors on the dev stack (make dev) use the key from .env instead.

  1. Start the server with the quickstart's "Run FleetML" block (or source ~/.fleetml.env in a new shell).

  2. Publish the Core ML sample models (with the requirements override from Which variant a device gets), create an enrollment key and deploy 1.0.0 to the sample app's label (app=sample):

    export FLEETML_SERVER=http://localhost:${FLEETML_REST_PORT:-18080} FLEETML_API_KEY=$FLEETML_ADMIN_KEY
    REQ='{"fleetml_requirements":{"min_ram_mb":64,"min_score":0}}'
    P=$(fleetml project create --name ios-demo -o json | jq -r .id)
    fleetml push models/iris/iris_v1.mlmodel --project $P --model iris --version 1.0.0 --framework COREML --quantization FP32 --metadata "$REQ"
    fleetml push models/iris/iris_v2.mlmodel --project $P --model iris --version 2.0.0 --framework COREML --quantization FP32 --metadata "$REQ"
    M=$(fleetml model list --project $P -o json | jq -r '.[] | select(.name == "iris") | .id')
    V1=$(fleetml ver list --model $M -o json | jq -r '.[] | select(.version == "1.0.0") | .id')
    V2=$(fleetml ver list --model $M -o json | jq -r '.[] | select(.version == "2.0.0") | .id')
    export FLEETML_ENROLLMENT_KEY=$(fleetml enrollment-key create --project $P --name simulator -o json | jq -r .key)
    D=$(fleetml deploy create --project $P --version $V1 --target '{"app":"sample"}' -o json | jq -r .id)
    fleetml deploy start $D
    

    Each push prints ✓ Pushed ... (FP32), and the deployment starts as COMPLETE (✓ Deployment start → COMPLETE): it waits for the device.

  3. Generate the Xcode project, build, install and launch the sample app on the first available iPhone simulator, with the settings in its environment (or, after xcodegen generate, open ios/SampleApp/FleetMLSample.xcodeproj, run it and type them on the config screen):

    cd ios/SampleApp && xcodegen generate
    SIM=$(xcrun simctl list devices available -j | jq -r '[.devices[][] | select(.name | startswith("iPhone"))][0].udid')
    xcodebuild -project FleetMLSample.xcodeproj -scheme FleetMLSample -destination "platform=iOS Simulator,id=$SIM" -derivedDataPath build build
    xcrun simctl boot $SIM; open -a Simulator
    xcrun simctl install $SIM build/Build/Products/Debug-iphonesimulator/FleetMLSample.app
    SIMCTL_CHILD_FLEETML_HOST=localhost SIMCTL_CHILD_FLEETML_PORT=${FLEETML_GRPC_PORT:-19090} SIMCTL_CHILD_FLEETML_ENROLLMENT_KEY=$FLEETML_ENROLLMENT_KEY xcrun simctl launch --console-pty $SIM com.fleetml.sample
    

    On a simulator where the sample app ran before, its saved settings stay, and the environment replaces what it sets: xcrun simctl uninstall $SIM com.fleetml.sample before the install starts it clean. The app registers, downloads iris 1.0.0, validates it with Core ML, shows Active model: iris 1.0.0 and classifies the sample flower with it, reporting inference telemetry; the console prints the same steps (FleetMLSample: Registered as ..., Model ready: iris 1.0.0). fleetml deploy create --project $P --version $V2 --target '{"app":"sample"}' and deploy start update the app to 2.0.0 on its next heartbeat, 60 seconds by default (1.0.0 is kept as last-known-good). fleetml deploy get $D | jq -c .devices counts the devices whose last heartbeat reported a version, so loaded turns one heartbeat after the app shows the model, and it is counted each time you ask: once 2.0.0 replaces 1.0.0, $D counts down to {"loaded":0,"targeted":1}.

  4. Offline start: stop the server (docker compose -f deploy/docker-compose.prod.yml stop server), then xcrun simctl terminate $SIM com.fleetml.sample and launch the app again. The cached model is active immediately and inference works; events are queued and sent once the server is back (docker compose -f deploy/docker-compose.prod.yml start server). On a server that others use, launch the app with a closed port instead; the launch line's SIMCTL_CHILD_FLEETML_PORT decides the port (a -fleetmlPort argument next to it is overridden), so change it there:

    SIMCTL_CHILD_FLEETML_PORT=19999 xcrun simctl launch --terminate-running-process $SIM com.fleetml.sample
    

    The app starts with its cached model and logs Registration failed: deadlineExceeded (a closed port, see When it does not connect). The same line with SIMCTL_CHILD_FLEETML_PORT=${FLEETML_GRPC_PORT:-19090} brings it back; the app keeps the last port it was given.

Physical device

The sample app runs on an iPhone too, over Wi-Fi, with the same server and models, after steps 1 and 2 of Your own app on an iPhone (Developer Mode, Apple ID, the server with S3_PUBLIC_ENDPOINT on the Mac's LAN address in ~/.fleetml.env), and with the phone unlocked and the app in front (step 5). Its Info.plist already has the keys a phone needs. After xcodegen generate, open ios/SampleApp/FleetMLSample.xcodeproj and, on the FleetMLSample target's Signing & Capabilities tab, turn on Automatically manage signing with your team; or pass the team on the command line as below, where <team> is its ID and <device> the phone's identifier from xcrun devicectl list devices. The config screen's default host, localhost, is the phone itself, so on a phone always give the Mac's address:

cd ios/SampleApp && xcodegen generate
xcodebuild -project FleetMLSample.xcodeproj -scheme FleetMLSample -destination 'id=<device>' -derivedDataPath build -allowProvisioningUpdates DEVELOPMENT_TEAM=<team> ENABLE_PREVIEWS=NO build
xcrun devicectl device install app --device <device> build/Build/Products/Debug-iphoneos/FleetMLSample.app
DEVICECTL_CHILD_FLEETML_HOST=<Mac LAN IP> DEVICECTL_CHILD_FLEETML_PORT=${FLEETML_GRPC_PORT:-19090} DEVICECTL_CHILD_FLEETML_ENROLLMENT_KEY=$FLEETML_ENROLLMENT_KEY xcrun devicectl device process launch --device <device> --console com.fleetml.sample

The trust prompt, the Local Network prompt and the error codes are as in steps 5 and 6 of the own app's runbook.

Tests

make ios-test

runs the unit tests on macOS (swift test at the repository root). The same suite runs on a simulator, from the root, with the first available iPhone simulator (xcrun simctl list devices available lists them):

SIM=$(xcrun simctl list devices available -j | jq -r '[.devices[][] | select(.name | startswith("iPhone"))][0].udid')
xcodebuild test -scheme FleetMLSDK -destination "platform=iOS Simulator,id=$SIM"

The package's deployment target is iOS 15. The suite, the live test and the signing check have passed on the iOS 16.4 simulator (an iPhone 14) and on the iOS 26 simulator; no iOS 15 runtime was tested. Older runtimes are installed with xcodebuild -downloadPlatform iOS -buildVersion 16.4. The SDK was also verified on a physical iPhone running iOS 26 (lifecycle, update, offline, download policy, signing, background refresh), with the sample app as in Physical device.

The suite covers the telemetry queue and reporter, the model store, the downloader (including an interrupted transfer resumed with Range), cache eviction, the model manager (activation, last-known-good, UPDATE + UNLOAD, restart), the token refresh policy, the heartbeat loop, connection errors reaching the delegate, the Core ML validator on the real Iris model, the battery and data policy, and signature checks against the shared vectors in internal/artifact/testdata. make proto-swift regenerates the gRPC client code from proto/.

The live tests (scripts/ios-e2e.sh and scripts/signing-e2e.sh ios) stay simulator-only, because Swift package tests cannot run on a physical device (xcodebuild reports "Tool-hosted testing is unavailable on device destinations") unless an app target hosts them. The download policy can be exercised on a phone by hand instead: on battery and with Low Data Mode on for the Wi-Fi network, a non-urgent deployment waits and an urgent one downloads.

For contributors

Against the dev stack (make dev): FLEETML_API_KEY=<admin key> ./scripts/ios-e2e.sh runs the live test, and FLEETML_API_KEY=<admin key> ./scripts/signing-e2e.sh ios the signature checks (tampered, and the rotation grace). scripts/ios-e2e.sh creates a non-urgent deployment and runs LiveServerTests on the simulator: a device on an expensive network and on battery registers, its model waits, then an urgent deployment downloads and activates. Without the script's environment the live test is skipped.

Contact