Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions Sources/FormbricksSDK/Formbricks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ import Network
if let existing = userManager?.userId, !existing.isEmpty {
logger?.debug("Different userId is being set, cleaning up previous user state")
userManager?.logout()
// An identity switch: the ambient Embedded Data bag may carry the previous user's
// context, which must not ride onto the next user's responses on a shared device.
// First-time identification keeps the bag — a host legitimately pushes context before
// it knows who the user is.
EmbeddedDataManager.shared.removeAll()
}

userManager?.set(userId: userId)
Expand Down Expand Up @@ -279,6 +284,71 @@ import Network

}

/**
Attaches Embedded Data to future responses without tying it to a trigger.

Merges into an in-memory bag — last write wins per key, and an explicit `nil` removes a key.
Values land only on the survey's declared *ingested* fields; anything else is dropped and
logged by the survey renderer, never fatal.

**`nil` removes; there is no "leave this alone" value.** Swift has no `undefined`, so this SDK
maps `nil` onto the JS SDK's `{ key: null }` (remove) and has nothing that spells its
`{ key: undefined }` (no-op). A host porting the cross-platform idiom of passing every field
unconditionally — `["plan": user.plan.map(EmbeddedDataValue.string)]` — therefore *clears*
`plan` here whenever the optional is empty, where the same code on web would leave the previous
value standing. Build the dictionary from the keys you actually have, or use
`clearEmbeddedData(_:)` when you mean to remove one.

Deliberately callable **before** `setup(with:)`, unlike the methods above: a host that pushes
context at launch must not have that value silently dropped because initialization had not
finished. The bag is pure memory — nothing here needs the SDK to be running.

The bag is snapshotted when a survey is displayed and frozen for its lifetime, so a value set
while a survey is on screen reaches the *next* response, not that one. It is never persisted:
a cold app start begins empty and the host re-pushes.

Example:
```swift
Formbricks.setEmbeddedData([
"plan": "pro",
"seats": 25,
"isTrial": false,
"screen": nil, // removes the key
])
```
*/
public static func setEmbeddedData(_ data: [String: EmbeddedDataValue?]) {
EmbeddedDataManager.shared.set(data)
}

/**
Removes one Embedded Data key. A key that was never set is a no-op.

The single-key and clear-everything forms are separate overloads on purpose: a `String` that
cannot be `nil` means a host reading the key from its own state cannot accidentally wipe the
whole bag.

Example:
```swift
Formbricks.clearEmbeddedData("plan")
```
*/
public static func clearEmbeddedData(_ key: String) {
EmbeddedDataManager.shared.remove(key: key)
}

/**
Clears the whole Embedded Data bag — logout, or a hard context switch.

Example:
```swift
Formbricks.clearEmbeddedData()
```
*/
public static func clearEmbeddedData() {
EmbeddedDataManager.shared.removeAll()
}

