diff --git a/Sources/FormbricksSDK/Formbricks.swift b/Sources/FormbricksSDK/Formbricks.swift index 8212bf2..9bae602 100644 --- a/Sources/FormbricksSDK/Formbricks.swift +++ b/Sources/FormbricksSDK/Formbricks.swift @@ -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 } diff --git a/Sources/FormbricksSDK/Manager/SurveyManager.swift b/Sources/FormbricksSDK/Manager/SurveyManager.swift index e0965f4..07bf5f4 100644 --- a/Sources/FormbricksSDK/Manager/SurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/SurveyManager.swift @@ -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" @@ -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 { @@ -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. diff --git a/Sources/FormbricksSDK/MobileCore/MobileCoreDecision.swift b/Sources/FormbricksSDK/MobileCore/MobileCoreDecision.swift new file mode 100644 index 0000000..0528282 --- /dev/null +++ b/Sources/FormbricksSDK/MobileCore/MobileCoreDecision.swift @@ -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? +} diff --git a/Sources/FormbricksSDK/MobileCore/MobileCoreLoader.swift b/Sources/FormbricksSDK/MobileCore/MobileCoreLoader.swift new file mode 100644 index 0000000..62b8848 --- /dev/null +++ b/Sources/FormbricksSDK/MobileCore/MobileCoreLoader.swift @@ -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) + } +} diff --git a/Sources/FormbricksSDK/MobileCore/MobileCoreRuntime.swift b/Sources/FormbricksSDK/MobileCore/MobileCoreRuntime.swift new file mode 100644 index 0000000..1f18f2c --- /dev/null +++ b/Sources/FormbricksSDK/MobileCore/MobileCoreRuntime.swift @@ -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()) + } +} diff --git a/Tests/FormbricksSDKTests/MobileCoreRuntimeTests.swift b/Tests/FormbricksSDKTests/MobileCoreRuntimeTests.swift new file mode 100644 index 0000000..56aa291 --- /dev/null +++ b/Tests/FormbricksSDKTests/MobileCoreRuntimeTests.swift @@ -0,0 +1,106 @@ +import XCTest +@testable import FormbricksSDK + +final class MobileCoreRuntimeTests: XCTestCase { + + /// A minimal stand-in for the server-delivered bundle, speaking bridge protocol v1. + /// Echoes enough of the payload back to prove state crosses the bridge intact. + private let stubBrain = """ + globalThis.formbricksMobileCore = { + protocolVersion: 1, + selectSurvey: function (payload) { + var surveys = (payload.workspaceState.data && payload.workspaceState.data.data.surveys) || []; + var alreadyDisplayed = (payload.userState.displays || []).length > 0; + if (surveys.length === 0 || alreadyDisplayed) { + return { v: 1, shouldDisplay: false, surveyId: null, delaySeconds: null, languageCode: null, reason: "stub: nothing to show" }; + } + return { + v: 1, + shouldDisplay: true, + surveyId: surveys[0].id, + delaySeconds: 2, + languageCode: payload.language, + reason: "stub: action=" + payload.action + " userId=" + payload.userState.userId + }; + } + }; + """ + + private let workspaceStateJSON = """ + { "data": { "data": { "surveys": [ { "id": "survey_123" } ] } } } + """ + + func testRuntimeInitializesWithValidBundle() { + XCTAssertNotNil(MobileCoreRuntime(bundleSource: stubBrain)) + } + + func testRuntimeRejectsBundleWithoutGlobal() { + XCTAssertNil(MobileCoreRuntime(bundleSource: "var x = 1;")) + } + + func testRuntimeRejectsBundleWithWrongProtocolVersion() { + let futureBrain = stubBrain.replacingOccurrences(of: "protocolVersion: 1", with: "protocolVersion: 2") + XCTAssertNil(MobileCoreRuntime(bundleSource: futureBrain)) + } + + func testRuntimeRejectsBundleThatFailsToEvaluate() { + XCTAssertNil(MobileCoreRuntime(bundleSource: "this is not javascript {{{")) + } + + func testSelectSurveyReturnsDecisionAndPassesStateThrough() throws { + let runtime = try XCTUnwrap(MobileCoreRuntime(bundleSource: stubBrain)) + let userState = MobileCoreUserState(userId: "user_1", segments: [], displays: [], responses: [], lastDisplayedAtMs: nil) + + let decision = try XCTUnwrap(runtime.selectSurvey( + action: "button_clicked", + workspaceStateJSON: workspaceStateJSON, + userState: userState, + language: "de" + )) + + XCTAssertTrue(decision.shouldDisplay) + XCTAssertEqual(decision.surveyId, "survey_123") + XCTAssertEqual(decision.delaySeconds, 2) + XCTAssertEqual(decision.languageCode, "de") + XCTAssertEqual(decision.reason, "stub: action=button_clicked userId=user_1") + } + + func testSelectSurveyRespectsUserStateAcrossBridge() throws { + let runtime = try XCTUnwrap(MobileCoreRuntime(bundleSource: stubBrain)) + let userState = MobileCoreUserState( + userId: "user_1", + segments: [], + displays: [Display(surveyId: "survey_123", createdAt: "2026-07-02T00:00:00Z")], + responses: [], + lastDisplayedAtMs: nil + ) + + let decision = try XCTUnwrap(runtime.selectSurvey( + action: "button_clicked", + workspaceStateJSON: workspaceStateJSON, + userState: userState, + language: "default" + )) + + XCTAssertFalse(decision.shouldDisplay) + XCTAssertNil(decision.surveyId) + } + + func testSelectSurveyReturnsNilWhenBrainThrows() throws { + let throwingBrain = """ + globalThis.formbricksMobileCore = { + protocolVersion: 1, + selectSurvey: function () { throw new Error("boom"); } + }; + """ + let runtime = try XCTUnwrap(MobileCoreRuntime(bundleSource: throwingBrain)) + let userState = MobileCoreUserState(userId: nil, segments: nil, displays: nil, responses: nil, lastDisplayedAtMs: nil) + + XCTAssertNil(runtime.selectSurvey( + action: "button_clicked", + workspaceStateJSON: workspaceStateJSON, + userState: userState, + language: "default" + )) + } +}