Skip to content
Draft
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
122 changes: 122 additions & 0 deletions Sources/KlaviyoCore/Utils/FileClientMigration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//
// FileClientMigration.swift
// KlaviyoSwift
//
// Migration logic for moving SDK files from Library/ to Library/Application Support/com.klaviyo/
//

import Foundation

/// Returns the Application Support directory for Klaviyo files
/// - Returns: URL pointing to Library/Application Support/com.klaviyo/
public func klaviyoApplicationSupportDirectory() -> URL {
let libraryDirectory = environment.fileClient.libraryDirectory()
return libraryDirectory
.appendingPathComponent("Application Support", isDirectory: true)
.appendingPathComponent("com.klaviyo", isDirectory: true)
}

/// Migrates Klaviyo files from the old location (Library/) to the new location (Library/Application Support/com.klaviyo/)
/// This function is idempotent and safe to call multiple times.
///
/// - Parameter apiKey: The API key used to identify Klaviyo files
/// - Returns: The directory URL where files should be stored (new location if migration succeeded, old location if it failed)
public func migrateFilesIfNeeded(apiKey: String) -> URL {
let newDirectory = klaviyoApplicationSupportDirectory()
let oldDirectory = environment.fileClient.libraryDirectory()

// Step 1: Create new directory if needed
do {
try environment.fileClient.createDirectory(newDirectory, true)
} catch {
environment.logger.error("Failed to create Application Support directory: \(error.localizedDescription)")
return oldDirectory
}

// Step 2: Check if migration already completed
let stateFileName = "klaviyo-\(apiKey)-state.json"
let newStateFile = newDirectory.appendingPathComponent(stateFileName, isDirectory: false)

if environment.fileClient.fileExists(newStateFile.path) {
// Migration already completed
return newDirectory
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant filesystem calls on every state save

Low Severity

migrateFilesIfNeeded is called on every debounced state save (via klaviyoStateFile), not just at initialization. After migration completes, every save still calls createDirectory (line 30) before the fileExists check (line 40), resulting in two unnecessary filesystem syscalls per save. Reordering the "already migrated" check before the createDirectory call, or caching the resolved directory, would eliminate the repeated I/O on the hot path.

Additional Locations (1)

Fix in Cursor Fix in Web


// Step 3: Check if old files exist
let oldStateFile = oldDirectory.appendingPathComponent(stateFileName, isDirectory: false)

if !environment.fileClient.fileExists(oldStateFile.path) {
// Fresh install - no files to migrate
return newDirectory
}

// Step 4: Perform migration
let filesToMigrate = [
"klaviyo-\(apiKey)-state.json",
"klaviyo-\(apiKey)-events.plist",
"klaviyo-\(apiKey)-people.plist"
]

var migratedFiles: [String] = []

for fileName in filesToMigrate {
let oldFilePath = oldDirectory.appendingPathComponent(fileName, isDirectory: false).path
let newFilePath = newDirectory.appendingPathComponent(fileName, isDirectory: false).path

// Only migrate files that exist
if environment.fileClient.fileExists(oldFilePath) {
do {
try environment.fileClient.copyItem(oldFilePath, newFilePath)
migratedFiles.append(fileName)
} catch {
environment.logger.error("Failed to copy \(fileName): \(error.localizedDescription)")
// Rollback: remove all migrated files
rollbackMigration(directory: newDirectory, files: migratedFiles)
return oldDirectory
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migration permanently stuck if destination file pre-exists

Low Severity

FileManager.copyItem throws (error code 516) when the destination file already exists. The migration loop doesn't check for or remove a pre-existing destination before calling copyItem. If a previous migration partially failed AND rollbackMigration also partially failed (leaving orphaned files in the new directory), all subsequent migration attempts will permanently fail on those orphaned files, since rollbackMigration only cleans up files from the current migratedFiles list, not leftover files from prior runs. The SDK falls back to the old directory gracefully, but migration never succeeds.

Additional Locations (1)

Fix in Cursor Fix in Web


// Step 5: Verify migration by checking if state file exists and has data
if migratedFiles.contains(stateFileName) {
guard environment.fileClient.fileExists(newStateFile.path),
let stateData = try? environment.dataFromUrl(newStateFile),
!stateData.isEmpty else {
environment.logger.error("Failed to verify migrated state file")
// Rollback: remove all migrated files
rollbackMigration(directory: newDirectory, files: migratedFiles)
return oldDirectory
}
}

// Step 6: Cleanup old files
for fileName in migratedFiles {
let oldFilePath = oldDirectory.appendingPathComponent(fileName, isDirectory: false).path
do {
try environment.fileClient.removeItem(oldFilePath)
} catch {
environment.logger.error("Failed to remove old file \(fileName): \(error.localizedDescription)")
// Continue anyway - migration succeeded, cleanup is best-effort
}
}

environment.logger.error("Successfully migrated \(migratedFiles.count) file(s) to Application Support")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Success message logged at error level

Medium Severity

The successful migration message is logged via environment.logger.error, which in production calls os_log with .error type. Every successful migration will appear as an error in the device's system logs and Xcode console. Since LoggerClient only exposes an error method, the success log either needs a new log level added or the message needs to be removed entirely to avoid polluting error diagnostics.

Fix in Cursor Fix in Web

return newDirectory
}

/// Removes all migrated files from the new directory in case of migration failure
/// - Parameters:
/// - directory: The directory containing the files to remove
/// - files: List of file names to remove
private func rollbackMigration(directory: URL, files: [String]) {
for fileName in files {
let filePath = directory.appendingPathComponent(fileName, isDirectory: false).path
if environment.fileClient.fileExists(filePath) {
do {
try environment.fileClient.removeItem(filePath)
} catch {
environment.logger.error("Failed to rollback file \(fileName): \(error.localizedDescription)")
}
}
}
}
16 changes: 14 additions & 2 deletions Sources/KlaviyoCore/Utils/FileUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,36 @@ public struct FileClient {
write: @escaping (Data, URL) throws -> Void,
fileExists: @escaping (String) -> Bool,
removeItem: @escaping (String) throws -> Void,
libraryDirectory: @escaping () -> URL
libraryDirectory: @escaping () -> URL,
createDirectory: @escaping (URL, Bool) throws -> Void,
copyItem: @escaping (String, String) throws -> Void
) {
self.write = write
self.fileExists = fileExists
self.removeItem = removeItem
self.libraryDirectory = libraryDirectory
self.createDirectory = createDirectory
self.copyItem = copyItem
}

public var write: (Data, URL) throws -> Void
public var fileExists: (String) -> Bool
public var removeItem: (String) throws -> Void
public var libraryDirectory: () -> URL
public var createDirectory: (URL, Bool) throws -> Void
public var copyItem: (String, String) throws -> Void

public static let production = FileClient(
write: write(data:url:),
fileExists: FileManager.default.fileExists(atPath:),
removeItem: FileManager.default.removeItem(atPath:),
libraryDirectory: { FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! }
libraryDirectory: { FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! },
createDirectory: { url, withIntermediateDirectories in
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: withIntermediateDirectories, attributes: nil)
},
copyItem: { atPath, toPath in
try FileManager.default.copyItem(atPath: atPath, toPath: toPath)
}
)
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/KlaviyoSwift/StateManagement/KlaviyoState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ func saveKlaviyoState(state: KlaviyoState) {

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

Expand Down
4 changes: 3 additions & 1 deletion Tests/KlaviyoCoreTests/TestUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ extension FileClient {
write: { _, _ in },
fileExists: { _ in true },
removeItem: { _ in },
libraryDirectory: { TEST_URL }
libraryDirectory: { TEST_URL },
createDirectory: { _, _ in },
copyItem: { _, _ in }
)
}

Expand Down
4 changes: 3 additions & 1 deletion Tests/KlaviyoFormsTests/KlaviyoFormsTestUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ extension FileClient {
write: { _, _ in },
fileExists: { _ in true },
removeItem: { _ in },
libraryDirectory: { TEST_URL }
libraryDirectory: { TEST_URL },
createDirectory: { _, _ in },
copyItem: { _, _ in }
)
}

Expand Down
4 changes: 3 additions & 1 deletion Tests/KlaviyoLocationTests/KlaviyoLocationTestUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ extension FileClient {
write: { _, _ in },
fileExists: { _ in true },
removeItem: { _ in },
libraryDirectory: { TEST_URL }
libraryDirectory: { TEST_URL },
createDirectory: { _, _ in },
copyItem: { _, _ in }
)
}

Expand Down
Loading
Loading