Skip to content

Commit d41e8ab

Browse files
ajaysubraclaude
andcommitted
Move SDK file storage to Application Support directory
Fixes #179 This change moves all Klaviyo SDK files from the Library/ directory to Library/Application Support/com.klaviyo/ following Apple's File System Programming Guide best practices. ## Changes - Add automatic file migration on SDK initialization - Files are migrated transparently with zero user impact - Migration is transactional with rollback on failure - Idempotent and safe to run multiple times ## Implementation - Extended FileClient with createDirectory and copyItem methods - Added FileClientMigration module with migration logic - Updated KlaviyoState to use migrated directory - Added comprehensive test coverage (10 new tests) ## Migration Behavior New installations: Files created directly in new location Existing installations: Automatic migration on first init after update Migration failure: Falls back to old location, SDK continues working Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 264c0b7 commit d41e8ab

8 files changed

Lines changed: 565 additions & 7 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//
2+
// FileClientMigration.swift
3+
// KlaviyoSwift
4+
//
5+
// Migration logic for moving SDK files from Library/ to Library/Application Support/com.klaviyo/
6+
//
7+
8+
import Foundation
9+
10+
/// Returns the Application Support directory for Klaviyo files
11+
/// - Returns: URL pointing to Library/Application Support/com.klaviyo/
12+
public func klaviyoApplicationSupportDirectory() -> URL {
13+
let libraryDirectory = environment.fileClient.libraryDirectory()
14+
return libraryDirectory
15+
.appendingPathComponent("Application Support", isDirectory: true)
16+
.appendingPathComponent("com.klaviyo", isDirectory: true)
17+
}
18+
19+
/// Migrates Klaviyo files from the old location (Library/) to the new location (Library/Application Support/com.klaviyo/)
20+
/// This function is idempotent and safe to call multiple times.
21+
///
22+
/// - Parameter apiKey: The API key used to identify Klaviyo files
23+
/// - Returns: The directory URL where files should be stored (new location if migration succeeded, old location if it failed)
24+
public func migrateFilesIfNeeded(apiKey: String) -> URL {
25+
let newDirectory = klaviyoApplicationSupportDirectory()
26+
let oldDirectory = environment.fileClient.libraryDirectory()
27+
28+
// Step 1: Create new directory if needed
29+
do {
30+
try environment.fileClient.createDirectory(newDirectory, true)
31+
} catch {
32+
environment.logger.error("Failed to create Application Support directory: \(error.localizedDescription)")
33+
return oldDirectory
34+
}
35+
36+
// Step 2: Check if migration already completed
37+
let stateFileName = "klaviyo-\(apiKey)-state.json"
38+
let newStateFile = newDirectory.appendingPathComponent(stateFileName, isDirectory: false)
39+
40+
if environment.fileClient.fileExists(newStateFile.path) {
41+
// Migration already completed
42+
return newDirectory
43+
}
44+
45+
// Step 3: Check if old files exist
46+
let oldStateFile = oldDirectory.appendingPathComponent(stateFileName, isDirectory: false)
47+
48+
if !environment.fileClient.fileExists(oldStateFile.path) {
49+
// Fresh install - no files to migrate
50+
return newDirectory
51+
}
52+
53+
// Step 4: Perform migration
54+
let filesToMigrate = [
55+
"klaviyo-\(apiKey)-state.json",
56+
"klaviyo-\(apiKey)-events.plist",
57+
"klaviyo-\(apiKey)-people.plist"
58+
]
59+
60+
var migratedFiles: [String] = []
61+
62+
for fileName in filesToMigrate {
63+
let oldFilePath = oldDirectory.appendingPathComponent(fileName, isDirectory: false).path
64+
let newFilePath = newDirectory.appendingPathComponent(fileName, isDirectory: false).path
65+
66+
// Only migrate files that exist
67+
if environment.fileClient.fileExists(oldFilePath) {
68+
do {
69+
try environment.fileClient.copyItem(oldFilePath, newFilePath)
70+
migratedFiles.append(fileName)
71+
} catch {
72+
environment.logger.error("Failed to copy \(fileName): \(error.localizedDescription)")
73+
// Rollback: remove all migrated files
74+
rollbackMigration(directory: newDirectory, files: migratedFiles)
75+
return oldDirectory
76+
}
77+
}
78+
}
79+
80+
// Step 5: Verify migration by checking if state file exists and has data
81+
if migratedFiles.contains(stateFileName) {
82+
guard environment.fileClient.fileExists(newStateFile.path),
83+
let stateData = try? environment.dataFromUrl(newStateFile),
84+
!stateData.isEmpty else {
85+
environment.logger.error("Failed to verify migrated state file")
86+
// Rollback: remove all migrated files
87+
rollbackMigration(directory: newDirectory, files: migratedFiles)
88+
return oldDirectory
89+
}
90+
}
91+
92+
// Step 6: Cleanup old files
93+
for fileName in migratedFiles {
94+
let oldFilePath = oldDirectory.appendingPathComponent(fileName, isDirectory: false).path
95+
do {
96+
try environment.fileClient.removeItem(oldFilePath)
97+
} catch {
98+
environment.logger.error("Failed to remove old file \(fileName): \(error.localizedDescription)")
99+
// Continue anyway - migration succeeded, cleanup is best-effort
100+
}
101+
}
102+
103+
environment.logger.error("Successfully migrated \(migratedFiles.count) file(s) to Application Support")
104+
return newDirectory
105+
}
106+
107+
/// Removes all migrated files from the new directory in case of migration failure
108+
/// - Parameters:
109+
/// - directory: The directory containing the files to remove
110+
/// - files: List of file names to remove
111+
private func rollbackMigration(directory: URL, files: [String]) {
112+
for fileName in files {
113+
let filePath = directory.appendingPathComponent(fileName, isDirectory: false).path
114+
if environment.fileClient.fileExists(filePath) {
115+
do {
116+
try environment.fileClient.removeItem(filePath)
117+
} catch {
118+
environment.logger.error("Failed to rollback file \(fileName): \(error.localizedDescription)")
119+
}
120+
}
121+
}
122+
}

