Mobile SDKs

Swift SDK

Introduction

The Swift SDK is intended to be used by Apple platform applications. It evaluates your configs against a user context, keeps them current as they change in the dashboard, and hands SwiftUI values it can re-render from.

PlatformMinimum
iOS / iPadOS15.0
macOS12.0
tvOS15.0
watchOS8.0

Building it requires Swift 6.0 or newer. The package is built in Swift 6 language mode under strict concurrency and every public type is Sendable, so the client can be shared across tasks and actors without wrapping it.

Config evaluation is synchronous and reads config state the client already holds in memory, which is what makes it safe to call from a SwiftUI body. Telemetry about those evaluations is aggregated and reported on its own task, so evaluating a config never waits on the network.

Installation

The SDK is distributed through Swift Package Manager from its repository: https://github.com/ConfigDirector/swift-sdk

In Xcode, go to File → Add Package Dependencies… and enter the package URL:

https://github.com/ConfigDirector/swift-sdk

To add it to a Swift package instead, declare it in Package.swift:

Package.swift
dependencies: [
    .package(url: "https://github.com/ConfigDirector/swift-sdk", from: "1.1.0"),
],
targets: [
    .target(
        name: "YourTarget",
        dependencies: [.product(name: "ConfigDirector", package: "swift-sdk")]
    ),
]

Configure and initialize the client

  1. Create an instance of the client providing your client SDK key. You can retrieve a client SDK key under your project settings in the Environments & SDK Keys tab.
  2. Initialize the client to initiate its connection lifecycle.
ConfigDirectorSetup.swift
import ConfigDirector

// Creating the client makes no network calls. It throws only when the SDK key is
// blank or a custom base URL is invalid.
let client = try ConfigDirectorClient(clientSDKKey: "YOUR-CLIENT-SDK-KEY")

// Connects and waits until config values are received, or until it times out.
// After a timeout the client keeps trying to connect in the background.
await client.initialize()

The client is thread safe. Create one and share it for the lifetime of the app.

Before the client is initialized, config values will evaluate to the default value provided in code.

The initializer throws a ConfigDirectorError in only two cases, both of which are programming errors rather than runtime conditions: missingClientSDKKey when the key is blank, and invalidBaseURL when a custom base URL is not absolute. Connection failures are not thrown — initialize returns normally whether or not config state arrived, and isReady tells you which happened.

Most applications create a single client instance, initialize it during startup, and let it live for the lifetime of the app. In a SwiftUI app that usually means owning it in the App:

MyApp.swift
import ConfigDirector
import SwiftUI

@main
struct MyApp: App {
    @State private var client = try? ConfigDirectorClient(clientSDKKey: "YOUR-CLIENT-SDK-KEY")

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.configDirectorClient, client)
                .task {
                    await client?.initialize(context: ConfigDirectorContext(id: "user-123"))
                }
        }
    }
}

extension EnvironmentValues {
    @Entry var configDirectorClient: ConfigDirectorClient?
}
The SDK follows the app lifecycle so it can release its network connection while the app is backgrounded. See connection below to manage that yourself instead.

Additional configuration options

These options can be passed in to ConfigDirectorClientOptions.

metadata

The metadata option allows you to provide your application's name and version. These values can be used in targeting rules conditionals. For example, if a certain feature should only be enabled starting with a certain version of your application.

Whichever of the two you leave unset is read from the running application's bundle — CFBundleDisplayName (falling back to CFBundleName) for the name, and CFBundleShortVersionString for the version — so most applications do not need to set either one.

ConfigDirectorSetup.swift
let client = try ConfigDirectorClient(
    clientSDKKey: "YOUR-CLIENT-SDK-KEY",
    options: ConfigDirectorClientOptions(
        metadata: ConfigDirectorMetaContext(
            appName: "YOUR-APP-NAME",
            appVersion: "1.0.2"
        )
    )
)

logger

By default, the SDK logs to the unified logging system under the com.configdirector.sdk subsystem, and it is set to log warnings and errors only. You can configure a logger by either creating a ConfigDirector console logger with a different log level, or by conforming to the ConfigDirectorLogger protocol to provide your own logger. The protocol can be used to create an adapter to another logging library.

Configure the ConfigDirector console logger to a different level:

ConfigDirectorSetup.swift
let client = try ConfigDirectorClient(
    clientSDKKey: "YOUR-CLIENT-SDK-KEY",
    options: ConfigDirectorClientOptions(logger: ConsoleLogger(level: .debug))
)

