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.
| Platform | Minimum |
|---|---|
| iOS / iPadOS | 15.0 |
| macOS | 12.0 |
| tvOS | 15.0 |
| watchOS | 8.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:
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
- 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 Keystab. - Initialize the client to initiate its connection lifecycle.
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.
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:
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?
}
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.
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:
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:
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
.streamingor.polling. It is recommended to use the default of.streamingunless you have a specific need to use.pollinginstead. .streamingholds a connection open and receives changes as they happen, reconnecting on its own with a backoff.- Defaults to
.streaming
- The connection mode, which can be
pollingInterval- How often to re-fetch config state when
modeis.polling. It has no effect in any other mode. - Defaults to
60seconds
- How often to re-fetch config state when
timeout- The timeout to be used in initialization and when updating the context. This is how long
initializewill wait for data from ConfigDirector services before returning. If the timeout is reached,initializewill 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
initializecall. - Defaults to
3seconds
- The timeout to be used in initialization and when updating the context. This is how long
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
falseto manage the connection yourself withpauseNetwork()andresumeNetwork(). - It has no effect on macOS, where an app keeps running, and keeps its connections, once it leaves the foreground.
- Defaults to
true
- 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
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.
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.
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
ConnectReasonis 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.
- Emitted whenever the user context takes effect, including during initialization. It is emitted before
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 tovalue(for:default:)orvalues(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 withevaluation.value.as(Bool.self), which returnsnilwhen the config was evaluated as some other type. Itsdescriptionis 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 thevalue. This can be useful for large values, like a JSON config, or to avoid disclosing the values themselves to third parties. It isnilif 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, thereasonwill 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.
for await evaluation in client.evaluations where evaluation.isDefaultValue {
print("'\(evaluation.key)' fell back to its default: \(evaluation.reason.rawValue)")
}
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")")
}
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.
// 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.
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:
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:
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:
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:
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:
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:
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))
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:
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.