Files
purchases-ios/Sources/Caching/LargeItemCacheType.swift
T
Antonio Pallares c9575c5165 Fix "Failed to create cache directory" error log on tvOS (#6487)
* Make DirectoryType.applicationSupport unavailable on tvOS

tvOS sandbox only allows writes under Library/Caches on physical
devices — attempts to create directories under Library/Application
Support fail with NSCocoaErrorDomain 513 / EPERM. Mark the
.applicationSupport case as @available(tvOS, unavailable) so callers
get a compile-time error, and fall back to .cache at each call site.

Made-with: Cursor

* Guard test code referencing .applicationSupport from tvOS

Wrap DiagnosticsFileOldFileDeletionTests and the .applicationSupport
switch arm in MockLargeItemCache with #if !os(tvOS) to match the
@available(tvOS, unavailable) annotation on the enum case.

Made-with: Cursor

* Log warning for ignored applicationSupportDirectory on tvOS, add directory type test

- Warn at init when applicationSupportDirectory override is passed on
  tvOS, since it is silently ignored in favour of .cache.
- Add testInitUsesCacheDirectoryOnTvOS verifying the correct directory
  type is selected per platform.

Made-with: Cursor

* Adapt DiagnosticsFileOldFileDeletionTests to run on tvOS

Instead of disabling the entire test class on tvOS, use a
persistenceDirectoryType helper that selects .cache on tvOS
and .applicationSupport() elsewhere, so the migration tests
verify the correct destination directory per platform.

Made-with: Cursor
2026-03-20 10:50:44 +00:00

195 lines
6.8 KiB
Swift

//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// LargeItemCacheType.swift
//
// Created by Jacob Zivan Rakidzich on 8/13/25.
import Foundation
/// An inteface representing a simple cache
protocol LargeItemCacheType {
/// Store data to a url
func saveData(_ data: Data, to url: URL) throws
/// Store data to a url
@available(iOS 15.0, macOS 12.0, tvOS 15.0, visionOS 1.0, watchOS 8.0, *)
func saveData(_ bytes: AsyncThrowingStream<UInt8, Error>, to url: URL, checksum: Checksum?) async throws
/// Check if there is content cached at the url
func cachedContentExists(at url: URL) -> Bool
/// Load data from url
func loadFile(at url: URL) throws -> Data
/// delete data at url
func remove(_ url: URL) throws
/// Creates a directory from a base path in the specified directory type
/// The `inAppSpecificDirectory` should be set to false only for components
/// that haven't migrated to the new app specific directory structure yet
func createDirectoryIfNeeded(
basePath: String,
directoryType: DirectoryHelper.DirectoryType,
inAppSpecificDirectory: Bool
) -> URL?
/// List all file URLs in a directory
func contentsOfDirectory(at url: URL) throws -> [URL]
}
extension LargeItemCacheType {
/// Creates a directory in the cache from a base path. Defaults `inAppSpecificDirectory` to true.
func createCacheDirectoryIfNeeded(basePath: String, inAppSpecificDirectory: Bool = true) -> URL? {
createDirectoryIfNeeded(
basePath: basePath,
directoryType: .cache,
inAppSpecificDirectory: inAppSpecificDirectory
)
}
#if !os(tvOS)
/// Creates a directory in the persistence (applicationSupport) directory from a base path.
/// Defaults `inAppSpecificDirectory` to true.
/// Note: tvOS only allows writes under the caches directory, so we don't provide this functionality there.
func createPersistenceDirectoryIfNeeded(basePath: String, inAppSpecificDirectory: Bool = true) -> URL? {
createDirectoryIfNeeded(
basePath: basePath,
directoryType: .applicationSupport(),
inAppSpecificDirectory: inAppSpecificDirectory
)
}
#endif
}
extension FileManager: LargeItemCacheType {
/// Store data to a url
func saveData(_ data: Data, to url: URL) throws {
let directoryURL = url.deletingLastPathComponent()
try createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
try data.write(to: url)
}
/// Store data to a url and validate that the file is correct before saving
@available(iOS 15.0, macOS 12.0, tvOS 15.0, visionOS 1.0, watchOS 8.0, *)
func saveData(
_ bytes: AsyncThrowingStream<UInt8, Error>,
to url: URL,
checksum: Checksum?
) async throws {
// Set up file handling
let tempFileURL = temporaryDirectory.appendingPathComponent((checksum?.value ?? "") + url.lastPathComponent)
guard createFile(atPath: tempFileURL.path, contents: nil, attributes: nil) else {
let message = Strings.fileRepository.failedToCreateTemporaryFile(tempFileURL)
Logger.error(message)
throw CocoaError(.fileWriteUnknown)
}
let fileHandle = try FileHandle(forWritingTo: tempFileURL)
defer { try? fileHandle.close() }
defer { try? removeItem(at: tempFileURL) }
// Write data in chunks to the temporary file
let bufferSize: Int = 262_144 // 256KB
var buffer = Data()
buffer.reserveCapacity(bufferSize)
var hasher = checksum?.algorithm.getHasher()
for try await byte in bytes {
buffer.append(byte)
if buffer.count >= bufferSize {
hasher?.update(data: buffer)
try fileHandle.write(contentsOf: buffer)
buffer.removeAll(keepingCapacity: true)
}
}
// Write any remaining bytes missed during the while loop
if !buffer.isEmpty {
hasher?.update(data: buffer)
try fileHandle.write(contentsOf: buffer)
}
// Validate the stored data matches what the server has
if let checksum = checksum, let hasher = hasher {
// If this fails… should we retry?
let digest = hasher.finalize()
let value = digest.compactMap { String(format: "%02x", $0) }.joined()
try Checksum(algorithm: checksum.algorithm, value: value)
.compare(to: checksum)
}
// If all succeeds, move the temporary file to the more permanant storage location
// effectively a "save" operation
let directoryURL = url.deletingLastPathComponent()
try createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
try moveItem(at: tempFileURL, to: url)
}
/// Check if there is content cached at the given path
func cachedContentExists(at url: URL) -> Bool {
do {
if let size = try self.attributesOfItem(atPath: url.path)[.size] as? UInt64 {
return size > 0
}
return false
} catch {
return false
}
}
/// Creates a directory from a base path in the specified directory type
/// The `inAppSpecificDirectory` should be set to false only for components
/// that haven't migrated to the new app specific directory structure yet
func createDirectoryIfNeeded(
basePath: String,
directoryType: DirectoryHelper.DirectoryType,
inAppSpecificDirectory: Bool
) -> URL? {
guard let baseDirectoryURL = DirectoryHelper.baseUrl(
for: directoryType,
inAppSpecificDirectory: inAppSpecificDirectory
) else { return nil }
let directoryURL = baseDirectoryURL.appendingPathComponent(basePath)
do {
try createDirectory(
at: directoryURL,
withIntermediateDirectories: true,
attributes: nil
)
} catch {
let message = Strings.fileRepository.failedToCreateCacheDirectory(directoryURL)
Logger.error(message)
}
return directoryURL
}
/// Load data from url
func loadFile(at url: URL) throws -> Data {
return try Data(contentsOf: url)
}
func remove(_ url: URL) throws {
try self.removeItem(at: url)
}
func contentsOfDirectory(at url: URL) throws -> [URL] {
return try self.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [])
}
}