/**
Logs out the current user. This will clear the user attributes and the user id.
The SDK must be initialized before calling this method.
Expand All @@ -296,6 +366,9 @@ import Network
}

userManager?.logout()
// Same identity-switch rule as setUserId: logout must not let the previous user's ambient
// context leak onto whoever uses the app next.
EmbeddedDataManager.shared.removeAll()
}

/**
Expand Down Expand Up @@ -332,6 +405,10 @@ import Network
}

private static func performCleanup() {
// An explicit full reset of the SDK, so the host-supplied context goes too. This is a
// stronger teardown than the JS SDK's internal one, which deliberately preserves the bag
// across a setup retry — `cleanup()` is not a retry, it is the host saying "forget it all".
EmbeddedDataManager.shared.removeAll()
userManager?.logout()
userManager?.cleanupUpdateQueue()
presentSurveyManager?.dismissView()
Expand Down
130 changes: 130 additions & 0 deletions Sources/FormbricksSDK/Manager/EmbeddedDataManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import Foundation

/// The in-memory Embedded Data bag: context a host app attaches to future responses without tying
/// it to a trigger — `Formbricks.setEmbeddedData(["screen": "checkout"])` once, instead of
/// repeating the same values on every possible `track(...)` call.
///
/// Mirrors the JS SDK's store key for key, so web and mobile behave identically.
///
/// Lifetime rules, all deliberate:
///
/// - **In-memory, process scoped, never persisted.** Not `UserDefaults`: persisting this bag would
/// blur the Embedded Data ↔ contact-attribute boundary and create a stale-data / PII-at-rest
/// surface. A cold app start begins empty; the host re-pushes.
/// - **Snapshot at display, then frozen.** The WebView payload copies the bag when the survey is
/// shown, so a later `setEmbeddedData` affects the next response, never the one on screen.
/// - **No filtering here.** The SDK is a dumb pipe: the survey renderer applies the ingest contract
/// — allow-list, coercion, `locked`, size caps — and logs what it refuses, and the server re-runs
/// all of it on ingest. Filtering here would ship a second copy of those rules for the four mobile
/// SDKs to drift from.
/// - **Independent of `setup`.** A singleton rather than a manager hung off `Formbricks`, because a
/// host legitimately pushes context before the SDK finishes initializing, and silently dropping
/// that write is the failure this API exists to avoid. `Formbricks.cleanup()` clears it, since
/// that is an explicit full reset of the SDK.
/// - **No network.** Every method is a synchronous memory write, so calling it on every screen
/// change is free. Values ride the existing response payload.
final class EmbeddedDataManager {
static let shared = EmbeddedDataManager()

/// Same idiom as `UpdateQueue`: the host may call from any thread, and the snapshot is read on
/// the main queue while a survey is being presented.
private let syncQueue = DispatchQueue(label: "com.formbricks.embeddedData")
private var data: [String: EmbeddedDataValue] = [:]

private init() {
/*
Private so the bag cannot be instantiated a second time: a host holding its own copy would
write into a store the survey payload never reads.
*/
}

/// Merge — never replace — so refreshing a volatile field (`screen`) cannot wipe the stable ones
/// (`plan`) set at launch. Per key: last write wins, and an explicit `nil` removes the key.
///
/// A key the caller simply leaves out is untouched; that is how a host skips a field it has no
/// value for this screen. `nil` is the deliberate "remove this" spelling, matching the JS SDK's
/// `{ key: null }`.
func set(_ data: [String: EmbeddedDataValue?]) {
var setKeys: [String] = []
var removedKeys: [String] = []
var held: [String] = []
syncQueue.sync {
for (key, value) in data {
guard let value = value else {
self.data.removeValue(forKey: key)
removedKeys.append(key)
continue
}
// Refused rather than stored: `JSONSerialization` throws on a non-finite Double, and
// the payload it would refuse is the whole survey's props blob — one bad value would
// cost the survey, not the field. Never fatal, always logged.
if case .number(let number) = value, !number.isFinite {
Formbricks.logger?.error(
"setEmbeddedData: \"\(key)\" is not a finite number — the key was skipped")
continue
}
self.data[key] = value
setKeys.append(key)
}
held = Array(self.data.keys)
}
// Built and logged outside the queue, so a log write never holds it.
Formbricks.logger?.debug(
EmbeddedDataManager.setTrace(set: setKeys, removed: removedKeys, held: held))
}

/// The success trace, because the bag is otherwise invisible: it lives in memory (nothing in
/// `UserDefaults` to inspect) and the API has no getter, so without this line a host wiring up
/// `setEmbeddedData` gets no confirmation until a survey happens to display. Logged at `.debug`,
/// so it is silent at the default log level.
///
/// Keys only, never values: the documented use of this bag includes hashed identity fields.
/// Separated from the logging call so that property is directly assertable in a test.
///
/// Every list is sorted, unlike the other three SDKs, which preserve insertion order: the bag
/// and the caller's argument are both Swift `Dictionary`s, whose iteration order is not
/// specified and varies per process. Sorting is the only way this line reads the same twice.
static func setTrace(set setKeys: [String], removed removedKeys: [String], held: [String])
-> String
{
let removed =
removedKeys.isEmpty ? "" : ", removed [\(removedKeys.sorted().joined(separator: ", "))]"
return "setEmbeddedData: set [\(setKeys.sorted().joined(separator: ", "))]\(removed) — the "
+ "bag now holds [\(held.sorted().joined(separator: ", "))]. Keys land on a response "
+ "only if the survey declares them as ingested Embedded Data fields."
}

