Skip to content
Draft
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
19 changes: 18 additions & 1 deletion Sources/FormbricksSDK/Formbricks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,24 @@ import Network

surveyManager?.refreshWorkspaceIfNeeded(force: force)
userManager?.syncUserStateIfNeeded()


// Fetch the server-delivered decision logic (the mobile core "brain").
// Until it arrives — or if it never does — the SDK runs on its built-in
// native logic, so this is a progressive enhancement, not a dependency.
MobileCoreLoader().load(appUrl: config.appUrl) { source in
guard let source = source else {
Formbricks.logger?.debug("No mobile core bundle available; using built-in survey logic.")
return
}
let runtime = MobileCoreRuntime(bundleSource: source)
DispatchQueue.main.async {
surveyManager?.mobileCoreRuntime = runtime
if runtime != nil {
Formbricks.logger?.debug("Mobile core bundle loaded; server-delivered survey logic active.")
}
}
}

self.isInitialized = true
}

Expand Down
71 changes: 71 additions & 0 deletions Sources/FormbricksSDK/Manager/SurveyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ final class SurveyManager {
)
}

/// The server-delivered JS brain. When present and healthy, it owns the
/// survey-selection decision; the native logic below remains as fallback.
internal var mobileCoreRuntime: MobileCoreRuntime?

