diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx index 18a0b6737e..dfa4db8c75 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx @@ -22,7 +22,7 @@ export const OrganizationSettingsNavbar = ({ loading, }: OrganizationSettingsNavbarProps) => { const pathname = usePathname(); - const { isMember } = getAccessFlags(membershipRole); + const { isMember, isOwner } = getAccessFlags(membershipRole); const isPricingDisabled = isMember; const { t } = useTranslate(); @@ -59,6 +59,7 @@ export const OrganizationSettingsNavbar = ({ label: t("common.api_keys"), href: `/environments/${environmentId}/settings/api-keys`, current: pathname?.includes("/api-keys"), + hidden: !isOwner, }, ]; diff --git a/apps/web/modules/organization/settings/api-keys/actions.ts b/apps/web/modules/organization/settings/api-keys/actions.ts index 0969fd9057..8ea5b388b6 100644 --- a/apps/web/modules/organization/settings/api-keys/actions.ts +++ b/apps/web/modules/organization/settings/api-keys/actions.ts @@ -25,7 +25,7 @@ export const deleteApiKeyAction = authenticatedActionClient access: [ { type: "organization", - roles: ["owner", "manager"], + roles: ["owner"], }, ], }); @@ -47,7 +47,7 @@ export const createApiKeyAction = authenticatedActionClient access: [ { type: "organization", - roles: ["owner", "manager"], + roles: ["owner"], }, ], }); @@ -69,7 +69,7 @@ export const updateApiKeyAction = authenticatedActionClient access: [ { type: "organization", - roles: ["owner", "manager"], + roles: ["owner"], }, ], }); diff --git a/apps/web/modules/organization/settings/api-keys/page.tsx b/apps/web/modules/organization/settings/api-keys/page.tsx index ddcfae4a89..6c0871a764 100644 --- a/apps/web/modules/organization/settings/api-keys/page.tsx +++ b/apps/web/modules/organization/settings/api-keys/page.tsx @@ -2,7 +2,6 @@ import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmen import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getProjectsByOrganizationId } from "@/modules/organization/settings/api-keys/lib/projects"; -import { Alert } from "@/modules/ui/components/alert"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { getTranslate } from "@/tolgee/server"; @@ -19,7 +18,9 @@ export const APIKeysPage = async (props) => { const projects = await getProjectsByOrganizationId(organization.id); - const isReadOnly = currentUserMembership.role !== "owner" && currentUserMembership.role !== "manager"; + const isNotOwner = currentUserMembership.role !== "owner"; + + if (isNotOwner) throw new Error(t("common.not_authorized")); return ( @@ -31,22 +32,16 @@ export const APIKeysPage = async (props) => { activeId="api-keys" /> - {isReadOnly ? ( - - {t("environments.settings.api_keys.only_organization_owners_and_managers_can_manage_api_keys")} - - ) : ( - - - - )} + + + ); }; diff --git a/packages/ios/Demo/Demo/AppDelegate.swift b/packages/ios/Demo/Demo/AppDelegate.swift index 256bb2deac..b6867d3666 100644 --- a/packages/ios/Demo/Demo/AppDelegate.swift +++ b/packages/ios/Demo/Demo/AppDelegate.swift @@ -3,18 +3,8 @@ import FormbricksSDK class AppDelegate: NSObject, UIApplicationDelegate { - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - let config = FormbricksConfig.Builder(appUrl: "[appUrl]", environmentId: "[environmentId]") - .setLogLevel(.debug) - .build() - - Formbricks.setup(with: config) - - - Formbricks.logout() - Formbricks.setUserId(UUID().uuidString) - + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { return true } - } diff --git a/packages/ios/Demo/Demo/ContentView.swift b/packages/ios/Demo/Demo/ContentView.swift deleted file mode 100644 index c99bc69ea5..0000000000 --- a/packages/ios/Demo/Demo/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -import SwiftUI -import FormbricksSDK - -struct ContentView: View { - var body: some View { - VStack { - Spacer() - HStack { - Spacer() - Button("Click me!") { - Formbricks.track("click_demo_button") - } - Spacer() - } - .padding() - Spacer() - } - } - -} - -#Preview { - ContentView() -} diff --git a/packages/ios/Demo/Demo/DemoApp.swift b/packages/ios/Demo/Demo/DemoApp.swift index 17c21302e9..9603805451 100644 --- a/packages/ios/Demo/Demo/DemoApp.swift +++ b/packages/ios/Demo/Demo/DemoApp.swift @@ -7,7 +7,7 @@ struct DemoApp: App { var body: some Scene { WindowGroup { - ContentView() + SetupView() } } } diff --git a/packages/ios/Demo/Demo/SetupView.swift b/packages/ios/Demo/Demo/SetupView.swift new file mode 100644 index 0000000000..e2eac11b4f --- /dev/null +++ b/packages/ios/Demo/Demo/SetupView.swift @@ -0,0 +1,72 @@ +import SwiftUI +import FormbricksSDK + +struct SetupView: View { + @State private var isSetup = false + @State private var isLoading = false + + var body: some View { + VStack(spacing: 20) { + if !isSetup { + if isLoading { + ProgressView("Setting up...") + .padding() + } else { + Button("Setup Formbricks SDK") { + isLoading = true + let config = FormbricksConfig.Builder(appUrl: "[appUrl]", environmentId: "[environmentId]") + .setLogLevel(.debug) + .build() + // Simulate async setup delay + DispatchQueue.global().async { + Formbricks.setup(with: config) + Formbricks.setUserId(UUID().uuidString) + + DispatchQueue.main.async { + isSetup = true + isLoading = false + } + } + } + .padding() + } + } else { + Button("Call Formbricks.track") { + Formbricks.track("click_demo_button") + } + .padding() + + Button("Call Formbricks.setAttribute") { + Formbricks.setAttribute("test@example.com", forKey: "user_email") + } + .padding() + + Button("Call Formbricks.setAttributes") { + Formbricks.setAttributes(["user_name": "John Doe", "user_age": "30"]) + } + .padding() + + Button("Call Formbricks.setLanguage") { + Formbricks.setLanguage("vi") + }.padding() + + Button("Call Formbricks.logout") { + Formbricks.logout() + }.padding() + + Button("Call Formbricks.cleanup") { + Formbricks.cleanup(waitForOperations: true) { + print(">>> Cleanup complete") + isSetup = false + } + } + .padding() + } + } + .navigationTitle("Setup SDK") + } +} + +#Preview { + SetupView() +} diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Formbricks.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Formbricks.swift index 25ca22d67d..c5cca4bdd3 100644 --- a/packages/ios/FormbricksSDK/FormbricksSDK/Formbricks.swift +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Formbricks.swift @@ -9,9 +9,12 @@ import Network static internal var language: String = "default" static internal var isInitialized: Bool = false - static internal var apiQueue = OperationQueue() - static internal var logger = Logger() - static internal var service = FormbricksService() + static internal var userManager: UserManager? + static internal var presentSurveyManager: PresentSurveyManager? + static internal var surveyManager: SurveyManager? + static internal var apiQueue: OperationQueue? = OperationQueue() + static internal var logger: Logger? + static internal var service = FormbricksService() // make this class not instantiatable outside of the SDK internal override init() { @@ -36,30 +39,39 @@ import Network Formbricks.setup(with: config) ``` */ - @objc public static func setup(with config: FormbricksConfig) { + @objc public static func setup(with config: FormbricksConfig, force: Bool = false) { + logger = Logger() + + if (force == true) { + isInitialized = false + } guard !isInitialized else { - Formbricks.logger.error(FormbricksSDKError(type: .sdkIsAlreadyInitialized).message) + Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsAlreadyInitialized).message) return } self.appUrl = config.appUrl self.environmentId = config.environmentId - self.logger.logLevel = config.logLevel + self.logger?.logLevel = config.logLevel + userManager = UserManager() if let userId = config.userId { - UserManager.shared.set(userId: userId) + userManager?.set(userId: userId) } if let attributes = config.attributes { - UserManager.shared.set(attributes: attributes) + userManager?.set(attributes: attributes) } if let language = config.attributes?["language"] { - UserManager.shared.set(language: language) + userManager?.set(language: language) self.language = language } + + presentSurveyManager = PresentSurveyManager() + surveyManager = SurveyManager.create(userManager: userManager!, presentSurveyManager: presentSurveyManager!) + userManager?.surveyManager = surveyManager - SurveyManager.shared.refreshEnvironmentIfNeeded() - UserManager.shared.syncUserStateIfNeeded() - + surveyManager?.refreshEnvironmentIfNeeded(force: force) + userManager?.syncUserStateIfNeeded() self.isInitialized = true } @@ -75,11 +87,11 @@ import Network */ @objc public static func setUserId(_ userId: String) { guard Formbricks.isInitialized else { - Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) + Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) return } - UserManager.shared.set(userId: userId) + userManager?.set(userId: userId) } /** @@ -93,11 +105,11 @@ import Network */ @objc public static func setAttribute(_ attribute: String, forKey key: String) { guard Formbricks.isInitialized else { - Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) + Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) return } - UserManager.shared.add(attribute: attribute, forKey: key) + userManager?.add(attribute: attribute, forKey: key) } /** @@ -111,11 +123,11 @@ import Network */ @objc public static func setAttributes(_ attributes: [String : String]) { guard Formbricks.isInitialized else { - Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) + Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) return } - UserManager.shared.set(attributes: attributes) + userManager?.set(attributes: attributes) } /** @@ -129,12 +141,16 @@ import Network */ @objc public static func setLanguage(_ language: String) { guard Formbricks.isInitialized else { - Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) + Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) + return + } + + if (Formbricks.language == language) { return } Formbricks.language = language - UserManager.shared.set(language: language) + userManager?.set(language: language) } /** @@ -148,15 +164,15 @@ import Network */ @objc public static func track(_ action: String) { guard Formbricks.isInitialized else { - Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) + Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) return } Formbricks.isInternetAvailabile { available in if available { - SurveyManager.shared.track(action) + surveyManager?.track(action) } else { - Formbricks.logger.warning(FormbricksSDKError.init(type: .networkError).message) + Formbricks.logger?.warning(FormbricksSDKError.init(type: .networkError).message) } } @@ -173,11 +189,58 @@ import Network */ @objc public static func logout() { guard Formbricks.isInitialized else { - Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) + Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message) return } - UserManager.shared.logout() + userManager?.logout() + } + + /** + Cleans up the SDK. This will clear the user attributes, the user id and the environment 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. + You can also provide a completion block that will be called when the cleanup is finished. + + Example: + ```swift + Formbricks.cleanup() + + Formbricks.cleanup(waitForOperations: true) { + // Cleanup completed + } + ``` + */ + + @objc public static func cleanup(waitForOperations: Bool = false, completion: (() -> Void)? = nil) { + if waitForOperations, let queue = apiQueue { + DispatchQueue.global(qos: .background).async { + queue.waitUntilAllOperationsAreFinished() + performCleanup() + DispatchQueue.main.async { + completion?() + } + } + } else { + apiQueue?.cancelAllOperations() + performCleanup() + completion?() + } + } + + private static func performCleanup() { + userManager?.cleanupUpdateQueue() + presentSurveyManager?.dismissView() + presentSurveyManager = nil + userManager = nil + surveyManager = nil + apiQueue = nil + isInitialized = false + appUrl = nil + environmentId = nil + logger = nil + language = "default" } } diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Logger/Logger.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Logger/Logger.swift index 63eae7a156..fbc54eb889 100644 --- a/packages/ios/FormbricksSDK/FormbricksSDK/Logger/Logger.swift +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Logger/Logger.swift @@ -52,8 +52,8 @@ private extension Logger { logString.append(messageListString) } if logLevel == .error || logLevel.rawValue >= self.logLevel.rawValue { - DispatchQueue.main.async { - let str = logString + "\(self.emoji)\n" + DispatchQueue.main.async { [weak self] in + let str = logString + "\(self?.emoji ?? "")\n" print(str) } } diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Manager/PresentSurveyManager.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Manager/PresentSurveyManager.swift index 815cc16e77..3cd731f9fc 100644 --- a/packages/ios/FormbricksSDK/FormbricksSDK/Manager/PresentSurveyManager.swift +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Manager/PresentSurveyManager.swift @@ -2,9 +2,8 @@ import SwiftUI /// Presents a survey webview to the window's root final class PresentSurveyManager { - static let shared = PresentSurveyManager() - private init() { - /* + init() { + /* This empty initializer prevents external instantiation of the PresentSurveyManager class. The class serves as a namespace for the present method, so instance creation is not needed and should be restricted. */ @@ -15,9 +14,10 @@ final class PresentSurveyManager { /// Present the webview func present(environmentResponse: EnvironmentResponse, id: String) { - DispatchQueue.main.async { + 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(environmentResponse: environmentResponse, surveyId: id)) let vc = UIHostingController(rootView: view) vc.modalPresentationStyle = .overCurrentContext vc.view.backgroundColor = UIColor.gray.withAlphaComponent(0.6) @@ -34,4 +34,8 @@ final class PresentSurveyManager { func dismissView() { viewController?.dismiss(animated: true) } + + deinit { + Formbricks.logger?.debug("Deinitializing \(self)") + } } diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Manager/SurveyManager.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Manager/SurveyManager.swift index e7e7084b1b..04d5a1de4a 100644 --- a/packages/ios/FormbricksSDK/FormbricksSDK/Manager/SurveyManager.swift +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Manager/SurveyManager.swift @@ -3,12 +3,16 @@ import SwiftUI /// The SurveyManager is responsible for managing the surveys that are displayed to the user. /// Filtering surveys based on the user's segments, responses, and displays. final class SurveyManager { - static let shared = SurveyManager() - private init() { - /* - This empty initializer prevents external instantiation of the SurveyManager class. - The class serves as a namespace for the shared instance, so instance creation is not needed and should be restricted. - */ + private let userManager: UserManager + private let presentSurveyManager: PresentSurveyManager + + private init(userManager: UserManager, presentSurveyManager: PresentSurveyManager) { + self.userManager = userManager + self.presentSurveyManager = presentSurveyManager + } + + static func create(userManager: UserManager, presentSurveyManager: PresentSurveyManager) -> SurveyManager { + return SurveyManager(userManager: userManager, presentSurveyManager: presentSurveyManager) } private static let environmentResponseObjectKey = "environmentResponseObjectKey" @@ -26,15 +30,15 @@ final class SurveyManager { guard let environment = environmentResponse else { return } guard let surveys = environment.data.data.surveys else { return } - let displays = UserManager.shared.displays ?? [] - let responses = UserManager.shared.responses ?? [] - let segments = UserManager.shared.segments ?? [] + 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) - + // If we have a user, we do more filtering - if UserManager.shared.userId != nil { + if userManager.userId != nil { if segments.isEmpty { filteredSurveys = [] return @@ -48,22 +52,23 @@ final class SurveyManager { /// Handles the display percentage and the delay of the survey. func track(_ action: String) { guard !isShowingSurvey else { return } + let actionClasses = environmentResponse?.data.data.actionClasses ?? [] let codeActionClasses = actionClasses.filter { $0.type == "code" } let actionClass = codeActionClasses.first { $0.key == action } let firstSurveyWithActionClass = filteredSurveys.first { survey in return survey.triggers?.contains(where: { $0.actionClass?.name == actionClass?.name }) ?? false } - + // Display percentage let shouldDisplay = shouldDisplayBasedOnPercentage(firstSurveyWithActionClass?.displayPercentage) - + // Display and delay it if needed if let surveyId = firstSurveyWithActionClass?.id, shouldDisplay { isShowingSurvey = true let timeout = firstSurveyWithActionClass?.delay ?? 0 - DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { - self.showSurvey(withId: surveyId) + DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { [weak self] in + self?.showSurvey(withId: surveyId) } } } @@ -74,7 +79,7 @@ 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)") + Formbricks.logger?.debug("Environment state is still valid until \(environmentResponse.data.expiresAt)") filterSurveys() return } @@ -88,7 +93,7 @@ extension SurveyManager { self?.filterSurveys() case .failure: self?.hasApiError = true - Formbricks.logger.error(FormbricksSDKError(type: .unableToRefreshEnvironment).message) + Formbricks.logger?.error(FormbricksSDKError(type: .unableToRefreshEnvironment).message) self?.startErrorTimer() } } @@ -96,12 +101,12 @@ extension SurveyManager { /// Posts a survey response to the Formbricks API. func postResponse(surveyId: String) { - UserManager.shared.onResponse(surveyId: surveyId) + 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.shared.onDisplay(surveyId: surveyId) + userManager.onDisplay(surveyId: surveyId) } } @@ -110,7 +115,7 @@ extension SurveyManager { /// Dismisses the presented survey window. func dismissSurveyWebView() { isShowingSurvey = false - PresentSurveyManager.shared.dismissView() + presentSurveyManager.dismissView() } } @@ -120,7 +125,7 @@ private extension SurveyManager { /// The view controller is presented over the current context. func showSurvey(withId id: String) { if let environmentResponse = environmentResponse { - PresentSurveyManager.shared.present(environmentResponse: environmentResponse, id: id) + presentSurveyManager.present(environmentResponse: environmentResponse, id: id) } } @@ -142,9 +147,9 @@ private extension SurveyManager { return } - DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { - Formbricks.logger.debug("Refreshing environment state.") - self.refreshEnvironmentIfNeeded(force: true) + DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [weak self] in + Formbricks.logger?.debug("Refreshing environment state.") + self?.refreshEnvironmentIfNeeded(force: true) } } @@ -166,7 +171,7 @@ extension SurveyManager { if let data = UserDefaults.standard.data(forKey: SurveyManager.environmentResponseObjectKey) { return try? JSONDecoder().decode(EnvironmentResponse.self, from: data) } else { - Formbricks.logger.error(FormbricksSDKError(type: .unableToRetrieveEnvironment).message) + Formbricks.logger?.error(FormbricksSDKError(type: .unableToRetrieveEnvironment).message) return nil } } @@ -175,7 +180,7 @@ extension SurveyManager { UserDefaults.standard.set(data, forKey: SurveyManager.environmentResponseObjectKey) backingEnvironmentResponse = newValue } else { - Formbricks.logger.error(FormbricksSDKError(type: .unableToPersistEnvironment).message) + Formbricks.logger?.error(FormbricksSDKError(type: .unableToPersistEnvironment).message) } } } @@ -207,7 +212,7 @@ private extension SurveyManager { } default: - Formbricks.logger.error(FormbricksSDKError(type: .invalidDisplayOption).message) + Formbricks.logger?.error(FormbricksSDKError(type: .invalidDisplayOption).message) return false } @@ -218,7 +223,7 @@ private extension SurveyManager { /// 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.shared.lastDisplayedAt else { return true } + guard let lastDisplayedAt = userManager.lastDisplayedAt else { return true } let recontactDays = survey.recontactDays ?? defaultRecontactDays if let recontactDays = recontactDays { diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Manager/UserManager.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Manager/UserManager.swift index 431885243f..08f0a344b5 100644 --- a/packages/ios/FormbricksSDK/FormbricksSDK/Manager/UserManager.swift +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Manager/UserManager.swift @@ -1,13 +1,11 @@ import Foundation /// Store and manage user state and sync with the server when needed. -final class UserManager { - static let shared = UserManager() - private init() { - /* - This empty initializer prevents external instantiation of the UserManager class. - The class serves as a namespace for the user state, so instance creation is not needed and should be restricted. - */ +final class UserManager: UserManagerSyncable { + weak var surveyManager: SurveyManager? + + init(surveyManager: SurveyManager? = nil) { + self.surveyManager = surveyManager } private static let userIdKey = "userIdKey" @@ -28,26 +26,30 @@ final class UserManager { private var backingLastDisplayedAt: Date? private var backingExpiresAt: Date? + lazy private var updateQueue: UpdateQueue? = { + return UpdateQueue(userManager: self) + }() + internal var syncTimer: Timer? /// Starts an update queue with the given user id. func set(userId: String) { - UpdateQueue.current.set(userId: userId) + updateQueue?.set(userId: userId) } /// Starts an update queue with the given attribute. func add(attribute: String, forKey key: String) { - UpdateQueue.current.add(attribute: attribute, forKey: key) + updateQueue?.add(attribute: attribute, forKey: key) } /// Starts an update queue with the given attributes. func set(attributes: [String: String]) { - UpdateQueue.current.set(attributes: attributes) + updateQueue?.set(attributes: attributes) } /// Starts an update queue with the given language.. func set(language: String) { - UpdateQueue.current.set(language: language) + updateQueue?.set(language: language) } /// Saves `surveyId` to the `displays` property and the current date to the `lastDisplayedAt` property. @@ -57,7 +59,7 @@ final class UserManager { newDisplays.append(Display(surveyId: surveyId, createdAt: DateFormatter.isoFormatter.string(from: lastDisplayedAt))) displays = newDisplays self.lastDisplayedAt = lastDisplayedAt - SurveyManager.shared.filterSurveys() + surveyManager?.filterSurveys() } /// Saves `surveyId` to the `responses` property. @@ -65,7 +67,7 @@ final class UserManager { var newResponses = responses ?? [] newResponses.append(surveyId) responses = newResponses - SurveyManager.shared.filterSurveys() + surveyManager?.filterSurveys() } /// Syncs the user state with the server if the user id is set and the expiration date has passed. @@ -80,7 +82,7 @@ final class UserManager { syncUser(withId: id) } - /// Syncs the user state with the server, calls the `SurveyManager.shared.filterSurveys()` method and starts the sync timer. + /// Syncs the user state with the server, calls the `self?.surveyManager?.filterSurveys()` method and starts the sync timer. func syncUser(withId id: String, attributes: [String: String]? = nil) { service.postUser(id: id, attributes: attributes) { [weak self] result in switch result { @@ -92,11 +94,12 @@ final class UserManager { self?.responses = userResponse.data.state?.data?.responses self?.lastDisplayedAt = userResponse.data.state?.data?.lastDisplayAt self?.expiresAt = userResponse.data.state?.expiresAt - UpdateQueue.current.reset() - SurveyManager.shared.filterSurveys() + + self?.updateQueue?.reset() + self?.surveyManager?.filterSurveys() self?.startSyncTimer() case .failure(let error): - Formbricks.logger.error(error) + Formbricks.logger?.error(error) } } } @@ -104,18 +107,31 @@ final class UserManager { /// Logs out the user and clears the user state. func logout() { UserDefaults.standard.removeObject(forKey: UserManager.userIdKey) + UserDefaults.standard.removeObject(forKey: UserManager.contactIdKey) UserDefaults.standard.removeObject(forKey: UserManager.segmentsKey) UserDefaults.standard.removeObject(forKey: UserManager.displaysKey) UserDefaults.standard.removeObject(forKey: UserManager.responsesKey) UserDefaults.standard.removeObject(forKey: UserManager.lastDisplayedAtKey) UserDefaults.standard.removeObject(forKey: UserManager.expiresAtKey) backingUserId = nil + backingContactId = nil backingSegments = nil backingDisplays = nil backingResponses = nil backingLastDisplayedAt = nil backingExpiresAt = nil - UpdateQueue.current.reset() + updateQueue?.reset() + + Formbricks.logger?.debug("Successfully logged out user and reset the user state.") + } + + func cleanupUpdateQueue() { + updateQueue?.cleanup() + updateQueue = nil // Release the instance so memory can be reclaimed. + } + + deinit { + Formbricks.logger?.debug("Deinitializing \(self)") } } diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Queue/UpdateQueue.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Queue/UpdateQueue.swift index 60fba47e1b..5d1ea197cb 100644 --- a/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Queue/UpdateQueue.swift +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Queue/UpdateQueue.swift @@ -1,68 +1,122 @@ import Foundation +protocol UserManagerSyncable: AnyObject { + func syncUser(withId id: String, attributes: [String: String]?) +} + /// Update queue. This class is used to queue updates to the user. /// The given properties will be sent to the backend and updated in the user object when the debounce interval is reached. final class UpdateQueue { private static var debounceInterval: TimeInterval = 0.5 - static var current = UpdateQueue() - private let semaphore = DispatchSemaphore(value: 1) + private let syncQueue = DispatchQueue(label: "com.formbricks.updateQueue") private var userId: String? private var attributes: [String : String]? private var language: String? private var timer: Timer? + private weak var userManager: UserManagerSyncable? + + init(userManager: UserManagerSyncable) { + self.userManager = userManager + } + func set(userId: String) { - semaphore.wait() - self.userId = userId - startDebounceTimer() + syncQueue.sync { + self.userId = userId + startDebounceTimer() + } } func set(attributes: [String : String]) { - semaphore.wait() - self.attributes = attributes - startDebounceTimer() + syncQueue.sync { + self.attributes = attributes + startDebounceTimer() + } } func add(attribute: String, forKey key: String) { - semaphore.wait() - if var attr = self.attributes { - attr[key] = attribute - self.attributes = attr - } else { - self.attributes = [key: attribute] - } - startDebounceTimer() + syncQueue.sync { + if var attr = self.attributes { + attr[key] = attribute + self.attributes = attr + } else { + self.attributes = [key: attribute] + } + startDebounceTimer() + } } func set(language: String) { - semaphore.wait() - add(attribute: "language", forKey: language) - startDebounceTimer() + syncQueue.sync { + self.language = language + + // Check if we have an effective userId + let effectiveUserId = self.userId ?? Formbricks.userManager?.userId + + if effectiveUserId != nil { + // If we have a userId, set attributes + self.attributes = ["language": language] + } else { + // If no userId, just update locally without API call + Formbricks.logger?.debug("UpdateQueue - updating language locally: \(language)") + } + + startDebounceTimer() + } } func reset() { - userId = nil - attributes = nil - language = nil + syncQueue.sync { + userId = nil + attributes = nil + language = nil + } + } + + deinit { + Formbricks.logger?.debug("Deinitializing \(self)") } } private extension UpdateQueue { func startDebounceTimer() { timer?.invalidate() - timer = Timer.scheduledTimer(timeInterval: UpdateQueue.debounceInterval, target: self, selector: #selector(commit), userInfo: nil, repeats: false) - semaphore.signal() + timer = nil + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.timer = Timer.scheduledTimer(timeInterval: UpdateQueue.debounceInterval, + target: self, + selector: #selector(self.commit), + userInfo: nil, + repeats: false) + } } @objc func commit() { - guard let userId = userId else { - Formbricks.logger.error(FormbricksSDKError(type: .userIdIsNotSetYet).message) + let effectiveUserId: String? = self.userId ?? Formbricks.userManager?.userId ?? nil + + guard let userId = effectiveUserId else { + Formbricks.logger?.error(FormbricksSDKError(type: .userIdIsNotSetYet).message) return } - Formbricks.logger.debug("UpdateQueue - commit() called on UpdateQueue with \(userId) and \(attributes ?? [:])") - UserManager.shared.syncUser(withId: userId, attributes: attributes) + Formbricks.logger?.debug("UpdateQueue - commit() called on UpdateQueue with \(userId) and \(attributes ?? [:])") + userManager?.syncUser(withId: userId, attributes: attributes) + } +} + +// Add a function to to stop the timer for cleanup +extension UpdateQueue { + func cleanup() { + syncQueue.sync { + timer?.invalidate() + timer = nil + userId = nil + attributes = nil + language = nil + } } } diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Service/FormbricksService.swift b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Service/FormbricksService.swift index bf8602d12e..338485f8f6 100644 --- a/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Service/FormbricksService.swift +++ b/packages/ios/FormbricksSDK/FormbricksSDK/Networking/Service/FormbricksService.swift @@ -20,6 +20,6 @@ private extension FormbricksService { /// Creates the APIClient operation and adds it to the queue func execute(_ request: Request, withCompletion completion: @escaping (ResultType) -> Void) { let operation = APIClient(request: request, completion: completion) - Formbricks.apiQueue.addOperation(operation) + Formbricks.apiQueue?.addOperation(operation) } } diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/WebView/FormbricksViewModel.swift b/packages/ios/FormbricksSDK/FormbricksSDK/WebView/FormbricksViewModel.swift index cddcae7b59..7127c3b948 100644 --- a/packages/ios/FormbricksSDK/FormbricksSDK/WebView/FormbricksViewModel.swift +++ b/packages/ios/FormbricksSDK/FormbricksSDK/WebView/FormbricksViewModel.swift @@ -64,7 +64,7 @@ private extension FormbricksViewModel { } const script = document.createElement("script"); - script.src = "\(FormbricksEnvironment.surveyScriptUrl)"; + script.src = "\(Formbricks.appUrl ?? "http://localhost:3000")/js/surveys.umd.cjs"; script.async = true; script.onload = () => loadSurvey(); script.onerror = (error) => { @@ -89,7 +89,7 @@ private class WebViewData { data["languageCode"] = Formbricks.language data["appUrl"] = Formbricks.appUrl data["environmentId"] = Formbricks.environmentId - data["contactId"] = UserManager.shared.contactId + data["contactId"] = Formbricks.userManager?.contactId data["isWebEnvironment"] = false let hasCustomStyling = environmentResponse.data.data.surveys?.first(where: { $0.id == surveyId })?.styling != nil @@ -103,7 +103,7 @@ private class WebViewData { let jsonData = try JSONSerialization.data(withJSONObject: data, options: []) return String(data: jsonData, encoding: .utf8)?.replacingOccurrences(of: "\\\"", with: "'") } catch { - Formbricks.logger.error(error.message) + Formbricks.logger?.error(error.message) return nil } } diff --git a/packages/ios/FormbricksSDK/FormbricksSDK/WebView/SurveyWebView.swift b/packages/ios/FormbricksSDK/FormbricksSDK/WebView/SurveyWebView.swift index d560189409..7865da02c8 100644 --- a/packages/ios/FormbricksSDK/FormbricksSDK/WebView/SurveyWebView.swift +++ b/packages/ios/FormbricksSDK/FormbricksSDK/WebView/SurveyWebView.swift @@ -39,6 +39,16 @@ struct SurveyWebView: UIViewRepresentable { return Coordinator() } + /// Called automatically by SwiftUI when the view is torn down. + static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { + let userContentController = uiView.configuration.userContentController + userContentController.removeScriptMessageHandler(forName: "logging") + userContentController.removeScriptMessageHandler(forName: "jsMessage") + + uiView.navigationDelegate = nil + uiView.uiDelegate = nil + Formbricks.logger?.debug("SurveyWebView: Dismantled") + } /// Clean up cookies and website data. func clean() { @@ -98,22 +108,22 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler { } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - Formbricks.logger.debug(message.body) + Formbricks.logger?.debug(message.body) if let body = message.body as? String, let data = body.data(using: .utf8), let obj = try? JSONDecoder().decode(JsMessageData.self, from: data) { switch obj.event { /// Happens when the user submits an answer. case .onResponseCreated: - SurveyManager.shared.postResponse(surveyId: surveyId) + Formbricks.surveyManager?.postResponse(surveyId: surveyId) /// Happens when a survey is shown. case .onDisplayCreated: - SurveyManager.shared.onNewDisplay(surveyId: surveyId) + Formbricks.surveyManager?.onNewDisplay(surveyId: surveyId) /// Happens when the user closes the survey view with the close button. case .onClose: - SurveyManager.shared.dismissSurveyWebView() + Formbricks.surveyManager?.dismissSurveyWebView() /// Happens when the survey wants to open an external link in the default browser. case .onOpenExternalURL: @@ -123,11 +133,11 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler { /// Happens when the survey library fails to load. case .onSurveyLibraryLoadError: - SurveyManager.shared.dismissSurveyWebView() + Formbricks.surveyManager?.dismissSurveyWebView() } } else { - Formbricks.logger.error("\(FormbricksSDKError(type: .invalidJavascriptMessage).message): \(message.body)") + Formbricks.logger?.error("\(FormbricksSDKError(type: .invalidJavascriptMessage).message): \(message.body)") } } } @@ -136,7 +146,7 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler { /// Handle and send console.log messages from the Javascript to the local logger. final class LoggingMessageHandler: NSObject, WKScriptMessageHandler { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - Formbricks.logger.debug(message.body) + Formbricks.logger?.debug(message.body) } } diff --git a/packages/ios/FormbricksSDK/FormbricksSDKTests/FormbricksSDKTests.swift b/packages/ios/FormbricksSDK/FormbricksSDKTests/FormbricksSDKTests.swift index 24767467ab..e6ad594be3 100644 --- a/packages/ios/FormbricksSDK/FormbricksSDKTests/FormbricksSDKTests.swift +++ b/packages/ios/FormbricksSDK/FormbricksSDKTests/FormbricksSDKTests.swift @@ -1,10 +1,3 @@ -// -// FormbricksSDKTests.swift -// FormbricksSDKTests -// -// Created by Peter Pesti-Varga on 2025. 02. 03.. -// - import XCTest @testable import FormbricksSDK @@ -15,128 +8,153 @@ final class FormbricksSDKTests: XCTestCase { let userId = "6CCCE716-6783-4D0F-8344-9C7DFA43D8F7" let surveyID = "cm6ovw6j7000gsf0kduf4oo4i" let mockService = MockFormbricksService() - - override func setUpWithError() throws { - UserManager.shared.logout() - SurveyManager.shared.service = mockService - UserManager.shared.service = mockService - SurveyManager.shared.environmentResponse = nil - } func testFormbricks() throws { - // Everything should be in the default state + // Everything should be in the default state before initialization. XCTAssertFalse(Formbricks.isInitialized) - XCTAssertEqual(SurveyManager.shared.filteredSurveys.count, 0) - XCTAssertFalse(SurveyManager.shared.isShowingSurvey) - XCTAssertNil(SurveyManager.shared.environmentResponse) - XCTAssertNil(UserManager.shared.syncTimer) - XCTAssertNil(UserManager.shared.userId) + XCTAssertNil(Formbricks.surveyManager) + XCTAssertNil(Formbricks.userManager) XCTAssertEqual(Formbricks.language, "default") - // Use methods before init should have no effect + // User manager default state: there is no user yet. + XCTAssertNil(Formbricks.userManager?.displays) + XCTAssertNil(Formbricks.userManager?.responses) + XCTAssertNil(Formbricks.userManager?.segments) + + // Use methods before init should have no effect. Formbricks.setUserId("userId") Formbricks.setLanguage("de") Formbricks.setAttributes(["testA" : "testB"]) Formbricks.setAttribute("test", forKey: "testKey") - XCTAssertNil(UserManager.shared.userId) + XCTAssertNil(Formbricks.userManager?.userId) XCTAssertEqual(Formbricks.language, "default") + + // 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) + .set(attributes: ["a": "b"]) + .add(attribute: "test", forKey: "key") + .setLogLevel(.debug) + .build()) + + // Set up the service dependency on both managers. + Formbricks.userManager?.service = mockService + Formbricks.surveyManager?.service = mockService - // Call the setup and initialize the SDK - Formbricks.setup(with: - FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) - .set(attributes: ["a":"b"]) - .add(attribute: "test", forKey: "key") - .setLogLevel(.debug) - .build() - ) - // Should be ignored, becuase we don't have user ID yet - Formbricks.setAttributes(["testA" : "testB"]) - Formbricks.setAttribute("test", forKey: "testKey") - XCTAssertNil(UserManager.shared.userId) - - // Verify the base variables are set properly - XCTAssertTrue(Formbricks.isInitialized) - XCTAssertEqual(Formbricks.appUrl, appUrl) - XCTAssertEqual(Formbricks.environmentId, environmentId) - - // User manager default state. There is no user yet. - XCTAssertNil(FormbricksSDK.UserManager.shared.displays) - XCTAssertNil(FormbricksSDK.UserManager.shared.responses) - XCTAssertNil(FormbricksSDK.UserManager.shared.segments) - - // Check error state handling - mockService.isErrorResponseNeeded = true - XCTAssertFalse(SurveyManager.shared.hasApiError) - SurveyManager.shared.refreshEnvironmentIfNeeded(force: true) - XCTAssertTrue(SurveyManager.shared.hasApiError) - mockService.isErrorResponseNeeded = false - - // Authenticate the user - Formbricks.setUserId(userId) - _ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 2.0) - XCTAssertEqual(UserManager.shared.userId, userId) - // User refresh timer should be set - XCTAssertNotNil(UserManager.shared.syncTimer) - - // The environment should be fetched already - XCTAssertNotNil(SurveyManager.shared.environmentResponse) - - // Check if the filter method works properly - XCTAssertEqual(SurveyManager.shared.filteredSurveys.count, 1) - - // Make sure we don't show any survey - XCTAssertNotNil(SurveyManager.shared.filteredSurveys) - XCTAssertFalse(SurveyManager.shared.isShowingSurvey) - - // Track an unknown event, shouldn't show the survey - Formbricks.track("unknown_event") - XCTAssertFalse(SurveyManager.shared.isShowingSurvey) - - // Track a known event, thus, the survey should be shown. - Formbricks.track("click_demo_button") - _ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0) - XCTAssertTrue(SurveyManager.shared.isShowingSurvey) - - // "Dismiss" the webview - SurveyManager.shared.dismissSurveyWebView() - XCTAssertFalse(SurveyManager.shared.isShowingSurvey) - - // Validate display and response - SurveyManager.shared.postResponse(surveyId: surveyID) - SurveyManager.shared.onNewDisplay(surveyId: surveyID) - XCTAssertEqual(UserManager.shared.responses?.count, 1) - XCTAssertEqual(UserManager.shared.displays?.count, 1) - - // Track a valid event, but the survey should not shown, because we already gave a response. - Formbricks.track("click_demo_button") - _ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0) - XCTAssertFalse(SurveyManager.shared.isShowingSurvey) - - // Validate logout - XCTAssertNotNil(UserManager.shared.userId) - XCTAssertNotNil(UserManager.shared.lastDisplayedAt) - XCTAssertNotNil(UserManager.shared.responses) - XCTAssertNotNil(UserManager.shared.displays) - XCTAssertNotNil(UserManager.shared.segments) - XCTAssertNotNil(UserManager.shared.expiresAt) - Formbricks.logout() - XCTAssertNil(UserManager.shared.userId) - XCTAssertNil(UserManager.shared.lastDisplayedAt) - XCTAssertNil(UserManager.shared.responses) - XCTAssertNil(UserManager.shared.displays) - XCTAssertNil(UserManager.shared.segments) - XCTAssertNil(UserManager.shared.expiresAt) - - // Clear the responses - Formbricks.logout() - SurveyManager.shared.filterSurveys() - - Formbricks.track("click_demo_button") - _ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0) - XCTAssertTrue(SurveyManager.shared.isShowingSurvey) - - SurveyManager.shared.dismissSurveyWebView() - XCTAssertFalse(SurveyManager.shared.isShowingSurvey) + XCTAssertTrue(Formbricks.isInitialized) + XCTAssertEqual(Formbricks.appUrl, appUrl) + XCTAssertEqual(Formbricks.environmentId, environmentId) + + // Check error state handling. + mockService.isErrorResponseNeeded = true + XCTAssertFalse(Formbricks.surveyManager?.hasApiError ?? false) + Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + XCTAssertTrue(Formbricks.surveyManager?.hasApiError ?? false) + + mockService.isErrorResponseNeeded = false + Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + + // Authenticate the user. + Formbricks.setUserId(userId) + _ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 2.0) + XCTAssertEqual(Formbricks.userManager?.userId, userId) + // User refresh timer should be set. + XCTAssertNotNil(Formbricks.userManager?.syncTimer) + + // The environment should be fetched. + XCTAssertNotNil(Formbricks.surveyManager?.environmentResponse) + + // Check if the filter method works properly. + XCTAssertEqual(Formbricks.surveyManager?.filteredSurveys.count, 1) + + // Verify that we’re not showing any survey initially. + XCTAssertNotNil(Formbricks.surveyManager?.filteredSurveys) + XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false) + + // Track an unknown event—survey should not be shown. + Formbricks.track("unknown_event") + XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false) + + // Track a known event—the survey should be shown. + Formbricks.track("click_demo_button") + _ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0) + XCTAssertTrue(Formbricks.surveyManager?.isShowingSurvey ?? false) + + // "Dismiss" the webview. + Formbricks.surveyManager?.dismissSurveyWebView() + XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false) + + // Validate display and response. + Formbricks.surveyManager?.postResponse(surveyId: surveyID) + Formbricks.surveyManager?.onNewDisplay(surveyId: surveyID) + XCTAssertEqual(Formbricks.userManager?.responses?.count, 1) + XCTAssertEqual(Formbricks.userManager?.displays?.count, 1) + + // Track a valid event, but survey should not be shown because a response was already submitted. + Formbricks.track("click_demo_button") + _ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0) + XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false) + + // Validate logout. + XCTAssertNotNil(Formbricks.userManager?.userId) + XCTAssertNotNil(Formbricks.userManager?.lastDisplayedAt) + XCTAssertNotNil(Formbricks.userManager?.responses) + XCTAssertNotNil(Formbricks.userManager?.displays) + XCTAssertNotNil(Formbricks.userManager?.segments) + XCTAssertNotNil(Formbricks.userManager?.expiresAt) + Formbricks.logout() + XCTAssertNil(Formbricks.userManager?.userId) + XCTAssertNil(Formbricks.userManager?.lastDisplayedAt) + XCTAssertNil(Formbricks.userManager?.responses) + XCTAssertNil(Formbricks.userManager?.displays) + XCTAssertNil(Formbricks.userManager?.segments) + XCTAssertNil(Formbricks.userManager?.expiresAt) + + // Clear the responses and verify survey behavior. + Formbricks.logout() + Formbricks.surveyManager?.filterSurveys() + + Formbricks.track("click_demo_button") + _ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0) + XCTAssertTrue(Formbricks.surveyManager?.isShowingSurvey ?? false) + + // Test the cleanup + Formbricks.cleanup() + XCTAssertNil(Formbricks.userManager) + XCTAssertNil(Formbricks.surveyManager) + XCTAssertNil(Formbricks.apiQueue) + XCTAssertNil(Formbricks.presentSurveyManager) + XCTAssertFalse(Formbricks.isInitialized) + XCTAssertNil(Formbricks.appUrl) + XCTAssertNil(Formbricks.environmentId) + XCTAssertNil(Formbricks.logger) } + func testCleanupWithCompletion() { + // Setup the SDK + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .build() + Formbricks.setup(with: config) + XCTAssertTrue(Formbricks.isInitialized) + + // Set expectation for cleanup completion + let cleanupExpectation = expectation(description: "Cleanup completed") + + Formbricks.cleanup(waitForOperations: true) { + cleanupExpectation.fulfill() + } + + wait(for: [cleanupExpectation], timeout: 2.0) + + // Validate cleanup: all main properties should be nil or false + XCTAssertNil(Formbricks.userManager) + XCTAssertNil(Formbricks.surveyManager) + XCTAssertNil(Formbricks.presentSurveyManager) + XCTAssertNil(Formbricks.apiQueue) + XCTAssertFalse(Formbricks.isInitialized) + XCTAssertNil(Formbricks.appUrl) + XCTAssertNil(Formbricks.environmentId) + XCTAssertNil(Formbricks.logger) + } } diff --git a/packages/ios/README.md b/packages/ios/README.md index 2bfe86b7db..dbbbedde50 100644 --- a/packages/ios/README.md +++ b/packages/ios/README.md @@ -5,7 +5,7 @@ To run the project, open `Formbricks.xcworkspace` in **Xcode**. The workspace co - **FormbricksSDK**: The SDK package. - **Demo**: A demo application to exercise the SDK. -Before launching the `Demo` app, update the mandatory variables in `AppDelegate`: +Before launching the `Demo` app, update the mandatory variables in `SetupView`: ```swift let config = FormbricksConfig.Builder(appUrl: "[APP_URL]", environmentId: "[ENVIRONMENT_ID]") @@ -13,15 +13,9 @@ let config = FormbricksConfig.Builder(appUrl: "[APP_URL]", environmentId: "[ENVI .build() ``` -Once these values are properly set, the demo app can be launched. -The demo app consists of a single view, `ContentView`. It is a SwiftUI view with a single button. -The button's action should be updated according to the survey actions: +Once these values are properly set, launch the demo app. The demo app uses a single view, `SetupView`, as the initial screen. In this view, tap the **Setup Formbricks SDK** button to initialize the SDK. -```swift -Formbricks.track("click_demo_button") -``` - -Replace `"click_demo_button"` with the desired action. +Once setup is complete, you can use the **Call Formbricks.track** button to trigger your survey and the **Call Formbricks.cleanup** button to reset the SDK and return to the setup screen. --- @@ -33,9 +27,9 @@ You can generate developer documentation for the SDK by pressing **Shift + Comma ## Unit Tests -The SDK includes a unit test to verify the Manager's functionality. To run it: +The SDK includes unit tests to verify the functionality of various components. To run them: 1. Select the `Test Navigator` tab in Xcode. -2. Run the `testFormbricks()` method. +2. Run the desired test methods. The coverage report can be found in the `Report Navigator` tab. diff --git a/packages/lib/messages/de-DE.json b/packages/lib/messages/de-DE.json index 095a956713..6930448e1c 100644 --- a/packages/lib/messages/de-DE.json +++ b/packages/lib/messages/de-DE.json @@ -478,7 +478,6 @@ "password_changed_email_heading": "Passwort geändert", "password_changed_email_text": "Dein Passwort wurde erfolgreich geändert.", "password_reset_notify_email_subject": "Dein Formbricks-Passwort wurde geändert", - "powered_by_formbricks": "Unterstützt von Formbricks", "privacy_policy": "Datenschutzerklärung", "reject": "Ablehnen", "render_email_response_value_file_upload_response_link_not_included": "Link zur hochgeladenen Datei ist aus Datenschutzgründen nicht enthalten", @@ -995,8 +994,7 @@ "api_keys": { "add_api_key": "API-Schlüssel hinzufügen", "add_permission": "Berechtigung hinzufügen", - "api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen", - "only_organization_owners_and_managers_can_manage_api_keys": "Nur Organisationsinhaber und -manager können API-Schlüssel verwalten" + "api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen" }, "billing": { "10000_monthly_responses": "10,000 monatliche Antworten", diff --git a/packages/lib/messages/en-US.json b/packages/lib/messages/en-US.json index 5e0e617e62..ffeefe4544 100644 --- a/packages/lib/messages/en-US.json +++ b/packages/lib/messages/en-US.json @@ -478,7 +478,6 @@ "password_changed_email_heading": "Password changed", "password_changed_email_text": "Your password has been changed successfully.", "password_reset_notify_email_subject": "Your Formbricks password has been changed", - "powered_by_formbricks": "Powered by Formbricks", "privacy_policy": "Privacy Policy", "reject": "Reject", "render_email_response_value_file_upload_response_link_not_included": "Link to uploaded file is not included for data privacy reasons", @@ -995,8 +994,7 @@ "api_keys": { "add_api_key": "Add API key", "add_permission": "Add permission", - "api_keys_description": "Manage API keys to access Formbricks management APIs", - "only_organization_owners_and_managers_can_manage_api_keys": "Only organization owners and managers can manage API keys" + "api_keys_description": "Manage API keys to access Formbricks management APIs" }, "billing": { "10000_monthly_responses": "10000 Monthly Responses", diff --git a/packages/lib/messages/fr-FR.json b/packages/lib/messages/fr-FR.json index a344cdfba9..a7b915892d 100644 --- a/packages/lib/messages/fr-FR.json +++ b/packages/lib/messages/fr-FR.json @@ -478,7 +478,6 @@ "password_changed_email_heading": "Mot de passe changé", "password_changed_email_text": "Votre mot de passe a été changé avec succès.", "password_reset_notify_email_subject": "Ton mot de passe Formbricks a été changé", - "powered_by_formbricks": "Propulsé par Formbricks", "privacy_policy": "Politique de confidentialité", "reject": "Rejeter", "render_email_response_value_file_upload_response_link_not_included": "Le lien vers le fichier téléchargé n'est pas inclus pour des raisons de confidentialité des données", @@ -995,8 +994,7 @@ "api_keys": { "add_api_key": "Ajouter une clé API", "add_permission": "Ajouter une permission", - "api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks", - "only_organization_owners_and_managers_can_manage_api_keys": "Seuls les propriétaires et les gestionnaires de l'organisation peuvent gérer les clés API" + "api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks" }, "billing": { "10000_monthly_responses": "10000 Réponses Mensuelles", diff --git a/packages/lib/messages/pt-BR.json b/packages/lib/messages/pt-BR.json index 63e53bfcad..ecc6728bf5 100644 --- a/packages/lib/messages/pt-BR.json +++ b/packages/lib/messages/pt-BR.json @@ -478,7 +478,6 @@ "password_changed_email_heading": "Senha alterada", "password_changed_email_text": "Sua senha foi alterada com sucesso.", "password_reset_notify_email_subject": "Sua senha Formbricks foi alterada", - "powered_by_formbricks": "Desenvolvido por Formbricks", "privacy_policy": "Política de Privacidade", "reject": "Rejeitar", "render_email_response_value_file_upload_response_link_not_included": "O link para o arquivo enviado não está incluído por motivos de privacidade de dados", @@ -995,8 +994,7 @@ "api_keys": { "add_api_key": "Adicionar chave de API", "add_permission": "Adicionar permissão", - "api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks", - "only_organization_owners_and_managers_can_manage_api_keys": "Apenas proprietários e gerentes da organização podem gerenciar chaves de API" + "api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks" }, "billing": { "10000_monthly_responses": "10000 Respostas Mensais", diff --git a/packages/lib/messages/pt-PT.json b/packages/lib/messages/pt-PT.json index c69077ebae..9930e9de90 100644 --- a/packages/lib/messages/pt-PT.json +++ b/packages/lib/messages/pt-PT.json @@ -478,7 +478,6 @@ "password_changed_email_heading": "Palavra-passe alterada", "password_changed_email_text": "A sua palavra-passe foi alterada com sucesso.", "password_reset_notify_email_subject": "A sua palavra-passe do Formbricks foi alterada", - "powered_by_formbricks": "Desenvolvido por Formbricks", "privacy_policy": "Política de Privacidade", "reject": "Rejeitar", "render_email_response_value_file_upload_response_link_not_included": "O link para o ficheiro carregado não está incluído por razões de privacidade de dados", @@ -995,8 +994,7 @@ "api_keys": { "add_api_key": "Adicionar chave API", "add_permission": "Adicionar permissão", - "api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks", - "only_organization_owners_and_managers_can_manage_api_keys": "Apenas os proprietários e gestores da organização podem gerir chaves API" + "api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks" }, "billing": { "10000_monthly_responses": "10000 Respostas Mensais", diff --git a/packages/lib/messages/zh-Hant-TW.json b/packages/lib/messages/zh-Hant-TW.json index 096ed256cf..6a6f694875 100644 --- a/packages/lib/messages/zh-Hant-TW.json +++ b/packages/lib/messages/zh-Hant-TW.json @@ -478,7 +478,6 @@ "password_changed_email_heading": "密碼已變更", "password_changed_email_text": "您的密碼已成功變更。", "password_reset_notify_email_subject": "您的 Formbricks 密碼已變更", - "powered_by_formbricks": "由 Formbricks 提供技術支援", "privacy_policy": "隱私權政策", "reject": "拒絕", "render_email_response_value_file_upload_response_link_not_included": "由於資料隱私原因,未包含上傳檔案的連結", @@ -995,8 +994,7 @@ "api_keys": { "add_api_key": "新增 API 金鑰", "add_permission": "新增權限", - "api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API", - "only_organization_owners_and_managers_can_manage_api_keys": "只有組織擁有者和管理員才能管理 API 金鑰" + "api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API" }, "billing": { "10000_monthly_responses": "10000 個每月回應",