The available levels are .off, .error, .warn (the default), .info and .debug.

Implement your own logger adapter:

MyLogger.swift
import ConfigDirector

struct MyLogger: ConfigDirectorLogger {
    // Messages more verbose than this level are never passed to `log`.
    let level: ConfigDirectorLogLevel = .info

    func log(_ level: ConfigDirectorLogLevel, message: String, error: (any Error)?) {
        // your specific logging library implementation here
    }
}

let client = try ConfigDirectorClient(
    clientSDKKey: "YOUR-CLIENT-SDK-KEY",
    options: ConfigDirectorClientOptions(logger: MyLogger())
)

connection

ConnectionOptions accepts five optional values:

  • mode
    • The connection mode, which can be .streaming or .polling. It is recommended to use the default of .streaming unless you have a specific need to use .polling instead.
    • .streaming holds a connection open and receives changes as they happen, reconnecting on its own with a backoff.
    • Defaults to .streaming
  • pollingInterval
    • How often to re-fetch config state when mode is .polling. It has no effect in any other mode.
    • Defaults to 60 seconds
  • timeout
    • The timeout to be used in initialization and when updating the context. This is how long initialize will wait for data from ConfigDirector services before returning. If the timeout is reached, initialize will return but the client will still be in an unready status and returning default values. The client will continue to attempt to connect and retrieve config values in the background.
    • If your application is used in environments where the users frequently have poor or no connection, you may want to use a lower timeout, or simply don't await the initialize call.
    • Defaults to 3 seconds
  • pausesWhileBackgrounded
    • Whether to pause the connection while the app is in the background and resume it when the app returns to the foreground. Mobile operating systems terminate background connections on their own, so this is enabled by default. Set it to false to manage the connection yourself with pauseNetwork() and resumeNetwork().
    • It has no effect on macOS, where an app keeps running, and keeps its connections, once it leaves the foreground.
    • Defaults to true
  • baseURL
    • The base URL used to connect to ConfigDirector services.
    • This should only be provided if your environment requires you to configure a proxy server in order to connect to ConfigDirector services.
ConfigDirectorSetup.swift
let client = try ConfigDirectorClient(
    clientSDKKey: "YOUR-CLIENT-SDK-KEY",
    options: ConfigDirectorClientOptions(
        connection: ConnectionOptions(mode: .polling, pollingInterval: 600, timeout: 2)
    )
)

Events

Events notify you of some key actions from the client. events publishes the client's lifecycle changes, and evaluations publishes every individual config evaluation. Each is an AsyncStream you consume with a for await loop, and each access to the property returns an independent stream, so several parts of your app can observe them at once. Cancelling the consuming task ends the loop, and every stream is finished when the client is closed.

ConfigDirectorSetup.swift
for await event in client.events {
    switch event {
    case let .ready(reason):
        print("Client is ready after \(reason)")
    case let .configsUpdated(keys):
        print("Configs updated: \(keys)")
    case let .contextUpdated(context):
        print("Context updated: \(context?.id ?? "none")")
    }
}

The following events are published on client.events:

  • ready(ConnectReason)
    • Emitted when the client is initialized, or after reconnection due to a context update or a resumed network connection. When this event is emitted it means the client has received a payload from the ConfigDirector servers and is ready to evaluate configs.
    • The associated ConnectReason is the action that triggered the client to connect/reconnect, which can be .initialization, .contextUpdate, or .networkResume.
  • configsUpdated(String)
    • Emitted when a payload is received from the ConfigDirector servers with config data. It is emitted during initialization or context update when an entire config payload is received. After initialization or context update, it is emitted when updates are pushed from the server (or discovered via polling if that connection mode is used).
    • The associated value lists the config keys that were included in the payload from the server. On a delta update, these are only the configs that changed.
  • contextUpdated(ConfigDirectorContext?)
    • Emitted whenever the user context takes effect, including during initialization. It is emitted before ready, therefore should not be relied upon as a lifecycle event but rather as an informational event to track updates to the user context.
    • The associated value is the context the client is now evaluating against. It can be nil.