internal static let workspaceResponseObjectKey = "workspaceResponseObjectKey"
/// Pre-workspace-rename storage key. Read on first access so existing installs can be migrated.
internal static let legacyEnvironmentResponseObjectKey = "environmentResponseObjectKey"
Expand Down Expand Up @@ -89,6 +93,14 @@ final class SurveyManager {
func track(_ action: String, completion: (() -> Void)? = nil) {
guard !isShowingSurvey else { return }

// When the server-delivered brain is available, it owns the decision.
// Any failure inside the remote path returns nil and we fall through
// to the built-in logic below.
if let decision = remoteDecision(for: action) {
handleRemoteDecision(decision, completion: completion)
return
}

let actionClasses = workspaceResponse?.data.data.actionClasses ?? []
let codeActionClasses = actionClasses.filter { $0.type == "code" }
guard let actionClass = codeActionClasses.first(where: { $0.key == action }) else {
Expand Down Expand Up @@ -144,6 +156,65 @@ final class SurveyManager {
}
}

// MARK: - Remote mobile core (server-delivered decision logic) -
extension SurveyManager {
/// Asks the JS brain for a display decision. Returns nil when the brain is
/// unavailable or errors, in which case the caller uses the native logic.
private func remoteDecision(for action: String) -> MobileCoreDecision? {
guard let runtime = mobileCoreRuntime else { return nil }
guard let workspaceResponse = workspaceResponse,
let workspaceData = try? JSONEncoder().encode(workspaceResponse),
let workspaceJSON = String(data: workspaceData, encoding: .utf8) else { return nil }

let userState = MobileCoreUserState(
userId: userManager.userId,
segments: userManager.segments,
displays: userManager.displays,
responses: userManager.responses,
lastDisplayedAtMs: userManager.lastDisplayedAt.map { $0.timeIntervalSince1970 * 1000 }
)

return runtime.selectSurvey(
action: action,
workspaceStateJSON: workspaceJSON,
userState: userState,
language: Formbricks.language
)
}

/// Executes a brain decision. The shell keeps only the native-only parts:
/// delay scheduling, language propagation, and presenting the WebView.
private func handleRemoteDecision(_ decision: MobileCoreDecision, completion: (() -> Void)? = nil) {
guard decision.shouldDisplay, let surveyId = decision.surveyId else {
Formbricks.logger?.info("Mobile core decided not to display a survey: \(decision.reason ?? "no reason given")")
return
}

Formbricks.logger?.info("Mobile core selected survey \(surveyId): \(decision.reason ?? "no reason given")")

if let languageCode = decision.languageCode {
Formbricks.language = languageCode
}

isShowingSurvey = true
let timeout = decision.delaySeconds ?? 0
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [weak self] in
guard let self = self else { return }
if let workspaceResponse = self.workspaceResponse {
self.presentSurveyManager.present(workspaceResponse: workspaceResponse, id: surveyId) { success in
if !success {
self.isShowingSurvey = false
}
completion?()
}
} else {
self.isShowingSurvey = false
completion?()
}
}
}
}

// MARK: - API calls -
extension SurveyManager {
/// Checks if the workspace state needs to be refreshed based on its `expiresAt` property, and if so, refreshes it, starts the refresh timer, and filters the surveys.
Expand Down
28 changes: 28 additions & 0 deletions Sources/FormbricksSDK/MobileCore/MobileCoreDecision.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation

/// The decision returned by the remote mobile core (the server-delivered JS "brain")
/// when asked whether a tracked action should display a survey.
struct MobileCoreDecision: Codable {
/// Protocol version of the decision payload. Lets old shells reject decisions
/// produced by a newer, incompatible brain instead of misinterpreting them.
let v: Int
let shouldDisplay: Bool
let surveyId: String?
let delaySeconds: Double?
/// The resolved survey language code (e.g. "default" or "de"), already validated
/// against the survey's enabled languages by the brain.
let languageCode: String?
/// Human-readable explanation of the decision, used for logging only.
let reason: String?
}

/// The user-state snapshot the shell hands to the brain alongside the workspace state.
/// Mirrors what `UserManager` persists; the brain owns all interpretation of it.
struct MobileCoreUserState: Codable {
let userId: String?
let segments: [String]?
let displays: [Display]?
let responses: [String]?
/// Milliseconds since epoch; JS-friendly representation of `lastDisplayedAt`.
let lastDisplayedAtMs: Double?
}
65 changes: 65 additions & 0 deletions Sources/FormbricksSDK/MobileCore/MobileCoreLoader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation

/// Downloads the server-delivered mobile core bundle (the JS "brain") and caches the
/// last successfully fetched copy, so the SDK keeps working offline and survives a
/// temporarily unreachable server. The bundle is versioned by bridge protocol in its
/// URL path: a v1 shell only ever asks for a v1-compatible bundle.
final class MobileCoreLoader {

/// Bridge protocol version this shell speaks. Bump only on breaking bridge changes.
static let bridgeProtocolVersion = 1

internal static let cachedBundleKey = "mobileCoreBundleKey"
internal static let cachedBundleURLKey = "mobileCoreBundleURLKey"

private let session: URLSession

init(session: URLSession = .shared) {
self.session = session
}

static func bundleURL(appUrl: String) -> URL? {
return URL(string: "\(appUrl)/js/mobile/v\(bridgeProtocolVersion)/core.umd.cjs")
}

/// Fetches the bundle from the server, falling back to the cached copy on any failure.
/// Completion is called with the JS source, or `nil` when neither network nor cache
/// can provide one (the shell then falls back to its built-in native logic).
func load(appUrl: String, completion: @escaping (String?) -> Void) {
guard let url = MobileCoreLoader.bundleURL(appUrl: appUrl) else {
completion(cachedBundle(for: appUrl))
return
}

// Same timeout the APIClient uses for its requests.
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 10)

let task = session.dataTask(with: request) { [weak self] data, response, error in
guard error == nil,
let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let data = data,
let source = String(data: data, encoding: .utf8),
!source.isEmpty else {
Formbricks.logger?.warning("Unable to fetch mobile core bundle from \(url.absoluteString), falling back to cached copy.")
completion(self?.cachedBundle(for: appUrl))
return
}

self?.cache(bundle: source, for: appUrl)
completion(source)
}
task.resume()
}

private func cache(bundle: String, for appUrl: String) {
UserDefaults.standard.set(bundle, forKey: MobileCoreLoader.cachedBundleKey)
UserDefaults.standard.set(appUrl, forKey: MobileCoreLoader.cachedBundleURLKey)
}

private func cachedBundle(for appUrl: String) -> String? {
// A cached brain from a different host must not run against this one.
guard UserDefaults.standard.string(forKey: MobileCoreLoader.cachedBundleURLKey) == appUrl else { return nil }
return UserDefaults.standard.string(forKey: MobileCoreLoader.cachedBundleKey)
}
}
108 changes: 108 additions & 0 deletions Sources/FormbricksSDK/MobileCore/MobileCoreRuntime.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import Foundation
import JavaScriptCore

/// Hosts the server-delivered mobile core bundle in a JavaScriptCore context and
/// exposes its decision API to the shell. Apple's guidelines permit downloaded code
/// only when run by WebKit or JavaScriptCore — this is the JavaScriptCore path,
/// so the brain runs without a DOM and without a hidden WebView.
///
/// The runtime is intentionally dumb about survey logic: it serializes state in,
/// gets a decision out, and never interprets the rules itself.
final class MobileCoreRuntime {

/// Global the bundle must define: `globalThis.formbricksMobileCore = { protocolVersion, selectSurvey }`.
private static let globalName = "formbricksMobileCore"

/// JSContext is not thread-safe; every evaluation goes through this serial queue.
private let jsQueue = DispatchQueue(label: "com.formbricks.mobilecore.js")
private let context: JSContext

/// Fails when the bundle doesn't evaluate, doesn't define the expected global,
/// or speaks a different bridge protocol than this shell.
init?(bundleSource: String) {
guard let context = JSContext() else { return nil }
self.context = context

context.exceptionHandler = { _, exception in
Formbricks.logger?.error("Mobile core JS exception: \(exception?.toString() ?? "unknown")")
}

// JSC has no console; route the brain's logging through the SDK logger.
let log: @convention(block) (String, String) -> Void = { level, message in
switch level {
case "error": Formbricks.logger?.error("[mobile-core] \(message)")
case "warn": Formbricks.logger?.warning("[mobile-core] \(message)")
default: Formbricks.logger?.debug("[mobile-core] \(message)")
}
}
context.setObject(log, forKeyedSubscript: "__fbNativeLog" as NSString)
context.evaluateScript("""
globalThis.console = {
log: (...a) => __fbNativeLog('log', a.join(' ')),
warn: (...a) => __fbNativeLog('warn', a.join(' ')),
error: (...a) => __fbNativeLog('error', a.join(' ')),
debug: (...a) => __fbNativeLog('log', a.join(' ')),
};
""")

context.evaluateScript(bundleSource)

let core = context.objectForKeyedSubscript(MobileCoreRuntime.globalName)
guard let core = core, !core.isUndefined, core.objectForKeyedSubscript("selectSurvey")?.isUndefined == false else {
Formbricks.logger?.error("Mobile core bundle did not define \(MobileCoreRuntime.globalName).selectSurvey.")
return nil
}

let protocolVersion = core.objectForKeyedSubscript("protocolVersion")?.toInt32() ?? 0
guard protocolVersion == MobileCoreLoader.bridgeProtocolVersion else {
Formbricks.logger?.error("Mobile core bundle speaks bridge protocol v\(protocolVersion), shell speaks v\(MobileCoreLoader.bridgeProtocolVersion). Ignoring bundle.")
return nil
}
}

/// Asks the brain which survey (if any) to display for a tracked action.
/// Returns `nil` when the brain fails in any way, so the caller can fall back
/// to the shell's built-in native logic.
func selectSurvey(action: String,
workspaceStateJSON: String,
userState: MobileCoreUserState,
language: String) -> MobileCoreDecision? {
guard let userStateData = try? JSONEncoder().encode(userState),
let userStateJSON = String(data: userStateData, encoding: .utf8) else {
return nil
}

return jsQueue.sync {
let call = """
JSON.stringify(globalThis.\(MobileCoreRuntime.globalName).selectSurvey({
action: \(MobileCoreRuntime.jsStringLiteral(action)),
workspaceState: \(workspaceStateJSON),
userState: \(userStateJSON),
language: \(MobileCoreRuntime.jsStringLiteral(language)),
nowMs: Date.now(),
}))
"""

guard let result = context.evaluateScript(call),
result.isString,
let json = result.toString(),
let data = json.data(using: .utf8),
let decision = try? JSONDecoder().decode(MobileCoreDecision.self, from: data) else {
Formbricks.logger?.error("Mobile core returned an unreadable decision for action '\(action)'.")
return nil
}

return decision
}
}

/// Embeds a Swift string into generated JS as a safe literal.
private static func jsStringLiteral(_ value: String) -> String {
guard let data = try? JSONEncoder().encode([value]),
let encoded = String(data: data, encoding: .utf8) else {
return "\"\""
}
// Encoded as a one-element array ["..."]; strip the brackets.
return String(encoded.dropFirst().dropLast())
}
}
Loading
Loading