/// Removes one key. A key that is not set is a no-op.
func remove(key: String) {
var held: [String] = []
syncQueue.sync {
_ = data.removeValue(forKey: key)
held = Array(data.keys)
}
Formbricks.logger?.debug(
"clearEmbeddedData: removed \"\(key)\" — the bag now holds "
+ "[\(held.sorted().joined(separator: ", "))]")
}

/// Removes everything — logout, or a hard context switch.
func removeAll() {
var clearedCount = 0
syncQueue.sync {
clearedCount = data.count
data.removeAll()
}
Formbricks.logger?.debug("clearEmbeddedData: cleared the whole bag (\(clearedCount) keys)")
}

/// A detached, JSON-safe copy for the display-time snapshot: mutating the bag after a survey has
/// rendered must not reach that survey's response.
func snapshot() -> [String: Any] {
syncQueue.sync {
data.reduce(into: [String: Any]()) { result, entry in
if let value = entry.value.jsonValue {
result[entry.key] = value
}
}
}
}
}
72 changes: 72 additions & 0 deletions Sources/FormbricksSDK/Model/EmbeddedData/EmbeddedDataValue.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation

/// A value a host app may attach to future responses with ``Formbricks/setEmbeddedData(_:)``.
///
/// Confined to the four scalars the Embedded Data ingest contract can store. A closed enum rather
/// than `Any` on purpose: the bag is serialized into the survey WebView's payload, so an
/// unrepresentable value would not be a dropped field but a `JSONSerialization` failure that takes
/// the whole survey down with it.
///
/// Supports literal syntax in dictionaries, so the common call reads as plain data:
///
/// ```swift
/// Formbricks.setEmbeddedData([
/// "plan": "pro",
/// "seats": 25,
/// "isTrial": false,
/// "screen": nil, // removes the key
/// ])
/// ```
///
/// Dates serialize as ISO 8601, which is what the ingest contract accepts for a `date` field.
public enum EmbeddedDataValue: Equatable {
case string(String)
case number(Double)
case bool(Bool)
case date(Date)

/// A `JSONSerialization`-safe representation, or `nil` for a value that cannot be serialized.
///
/// The `nil` case is not defensive habit. `JSONSerialization` **throws** on a non-finite
/// `Double`, and the payload it would have refused is the whole survey's props blob — so a
/// single `.number(.nan)` would mean no survey rather than one missing field. The store rejects
/// those at the door and logs; this is the second line of the same rule.
var jsonValue: Any? {
switch self {
case .string(let value):
return value
case .number(let value):
return value.isFinite ? value : nil
case .bool(let value):
return value
case .date(let value):
return ISO8601DateFormatter().string(from: value)
}
}
}

// MARK: - Literal conformances for ergonomic dictionary syntax

extension EmbeddedDataValue: ExpressibleByStringLiteral {
public init(stringLiteral value: String) {
self = .string(value)
}
}

extension EmbeddedDataValue: ExpressibleByIntegerLiteral {
public init(integerLiteral value: Int) {
self = .number(Double(value))
}
}

extension EmbeddedDataValue: ExpressibleByFloatLiteral {
public init(floatLiteral value: Double) {
self = .number(value)
}
}

extension EmbeddedDataValue: ExpressibleByBooleanLiteral {
public init(booleanLiteral value: Bool) {
self = .bool(value)
}
}
6 changes: 6 additions & 0 deletions Sources/FormbricksSDK/WebView/FormbricksViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ private class WebViewData {
data["environmentId"] = Formbricks.workspaceId
data["contactId"] = Formbricks.userManager?.contactId
data["isWebEnvironment"] = false
// The Embedded Data bag, snapshotted here — this initializer runs when the survey is
// actually presented, after any configured delay — and frozen for the survey's life. Passed
// raw and unfiltered: the ingest contract (allow-list, coercion, `locked`, size caps) lives
// in the renderer, so all four mobile SDKs inherit the same rules without each shipping a
// copy, and the server re-runs all of it on ingest.
data["hiddenFieldsRecord"] = EmbeddedDataManager.shared.snapshot()
data["isBrandingEnabled"] = settings.inAppSurveyBranding ?? true

if let placementEnum = matchedSurvey?.projectOverwrites?.placement {
Expand Down
Loading
Loading