client.evaluations publishes a ConfigEvaluation every time a config is evaluated, which can be useful for debugging what a screen actually read, and for capturing additional context for analytics events. This includes calls to value(for:default:) and evaluations delivered by a values(for:default:) stream. A ConfigEvaluation carries:

  • key: String - The config key that was evaluated.
  • value: ConfigEvaluationValue - The value the config evaluated to. It can be the default value provided to value(for:default:) or values(for:default:), for example if the config was evaluated before the client was initialized. Read it back as the type the config was evaluated as with evaluation.value.as(Bool.self), which returns nil when the config was evaluated as some other type. Its description is a textual form of the value, suitable for logging.
  • valueID: String? - The value ID, which is a stable hash of the value that can be used for analytics or other third parties rather than sending the value. This can be useful for large values, like a JSON config, or to avoid disclosing the values themselves to third parties. It is nil if the evaluation had to fall back to the default value.
  • isDefaultValue: Bool - Whether or not the evaluation fell back to the default value provided in code.
  • context: ConfigDirectorContext? - The context the config was evaluated against, if one was set.
  • reason: EvaluationReason - The reason for the evaluation resolution. In the case the config successfully evaluated based on server-provided targeting rules, it will be .foundMatch. If the evaluation had to fall back to the default value, the reason will encode why the fallback was required:
    • .clientNotReady - The evaluation happened before the client finished initialization.
    • .configStateMissing - The requested config key was not present in the payload received from the server. This could be due to an incorrect config key, or a config that was not enabled to be available to client SDKs.
    • .valueMissing - The config had no value for the current context.
    • .invalidNumber - The config was requested as a number but the value received from the server could not be converted to a number.
    • .invalidBoolean - The config was requested as a boolean but the value received from the server could not be converted to a boolean.
    • .invalidJSON - The config was requested as JSON but the value received from the server could not be decoded into the requested type.
    • .typeMismatch - The config was requested with a data type that did not match the type of the config and no reasonable type conversion was possible.

ConfigEvaluation is Equatable, so evaluations can be compared or de-duplicated before they are forwarded on.

ConfigDirectorSetup.swift
for await evaluation in client.evaluations where evaluation.isDefaultValue {
    print("'\(evaluation.key)' fell back to its default: \(evaluation.reason.rawValue)")
}
ConfigDirectorSetup.swift
for await evaluation in client.evaluations {
    guard let isOn = evaluation.value.as(Bool.self) else { continue }

    print("'\(evaluation.key)' evaluated to \(isOn), value ID \(evaluation.valueID ?? "none")")
}
Do not update view state straight from the evaluations stream. The state change re-renders the view, which reads the config again, which publishes another evaluation.

Retrieve config values

To synchronously retrieve config values, use the client's value(for:default:) method. It requires two arguments. The first argument is the config key, and the second is the default value to be returned if the client has not yet received config values from ConfigDirector services.

You can also subscribe to config value changes via the values(for:default:) method. It takes the same two arguments and returns an AsyncStream that emits the config's current value on subscription and then every time the evaluated value changes, whether because it was edited in the ConfigDirector dashboard or because the context was updated. Consecutive identical values are not re-emitted. Cancelling the task consuming the stream stops watching.

ContentView.swift
// Retrieve the current value. The default value decides the type the config
// is read as, and is returned until the client is ready.
let darkMode = client.value(for: "my-config-key", default: false)

// Subscribe to value updates. The stream yields the current value right away
// and then every time the evaluated value changes.
for await darkMode in client.values(for: "my-config-key", default: false) {
    print("Value updated: \(darkMode)")
}

Both methods infer their type from the default value. Bool, String, Int and Double are supported out of the box. If there is a type mismatch at runtime, the SDK will attempt to convert the served value to the requested type. If the conversion fails, it will return the default value rather than trapping. Mismatched types at runtime are captured by the telemetry collector and will surface as warnings in the ConfigDirector dashboard.

ContentView.swift
client.value(for: "my-string-config-key", default: "Default")

client.value(for: "my-integer-config-key", default: 100)

client.value(for: "my-boolean-config-key", default: false)

JSON configs

A config holding a JSON document is read with value(for:as:default:), which decodes it into any Decodable type. It returns the default value when the config is not a JSON config, or when its document cannot be decoded into the type you asked for:

ContentView.swift
struct Theme: Decodable {
    let primary: String
    let cornerRadius: Double
}

let theme = client.value(
    for: "theme",
    as: Theme.self,
    default: Theme(primary: "#101010", cornerRadius: 8)
)

values(for:as:default:) is the streaming counterpart. Its type has to be Equatable as well, since that is how the stream knows a decoded value did not change:

ContentView.swift
for await theme in client.values(for: "theme", as: Theme.self, default: .fallback) {
    self.theme = theme
}

Custom value types

