fix: iOS SDK memory leaks (#5388)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
victorvhs017
2025-04-18 01:03:05 +07:00
committed by GitHub
parent 2e979c7323
commit 81d717ccff
22 changed files with 509 additions and 307 deletions
+2 -11
View File
@@ -3,17 +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
}
}
-24
View File
@@ -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()
}
+1 -1
View File
@@ -7,7 +7,7 @@ struct DemoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
SetupView()
}
}
}
+72
View File
@@ -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()
}
@@ -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"
}
}
@@ -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)
}
}
@@ -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)")
}
}
@@ -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 {
@@ -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)")
}
}
@@ -45,7 +45,7 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
requestLogMessage.append("\nBody: \(String(data: body, encoding: .utf8) ?? "")")
}
Formbricks.logger.info(requestLogMessage)
Formbricks.logger?.info(requestLogMessage)
session.dataTask(with: urlRequest) { (data, response, error) in
if let httpStatus = (response as? HTTPURLResponse)?.status {
@@ -65,11 +65,16 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
do {
if Request.Response.self == VoidResponse.self {
Formbricks.logger.info(responseLogMessage)
self.completion?(.success(VoidResponse() as! Request.Response))
Formbricks.logger?.info(responseLogMessage)
if let response = VoidResponse() as? Request.Response {
self.completion?(.success(response))
} else {
self.completion?(.failure(FormbricksAPIClientError(type: .invalidResponse)))
}
} else {
var body = try self.request.decoder.decode(Request.Response.self, from: data)
Formbricks.logger.info(responseLogMessage)
Formbricks.logger?.info(responseLogMessage)
// We want to save the entire response dictionary for the environment response
if var environmentResponse = body as? EnvironmentResponse,
@@ -84,50 +89,50 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
}
catch let DecodingError.dataCorrupted(context) {
responseLogMessage.append("Data corrupted \(context)\n")
Formbricks.logger.error(responseLogMessage)
Formbricks.logger?.error(responseLogMessage)
self.completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: httpStatus.rawValue)))
}
catch let DecodingError.keyNotFound(key, context) {
responseLogMessage.append("Key '\(key)' not found: \(context.debugDescription)\n")
responseLogMessage.append("codingPath: \(context.codingPath)")
Formbricks.logger.error(responseLogMessage)
Formbricks.logger?.error(responseLogMessage)
self.completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: httpStatus.rawValue)))
}
catch let DecodingError.valueNotFound(value, context) {
responseLogMessage.append("Value '\(value)' not found: \(context.debugDescription)\n")
responseLogMessage.append("codingPath: \(context.codingPath)")
Formbricks.logger.error(responseLogMessage)
Formbricks.logger?.error(responseLogMessage)
self.completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: httpStatus.rawValue)))
}
catch let DecodingError.typeMismatch(type, context) {
responseLogMessage.append("Type '\(type)' mismatch: \(context.debugDescription)\n")
responseLogMessage.append("codingPath: \(context.codingPath)")
Formbricks.logger.error(responseLogMessage)
Formbricks.logger?.error(responseLogMessage)
self.completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: httpStatus.rawValue)))
}
catch {
responseLogMessage.append("error: \(error.message)")
Formbricks.logger.error(responseLogMessage)
Formbricks.logger?.error(responseLogMessage)
self.completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: httpStatus.rawValue)))
}
} else {
if let error = error {
responseLogMessage.append("\nError: \(error.localizedDescription)")
Formbricks.logger.error(responseLogMessage)
Formbricks.logger?.error(responseLogMessage)
self.completion?(.failure(error))
} else if let data = data, let apiError = try? self.request.decoder.decode(FormbricksAPIError.self, from: data) {
Formbricks.logger.error("\(responseLogMessage)\n\(apiError.getDetailedErrorMessage())")
Formbricks.logger?.error("\(responseLogMessage)\n\(apiError.getDetailedErrorMessage())")
self.completion?(.failure(apiError))
} else {
let error = FormbricksAPIClientError(type: .responseError, statusCode: httpStatus.rawValue)
Formbricks.logger.error("\(responseLogMessage)\n\(error.message)")
Formbricks.logger?.error("\(responseLogMessage)\n\(error.message)")
self.completion?(.failure(error))
}
}
}
else {
let error = FormbricksAPIClientError(type: .invalidResponse)
Formbricks.logger.error("ERROR \(error.message)")
Formbricks.logger?.error("ERROR \(error.message)")
self.completion?(.failure(error))
}
}.resume()
@@ -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
}
}
}
@@ -20,6 +20,6 @@ private extension FormbricksService {
/// Creates the APIClient operation and adds it to the queue
func execute<Request: CodableRequest>(_ request: Request, withCompletion completion: @escaping (ResultType<Request.Response>) -> Void) {
let operation = APIClient(request: request, completion: completion)
Formbricks.apiQueue.addOperation(operation)
Formbricks.apiQueue?.addOperation(operation)
}
}
@@ -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
}
}
@@ -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() {
@@ -94,22 +104,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:
@@ -119,11 +129,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)")
}
}
}
@@ -132,7 +142,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)
}
}
@@ -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 were not showing any survey initially.
XCTAssertNotNil(Formbricks.surveyManager?.filteredSurveys)
XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false)
// Track an unknown eventsurvey should not be shown.
Formbricks.track("unknown_event")
XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false)
// Track a known eventthe 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)
}
}
+5 -11
View File
@@ -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.
+1 -2
View File
@@ -994,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",
+1 -2
View File
@@ -994,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",
+1 -2
View File
@@ -994,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",
+1 -2
View File
@@ -994,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",
+1 -2
View File
@@ -994,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",
+1 -2
View File
@@ -994,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 個每月回應",