Sources/KlaviyoCore/Utils/FileUtils.swift

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,36 @@ public struct FileClient {
1616
write: @escaping (Data, URL) throws -> Void,
1717
fileExists: @escaping (String) -> Bool,
1818
removeItem: @escaping (String) throws -> Void,
19-
libraryDirectory: @escaping () -> URL
19+
libraryDirectory: @escaping () -> URL,
20+
createDirectory: @escaping (URL, Bool) throws -> Void,
21+
copyItem: @escaping (String, String) throws -> Void
2022
) {
2123
self.write = write
2224
self.fileExists = fileExists
2325
self.removeItem = removeItem
2426
self.libraryDirectory = libraryDirectory
27+
self.createDirectory = createDirectory
28+
self.copyItem = copyItem
2529
}
2630

2731
public var write: (Data, URL) throws -> Void
2832
public var fileExists: (String) -> Bool
2933
public var removeItem: (String) throws -> Void
3034
public var libraryDirectory: () -> URL
35+
public var createDirectory: (URL, Bool) throws -> Void
36+
public var copyItem: (String, String) throws -> Void
3137

3238
public static let production = FileClient(
3339
write: write(data:url:),
3440
fileExists: FileManager.default.fileExists(atPath:),
3541
removeItem: FileManager.default.removeItem(atPath:),
36-
libraryDirectory: { FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! }
42+
libraryDirectory: { FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! },
43+
createDirectory: { url, withIntermediateDirectories in
44+
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: withIntermediateDirectories, attributes: nil)
45+
},
46+
copyItem: { atPath, toPath in
47+
try FileManager.default.copyItem(atPath: atPath, toPath: toPath)
48+
}
3749
)
3850
}
3951

Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ func saveKlaviyoState(state: KlaviyoState) {
329329

330330
private func klaviyoStateFile(apiKey: String) -> URL {
331331
let fileName = "klaviyo-\(apiKey)-state.json"
332-
let directory = environment.fileClient.libraryDirectory()
332+
let directory = migrateFilesIfNeeded(apiKey: apiKey)
333333
return directory.appendingPathComponent(fileName, isDirectory: false)
334334
}
335335

Tests/KlaviyoCoreTests/TestUtils.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,9 @@ extension FileClient {
120120
write: { _, _ in },
121121
fileExists: { _ in true },
122122
removeItem: { _ in },
123-
libraryDirectory: { TEST_URL }
123+
libraryDirectory: { TEST_URL },
124+
createDirectory: { _, _ in },
125+
copyItem: { _, _ in }
124126
)
125127
}
126128

Tests/KlaviyoFormsTests/KlaviyoFormsTestUtils.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ extension FileClient {
121121
write: { _, _ in },
122122
fileExists: { _ in true },
123123
removeItem: { _ in },
124-
libraryDirectory: { TEST_URL }
124+
libraryDirectory: { TEST_URL },
125+
createDirectory: { _, _ in },
126+
copyItem: { _, _ in }
125127
)
126128
}
127129

Tests/KlaviyoLocationTests/KlaviyoLocationTestUtils.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ extension FileClient {
6363
write: { _, _ in },
6464
fileExists: { _ in true },
6565
removeItem: { _ in },
66-
libraryDirectory: { TEST_URL }
66+
libraryDirectory: { TEST_URL },
67+
createDirectory: { _, _ in },
68+
copyItem: { _, _ in }
6769
)
6870
}
6971

0 commit comments

Comments
 (0)