From 0e9eb11bf2829e9372ea3015dc286fcafc768c4f Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Thu, 23 Apr 2026 17:53:39 +0530 Subject: [PATCH 1/4] feat: adds workspace support --- Sources/FormbricksSDK/Formbricks.swift | 40 ++- .../FormbricksSDK/Helpers/ConfigBuilder.swift | 48 +++- ...onment.swift => FormbricksWorkspace.swift} | 12 +- .../Manager/PresentSurveyManager.swift | 4 +- .../FormbricksSDK/Manager/SurveyManager.swift | 182 +++++++----- .../Model/Environment/EnvironmentData.swift | 6 - .../Environment/EnvironmentResponseData.swift | 6 - .../ActionClass/ActionClass.swift | 0 .../Common/LocalizedText.swift | 0 .../Settings}/BrandColor.swift | 0 .../Settings/Settings.swift} | 2 +- .../Settings}/Styling.swift | 0 .../{Environment => Workspace}/Survey.swift | 0 .../Surveys/ActionClassReference.swift | 0 .../Surveys/Segment.swift | 36 ++- .../Surveys/Trigger.swift | 0 .../Model/Workspace/WorkspaceData.swift | 47 ++++ .../WorkspaceResponse.swift} | 21 +- .../Workspace/WorkspaceResponseData.swift | 6 + .../Networking/Base/APIClient.swift | 28 +- .../Environment/GetEnvironmentRequest.swift | 5 - .../Endpoints/User/PostUserRequest.swift | 2 +- .../Workspace/GetWorkspaceRequest.swift | 5 + .../Service/FormbricksService.swift | 14 +- .../WebView/FormbricksViewModel.swift | 63 +++-- .../FormbricksSDKTests.swift | 266 +++++++++++++++--- ...s.swift => FormbricksWorkspaceTests.swift} | 40 +-- .../MockFormbricksService.swift | 2 +- .../Networking/APIClientTests.swift | 12 +- .../Networking/ClientAPIEndpointsTests.swift | 6 +- 30 files changed, 585 insertions(+), 268 deletions(-) rename Sources/FormbricksSDK/Helpers/{FormbricksEnvironment.swift => FormbricksWorkspace.swift} (61%) delete mode 100644 Sources/FormbricksSDK/Model/Environment/EnvironmentData.swift delete mode 100644 Sources/FormbricksSDK/Model/Environment/EnvironmentResponseData.swift rename Sources/FormbricksSDK/Model/{Environment => Workspace}/ActionClass/ActionClass.swift (100%) rename Sources/FormbricksSDK/Model/{Environment => Workspace}/Common/LocalizedText.swift (100%) rename Sources/FormbricksSDK/Model/{Environment/Project => Workspace/Settings}/BrandColor.swift (100%) rename Sources/FormbricksSDK/Model/{Environment/Project/Project.swift => Workspace/Settings/Settings.swift} (88%) rename Sources/FormbricksSDK/Model/{Environment/Project => Workspace/Settings}/Styling.swift (100%) rename Sources/FormbricksSDK/Model/{Environment => Workspace}/Survey.swift (100%) rename Sources/FormbricksSDK/Model/{Environment => Workspace}/Surveys/ActionClassReference.swift (100%) rename Sources/FormbricksSDK/Model/{Environment => Workspace}/Surveys/Segment.swift (74%) rename Sources/FormbricksSDK/Model/{Environment => Workspace}/Surveys/Trigger.swift (100%) create mode 100644 Sources/FormbricksSDK/Model/Workspace/WorkspaceData.swift rename Sources/FormbricksSDK/Model/{Environment/EnvironmentResponse.swift => Workspace/WorkspaceResponse.swift} (76%) create mode 100644 Sources/FormbricksSDK/Model/Workspace/WorkspaceResponseData.swift delete mode 100644 Sources/FormbricksSDK/Networking/ClientAPI/Endpoints/Environment/GetEnvironmentRequest.swift create mode 100644 Sources/FormbricksSDK/Networking/ClientAPI/Endpoints/Workspace/GetWorkspaceRequest.swift rename Tests/FormbricksSDKTests/{FormbricksEnvironmentTests.swift => FormbricksWorkspaceTests.swift} (63%) diff --git a/Sources/FormbricksSDK/Formbricks.swift b/Sources/FormbricksSDK/Formbricks.swift index d1a5315c..0a30b161 100644 --- a/Sources/FormbricksSDK/Formbricks.swift +++ b/Sources/FormbricksSDK/Formbricks.swift @@ -5,7 +5,13 @@ import Network @objc(Formbricks) public class Formbricks: NSObject { static internal var appUrl: String? - static internal var environmentId: String? + static internal var workspaceId: String? + /// Backward-compatible alias for `workspaceId`. + @available(*, deprecated, renamed: "workspaceId", message: "Use workspaceId instead. environmentId will be removed in a future version.") + static internal var environmentId: String? { + get { workspaceId } + set { workspaceId = newValue } + } static internal var language: String = "default" static internal var isInitialized: Bool = false @@ -30,31 +36,35 @@ import Network Example: ```swift - let config = FormbricksConfig.Builder(appUrl: "APP_URL_HERE", environmentId: "TOKEN_HERE") + let config = FormbricksConfig.Builder(appUrl: "APP_URL_HERE", workspaceId: "TOKEN_HERE") .setUserId("USER_ID_HERE") .setLogLevel(.debug) .build() - + Formbricks.setup(with: config) ``` */ @objc public static func setup(with config: FormbricksConfig, force: Bool = false) { logger = Logger() apiQueue = OperationQueue() - + if force { isInitialized = false } - + guard !isInitialized else { let error = FormbricksSDKError(type: .sdkIsAlreadyInitialized) Formbricks.logger?.error(error.message) return } - + self.appUrl = config.appUrl - self.environmentId = config.environmentId + self.workspaceId = config.workspaceId self.logger?.logLevel = config.logLevel + + if config.usedDeprecatedEnvironmentId { + Formbricks.logger?.debug("environmentId is deprecated and will be removed in a future version. Please use workspaceId instead.") + } // Validate appUrl before proceeding with setup guard let url = URL(string: config.appUrl) else { @@ -63,11 +73,11 @@ import Network } // Validate that appUrl uses HTTPS (block HTTP for security) - guard url.scheme?.lowercased() == "https" else { - let errorMessage = "HTTP requests are blocked for security. Only HTTPS URLs are allowed. Provided app url: \(config.appUrl). SDK setup aborted." - Formbricks.logger?.error(errorMessage) - return - } + guard url.scheme?.lowercased() == "https" else { + let errorMessage = "HTTP requests are blocked for security. Only HTTPS URLs are allowed. Provided app url: \(config.appUrl). SDK setup aborted." + Formbricks.logger?.error(errorMessage) + return + } let svc: FormbricksServiceProtocol = config.customService ?? FormbricksService() @@ -88,7 +98,7 @@ import Network surveyManager = SurveyManager.create(userManager: userManager!, presentSurveyManager: presentSurveyManager!, service: svc) userManager?.surveyManager = surveyManager - surveyManager?.refreshEnvironmentIfNeeded(force: force) + surveyManager?.refreshWorkspaceIfNeeded(force: force) userManager?.syncUserStateIfNeeded() self.isInitialized = true @@ -289,7 +299,7 @@ import Network } /** - Cleans up the SDK. This will clear the user attributes, the user id and the environment state. + Cleans up the SDK. This will clear the user attributes, the user id and the workspace state. The SDK must be initialized before calling this method. If `waitForOperations` is set to `true`, it will wait for all operations to finish before cleaning up. If `waitForOperations` is set to `false`, it will clean up immediately. @@ -331,7 +341,7 @@ import Network apiQueue = nil isInitialized = false appUrl = nil - environmentId = nil + workspaceId = nil logger = nil language = "default" } diff --git a/Sources/FormbricksSDK/Helpers/ConfigBuilder.swift b/Sources/FormbricksSDK/Helpers/ConfigBuilder.swift index 28df5356..b9df6a2e 100644 --- a/Sources/FormbricksSDK/Helpers/ConfigBuilder.swift +++ b/Sources/FormbricksSDK/Helpers/ConfigBuilder.swift @@ -3,43 +3,61 @@ import Foundation /// The configuration object for the Formbricks SDK. @objc(FormbricksConfig) public class FormbricksConfig: NSObject { let appUrl: String - let environmentId: String + let workspaceId: String let userId: String? let attributes: [String: AttributeValue]? let logLevel: LogLevel /// Optional custom service, injected via Builder let customService: FormbricksServiceProtocol? - - init(appUrl: String, environmentId: String, userId: String?, attributes: [String: AttributeValue]?, logLevel: LogLevel, customService: FormbricksServiceProtocol?) { + /// True if this config was built using the deprecated `environmentId` parameter. + let usedDeprecatedEnvironmentId: Bool + + /// Backward-compatible alias for `workspaceId`. + @available(*, deprecated, renamed: "workspaceId", message: "Use workspaceId instead. environmentId will be removed in a future version.") + @objc public var environmentId: String { workspaceId } + + init(appUrl: String, workspaceId: String, userId: String?, attributes: [String: AttributeValue]?, logLevel: LogLevel, customService: FormbricksServiceProtocol?, usedDeprecatedEnvironmentId: Bool = false) { self.appUrl = appUrl - self.environmentId = environmentId + self.workspaceId = workspaceId self.userId = userId self.attributes = attributes self.logLevel = logLevel self.customService = customService + self.usedDeprecatedEnvironmentId = usedDeprecatedEnvironmentId } - + /// The builder class for the FormbricksConfig object. @objc(FormbricksConfigBuilder) public class Builder: NSObject { var appUrl: String - var environmentId: String + var workspaceId: String var userId: String? var attributes: [String: AttributeValue] = [:] var logLevel: LogLevel = .error /// Optional custom service, injected via Builder var customService: FormbricksServiceProtocol? - + var usedDeprecatedEnvironmentId: Bool = false + + /// Initializes the builder with the workspace ID. + @objc public init(appUrl: String, workspaceId: String) { + self.appUrl = appUrl + self.workspaceId = workspaceId + } + + /// Initializes the builder with the environment ID. + /// - Warning: `environmentId` is deprecated — use `init(appUrl:workspaceId:)` instead. + @available(*, deprecated, renamed: "init(appUrl:workspaceId:)", message: "Use init(appUrl:workspaceId:) instead. environmentId will be removed in a future version.") @objc public init(appUrl: String, environmentId: String) { self.appUrl = appUrl - self.environmentId = environmentId + self.workspaceId = environmentId + self.usedDeprecatedEnvironmentId = true } - + /// Sets the user id for the Builder object. @objc public func set(userId: String) -> Builder { self.userId = userId return self } - + /// Sets the attributes for the Builder object. /// /// Thanks to `ExpressibleByStringLiteral`, `ExpressibleByIntegerLiteral`, @@ -58,27 +76,27 @@ import Foundation self.attributes = stringAttributes.mapValues { .string($0) } return self } - + /// Adds a string attribute to the Builder object (Obj-C compatible). @objc public func add(attribute: String, forKey key: String) -> Builder { self.attributes[key] = .string(attribute) return self } - + /// Sets the log level for the Builder object. @objc public func setLogLevel(_ logLevel: LogLevel) -> Builder { self.logLevel = logLevel return self } - + func service(_ svc: FormbricksServiceProtocol) -> FormbricksConfig.Builder { self.customService = svc return self } - + /// Builds the FormbricksConfig object from the Builder object. @objc public func build() -> FormbricksConfig { - return FormbricksConfig(appUrl: appUrl, environmentId: environmentId, userId: userId, attributes: attributes, logLevel: logLevel, customService: customService) + return FormbricksConfig(appUrl: appUrl, workspaceId: workspaceId, userId: userId, attributes: attributes, logLevel: logLevel, customService: customService, usedDeprecatedEnvironmentId: usedDeprecatedEnvironmentId) } } } diff --git a/Sources/FormbricksSDK/Helpers/FormbricksEnvironment.swift b/Sources/FormbricksSDK/Helpers/FormbricksWorkspace.swift similarity index 61% rename from Sources/FormbricksSDK/Helpers/FormbricksEnvironment.swift rename to Sources/FormbricksSDK/Helpers/FormbricksWorkspace.swift index 31111174..b5bb8d9d 100644 --- a/Sources/FormbricksSDK/Helpers/FormbricksEnvironment.swift +++ b/Sources/FormbricksSDK/Helpers/FormbricksWorkspace.swift @@ -1,6 +1,6 @@ import Foundation -internal enum FormbricksEnvironment { +internal enum FormbricksWorkspace { /// Only `appUrl` is user-supplied. Returns nil if it's missing. internal static var baseApiUrl: String? { @@ -18,13 +18,13 @@ internal enum FormbricksEnvironment { return surveyScriptURL.absoluteString } - /// Returns the full environment‐fetch URL as a String for the given ID - static var getEnvironmentRequestEndpoint: String { - return ["api", "v2", "client", "{environmentId}", "environment"].joined(separator: "/") + /// Returns the workspace-state fetch URL path with a `{workspaceId}` placeholder. + static var getWorkspaceStateRequestEndpoint: String { + return ["api", "v2", "client", "{workspaceId}", "environment"].joined(separator: "/") } - /// Returns the full post-user URL as a String for the given ID + /// Returns the post-user URL path with a `{workspaceId}` placeholder. static var postUserRequestEndpoint: String { - return ["api", "v2", "client", "{environmentId}", "user"].joined(separator: "/") + return ["api", "v2", "client", "{workspaceId}", "user"].joined(separator: "/") } } diff --git a/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift b/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift index 5875cf84..dc654f94 100644 --- a/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift @@ -15,11 +15,11 @@ final class PresentSurveyManager { /// Present the webview /// The native background is always `.clear` — overlay rendering is handled /// entirely by the JS survey library inside the WebView to avoid double-overlay artifacts. - func present(environmentResponse: EnvironmentResponse, id: String, completion: ((Bool) -> Void)? = nil) { + func present(workspaceResponse: WorkspaceResponse, id: String, completion: ((Bool) -> Void)? = nil) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } if let window = UIApplication.safeKeyWindow { - let view = FormbricksView(viewModel: FormbricksViewModel(environmentResponse: environmentResponse, surveyId: id)) + let view = FormbricksView(viewModel: FormbricksViewModel(workspaceResponse: workspaceResponse, surveyId: id)) let vc = UIHostingController(rootView: view) vc.modalPresentationStyle = .overCurrentContext vc.view.backgroundColor = .clear diff --git a/Sources/FormbricksSDK/Manager/SurveyManager.swift b/Sources/FormbricksSDK/Manager/SurveyManager.swift index b01be37a..a117cf5f 100644 --- a/Sources/FormbricksSDK/Manager/SurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/SurveyManager.swift @@ -1,6 +1,11 @@ import SwiftUI public extension Notification.Name { + /// Posted when the workspace state has been refreshed. + static let workspaceRefreshed = Notification.Name("Formbricks.workspaceRefreshed") + + /// Backward-compatible alias for `workspaceRefreshed`. The SDK posts both names. + @available(*, deprecated, renamed: "workspaceRefreshed", message: "Use .workspaceRefreshed instead. environmentRefreshed will be removed in a future version.") static let environmentRefreshed = Notification.Name("Formbricks.environmentRefreshed") } @@ -17,7 +22,7 @@ final class SurveyManager { self.presentSurveyManager = presentSurveyManager self.service = service } - + static func create( userManager: UserManager, presentSurveyManager: PresentSurveyManager, @@ -29,28 +34,30 @@ final class SurveyManager { service: service ) } - - private static let environmentResponseObjectKey = "environmentResponseObjectKey" - private var backingEnvironmentResponse: EnvironmentResponse? + + 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" + private var backingWorkspaceResponse: WorkspaceResponse? /// Stores the surveys that are filtered based on the defined criteria, such as recontact days, display options etc. internal private(set) var filteredSurveys: [Survey] = [] /// Stores is a survey is being shown or the show in delayed internal private(set) var isShowingSurvey: Bool = false /// Store error state internal private(set) var hasApiError: Bool = false - + /// Fills up the `filteredSurveys` array func filterSurveys() { - guard let environment = environmentResponse else { return } - guard let surveys = environment.data.data.surveys else { return } - + guard let workspace = workspaceResponse else { return } + guard let surveys = workspace.data.data.surveys else { return } + let displays = userManager.displays ?? [] let responses = userManager.responses ?? [] let segments = userManager.segments ?? [] - + filteredSurveys = filterSurveysBasedOnDisplayType(surveys, displays: displays, responses: responses) - filteredSurveys = filterSurveysBasedOnRecontactDays(filteredSurveys, defaultRecontactDays: environment.data.data.project.recontactDays) - + filteredSurveys = filterSurveysBasedOnRecontactDays(filteredSurveys, defaultRecontactDays: workspace.data.data.settings.recontactDays) + // If we don't have a user, we exclude surveys that have segments with filters if userManager.userId == nil { filteredSurveys = filteredSurveys.filter { survey in @@ -58,39 +65,39 @@ final class SurveyManager { guard let segment = survey.segment else { return true } - + // Include surveys with segments but no filters return segment.filters.isEmpty } } - + // If we have a user, we do more filtering if userManager.userId != nil { if segments.isEmpty { filteredSurveys = [] return } - + filteredSurveys = filterSurveysBasedOnSegments(filteredSurveys, segments: segments) } } - + /// Checks if there are any surveys to display, based in the track action, and if so, displays the first one. /// Handles the display percentage and the delay of the survey. func track(_ action: String, completion: (() -> Void)? = nil) { guard !isShowingSurvey else { return } - - let actionClasses = environmentResponse?.data.data.actionClasses ?? [] + + let actionClasses = workspaceResponse?.data.data.actionClasses ?? [] let codeActionClasses = actionClasses.filter { $0.type == "code" } guard let actionClass = codeActionClasses.first(where: { $0.key == action }) else { Formbricks.logger?.error("Action with identifier '\(action)' is unknown. Please add this action in Formbricks in order to use it via the SDK action tracking.") return } - + let firstSurveyWithActionClass = filteredSurveys.first { survey in return survey.triggers?.contains(where: { $0.actionClass?.name == actionClass.name }) ?? false } - + // Display percentage let shouldDisplay = shouldDisplayBasedOnPercentage(firstSurveyWithActionClass?.displayPercentage) if let survey = firstSurveyWithActionClass, !shouldDisplay { @@ -106,7 +113,7 @@ final class SurveyManager { Formbricks.logger?.error("Survey \(survey.name) is not available in language “\(currentLanguage)”. Skipping.") return } - + Formbricks.language = languageCode } @@ -119,8 +126,8 @@ final class SurveyManager { } DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { [weak self] in guard let self = self else { return } - if let environmentResponse = self.environmentResponse { - self.presentSurveyManager.present(environmentResponse: environmentResponse, id: survey.id) { success in + if let workspaceResponse = self.workspaceResponse { + self.presentSurveyManager.present(workspaceResponse: workspaceResponse, id: survey.id) { success in if !success { self.isShowingSurvey = false } @@ -137,37 +144,37 @@ final class SurveyManager { // MARK: - API calls - extension SurveyManager { - /// Checks if the environment state needs to be refreshed based on its `expiresAt` property, and if so, refreshes it, starts the refresh timer, and filters the surveys. - func refreshEnvironmentIfNeeded(force: Bool = false) { - if let environmentResponse = environmentResponse, environmentResponse.data.expiresAt.timeIntervalSinceNow > 0, !force { - Formbricks.logger?.debug("Environment state is still valid until \(environmentResponse.data.expiresAt)") + /// 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. + func refreshWorkspaceIfNeeded(force: Bool = false) { + if let workspaceResponse = workspaceResponse, workspaceResponse.data.expiresAt.timeIntervalSinceNow > 0, !force { + Formbricks.logger?.debug("Workspace state is still valid until \(workspaceResponse.data.expiresAt)") filterSurveys() return } - - service.getEnvironmentState { [weak self] result in + + service.getWorkspaceState { [weak self] result in switch result { case .success(let response): self?.hasApiError = false - self?.environmentResponse = response + self?.workspaceResponse = response self?.startRefreshTimer(expiresAt: response.data.expiresAt) self?.filterSurveys() - NotificationCenter.default.post(name: .environmentRefreshed, object: self) + SurveyManager.postWorkspaceRefreshed(object: self) case .failure: self?.hasApiError = true let error = FormbricksSDKError(type: .unableToRefreshEnvironment) Formbricks.logger?.error(error.message) self?.startErrorTimer() - NotificationCenter.default.post(name: .environmentRefreshed, object: self) + SurveyManager.postWorkspaceRefreshed(object: self) } } } - + /// Posts a survey response to the Formbricks API. func postResponse(surveyId: String) { userManager.onResponse(surveyId: surveyId) } - + /// Creates a new display for the survey. It is called when the survey is displayed to the user. func onNewDisplay(surveyId: String) { userManager.onDisplay(surveyId: surveyId) @@ -188,34 +195,34 @@ private extension SurveyManager { /// The survey is displayed based on the `FormbricksView`. /// The view controller is presented over the current context. func showSurvey(withId id: String) { - if let environmentResponse = environmentResponse { - presentSurveyManager.present(environmentResponse: environmentResponse, id: id) + if let workspaceResponse = workspaceResponse { + presentSurveyManager.present(workspaceResponse: workspaceResponse, id: id) } } - - /// Starts a timer to refresh the environment state after the given timeout (`expiresAt`). + + /// Starts a timer to refresh the workspace state after the given timeout (`expiresAt`). func startRefreshTimer(expiresAt: Date) { let timeout = expiresAt.timeIntervalSinceNow - refreshEnvironmentAfter(timeout: timeout) + refreshWorkspaceAfter(timeout: timeout) } - - /// When an error occurs, it starts a timer to refresh the environment state after the given timeout. + + /// When an error occurs, it starts a timer to refresh the workspace state after the given timeout. func startErrorTimer() { - refreshEnvironmentAfter(timeout: Double(Config.Environment.refreshStateOnErrorTimeoutInMinutes) * 60.0) + refreshWorkspaceAfter(timeout: Double(Config.Environment.refreshStateOnErrorTimeoutInMinutes) * 60.0) } - - /// Refreshes the environment state after the given timeout. - internal func refreshEnvironmentAfter(timeout: Double) { + + /// Refreshes the workspace state after the given timeout. + internal func refreshWorkspaceAfter(timeout: Double) { guard timeout > 0 else { return } - + DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [weak self] in - Formbricks.logger?.debug("Refreshing environment state.") - self?.refreshEnvironmentIfNeeded(force: true) + Formbricks.logger?.debug("Refreshing workspace state.") + self?.refreshWorkspaceIfNeeded(force: true) } } - + /// Decides if the survey should be displayed based on the display percentage. internal func shouldDisplayBasedOnPercentage(_ displayPercentage: Double?) -> Bool { guard let displayPercentage = displayPercentage else { return true } @@ -223,27 +230,46 @@ private extension SurveyManager { let draw = Double.random(in: 0..<100) return draw < clampedPercentage } + + /// Posts both `.workspaceRefreshed` (new) and `.environmentRefreshed` (legacy alias) + /// so existing subscribers keep working after the rename. + static func postWorkspaceRefreshed(object: Any?) { + NotificationCenter.default.post(name: .workspaceRefreshed, object: object) + NotificationCenter.default.post(name: Notification.Name("Formbricks.environmentRefreshed"), object: object) + } } // MARK: - Store data in the UserDefaults - extension SurveyManager { - var environmentResponse: EnvironmentResponse? { + var workspaceResponse: WorkspaceResponse? { get { - if let environmentResponse = backingEnvironmentResponse { - return environmentResponse - } else { - if let data = UserDefaults.standard.data(forKey: SurveyManager.environmentResponseObjectKey) { - return try? JSONDecoder().decode(EnvironmentResponse.self, from: data) - } else { - let error = FormbricksSDKError(type: .unableToRetrieveEnvironment) - Formbricks.logger?.error(error.message) - return nil - } + if let workspaceResponse = backingWorkspaceResponse { + return workspaceResponse + } + + // Prefer the new key; fall back to the legacy key for installs that still + // have data stored under the pre-rename `environmentResponseObjectKey`. + let defaults = UserDefaults.standard + if let data = defaults.data(forKey: SurveyManager.workspaceResponseObjectKey) { + return try? JSONDecoder().decode(WorkspaceResponse.self, from: data) } + + if let legacyData = defaults.data(forKey: SurveyManager.legacyEnvironmentResponseObjectKey) { + // Migrate the legacy blob to the new key and drop the old one. + defaults.set(legacyData, forKey: SurveyManager.workspaceResponseObjectKey) + defaults.removeObject(forKey: SurveyManager.legacyEnvironmentResponseObjectKey) + return try? JSONDecoder().decode(WorkspaceResponse.self, from: legacyData) + } + + let error = FormbricksSDKError(type: .unableToRetrieveEnvironment) + Formbricks.logger?.error(error.message) + return nil } set { if let data = try? JSONEncoder().encode(newValue) { - UserDefaults.standard.set(data, forKey: SurveyManager.environmentResponseObjectKey) - backingEnvironmentResponse = newValue + UserDefaults.standard.set(data, forKey: SurveyManager.workspaceResponseObjectKey) + // Drop the legacy cache key once we've written to the new one. + UserDefaults.standard.removeObject(forKey: SurveyManager.legacyEnvironmentResponseObjectKey) + backingWorkspaceResponse = newValue } else { let error = FormbricksSDKError(type: .unableToPersistEnvironment) Formbricks.logger?.error(error.message) @@ -260,13 +286,13 @@ extension SurveyManager { switch survey.displayOption { case .respondMultiple: return true - + case .displayOnce: return !displays.contains { $0.surveyId == survey.id } - + case .displayMultiple: return !responses.contains { $0 == survey.id } - + case .displaySome: if let limit = survey.displayLimit { if responses.contains(where: { $0 == survey.id }) { @@ -276,33 +302,33 @@ extension SurveyManager { } else { return true } - + default: let error = FormbricksSDKError(type: .invalidDisplayOption) Formbricks.logger?.error(error.message) return false } - - + + } } - + /// Filters the surveys based on the recontact days and the `lastDisplayedAt` date. func filterSurveysBasedOnRecontactDays(_ surveys: [Survey], defaultRecontactDays: Int?) -> [Survey] { surveys.filter { survey in guard let lastDisplayedAt = userManager.lastDisplayedAt else { return true } let recontactDays = survey.recontactDays ?? defaultRecontactDays - + if let recontactDays = recontactDays { let secondsElapsed = Date().timeIntervalSince(lastDisplayedAt) let daysBetween = Int(secondsElapsed / 86_400) return daysBetween >= recontactDays } - + return true } } - + internal func getLanguageCode( survey: Survey, language: String? @@ -310,27 +336,27 @@ extension SurveyManager { // 1) Collect all codes let availableLanguageCodes = survey.languages? .map { $0.language.code } - + // 2) If no language was passed or it's the explicit "default" token → default guard let raw = language?.lowercased(), !raw.isEmpty else { return "default" } - + if raw == "default" { return "default" } - + // 3) Find matching entry by code or alias let selected = survey.languages?.first { entry in entry.language.code.lowercased() == raw || entry.language.alias?.lowercased() == raw } - + // 4) If that entry is marked default → default if selected?.isDefault == true { return "default" } - + // 5) If no entry, or not enabled, or code not in the available list → nil guard let entry = selected, @@ -339,11 +365,11 @@ extension SurveyManager { else { return nil } - + // 6) Otherwise return its code return entry.language.code } - + /// Filters the surveys based on the user's segments. func filterSurveysBasedOnSegments(_ surveys: [Survey], segments: [String]) -> [Survey] { return surveys.filter { survey in diff --git a/Sources/FormbricksSDK/Model/Environment/EnvironmentData.swift b/Sources/FormbricksSDK/Model/Environment/EnvironmentData.swift deleted file mode 100644 index 47407f79..00000000 --- a/Sources/FormbricksSDK/Model/Environment/EnvironmentData.swift +++ /dev/null @@ -1,6 +0,0 @@ -struct EnvironmentData: Codable { - let surveys: [Survey]? - let actionClasses: [ActionClass]? - let project: Project - let recaptchaSiteKey: String? -} diff --git a/Sources/FormbricksSDK/Model/Environment/EnvironmentResponseData.swift b/Sources/FormbricksSDK/Model/Environment/EnvironmentResponseData.swift deleted file mode 100644 index 507df667..00000000 --- a/Sources/FormbricksSDK/Model/Environment/EnvironmentResponseData.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation - -struct EnvironmentResponseData: Codable { - let data: EnvironmentData - let expiresAt: Date -} diff --git a/Sources/FormbricksSDK/Model/Environment/ActionClass/ActionClass.swift b/Sources/FormbricksSDK/Model/Workspace/ActionClass/ActionClass.swift similarity index 100% rename from Sources/FormbricksSDK/Model/Environment/ActionClass/ActionClass.swift rename to Sources/FormbricksSDK/Model/Workspace/ActionClass/ActionClass.swift diff --git a/Sources/FormbricksSDK/Model/Environment/Common/LocalizedText.swift b/Sources/FormbricksSDK/Model/Workspace/Common/LocalizedText.swift similarity index 100% rename from Sources/FormbricksSDK/Model/Environment/Common/LocalizedText.swift rename to Sources/FormbricksSDK/Model/Workspace/Common/LocalizedText.swift diff --git a/Sources/FormbricksSDK/Model/Environment/Project/BrandColor.swift b/Sources/FormbricksSDK/Model/Workspace/Settings/BrandColor.swift similarity index 100% rename from Sources/FormbricksSDK/Model/Environment/Project/BrandColor.swift rename to Sources/FormbricksSDK/Model/Workspace/Settings/BrandColor.swift diff --git a/Sources/FormbricksSDK/Model/Environment/Project/Project.swift b/Sources/FormbricksSDK/Model/Workspace/Settings/Settings.swift similarity index 88% rename from Sources/FormbricksSDK/Model/Environment/Project/Project.swift rename to Sources/FormbricksSDK/Model/Workspace/Settings/Settings.swift index cd92c094..cbb4bf58 100644 --- a/Sources/FormbricksSDK/Model/Environment/Project/Project.swift +++ b/Sources/FormbricksSDK/Model/Workspace/Settings/Settings.swift @@ -1,4 +1,4 @@ -struct Project: Codable { +struct Settings: Codable { let id: String? let recontactDays: Int? let clickOutsideClose: Bool? diff --git a/Sources/FormbricksSDK/Model/Environment/Project/Styling.swift b/Sources/FormbricksSDK/Model/Workspace/Settings/Styling.swift similarity index 100% rename from Sources/FormbricksSDK/Model/Environment/Project/Styling.swift rename to Sources/FormbricksSDK/Model/Workspace/Settings/Styling.swift diff --git a/Sources/FormbricksSDK/Model/Environment/Survey.swift b/Sources/FormbricksSDK/Model/Workspace/Survey.swift similarity index 100% rename from Sources/FormbricksSDK/Model/Environment/Survey.swift rename to Sources/FormbricksSDK/Model/Workspace/Survey.swift diff --git a/Sources/FormbricksSDK/Model/Environment/Surveys/ActionClassReference.swift b/Sources/FormbricksSDK/Model/Workspace/Surveys/ActionClassReference.swift similarity index 100% rename from Sources/FormbricksSDK/Model/Environment/Surveys/ActionClassReference.swift rename to Sources/FormbricksSDK/Model/Workspace/Surveys/ActionClassReference.swift diff --git a/Sources/FormbricksSDK/Model/Environment/Surveys/Segment.swift b/Sources/FormbricksSDK/Model/Workspace/Surveys/Segment.swift similarity index 74% rename from Sources/FormbricksSDK/Model/Environment/Surveys/Segment.swift rename to Sources/FormbricksSDK/Model/Workspace/Surveys/Segment.swift index 27436ae4..8f94a837 100644 --- a/Sources/FormbricksSDK/Model/Environment/Surveys/Segment.swift +++ b/Sources/FormbricksSDK/Model/Workspace/Surveys/Segment.swift @@ -189,14 +189,44 @@ struct Segment: Codable { let description: String? let isPrivate: Bool let filters: [SegmentFilter] - let environmentId: String + let workspaceId: String? let createdAt: Date let updatedAt: Date let surveys: [String] private enum CodingKeys: String, CodingKey { - case id, title, description, filters, surveys + case id, title, description, filters, surveys, createdAt, updatedAt case isPrivate = "isPrivate" - case environmentId, createdAt, updatedAt + case workspaceId + case environmentId + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + title = try container.decode(String.self, forKey: .title) + description = try container.decodeIfPresent(String.self, forKey: .description) + isPrivate = try container.decode(Bool.self, forKey: .isPrivate) + filters = try container.decode([SegmentFilter].self, forKey: .filters) + createdAt = try container.decode(Date.self, forKey: .createdAt) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + surveys = try container.decode([String].self, forKey: .surveys) + // Server may send `workspaceId` (new) or `environmentId` (legacy). Field is + // informational only — not read by SDK logic — so keep it optional. + workspaceId = try container.decodeIfPresent(String.self, forKey: .workspaceId) + ?? container.decodeIfPresent(String.self, forKey: .environmentId) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(title, forKey: .title) + try container.encodeIfPresent(description, forKey: .description) + try container.encode(isPrivate, forKey: .isPrivate) + try container.encode(filters, forKey: .filters) + try container.encode(createdAt, forKey: .createdAt) + try container.encode(updatedAt, forKey: .updatedAt) + try container.encode(surveys, forKey: .surveys) + try container.encodeIfPresent(workspaceId, forKey: .workspaceId) } } diff --git a/Sources/FormbricksSDK/Model/Environment/Surveys/Trigger.swift b/Sources/FormbricksSDK/Model/Workspace/Surveys/Trigger.swift similarity index 100% rename from Sources/FormbricksSDK/Model/Environment/Surveys/Trigger.swift rename to Sources/FormbricksSDK/Model/Workspace/Surveys/Trigger.swift diff --git a/Sources/FormbricksSDK/Model/Workspace/WorkspaceData.swift b/Sources/FormbricksSDK/Model/Workspace/WorkspaceData.swift new file mode 100644 index 00000000..56c86877 --- /dev/null +++ b/Sources/FormbricksSDK/Model/Workspace/WorkspaceData.swift @@ -0,0 +1,47 @@ +struct WorkspaceData: Codable { + let surveys: [Survey]? + let actionClasses: [ActionClass]? + let settings: Settings + let recaptchaSiteKey: String? + + enum CodingKeys: String, CodingKey { + case surveys + case actionClasses + case settings + case workspace + case project + case recaptchaSiteKey + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + surveys = try container.decodeIfPresent([Survey].self, forKey: .surveys) + actionClasses = try container.decodeIfPresent([ActionClass].self, forKey: .actionClasses) + recaptchaSiteKey = try container.decodeIfPresent(String.self, forKey: .recaptchaSiteKey) + + // Server may respond with `settings`, `workspace`, or legacy `project` — all carry the same shape. + if let settings = try container.decodeIfPresent(Settings.self, forKey: .settings) { + self.settings = settings + } else if let workspace = try container.decodeIfPresent(Settings.self, forKey: .workspace) { + self.settings = workspace + } else if let project = try container.decodeIfPresent(Settings.self, forKey: .project) { + self.settings = project + } else { + throw DecodingError.keyNotFound( + CodingKeys.settings, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected one of 'settings', 'workspace', or 'project' key in workspace data" + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(surveys, forKey: .surveys) + try container.encodeIfPresent(actionClasses, forKey: .actionClasses) + try container.encodeIfPresent(recaptchaSiteKey, forKey: .recaptchaSiteKey) + try container.encode(settings, forKey: .settings) + } +} diff --git a/Sources/FormbricksSDK/Model/Environment/EnvironmentResponse.swift b/Sources/FormbricksSDK/Model/Workspace/WorkspaceResponse.swift similarity index 76% rename from Sources/FormbricksSDK/Model/Environment/EnvironmentResponse.swift rename to Sources/FormbricksSDK/Model/Workspace/WorkspaceResponse.swift index ce5e8f02..aedcd5a5 100644 --- a/Sources/FormbricksSDK/Model/Environment/EnvironmentResponse.swift +++ b/Sources/FormbricksSDK/Model/Workspace/WorkspaceResponse.swift @@ -1,17 +1,17 @@ import Foundation -struct EnvironmentResponse: Codable { - let data: EnvironmentResponseData - +struct WorkspaceResponse: Codable { + let data: WorkspaceResponseData + var responseString: String? - + enum CodingKeys: CodingKey { case data case responseString } } -extension EnvironmentResponse { +extension WorkspaceResponse { func getSurveyJson(forSurveyId surveyId: String) -> [String: Any]? { guard let jsonData = responseString?.data(using: .utf8) else { return nil } let responseDictionary = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] @@ -20,7 +20,7 @@ extension EnvironmentResponse { let surveysArray = dataDict?["surveys"] as? [[String: Any]] return surveysArray?.first(where: { $0["id"] as? String == surveyId }) as? [String: Any] } - + func getSurveyStylingJson(forSurveyId surveyId: String) -> [String: Any]? { guard let jsonData = responseString?.data(using: .utf8) else { return nil } let responseDictionary = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] @@ -31,12 +31,15 @@ extension EnvironmentResponse { return survey?["styling"] as? [String: Any] } - func getProjectStylingJson() -> [String: Any]? { + func getSettingsStylingJson() -> [String: Any]? { guard let jsonData = responseString?.data(using: .utf8) else { return nil } let responseDictionary = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] let responseDict = responseDictionary?["data"] as? [String: Any] let dataDict = responseDict?["data"] as? [String: Any] - let projectDict = dataDict?["project"] as? [String: Any] - return projectDict?["styling"] as? [String: Any] + // Server may respond with `settings`, `workspace`, or legacy `project` — all carry the same shape. + let settingsDict = (dataDict?["settings"] as? [String: Any]) + ?? (dataDict?["workspace"] as? [String: Any]) + ?? (dataDict?["project"] as? [String: Any]) + return settingsDict?["styling"] as? [String: Any] } } diff --git a/Sources/FormbricksSDK/Model/Workspace/WorkspaceResponseData.swift b/Sources/FormbricksSDK/Model/Workspace/WorkspaceResponseData.swift new file mode 100644 index 00000000..8576bd34 --- /dev/null +++ b/Sources/FormbricksSDK/Model/Workspace/WorkspaceResponseData.swift @@ -0,0 +1,6 @@ +import Foundation + +struct WorkspaceResponseData: Codable { + let data: WorkspaceData + let expiresAt: Date +} diff --git a/Sources/FormbricksSDK/Networking/Base/APIClient.swift b/Sources/FormbricksSDK/Networking/Base/APIClient.swift index 45e1a8a8..5d462312 100644 --- a/Sources/FormbricksSDK/Networking/Base/APIClient.swift +++ b/Sources/FormbricksSDK/Networking/Base/APIClient.swift @@ -30,11 +30,11 @@ class APIClient: Operation, @unchecked Sendable { guard let apiURL = request.baseURL, var components = URLComponents(string: apiURL) else { return nil } // Ensure only HTTPS requests are allowed (block HTTP) - guard let scheme = components.scheme?.lowercased(), scheme == "https" else { - let errorMessage = "HTTP requests are blocked for security. Only HTTPS requests are allowed. Provided app url: \(apiURL)" - Formbricks.logger?.error(errorMessage) - return nil - } + guard let scheme = components.scheme?.lowercased(), scheme == "https" else { + let errorMessage = "HTTP requests are blocked for security. Only HTTPS requests are allowed. Provided app url: \(apiURL)" + Formbricks.logger?.error(errorMessage) + return nil + } components.queryItems = request.queryParams?.map { URLQueryItem(name: $0.key, value: $0.value) } @@ -78,9 +78,9 @@ class APIClient: Operation, @unchecked Sendable { completion?(.success(VoidResponse() as! Request.Response)) } else { var body = try request.decoder.decode(Request.Response.self, from: data) - if var env = body as? EnvironmentResponse, let jsonString = String(data: data, encoding: .utf8) { - env.responseString = jsonString - body = env as! Request.Response + if var workspace = body as? WorkspaceResponse, let jsonString = String(data: data, encoding: .utf8) { + workspace.responseString = jsonString + body = workspace as! Request.Response } Formbricks.logger?.info(message) completion?(.success(body)) @@ -120,7 +120,7 @@ class APIClient: Operation, @unchecked Sendable { default: message.append("Error: \(error.localizedDescription)") } - + let error = FormbricksAPIClientError(type: .invalidResponse, statusCode: statusCode) Formbricks.logger?.error(error.message) completion?(.failure(error)) @@ -162,14 +162,16 @@ private extension APIClient { func setPathParams(_ path: String) -> String? { var newPath = path - if let environmentId = Formbricks.environmentId { - newPath = newPath.replacingOccurrences(of: "{environmentId}", with: environmentId) + if let workspaceId = Formbricks.workspaceId { + newPath = newPath.replacingOccurrences(of: "{workspaceId}", with: workspaceId) + // Backward-compatible: still replace the legacy `{environmentId}` placeholder. + newPath = newPath.replacingOccurrences(of: "{environmentId}", with: workspaceId) } - + request.pathParams?.forEach { key, value in newPath = newPath.replacingOccurrences(of: key, with: value) } - + return newPath } } diff --git a/Sources/FormbricksSDK/Networking/ClientAPI/Endpoints/Environment/GetEnvironmentRequest.swift b/Sources/FormbricksSDK/Networking/ClientAPI/Endpoints/Environment/GetEnvironmentRequest.swift deleted file mode 100644 index 93e381ee..00000000 --- a/Sources/FormbricksSDK/Networking/ClientAPI/Endpoints/Environment/GetEnvironmentRequest.swift +++ /dev/null @@ -1,5 +0,0 @@ -struct GetEnvironmentRequest: CodableRequest { - typealias Response = EnvironmentResponse - var requestEndPoint: String { return FormbricksEnvironment.getEnvironmentRequestEndpoint } - var requestType: HTTPMethod { return .get } -} diff --git a/Sources/FormbricksSDK/Networking/ClientAPI/Endpoints/User/PostUserRequest.swift b/Sources/FormbricksSDK/Networking/ClientAPI/Endpoints/User/PostUserRequest.swift index 41078ed8..fd3f68cb 100644 --- a/Sources/FormbricksSDK/Networking/ClientAPI/Endpoints/User/PostUserRequest.swift +++ b/Sources/FormbricksSDK/Networking/ClientAPI/Endpoints/User/PostUserRequest.swift @@ -1,5 +1,5 @@ final class PostUserRequest: EncodableRequest, CodableRequest { - var requestEndPoint: String { return FormbricksEnvironment.postUserRequestEndpoint } + var requestEndPoint: String { return FormbricksWorkspace.postUserRequestEndpoint } var requestType: HTTPMethod { return .post } diff --git a/Sources/FormbricksSDK/Networking/ClientAPI/Endpoints/Workspace/GetWorkspaceRequest.swift b/Sources/FormbricksSDK/Networking/ClientAPI/Endpoints/Workspace/GetWorkspaceRequest.swift new file mode 100644 index 00000000..7da93bc1 --- /dev/null +++ b/Sources/FormbricksSDK/Networking/ClientAPI/Endpoints/Workspace/GetWorkspaceRequest.swift @@ -0,0 +1,5 @@ +struct GetWorkspaceRequest: CodableRequest { + typealias Response = WorkspaceResponse + var requestEndPoint: String { return FormbricksWorkspace.getWorkspaceStateRequestEndpoint } + var requestType: HTTPMethod { return .get } +} diff --git a/Sources/FormbricksSDK/Networking/Service/FormbricksService.swift b/Sources/FormbricksSDK/Networking/Service/FormbricksService.swift index 3d45f4c4..be29c023 100644 --- a/Sources/FormbricksSDK/Networking/Service/FormbricksService.swift +++ b/Sources/FormbricksSDK/Networking/Service/FormbricksService.swift @@ -1,10 +1,10 @@ /// FormbricksService is a service class that handles the network requests for Formbricks API. class FormbricksService: FormbricksServiceProtocol { - - // MARK: - Environment - - /// Get the current environment state. - func getEnvironmentState(completion: @escaping (ResultType) -> Void) { - let endPointRequest = GetEnvironmentRequest() + + // MARK: - Workspace - + /// Fetch the current workspace state. + func getWorkspaceState(completion: @escaping (ResultType) -> Void) { + let endPointRequest = GetWorkspaceRequest() execute(endPointRequest, withCompletion: completion) } @@ -17,8 +17,8 @@ class FormbricksService: FormbricksServiceProtocol { } protocol FormbricksServiceProtocol { - func getEnvironmentState( - completion: @escaping (ResultType) -> Void + func getWorkspaceState( + completion: @escaping (ResultType) -> Void ) func postUser( id: String, diff --git a/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift b/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift index 439c78b5..54e52a69 100644 --- a/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift +++ b/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift @@ -5,11 +5,11 @@ import SwiftUI final class FormbricksViewModel: ObservableObject { @Published var htmlString: String? let surveyId: String - - init(environmentResponse: EnvironmentResponse, surveyId: String) { + + init(workspaceResponse: WorkspaceResponse, surveyId: String) { self.surveyId = surveyId - if let webviewDataJson = WebViewData(environmentResponse: environmentResponse, surveyId: surveyId).getJsonString(), - let surveyScriptUrl = FormbricksEnvironment.surveyScriptUrlString { + if let webviewDataJson = WebViewData(workspaceResponse: workspaceResponse, surveyId: surveyId).getJsonString(), + let surveyScriptUrl = FormbricksWorkspace.surveyScriptUrlString { htmlString = htmlTemplate.replacingOccurrences(of: "{{WEBVIEW_DATA}}", with: webviewDataJson) .replacingOccurrences(of: "{{SURVEY_SCRIPT_URL}}", with: surveyScriptUrl) } @@ -23,7 +23,7 @@ private extension FormbricksViewModel { - + Formbricks WebView Survey @@ -43,11 +43,11 @@ private extension FormbricksViewModel { function onDisplayCreated() { window.webkit.messageHandlers.jsMessage.postMessage(JSON.stringify({ event: "onDisplayCreated" })); }; - + function onResponseCreated() { window.webkit.messageHandlers.jsMessage.postMessage(JSON.stringify({ event: "onResponseCreated" })); }; - + function onOpenExternalURL(url) { window.webkit.messageHandlers.jsMessage.postMessage(JSON.stringify({ event: "onOpenExternalURL", onOpenExternalURLParams: { url: url } })); }; @@ -55,7 +55,7 @@ private extension FormbricksViewModel { let setResponseFinished = null; function getSetIsResponseSendingFinished(callback) { setResponseFinished = callback; - } + } function loadSurvey() { const options = JSON.parse(json); @@ -83,47 +83,50 @@ private extension FormbricksViewModel { """ } - + } // MARK: - Helper class - private class WebViewData { var data: [String: Any] = [:] - - init(environmentResponse: EnvironmentResponse, surveyId: String) { - let matchedSurvey = environmentResponse.data.data.surveys?.first(where: {$0.id == surveyId}) - let project = environmentResponse.data.data.project - - data["survey"] = environmentResponse.getSurveyJson(forSurveyId: surveyId) + + init(workspaceResponse: WorkspaceResponse, surveyId: String) { + let matchedSurvey = workspaceResponse.data.data.surveys?.first(where: {$0.id == surveyId}) + let settings = workspaceResponse.data.data.settings + + data["survey"] = workspaceResponse.getSurveyJson(forSurveyId: surveyId) data["appUrl"] = Formbricks.appUrl - data["environmentId"] = Formbricks.environmentId + data["workspaceId"] = Formbricks.workspaceId + // Keep `environmentId` in the payload for backward compatibility with older + // survey-script versions that still read it. + data["environmentId"] = Formbricks.workspaceId data["contactId"] = Formbricks.userManager?.contactId data["isWebEnvironment"] = false - data["isBrandingEnabled"] = project.inAppSurveyBranding ?? true - + data["isBrandingEnabled"] = settings.inAppSurveyBranding ?? true + if let placementEnum = matchedSurvey?.projectOverwrites?.placement { data["placement"] = placementEnum.rawValue } else { - data["placement"] = project.placement + data["placement"] = settings.placement } - - data["clickOutside"] = matchedSurvey?.projectOverwrites?.clickOutsideClose ?? project.clickOutsideClose ?? false - data["overlay"] = (matchedSurvey?.projectOverwrites?.overlay ?? project.overlay ?? .none).rawValue - + + data["clickOutside"] = matchedSurvey?.projectOverwrites?.clickOutsideClose ?? settings.clickOutsideClose ?? false + data["overlay"] = (matchedSurvey?.projectOverwrites?.overlay ?? settings.overlay ?? .none).rawValue + let isMultiLangSurvey = (matchedSurvey?.languages?.count ?? 0) > 1 - + if isMultiLangSurvey { data["languageCode"] = Formbricks.language } else { data["languageCode"] = "default" } - + let hasCustomStyling = matchedSurvey?.styling != nil - let enabled = project.styling?.allowStyleOverwrite ?? false - - data["styling"] = hasCustomStyling && enabled ? environmentResponse.getSurveyStylingJson(forSurveyId: surveyId): environmentResponse.getProjectStylingJson() + let enabled = settings.styling?.allowStyleOverwrite ?? false + + data["styling"] = hasCustomStyling && enabled ? workspaceResponse.getSurveyStylingJson(forSurveyId: surveyId): workspaceResponse.getSettingsStylingJson() } - + func getJsonString() -> String? { do { let jsonData = try JSONSerialization.data(withJSONObject: data, options: []) @@ -133,5 +136,5 @@ private class WebViewData { return nil } } - + } diff --git a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift index 65a3e8b9..ba3bb6ae 100644 --- a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift +++ b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import FormbricksSDK final class FormbricksSDKTests: XCTestCase { - let environmentId = "environmentId" + let workspaceId = "workspaceId" let appUrl = "https://example.com" let userId = "6CCCE716-6783-4D0F-8344-9C7DFA43D8F7" let surveyID = "cm6ovw6j7000gsf0kduf4oo4i" @@ -46,7 +46,7 @@ final class FormbricksSDKTests: XCTestCase { // Setup the SDK using your new instance-based design. // This creates new instances for both the UserManager and SurveyManager. - Formbricks.setup(with: FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + Formbricks.setup(with: FormbricksConfig.Builder(appUrl: appUrl, workspaceId: workspaceId) .set(attributes: ["a": "b"]) .add(attribute: "test", forKey: "key") .setLogLevel(.debug) @@ -56,17 +56,17 @@ final class FormbricksSDKTests: XCTestCase { XCTAssertTrue(Formbricks.isInitialized) XCTAssertEqual(Formbricks.appUrl, appUrl) - XCTAssertEqual(Formbricks.environmentId, environmentId) + XCTAssertEqual(Formbricks.workspaceId, workspaceId) // Check error state handling. XCTAssertFalse(Formbricks.surveyManager?.hasApiError ?? false) mockService.isErrorResponseNeeded = true - Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + Formbricks.surveyManager?.refreshWorkspaceIfNeeded(force: true) XCTAssertTrue(Formbricks.surveyManager?.hasApiError ?? false) mockService.isErrorResponseNeeded = false - Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + Formbricks.surveyManager?.refreshWorkspaceIfNeeded(force: true) // Wait for environment to refresh let refreshExpectation = expectation(description: "Environment refreshed") @@ -91,7 +91,7 @@ final class FormbricksSDKTests: XCTestCase { XCTAssertNotNil(Formbricks.userManager?.syncTimer, "Sync timer should be set") // The environment should be fetched. - XCTAssertNotNil(Formbricks.surveyManager?.environmentResponse) + XCTAssertNotNil(Formbricks.surveyManager?.workspaceResponse) // Check if the filter method works properly. XCTAssertEqual(Formbricks.surveyManager?.filteredSurveys.count, 1) @@ -173,13 +173,13 @@ final class FormbricksSDKTests: XCTestCase { XCTAssertNil(Formbricks.presentSurveyManager) XCTAssertFalse(Formbricks.isInitialized) XCTAssertNil(Formbricks.appUrl) - XCTAssertNil(Formbricks.environmentId) + XCTAssertNil(Formbricks.workspaceId) XCTAssertNil(Formbricks.logger) } - + func testCleanupWithCompletion() { // Setup the SDK - let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + let config = FormbricksConfig.Builder(appUrl: appUrl, workspaceId: workspaceId) .setLogLevel(.debug) .service(mockService) .build() @@ -203,7 +203,7 @@ final class FormbricksSDKTests: XCTestCase { XCTAssertNil(Formbricks.apiQueue, "API queue should be nil") XCTAssertFalse(Formbricks.isInitialized, "SDK should not be initialized") XCTAssertNil(Formbricks.appUrl, "App URL should be nil") - XCTAssertNil(Formbricks.environmentId, "Environment ID should be nil") + XCTAssertNil(Formbricks.workspaceId, "Workspace ID should be nil") XCTAssertNil(Formbricks.logger, "Logger should be nil") } @@ -219,13 +219,15 @@ final class FormbricksSDKTests: XCTestCase { XCTAssertTrue(manager.shouldDisplayBasedOnPercentage(100)) XCTAssertFalse(manager.shouldDisplayBasedOnPercentage(0)) - // UserDefaults: corrupt data - UserDefaults.standard.set(Data([0x00, 0x01]), forKey: "environmentResponseObjectKey") - XCTAssertNil(manager.environmentResponse) + // UserDefaults: corrupt data under both the new and legacy keys so we exercise + // the fallback path too. + UserDefaults.standard.set(Data([0x00, 0x01]), forKey: SurveyManager.workspaceResponseObjectKey) + UserDefaults.standard.removeObject(forKey: SurveyManager.legacyEnvironmentResponseObjectKey) + XCTAssertNil(manager.workspaceResponse) - // Timer-based refresh: wait deterministically for the environment refresh notification - let notificationExpectation = expectation(forNotification: .environmentRefreshed, object: manager, handler: nil) - manager.refreshEnvironmentAfter(timeout: 0.1) + // Timer-based refresh: wait deterministically for the workspace refresh notification + let notificationExpectation = expectation(forNotification: .workspaceRefreshed, object: manager, handler: nil) + manager.refreshWorkspaceAfter(timeout: 0.1) wait(for: [notificationExpectation], timeout: 2.0) // getLanguageCode coverage @@ -267,14 +269,14 @@ final class FormbricksSDKTests: XCTestCase { let errorsMockService = MockFormbricksService() errorsMockService.userMockResponse = .userWithErrors - let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + let config = FormbricksConfig.Builder(appUrl: appUrl, workspaceId: workspaceId) .setLogLevel(.debug) .service(errorsMockService) .build() Formbricks.setup(with: config) // Refresh environment first - Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + Formbricks.surveyManager?.refreshWorkspaceIfNeeded(force: true) let envExpectation = expectation(description: "Env loaded") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { envExpectation.fulfill() } wait(for: [envExpectation]) @@ -297,14 +299,14 @@ final class FormbricksSDKTests: XCTestCase { let messagesMockService = MockFormbricksService() messagesMockService.userMockResponse = .userWithMessages - let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + let config = FormbricksConfig.Builder(appUrl: appUrl, workspaceId: workspaceId) .setLogLevel(.debug) .service(messagesMockService) .build() Formbricks.setup(with: config) // Refresh environment first - Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + Formbricks.surveyManager?.refreshWorkspaceIfNeeded(force: true) let envExpectation = expectation(description: "Env loaded") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { envExpectation.fulfill() } wait(for: [envExpectation]) @@ -327,14 +329,14 @@ final class FormbricksSDKTests: XCTestCase { let bothMockService = MockFormbricksService() bothMockService.userMockResponse = .userWithErrorsAndMessages - let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + let config = FormbricksConfig.Builder(appUrl: appUrl, workspaceId: workspaceId) .setLogLevel(.debug) .service(bothMockService) .build() Formbricks.setup(with: config) // Refresh environment first - Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + Formbricks.surveyManager?.refreshWorkspaceIfNeeded(force: true) let envExpectation = expectation(description: "Env loaded") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { envExpectation.fulfill() } wait(for: [envExpectation]) @@ -356,14 +358,14 @@ final class FormbricksSDKTests: XCTestCase { // MARK: - setUserId override behavior tests func testSetUserIdSameValueIsNoOp() { - let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + let config = FormbricksConfig.Builder(appUrl: appUrl, workspaceId: workspaceId) .setLogLevel(.debug) .service(mockService) .build() Formbricks.setup(with: config) // Refresh environment first - Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + Formbricks.surveyManager?.refreshWorkspaceIfNeeded(force: true) let envExpectation = expectation(description: "Env loaded") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { envExpectation.fulfill() } wait(for: [envExpectation]) @@ -382,14 +384,14 @@ final class FormbricksSDKTests: XCTestCase { } func testSetUserIdDifferentValueOverridesPrevious() { - let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + let config = FormbricksConfig.Builder(appUrl: appUrl, workspaceId: workspaceId) .setLogLevel(.debug) .service(mockService) .build() Formbricks.setup(with: config) // Refresh environment first - Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + Formbricks.surveyManager?.refreshWorkspaceIfNeeded(force: true) let envExpectation = expectation(description: "Env loaded") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { envExpectation.fulfill() } wait(for: [envExpectation]) @@ -419,7 +421,7 @@ final class FormbricksSDKTests: XCTestCase { } func testLogoutWithoutUserIdDoesNotError() { - let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + let config = FormbricksConfig.Builder(appUrl: appUrl, workspaceId: workspaceId) .setLogLevel(.debug) .service(mockService) .build() @@ -434,7 +436,7 @@ final class FormbricksSDKTests: XCTestCase { // MARK: - setAttribute overload tests func testSetAttributeDouble() { - let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + let config = FormbricksConfig.Builder(appUrl: appUrl, workspaceId: workspaceId) .setLogLevel(.debug) .service(mockService) .build() @@ -445,7 +447,7 @@ final class FormbricksSDKTests: XCTestCase { } func testSetAttributeDate() { - let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + let config = FormbricksConfig.Builder(appUrl: appUrl, workspaceId: workspaceId) .setLogLevel(.debug) .service(mockService) .build() @@ -458,7 +460,7 @@ final class FormbricksSDKTests: XCTestCase { // MARK: - ConfigBuilder coverage tests func testConfigBuilderStringAttributes() { - let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + let config = FormbricksConfig.Builder(appUrl: appUrl, workspaceId: workspaceId) .set(stringAttributes: ["key1": "val1", "key2": "val2"]) .build() @@ -467,7 +469,7 @@ final class FormbricksSDKTests: XCTestCase { } func testConfigBuilderAddAttribute() { - let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + let config = FormbricksConfig.Builder(appUrl: appUrl, workspaceId: workspaceId) .add(attribute: "hello", forKey: "greeting") .build() @@ -479,25 +481,25 @@ final class FormbricksSDKTests: XCTestCase { func testPresentCompletesInHeadlessEnvironment() { // In a headless test environment there is no key window, so present() should // call the completion with false. - let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + let config = FormbricksConfig.Builder(appUrl: appUrl, workspaceId: workspaceId) .setLogLevel(.debug) .service(mockService) .build() Formbricks.setup(with: config) - Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + Formbricks.surveyManager?.refreshWorkspaceIfNeeded(force: true) let loadExpectation = expectation(description: "Env loaded") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { loadExpectation.fulfill() } wait(for: [loadExpectation]) - guard let env = Formbricks.surveyManager?.environmentResponse else { - XCTFail("Missing environmentResponse") + guard let workspace = Formbricks.surveyManager?.workspaceResponse else { + XCTFail("Missing workspaceResponse") return } let manager = PresentSurveyManager() let presentExpectation = expectation(description: "Present completes") - manager.present(environmentResponse: env, id: surveyID) { success in + manager.present(workspaceResponse: workspace, id: surveyID) { success in // No key window in headless tests → completion(false) XCTAssertFalse(success, "Presentation should fail in headless environment") presentExpectation.fulfill() @@ -509,25 +511,25 @@ final class FormbricksSDKTests: XCTestCase { func testWebViewDataUsesSurveyOverwrites() { // Setup SDK with mock service loading Environment.json (which now includes projectOverwrites) - let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + let config = FormbricksConfig.Builder(appUrl: appUrl, workspaceId: workspaceId) .setLogLevel(.debug) .service(mockService) .build() Formbricks.setup(with: config) // Force refresh and wait briefly for async fetch - Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + Formbricks.surveyManager?.refreshWorkspaceIfNeeded(force: true) let expectation = self.expectation(description: "Env loaded") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { expectation.fulfill() } wait(for: [expectation]) - guard let env = Formbricks.surveyManager?.environmentResponse else { - XCTFail("Missing environmentResponse") + guard let workspace = Formbricks.surveyManager?.workspaceResponse else { + XCTFail("Missing workspaceResponse") return } // Build the view model to produce WEBVIEW_DATA - let vm = FormbricksViewModel(environmentResponse: env, surveyId: surveyID) + let vm = FormbricksViewModel(workspaceResponse: workspace, surveyId: surveyID) guard let html = vm.htmlString else { XCTFail("Missing htmlString") return @@ -556,5 +558,187 @@ final class FormbricksSDKTests: XCTestCase { XCTAssertEqual(object["placement"] as? String, "center") XCTAssertEqual(object["overlay"] as? String, "dark") XCTAssertEqual(object["clickOutside"] as? Bool, false) + + // WEBVIEW_DATA should include workspaceId (plus environmentId alias for back-compat) + XCTAssertEqual(object["workspaceId"] as? String, workspaceId) + XCTAssertEqual(object["environmentId"] as? String, workspaceId) + } + + // MARK: - workspaceId / environmentId parameter tests + + /// The deprecated `environmentId` init is still supported for backward compatibility. + @available(*, deprecated) + func testSetupWithDeprecatedEnvironmentId() { + let legacyId = "legacy-env-id" + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: legacyId) + .setLogLevel(.debug) + .service(mockService) + .build() + Formbricks.setup(with: config) + + XCTAssertTrue(Formbricks.isInitialized) + XCTAssertEqual(Formbricks.workspaceId, legacyId, "environmentId should be stored as workspaceId") + XCTAssertTrue(config.usedDeprecatedEnvironmentId) + } + + /// New `workspaceId` init does not mark the config as using a deprecated parameter. + func testSetupWithWorkspaceIdDoesNotFlagDeprecation() { + let config = FormbricksConfig.Builder(appUrl: appUrl, workspaceId: workspaceId) + .setLogLevel(.debug) + .service(mockService) + .build() + Formbricks.setup(with: config) + + XCTAssertTrue(Formbricks.isInitialized) + XCTAssertEqual(Formbricks.workspaceId, workspaceId) + XCTAssertFalse(config.usedDeprecatedEnvironmentId) + } + + /// The legacy `Formbricks.environmentId` accessor still returns the canonical id. + @available(*, deprecated) + func testLegacyEnvironmentIdAccessorMirrorsWorkspaceId() { + let config = FormbricksConfig.Builder(appUrl: appUrl, workspaceId: workspaceId) + .service(mockService) + .build() + Formbricks.setup(with: config) + + XCTAssertEqual(Formbricks.environmentId, workspaceId) + XCTAssertEqual(Formbricks.environmentId, Formbricks.workspaceId) + } + + // MARK: - Tolerant decoding tests + + /// Workspace data should decode when the server sends the new `settings` key. + func testWorkspaceDataDecodesFromSettingsKey() throws { + let json = """ + { + "data": { + "data": { + "settings": { + "recontactDays": 7, + "clickOutsideClose": true, + "overlay": "none", + "placement": "bottomRight", + "inAppSurveyBranding": true, + "styling": { "allowStyleOverwrite": true } + }, + "surveys": [], + "actionClasses": [] + }, + "expiresAt": "2099-12-31T23:59:59.999Z" + } + } + """.data(using: .utf8)! + + let response = try JSONDecoder.iso8601Full.decode(WorkspaceResponse.self, from: json) + XCTAssertEqual(response.data.data.settings.recontactDays, 7) + XCTAssertEqual(response.data.data.settings.placement, "bottomRight") + } + + /// Workspace data should decode when the server sends the `workspace` key. + func testWorkspaceDataDecodesFromWorkspaceKey() throws { + let json = """ + { + "data": { + "data": { + "workspace": { + "recontactDays": 3, + "clickOutsideClose": false, + "overlay": "none", + "placement": "center", + "inAppSurveyBranding": false, + "styling": { "allowStyleOverwrite": false } + }, + "surveys": [], + "actionClasses": [] + }, + "expiresAt": "2099-12-31T23:59:59.999Z" + } + } + """.data(using: .utf8)! + + let response = try JSONDecoder.iso8601Full.decode(WorkspaceResponse.self, from: json) + XCTAssertEqual(response.data.data.settings.recontactDays, 3) + XCTAssertEqual(response.data.data.settings.placement, "center") + } + + /// Workspace data should still decode when the server sends the legacy `project` key, + /// which lets the SDK read cached blobs written by older SDK versions. + func testWorkspaceDataDecodesFromLegacyProjectKey() throws { + let json = """ + { + "data": { + "data": { + "project": { + "recontactDays": 14, + "clickOutsideClose": true, + "overlay": "none", + "placement": "bottomLeft", + "inAppSurveyBranding": true, + "styling": { "allowStyleOverwrite": true } + }, + "surveys": [], + "actionClasses": [] + }, + "expiresAt": "2099-12-31T23:59:59.999Z" + } + } + """.data(using: .utf8)! + + let response = try JSONDecoder.iso8601Full.decode(WorkspaceResponse.self, from: json) + XCTAssertEqual(response.data.data.settings.recontactDays, 14) + XCTAssertEqual(response.data.data.settings.placement, "bottomLeft") + } + + // MARK: - UserDefaults migration tests + + /// A cache blob written under the pre-rename key should be read once, migrated to + /// the new key, and then removed from the legacy slot. + func testLegacyEnvironmentResponseCacheIsMigratedOnRead() throws { + // Clear both keys to start from a known state. + let defaults = UserDefaults.standard + defaults.removeObject(forKey: SurveyManager.workspaceResponseObjectKey) + defaults.removeObject(forKey: SurveyManager.legacyEnvironmentResponseObjectKey) + + // Build a blob the way the old SDK version would have written it: decode the + // fixture with the SDK's iso8601 decoder, then re-encode with the plain + // JSONEncoder used by the persisted-cache path. + guard let fixtureUrl = Bundle.module.url(forResource: "Environment", withExtension: "json"), + let fixtureData = try? Data(contentsOf: fixtureUrl), + let fixtureResponse = try? JSONDecoder.iso8601Full.decode(WorkspaceResponse.self, from: fixtureData), + let legacyBlob = try? JSONEncoder().encode(fixtureResponse) else { + XCTFail("Missing or invalid Environment.json fixture") + return + } + defaults.set(legacyBlob, forKey: SurveyManager.legacyEnvironmentResponseObjectKey) + + // Fresh SurveyManager so the in-memory backing cache is empty. + let userManager = UserManager() + let presentSurveyManager = PresentSurveyManager() + let manager = SurveyManager.create(userManager: userManager, presentSurveyManager: presentSurveyManager, service: MockFormbricksService()) + + // Reading should migrate and return a decoded WorkspaceResponse. + XCTAssertNotNil(manager.workspaceResponse, "Legacy cache should be read and decoded") + + // Legacy key is gone, new key is populated. + XCTAssertNil(defaults.data(forKey: SurveyManager.legacyEnvironmentResponseObjectKey)) + XCTAssertNotNil(defaults.data(forKey: SurveyManager.workspaceResponseObjectKey)) + } + + // MARK: - Notification back-compat + + /// Subscribers of the deprecated `.environmentRefreshed` should still get notified + /// while we also post the new `.workspaceRefreshed` name. + func testEnvironmentRefreshedNotificationStillFiresForBackwardCompat() { + let userManager = UserManager() + let presentSurveyManager = PresentSurveyManager() + let manager = SurveyManager.create(userManager: userManager, presentSurveyManager: presentSurveyManager, service: MockFormbricksService()) + + let legacyExpectation = expectation(forNotification: Notification.Name("Formbricks.environmentRefreshed"), object: manager, handler: nil) + let newExpectation = expectation(forNotification: .workspaceRefreshed, object: manager, handler: nil) + + manager.refreshWorkspaceAfter(timeout: 0.1) + + wait(for: [legacyExpectation, newExpectation], timeout: 2.0) } } diff --git a/Tests/FormbricksSDKTests/FormbricksEnvironmentTests.swift b/Tests/FormbricksSDKTests/FormbricksWorkspaceTests.swift similarity index 63% rename from Tests/FormbricksSDKTests/FormbricksEnvironmentTests.swift rename to Tests/FormbricksSDKTests/FormbricksWorkspaceTests.swift index 1f6d521a..27496dba 100644 --- a/Tests/FormbricksSDKTests/FormbricksEnvironmentTests.swift +++ b/Tests/FormbricksSDKTests/FormbricksWorkspaceTests.swift @@ -1,51 +1,51 @@ import XCTest @testable import FormbricksSDK -final class FormbricksEnvironmentTests: XCTestCase { - +final class FormbricksWorkspaceTests: XCTestCase { + override func setUp() { super.setUp() // Always clean up before each test Formbricks.cleanup() } - + override func tearDown() { Formbricks.cleanup() super.tearDown() } - + func testBaseApiUrl() { // Test that baseApiUrl returns nil when appUrl is nil - XCTAssertNil(FormbricksEnvironment.baseApiUrl) - + XCTAssertNil(FormbricksWorkspace.baseApiUrl) + // Setup SDK with valid appUrl - Formbricks.setup(with: FormbricksConfig.Builder(appUrl: "https://app.formbricks.com", environmentId: "test-env-id") + Formbricks.setup(with: FormbricksConfig.Builder(appUrl: "https://app.formbricks.com", workspaceId: "test-workspace-id") .setLogLevel(.debug) .build()) - + // Test that baseApiUrl returns the correct URL - XCTAssertEqual(FormbricksEnvironment.baseApiUrl, "https://app.formbricks.com") + XCTAssertEqual(FormbricksWorkspace.baseApiUrl, "https://app.formbricks.com") } - + func testSurveyScriptUrlString() { // Test that surveyScriptUrlString returns nil when appUrl is nil - XCTAssertNil(FormbricksEnvironment.surveyScriptUrlString) - + XCTAssertNil(FormbricksWorkspace.surveyScriptUrlString) + // Setup SDK with valid appUrl - Formbricks.setup(with: FormbricksConfig.Builder(appUrl: "https://app.formbricks.com", environmentId: "test-env-id") + Formbricks.setup(with: FormbricksConfig.Builder(appUrl: "https://app.formbricks.com", workspaceId: "test-workspace-id") .setLogLevel(.debug) .build()) - + // Test that surveyScriptUrlString returns the correct URL - XCTAssertEqual(FormbricksEnvironment.surveyScriptUrlString, "https://app.formbricks.com/js/surveys.umd.cjs") - + XCTAssertEqual(FormbricksWorkspace.surveyScriptUrlString, "https://app.formbricks.com/js/surveys.umd.cjs") + // Test with invalid URL Formbricks.cleanup() - Formbricks.setup(with: FormbricksConfig.Builder(appUrl: "invalid url", environmentId: "test-env-id") + Formbricks.setup(with: FormbricksConfig.Builder(appUrl: "invalid url", workspaceId: "test-workspace-id") .setLogLevel(.debug) .build()) - + // Test that surveyScriptUrlString returns nil for invalid URL - XCTAssertNil(FormbricksEnvironment.surveyScriptUrlString) + XCTAssertNil(FormbricksWorkspace.surveyScriptUrlString) } -} \ No newline at end of file +} diff --git a/Tests/FormbricksSDKTests/MockFormbricksService/MockFormbricksService.swift b/Tests/FormbricksSDKTests/MockFormbricksService/MockFormbricksService.swift index 0a560dda..d86c5e1a 100644 --- a/Tests/FormbricksSDKTests/MockFormbricksService/MockFormbricksService.swift +++ b/Tests/FormbricksSDKTests/MockFormbricksService/MockFormbricksService.swift @@ -17,7 +17,7 @@ class MockFormbricksService: FormbricksService { /// Defaults to `.user` (the standard User.json). var userMockResponse: MockResponse = .user - override func getEnvironmentState(completion: @escaping (ResultType) -> Void) { + override func getWorkspaceState(completion: @escaping (ResultType) -> Void) { if isErrorResponseNeeded { completion(.failure(RuntimeError(message: ""))) } else { diff --git a/Tests/FormbricksSDKTests/Networking/APIClientTests.swift b/Tests/FormbricksSDKTests/Networking/APIClientTests.swift index e8b61bc9..abb8c495 100644 --- a/Tests/FormbricksSDKTests/Networking/APIClientTests.swift +++ b/Tests/FormbricksSDKTests/Networking/APIClientTests.swift @@ -56,13 +56,13 @@ final class APIClientTests: XCTestCase { override func setUp() { super.setUp() mockURLSession = MockURLSession() - Formbricks.environmentId = "test-env-id" + Formbricks.workspaceId = "test-workspace-id" } - + override func tearDown() { mockURLSession = nil sut = nil - Formbricks.environmentId = nil + Formbricks.workspaceId = nil super.tearDown() } @@ -627,11 +627,11 @@ final class APIClientTests: XCTestCase { wait(for: [expectation], timeout: 1.0) } - func testEnvironmentResponse() { + func testWorkspaceResponse() { // Given let expectation = XCTestExpectation(description: "API call completes") - - let request = GetEnvironmentRequest() + + let request = GetWorkspaceRequest() Formbricks.appUrl = "https://api.test.com" addTeardownBlock { Formbricks.appUrl = nil diff --git a/Tests/FormbricksSDKTests/Networking/ClientAPIEndpointsTests.swift b/Tests/FormbricksSDKTests/Networking/ClientAPIEndpointsTests.swift index d91729a2..2c93191a 100644 --- a/Tests/FormbricksSDKTests/Networking/ClientAPIEndpointsTests.swift +++ b/Tests/FormbricksSDKTests/Networking/ClientAPIEndpointsTests.swift @@ -1,9 +1,9 @@ import XCTest @testable import FormbricksSDK -final class GetEnvironmentRequestTests: XCTestCase { +final class GetWorkspaceRequestTests: XCTestCase { func testInit() { - let req = GetEnvironmentRequest() + let req = GetWorkspaceRequest() XCTAssertEqual(req.requestType, .get) XCTAssertFalse(req.requestEndPoint.isEmpty) } @@ -15,4 +15,4 @@ final class PostUserRequestTests: XCTestCase { XCTAssertEqual(req.requestType, .post) XCTAssertFalse(req.requestEndPoint.isEmpty) } -} \ No newline at end of file +} From c726d87542ae9a467b0551dbd3ccf53bdf4f3abe Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Fri, 24 Apr 2026 15:28:50 +0530 Subject: [PATCH 2/4] fix: workspace cache getter, refresh timer, and config access level - Getter: fall back to legacy key when new-key blob fails to decode, and only migrate/delete legacy after it decodes as WorkspaceResponse so a corrupt blob can't poison the new key. - refreshWorkspaceIfNeeded: schedule startRefreshTimer on the cached-valid path so refresh still fires when the cached response later expires. - FormbricksConfig.workspaceId: expose as @objc public to match the deprecated environmentId alias visibility. - Tests: clear persisted workspace cache keys in setUp/tearDown to avoid cross-test leakage (Formbricks.cleanup() intentionally leaves them). Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/FormbricksSDK/Helpers/ConfigBuilder.swift | 2 +- Sources/FormbricksSDK/Manager/SurveyManager.swift | 14 +++++++++----- Tests/FormbricksSDKTests/FormbricksSDKTests.swift | 13 ++++++++++++- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Sources/FormbricksSDK/Helpers/ConfigBuilder.swift b/Sources/FormbricksSDK/Helpers/ConfigBuilder.swift index b9df6a2e..67efe1e9 100644 --- a/Sources/FormbricksSDK/Helpers/ConfigBuilder.swift +++ b/Sources/FormbricksSDK/Helpers/ConfigBuilder.swift @@ -3,7 +3,7 @@ import Foundation /// The configuration object for the Formbricks SDK. @objc(FormbricksConfig) public class FormbricksConfig: NSObject { let appUrl: String - let workspaceId: String + @objc public let workspaceId: String let userId: String? let attributes: [String: AttributeValue]? let logLevel: LogLevel diff --git a/Sources/FormbricksSDK/Manager/SurveyManager.swift b/Sources/FormbricksSDK/Manager/SurveyManager.swift index a117cf5f..907cb5ff 100644 --- a/Sources/FormbricksSDK/Manager/SurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/SurveyManager.swift @@ -149,6 +149,7 @@ extension SurveyManager { if let workspaceResponse = workspaceResponse, workspaceResponse.data.expiresAt.timeIntervalSinceNow > 0, !force { Formbricks.logger?.debug("Workspace state is still valid until \(workspaceResponse.data.expiresAt)") filterSurveys() + startRefreshTimer(expiresAt: workspaceResponse.data.expiresAt) return } @@ -250,15 +251,18 @@ extension SurveyManager { // Prefer the new key; fall back to the legacy key for installs that still // have data stored under the pre-rename `environmentResponseObjectKey`. let defaults = UserDefaults.standard - if let data = defaults.data(forKey: SurveyManager.workspaceResponseObjectKey) { - return try? JSONDecoder().decode(WorkspaceResponse.self, from: data) + if let data = defaults.data(forKey: SurveyManager.workspaceResponseObjectKey), + let decoded = try? JSONDecoder().decode(WorkspaceResponse.self, from: data) { + return decoded } - if let legacyData = defaults.data(forKey: SurveyManager.legacyEnvironmentResponseObjectKey) { - // Migrate the legacy blob to the new key and drop the old one. + if let legacyData = defaults.data(forKey: SurveyManager.legacyEnvironmentResponseObjectKey), + let decoded = try? JSONDecoder().decode(WorkspaceResponse.self, from: legacyData) { + // Only migrate after a successful decode, so a corrupt legacy blob + // can't poison the new key or get silently discarded. defaults.set(legacyData, forKey: SurveyManager.workspaceResponseObjectKey) defaults.removeObject(forKey: SurveyManager.legacyEnvironmentResponseObjectKey) - return try? JSONDecoder().decode(WorkspaceResponse.self, from: legacyData) + return decoded } let error = FormbricksSDKError(type: .unableToRetrieveEnvironment) diff --git a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift index ba3bb6ae..c07d9f78 100644 --- a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift +++ b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift @@ -13,12 +13,23 @@ final class FormbricksSDKTests: XCTestCase { super.setUp() // Always clean up before each test Formbricks.cleanup() + clearPersistedWorkspaceCache() } - + override func tearDown() { Formbricks.cleanup() + clearPersistedWorkspaceCache() super.tearDown() } + + /// Removes the cached workspace blobs from UserDefaults so tests don't leak + /// cache state into each other. `Formbricks.cleanup()` intentionally does + /// not wipe these keys because real apps rely on the cache across launches. + private func clearPersistedWorkspaceCache() { + let defaults = UserDefaults.standard + defaults.removeObject(forKey: SurveyManager.workspaceResponseObjectKey) + defaults.removeObject(forKey: SurveyManager.legacyEnvironmentResponseObjectKey) + } func testFormbricks() throws { // Everything should be in the default state before initialization. From 7e8f4d8fd6e895fede9738e4bc5c91628ba95f9d Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Fri, 24 Apr 2026 16:32:13 +0530 Subject: [PATCH 3/4] ci: bump sonarqube workflow sim to iPhone 16 / OS=latest macos-15 runners no longer ship the iPhone SE (3rd generation) iOS 18.5 simulator runtime, so the pinned destination fails with "no available devices matched the request". Switch to iPhone 16 with OS=latest so the runner picks whatever simulator runtime is installed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/sonarqube.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index c56c9da2..76e50160 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -34,7 +34,7 @@ jobs: -scheme 'FormbricksSDK' \ -sdk iphonesimulator \ -config Debug \ - -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation),OS=18.5' \ + -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \ -derivedDataPath build \ -enableCodeCoverage YES From 4c8a5887de83f584652c30653b1b60b4ad7bc055 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Fri, 24 Apr 2026 16:37:02 +0530 Subject: [PATCH 4/4] ci: select latest-stable Xcode before running sonarqube tests Previous run on macos-15 reported zero available iOS Simulator devices (only placeholders), meaning xcode-select was pointing at an Xcode without any installed simulator runtimes. Use maxim-lobanov/setup-xcode to pin to the latest stable Xcode (which ships with simulators), and print the sim device list so future failures are self-diagnosing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/sonarqube.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 76e50160..20c68a66 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -25,6 +25,14 @@ jobs: with: fetch-depth: 0 # Shallow clones should be disabled for better SonarQube analysis + - name: Select Xcode + uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0 + with: + xcode-version: latest-stable + + - name: Show available simulators + run: xcrun simctl list devices available + - name: Install Dependencies run: swift package resolve