Skip to content

Commit e9bf9cb

Browse files
authored
Add storage global object (#32)
1 parent d230b0d commit e9bf9cb

File tree

6 files changed

+295
-3
lines changed

6 files changed

+295
-3
lines changed

Package.resolved

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ let package = Package(
2020
dependencies: [
2121
.package(url: "https://github.com/segmentio/analytics-swift.git", from: "1.9.1"),
2222
//.package(path: "../analytics-swift"),
23-
.package(url: "https://github.com/segmentio/substrata-swift.git", from: "2.0.11"),
23+
.package(url: "https://github.com/segmentio/substrata-swift.git", from: "2.2.0"),
2424
//.package(path: "../substrata-swift")
2525
],
2626
targets: [
@@ -40,6 +40,7 @@ let package = Package(
4040
.copy("TestHelpers/filterSettings.json"),
4141
.copy("TestHelpers/badSettings.json"),
4242
.copy("TestHelpers/testbundle.js"),
43+
.copy("TestHelpers/teststorage.js"),
4344
.copy("TestHelpers/addliveplugin.js"),
4445
.copy("TestHelpers/MyEdgeFunctions.js"),
4546
.copy("TestHelpers/badtest.js"),

Sources/AnalyticsLive/LivePlugins/LivePlugins.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public class LivePlugins: UtilityPlugin, WaitingPlugin {
3434
internal let fallbackFileURL: URL?
3535
internal let forceFallback: Bool
3636
internal var analyticsJS: AnalyticsJS?
37+
internal var storageJS: StorageJS?
3738
internal let localJSURLs: [URL]
3839

3940
@Atomic var dependents = [LivePluginsDependent]()
@@ -112,6 +113,11 @@ extension LivePlugins {
112113
engine.export(instance: a, className: "Analytics", as: "analytics")
113114
analyticsJS = a
114115

116+
// set the system storage object.
117+
let s = StorageJS()
118+
engine.export(instance: s, className: "Storage", as: "storage")
119+
storageJS = s
120+
115121
// setup our embedded scripts ...
116122
engine.evaluate(script: EmbeddedJS.enumSetupScript, evaluator: "EmbeddedJS.enumSetupScript")
117123
engine.evaluate(script: EmbeddedJS.edgeFnBaseSetupScript, evaluator: "EmbeddedJS.edgeFnBaseSetupScript")
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
//
2+
// StorageJS.swift
3+
// AnalyticsLive
4+
//
5+
// Created by Brandon Sneed on 10/23/25.
6+
//
7+
import Foundation
8+
import Substrata
9+
10+
internal class StorageJS: JSExport {
11+
static let NULL_SENTINEL = "__SENTINEL_NSDEFAULTS_NULL__"
12+
var userDefaults = UserDefaults(suiteName: "live.analytics.storage")
13+
required init() {
14+
super.init()
15+
exportMethod(named: "getValue", function: getValue)
16+
exportMethod(named: "setValue", function: setValue)
17+
exportMethod(named: "removeValue", function: removeValue)
18+
}
19+
20+
func setValue(args: [JSConvertible?]) throws -> JSConvertible? {
21+
guard let key = args.typed(as: String.self, index: 0) else { return nil }
22+
let value: JSConvertible? = args.index(1)
23+
if isBasicType(value: value) {
24+
// Sanitize NSNull values before storing
25+
let sanitized = sanitizeForUserDefaults(value as Any)
26+
27+
// need to do some type checking cuz some things easily translate to strings,
28+
// and we need to validate that it's something UserDefaults can actually take.
29+
if let v = sanitized as? Bool {
30+
userDefaults?.set(v, forKey: key)
31+
} else if let v = sanitized as? NSNumber {
32+
userDefaults?.set(v, forKey: key)
33+
} else if let v = sanitized as? Date {
34+
userDefaults?.set(v, forKey: key)
35+
} else if let v = sanitized as? String {
36+
userDefaults?.set(v, forKey: key)
37+
} else if let v = sanitized as? [String: Any] {
38+
userDefaults?.set(v, forKey: key)
39+
} else if let v = sanitized as? [Any] {
40+
userDefaults?.set(v, forKey: key)
41+
}
42+
// queries to userDefaults happen async, so make sure we're done before moving on.
43+
// we're already on a background thread.
44+
userDefaults?.synchronize()
45+
}
46+
return nil; // translates to Undefined in JS
47+
}
48+
49+
func getValue(args: [JSConvertible?]) throws -> JSConvertible? {
50+
guard let key = args.typed(as: String.self, index: 0) else { return nil }
51+
guard let value = userDefaults?.value(forKey: key) else { return nil }
52+
53+
// Desanitize NSNull sentinel values
54+
let desanitized = desanitizeFromUserDefaults(value)
55+
56+
return convertToJSConvertible(desanitized)
57+
}
58+
59+
func removeValue(args: [JSConvertible?]) throws -> JSConvertible? {
60+
guard let key = args.typed(as: String.self, index: 0) else { return nil }
61+
userDefaults?.removeObject(forKey: key)
62+
// queries to userDefaults happen async, so make sure we're done before moving on.
63+
// we're already on a background thread.
64+
userDefaults?.synchronize()
65+
return nil; // undefined in js
66+
}
67+
}
68+
69+
extension StorageJS {
70+
internal func isBasicType<T>(value: T?) -> Bool {
71+
var result = false
72+
if value == nil {
73+
result = true
74+
} else {
75+
switch value {
76+
case is NSNull:
77+
fallthrough
78+
case is Array<Any>:
79+
fallthrough
80+
case is Dictionary<String, Any>:
81+
fallthrough
82+
case is Decimal:
83+
fallthrough
84+
case is NSNumber:
85+
fallthrough
86+
case is Bool:
87+
fallthrough
88+
case is Date:
89+
fallthrough
90+
case is String:
91+
result = true
92+
default:
93+
break
94+
}
95+
}
96+
return result
97+
}
98+
99+
func convertToJSConvertible(_ value: Any) -> JSConvertible? {
100+
// Fast path - already JSConvertible
101+
if let v = value as? JSConvertible {
102+
return v
103+
}
104+
105+
// Handle NSNull explicitly
106+
if value is NSNull {
107+
return NSNull()
108+
}
109+
110+
// Foundation -> Swift bridging (check BEFORE Swift types)
111+
if let v = value as? NSNumber {
112+
// Check if it's actually a boolean
113+
let objCType = String(cString: v.objCType)
114+
if objCType == "c" || objCType == "B" {
115+
return v.boolValue
116+
}
117+
// Otherwise treat as number
118+
return v.doubleValue
119+
}
120+
if let v = value as? NSString {
121+
return v as String
122+
}
123+
124+
// Direct Swift types
125+
if let v = value as? Bool { return v }
126+
if let v = value as? Int { return v }
127+
if let v = value as? Double { return v }
128+
if let v = value as? String { return v }
129+
if let v = value as? Date { return v }
130+
131+
// Arrays - recursively convert each element
132+
if let array = value as? [Any] {
133+
let converted = array.compactMap { convertToJSConvertible($0) }
134+
return converted.isEmpty && !array.isEmpty ? nil : converted
135+
}
136+
137+
// Dictionaries - recursively convert values
138+
if let dict = value as? [String: Any] {
139+
var converted: [String: JSConvertible] = [:]
140+
for (key, val) in dict {
141+
if let convertedVal = convertToJSConvertible(val) {
142+
converted[key] = convertedVal
143+
}
144+
}
145+
return converted.isEmpty && !dict.isEmpty ? nil : converted
146+
}
147+
148+
return nil
149+
}
150+
151+
func sanitizeForUserDefaults(_ value: Any) -> Any? {
152+
if value is NSNull {
153+
return StorageJS.NULL_SENTINEL
154+
}
155+
if let array = value as? [Any] {
156+
return array.map { sanitizeForUserDefaults($0) ?? StorageJS.NULL_SENTINEL }
157+
}
158+
if let dict = value as? [String: Any] {
159+
return dict.mapValues { sanitizeForUserDefaults($0) ?? StorageJS.NULL_SENTINEL }
160+
}
161+
return value
162+
}
163+
164+
func desanitizeFromUserDefaults(_ value: Any) -> Any {
165+
if let str = value as? String, str == StorageJS.NULL_SENTINEL {
166+
return NSNull()
167+
}
168+
if let array = value as? [Any] {
169+
return array.map { desanitizeFromUserDefaults($0) }
170+
}
171+
if let dict = value as? [String: Any] {
172+
return dict.mapValues { desanitizeFromUserDefaults($0) }
173+
}
174+
return value
175+
}
176+
}

Tests/AnalyticsLiveTests/LivePlugins/LivePluginTests.swift

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,64 @@ class LivePluginTests: XCTestCase {
8989
waitUntilFinished(analytics: analytics)
9090
}
9191

92+
func testStorage() throws {
93+
LivePlugins.clearCache()
94+
95+
let analytics = Analytics(configuration: Configuration(writeKey: "1234"))
96+
analytics.add(plugin: LivePlugins(fallbackFileURL: bundleTestFile(file: "teststorage.js")))
97+
98+
let outputReader = OutputReaderPlugin()
99+
analytics.add(plugin: outputReader)
100+
101+
waitUntilStarted(analytics: analytics)
102+
103+
var lastEvent: RawEvent? = nil
104+
while lastEvent == nil {
105+
RunLoop.main.run(until: Date.distantPast)
106+
lastEvent = outputReader.lastEvent
107+
}
108+
109+
let trackEvent = lastEvent as? TrackEvent
110+
XCTAssertNotNil(trackEvent)
111+
112+
let properties = (trackEvent?.properties as? JSON)?.dictionaryValue
113+
XCTAssertNotNil(properties)
114+
XCTAssertEqual(properties?["testString"] as? String, "someString")
115+
XCTAssertEqual(properties?["testNumber"] as? Int, 120)
116+
XCTAssertEqual(properties?["testBool"] as? Bool, true)
117+
// NOTE: this is going to come back as a string since it's been through JSON conversion.
118+
XCTAssertEqual(properties?["testDate"] as? String, "2024-05-01T12:00:00.000Z")
119+
120+
let testNull = properties?["testNull"] as? [Any]
121+
XCTAssertNotNil(testNull)
122+
XCTAssertEqual(testNull?[0] as? Int, 1)
123+
XCTAssertTrue(testNull?[1] is NSNull)
124+
XCTAssertEqual(testNull?[2] as? String, "test")
125+
126+
let dict = properties?["testDict"] as? [String: Any]
127+
XCTAssertNotNil(dict)
128+
XCTAssertEqual(dict?["testString"] as? String, "someString")
129+
XCTAssertEqual(dict?["testNumber"] as? Int, 120)
130+
let nestedDict = dict?["testDict"] as? [String: Int]
131+
XCTAssertEqual(nestedDict?["someValue"], 1)
132+
133+
let array = properties?["testArray"] as? [Any]
134+
XCTAssertEqual(array?[0] as? Int, 1)
135+
XCTAssertEqual(array?[1] as? String, "test")
136+
XCTAssertEqual(array?[2] as? [String: Int], ["blah": 1])
137+
138+
let remove = properties?["remove"] as? [Bool]
139+
XCTAssertNotNil(remove)
140+
XCTAssertTrue(remove![0])
141+
XCTAssertTrue(remove![1])
142+
XCTAssertTrue(remove![2])
143+
XCTAssertTrue(remove![3])
144+
XCTAssertTrue(remove![4])
145+
XCTAssertTrue(remove![5])
146+
147+
waitUntilFinished(analytics: analytics)
148+
}
149+
92150
func testEventMorphing() throws {
93151
LivePlugins.clearCache()
94152

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
2+
storage.setValue("testString", "someString")
3+
storage.setValue("testNumber", 120)
4+
storage.setValue("testBool", true)
5+
storage.setValue("testDict", { testString: "someString", testNumber: 120, testDict: { someValue: 1 } })
6+
storage.setValue("testDate", new Date("2024-05-01T12:00:00Z"))
7+
storage.setValue("testArray", [1, "test", { blah: 1 }])
8+
storage.setValue("testNull", [1, null, "test"])
9+
10+
let testString = storage.getValue("testString")
11+
let testNumber = storage.getValue("testNumber")
12+
let testBool = storage.getValue("testBool")
13+
let testDict = storage.getValue("testDict")
14+
let testDate = storage.getValue("testDate")
15+
let testArray = storage.getValue("testArray")
16+
let testNull = storage.getValue("testNull")
17+
18+
storage.removeValue("testString")
19+
storage.removeValue("testNumber")
20+
storage.removeValue("testBool")
21+
storage.removeValue("testDict")
22+
storage.removeValue("testDate")
23+
storage.removeValue("testArray")
24+
storage.removeValue("testNull")
25+
26+
let remove1 = storage.getValue("testString") == undefined
27+
let remove2 = storage.getValue("testNumber") == undefined
28+
let remove3 = storage.getValue("tsetBool") == undefined
29+
let remove4 = storage.getValue("testDict") == undefined
30+
let remove5 = storage.getValue("testDate") == undefined
31+
let remove6 = storage.getValue("testArray") == undefined
32+
let remove7 = storage.getValue("testNull") == undefined
33+
34+
analytics.track("test", {
35+
testString: testString,
36+
testNumber: testNumber,
37+
testBool: testBool,
38+
testDict: testDict,
39+
testDate: testDate,
40+
testNull: testNull,
41+
testArray: testArray,
42+
remove: [
43+
remove1,
44+
remove2,
45+
remove3,
46+
remove4,
47+
remove5,
48+
remove6,
49+
remove7
50+
]
51+
})

0 commit comments

Comments
 (0)