To read a config as one of your own types, conform it to ConfigValue. A config value arrives as the string the server served, and configValueKind decides which configs the type can be read from — .boolean, .string, or .number. Returning nil from the initializer evaluates the config to the caller's default value:

Locale+ConfigValue.swift
import ConfigDirector

extension Locale: ConfigValue {
    public static var configValueKind: ConfigValueKind { .string }

    public init?(configValue: String) {
        self.init(identifier: configValue)
    }
}

let locale = client.value(for: "default-locale", default: Locale(identifier: "en_US"))

Reading configs in SwiftUI

value(for:default:) is synchronous and makes no network calls, so it is safe to call directly from a body. That gives you the current value, but the view will not re-render on its own when the value changes.

To have the view follow a config, drive a @State from values(for:default:) inside a .task. The stream yields the current value immediately, so there is no frame where the view shows something other than the default before the first value arrives:

DarkModeToggle.swift
import ConfigDirector
import SwiftUI

struct DarkModeToggle: View {
    let client: ConfigDirectorClient

    @State private var isOn = false

    var body: some View {
        Toggle("Dark mode", isOn: $isOn)
            .task {
                for await value in client.values(for: "dark-mode", default: false) {
                    isOn = value
                }
            }
    }
}
.task cancels its work when the view disappears and starts it again when the view returns, which ends and re-subscribes the stream for you. Use .task(id:) when the config key or the default value can change, so the stream is replaced when they do.

Update the user context

A user context can be provided when initializing the client:

ConfigDirectorSetup.swift
import ConfigDirector

let client = try ConfigDirectorClient(clientSDKKey: "YOUR-CLIENT-SDK-KEY")

await client.initialize(
    context: ConfigDirectorContext(
        id: "12345",
        name: "Example User",
        traits: [
            // Any arbitrary traits which can be referenced in targeting rules
            "region": "North America",
        ]
    )
)

The user context can also be updated via updateContext:

ContentView.swift
await client.updateContext(
    ConfigDirectorContext(
        id: "654321",
        name: "Another User",
        traits: ["region": "Australia"]
    )
)

// Update it to an anonymous context when a user signs out. Leaving `id` unset
// lets the SDK generate a random identifier for it.
await client.updateContext(ConfigDirectorContext(isAnonymous: true))
In client SDKs (browser and mobile), updating the user context re-establishes a new connection to ConfigDirector servers with the new context. While the new connection is in flight, config values will continue to evaluate to the currently cached values from the prior user context.
Awaiting updateContext will wait until the new config values are downloaded or the connection times out. In the case of a timeout, the client will continue to attempt to connect with the new context in the background.

Traits are written as ordinary Swift literals. Strings, integers, doubles, booleans, arrays, dictionaries and nil are all accepted:

ContentView.swift
await client.updateContext(
    ConfigDirectorContext(
        id: "user-123",
        name: "Ada Lovelace",
        traits: [
            "plan": "pro",
            "seats": 12,
            "beta": true,
            "regions": ["us-east", "eu-west"],
        ]
    )
)

For a signed-out user, set isAnonymous to true. The values are still used to evaluate targeting rules, but the context is not persisted and does not appear in the dashboard. Leaving id unset lets the SDK generate a random identifier for it — note that this value segments users in percentage rollouts, so changing it can move a user into a different percentile.

Other useful client features

The client provides additional properties and methods.

context

Returns the current user context (which may be nil). When calling updateContext, the context is not immediately updated. The context is updated after the new connection for the new context succeeds (or times out and goes on retry).

isReady

Returns a Bool indicating if the client has been successfully initialized and is ready to evaluate configs for the given user context. It is initially false and becomes true after initialize succeeds. Upon calling updateContext it becomes false again and it is set to true once updateContext succeeds.

isInitializing

Returns a Bool indicating whether the client is currently initializing. It is false on creation, true after initialize is called, and false again once initialization completes. This is useful to show a loading state instead of transitioning from the in-code default value to the evaluated value on a slow connection.

pauseNetwork() and resumeNetwork()

pauseNetwork() releases the network connection without discarding config state, event streams, or watch streams, and resumeNetwork() re-establishes it using the last context given to initialize or updateContext.

The client already does this on its own while the app is backgrounded, so these are only needed if you set pausesWhileBackgrounded to false in order to manage the connection yourself.

close()

Closes the connection, every watch stream, and every event stream, and reports whatever telemetry is left. The client cannot be used afterwards.

The client closes itself when it is released, so calling close() is only necessary to shut it down while a reference to it is still held.

Copyright © 2026