diff --git a/Package.resolved b/Package.resolved index 8e64999..c957e27 100644 --- a/Package.resolved +++ b/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/segmentio/substrata-swift.git", "state" : { - "revision" : "293df9d9ad5339bf24abaf9525518c5019a061b7", - "version" : "2.1.0" + "revision" : "5a4e9ecd691d984320dfb20aaf66fb4bedd58a26", + "version" : "2.2.0" } } ], diff --git a/Package.swift b/Package.swift index ca93d4f..2c9d018 100755 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/segmentio/analytics-swift.git", from: "1.9.1"), //.package(path: "../analytics-swift"), - .package(url: "https://github.com/segmentio/substrata-swift.git", from: "2.0.11"), + .package(url: "https://github.com/segmentio/substrata-swift.git", from: "2.2.0"), //.package(path: "../substrata-swift") ], targets: [ @@ -40,6 +40,7 @@ let package = Package( .copy("TestHelpers/filterSettings.json"), .copy("TestHelpers/badSettings.json"), .copy("TestHelpers/testbundle.js"), + .copy("TestHelpers/teststorage.js"), .copy("TestHelpers/addliveplugin.js"), .copy("TestHelpers/MyEdgeFunctions.js"), .copy("TestHelpers/badtest.js"), diff --git a/Sources/AnalyticsLive/LivePlugins/LivePlugins.swift b/Sources/AnalyticsLive/LivePlugins/LivePlugins.swift index d51f38c..bcc6993 100755 --- a/Sources/AnalyticsLive/LivePlugins/LivePlugins.swift +++ b/Sources/AnalyticsLive/LivePlugins/LivePlugins.swift @@ -34,6 +34,7 @@ public class LivePlugins: UtilityPlugin, WaitingPlugin { internal let fallbackFileURL: URL? internal let forceFallback: Bool internal var analyticsJS: AnalyticsJS? + internal var storageJS: StorageJS? internal let localJSURLs: [URL] @Atomic var dependents = [LivePluginsDependent]() @@ -112,6 +113,11 @@ extension LivePlugins { engine.export(instance: a, className: "Analytics", as: "analytics") analyticsJS = a + // set the system storage object. + let s = StorageJS() + engine.export(instance: s, className: "Storage", as: "storage") + storageJS = s + // setup our embedded scripts ... engine.evaluate(script: EmbeddedJS.enumSetupScript, evaluator: "EmbeddedJS.enumSetupScript") engine.evaluate(script: EmbeddedJS.edgeFnBaseSetupScript, evaluator: "EmbeddedJS.edgeFnBaseSetupScript") diff --git a/Sources/AnalyticsLive/LivePlugins/StorageJS.swift b/Sources/AnalyticsLive/LivePlugins/StorageJS.swift new file mode 100644 index 0000000..6db4ca5 --- /dev/null +++ b/Sources/AnalyticsLive/LivePlugins/StorageJS.swift @@ -0,0 +1,176 @@ +// +// StorageJS.swift +// AnalyticsLive +// +// Created by Brandon Sneed on 10/23/25. +// +import Foundation +import Substrata + +internal class StorageJS: JSExport { + static let NULL_SENTINEL = "__SENTINEL_NSDEFAULTS_NULL__" + var userDefaults = UserDefaults(suiteName: "live.analytics.storage") + required init() { + super.init() + exportMethod(named: "getValue", function: getValue) + exportMethod(named: "setValue", function: setValue) + exportMethod(named: "removeValue", function: removeValue) + } + + func setValue(args: [JSConvertible?]) throws -> JSConvertible? { + guard let key = args.typed(as: String.self, index: 0) else { return nil } + let value: JSConvertible? = args.index(1) + if isBasicType(value: value) { + // Sanitize NSNull values before storing + let sanitized = sanitizeForUserDefaults(value as Any) + + // need to do some type checking cuz some things easily translate to strings, + // and we need to validate that it's something UserDefaults can actually take. + if let v = sanitized as? Bool { + userDefaults?.set(v, forKey: key) + } else if let v = sanitized as? NSNumber { + userDefaults?.set(v, forKey: key) + } else if let v = sanitized as? Date { + userDefaults?.set(v, forKey: key) + } else if let v = sanitized as? String { + userDefaults?.set(v, forKey: key) + } else if let v = sanitized as? [String: Any] { + userDefaults?.set(v, forKey: key) + } else if let v = sanitized as? [Any] { + userDefaults?.set(v, forKey: key) + } + // queries to userDefaults happen async, so make sure we're done before moving on. + // we're already on a background thread. + userDefaults?.synchronize() + } + return nil; // translates to Undefined in JS + } + + func getValue(args: [JSConvertible?]) throws -> JSConvertible? { + guard let key = args.typed(as: String.self, index: 0) else { return nil } + guard let value = userDefaults?.value(forKey: key) else { return nil } + + // Desanitize NSNull sentinel values + let desanitized = desanitizeFromUserDefaults(value) + + return convertToJSConvertible(desanitized) + } + + func removeValue(args: [JSConvertible?]) throws -> JSConvertible? { + guard let key = args.typed(as: String.self, index: 0) else { return nil } + userDefaults?.removeObject(forKey: key) + // queries to userDefaults happen async, so make sure we're done before moving on. + // we're already on a background thread. + userDefaults?.synchronize() + return nil; // undefined in js + } +} + +extension StorageJS { + internal func isBasicType(value: T?) -> Bool { + var result = false + if value == nil { + result = true + } else { + switch value { + case is NSNull: + fallthrough + case is Array: + fallthrough + case is Dictionary: + fallthrough + case is Decimal: + fallthrough + case is NSNumber: + fallthrough + case is Bool: + fallthrough + case is Date: + fallthrough + case is String: + result = true + default: + break + } + } + return result + } + + func convertToJSConvertible(_ value: Any) -> JSConvertible? { + // Fast path - already JSConvertible + if let v = value as? JSConvertible { + return v + } + + // Handle NSNull explicitly + if value is NSNull { + return NSNull() + } + + // Foundation -> Swift bridging (check BEFORE Swift types) + if let v = value as? NSNumber { + // Check if it's actually a boolean + let objCType = String(cString: v.objCType) + if objCType == "c" || objCType == "B" { + return v.boolValue + } + // Otherwise treat as number + return v.doubleValue + } + if let v = value as? NSString { + return v as String + } + + // Direct Swift types + if let v = value as? Bool { return v } + if let v = value as? Int { return v } + if let v = value as? Double { return v } + if let v = value as? String { return v } + if let v = value as? Date { return v } + + // Arrays - recursively convert each element + if let array = value as? [Any] { + let converted = array.compactMap { convertToJSConvertible($0) } + return converted.isEmpty && !array.isEmpty ? nil : converted + } + + // Dictionaries - recursively convert values + if let dict = value as? [String: Any] { + var converted: [String: JSConvertible] = [:] + for (key, val) in dict { + if let convertedVal = convertToJSConvertible(val) { + converted[key] = convertedVal + } + } + return converted.isEmpty && !dict.isEmpty ? nil : converted + } + + return nil + } + + func sanitizeForUserDefaults(_ value: Any) -> Any? { + if value is NSNull { + return StorageJS.NULL_SENTINEL + } + if let array = value as? [Any] { + return array.map { sanitizeForUserDefaults($0) ?? StorageJS.NULL_SENTINEL } + } + if let dict = value as? [String: Any] { + return dict.mapValues { sanitizeForUserDefaults($0) ?? StorageJS.NULL_SENTINEL } + } + return value + } + + func desanitizeFromUserDefaults(_ value: Any) -> Any { + if let str = value as? String, str == StorageJS.NULL_SENTINEL { + return NSNull() + } + if let array = value as? [Any] { + return array.map { desanitizeFromUserDefaults($0) } + } + if let dict = value as? [String: Any] { + return dict.mapValues { desanitizeFromUserDefaults($0) } + } + return value + } +} diff --git a/Tests/AnalyticsLiveTests/LivePlugins/LivePluginTests.swift b/Tests/AnalyticsLiveTests/LivePlugins/LivePluginTests.swift index ea965d7..f208c54 100755 --- a/Tests/AnalyticsLiveTests/LivePlugins/LivePluginTests.swift +++ b/Tests/AnalyticsLiveTests/LivePlugins/LivePluginTests.swift @@ -89,6 +89,64 @@ class LivePluginTests: XCTestCase { waitUntilFinished(analytics: analytics) } + func testStorage() throws { + LivePlugins.clearCache() + + let analytics = Analytics(configuration: Configuration(writeKey: "1234")) + analytics.add(plugin: LivePlugins(fallbackFileURL: bundleTestFile(file: "teststorage.js"))) + + let outputReader = OutputReaderPlugin() + analytics.add(plugin: outputReader) + + waitUntilStarted(analytics: analytics) + + var lastEvent: RawEvent? = nil + while lastEvent == nil { + RunLoop.main.run(until: Date.distantPast) + lastEvent = outputReader.lastEvent + } + + let trackEvent = lastEvent as? TrackEvent + XCTAssertNotNil(trackEvent) + + let properties = (trackEvent?.properties as? JSON)?.dictionaryValue + XCTAssertNotNil(properties) + XCTAssertEqual(properties?["testString"] as? String, "someString") + XCTAssertEqual(properties?["testNumber"] as? Int, 120) + XCTAssertEqual(properties?["testBool"] as? Bool, true) + // NOTE: this is going to come back as a string since it's been through JSON conversion. + XCTAssertEqual(properties?["testDate"] as? String, "2024-05-01T12:00:00.000Z") + + let testNull = properties?["testNull"] as? [Any] + XCTAssertNotNil(testNull) + XCTAssertEqual(testNull?[0] as? Int, 1) + XCTAssertTrue(testNull?[1] is NSNull) + XCTAssertEqual(testNull?[2] as? String, "test") + + let dict = properties?["testDict"] as? [String: Any] + XCTAssertNotNil(dict) + XCTAssertEqual(dict?["testString"] as? String, "someString") + XCTAssertEqual(dict?["testNumber"] as? Int, 120) + let nestedDict = dict?["testDict"] as? [String: Int] + XCTAssertEqual(nestedDict?["someValue"], 1) + + let array = properties?["testArray"] as? [Any] + XCTAssertEqual(array?[0] as? Int, 1) + XCTAssertEqual(array?[1] as? String, "test") + XCTAssertEqual(array?[2] as? [String: Int], ["blah": 1]) + + let remove = properties?["remove"] as? [Bool] + XCTAssertNotNil(remove) + XCTAssertTrue(remove![0]) + XCTAssertTrue(remove![1]) + XCTAssertTrue(remove![2]) + XCTAssertTrue(remove![3]) + XCTAssertTrue(remove![4]) + XCTAssertTrue(remove![5]) + + waitUntilFinished(analytics: analytics) + } + func testEventMorphing() throws { LivePlugins.clearCache() diff --git a/Tests/AnalyticsLiveTests/TestHelpers/teststorage.js b/Tests/AnalyticsLiveTests/TestHelpers/teststorage.js new file mode 100644 index 0000000..caed760 --- /dev/null +++ b/Tests/AnalyticsLiveTests/TestHelpers/teststorage.js @@ -0,0 +1,51 @@ + +storage.setValue("testString", "someString") +storage.setValue("testNumber", 120) +storage.setValue("testBool", true) +storage.setValue("testDict", { testString: "someString", testNumber: 120, testDict: { someValue: 1 } }) +storage.setValue("testDate", new Date("2024-05-01T12:00:00Z")) +storage.setValue("testArray", [1, "test", { blah: 1 }]) +storage.setValue("testNull", [1, null, "test"]) + +let testString = storage.getValue("testString") +let testNumber = storage.getValue("testNumber") +let testBool = storage.getValue("testBool") +let testDict = storage.getValue("testDict") +let testDate = storage.getValue("testDate") +let testArray = storage.getValue("testArray") +let testNull = storage.getValue("testNull") + +storage.removeValue("testString") +storage.removeValue("testNumber") +storage.removeValue("testBool") +storage.removeValue("testDict") +storage.removeValue("testDate") +storage.removeValue("testArray") +storage.removeValue("testNull") + +let remove1 = storage.getValue("testString") == undefined +let remove2 = storage.getValue("testNumber") == undefined +let remove3 = storage.getValue("tsetBool") == undefined +let remove4 = storage.getValue("testDict") == undefined +let remove5 = storage.getValue("testDate") == undefined +let remove6 = storage.getValue("testArray") == undefined +let remove7 = storage.getValue("testNull") == undefined + +analytics.track("test", { + testString: testString, + testNumber: testNumber, + testBool: testBool, + testDict: testDict, + testDate: testDate, + testNull: testNull, + testArray: testArray, + remove: [ + remove1, + remove2, + remove3, + remove4, + remove5, + remove6, + remove7 + ] +})