Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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"),
Expand Down
6 changes: 6 additions & 0 deletions Sources/AnalyticsLive/LivePlugins/LivePlugins.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]()
Expand Down Expand Up @@ -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")
Expand Down
176 changes: 176 additions & 0 deletions Sources/AnalyticsLive/LivePlugins/StorageJS.swift
Original file line number Diff line number Diff line change
@@ -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<T>(value: T?) -> Bool {
var result = false
if value == nil {
result = true
} else {
switch value {
case is NSNull:
fallthrough
case is Array<Any>:
fallthrough
case is Dictionary<String, Any>:
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
}
}
58 changes: 58 additions & 0 deletions Tests/AnalyticsLiveTests/LivePlugins/LivePluginTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
51 changes: 51 additions & 0 deletions Tests/AnalyticsLiveTests/TestHelpers/teststorage.js
Original file line number Diff line number Diff line change
@@ -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
]
})
Loading