Files
Antonio Pallares b9d7588812 Unify event store directory resolution via DirectoryHelper (#6501)
* 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

* Use caches directory for AdEventStore and FeatureEventStore on tvOS

These stores had their own private applicationSupportDirectory property
that bypassed DirectoryHelper, so they weren't covered by the parent
branch's fix. Rename the parameter/property to persistenceDirectory
to reflect the platform-dependent behavior.

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

* Use DirectoryHelper in AdEventStore and FeatureEventStore

Both stores had their own defaultPersistenceDirectory property with
copy-pasted platform-specific logic (tvOS → caches, others →
application support) that duplicated DirectoryHelper. Replace with
calls to DirectoryHelper.baseUrl(for:inAppSpecificDirectory:).

Also fix DirectoryHelper.baseUrl to only require bundleIdentifier
when inAppSpecificDirectory is true, since the identifier is unused
when returning the raw base directory.

Made-with: Cursor

* Restore comment explaining why documents directory is avoided

Made-with: Cursor

* Centralize persistence directory in DirectoryHelper, simplify error handling

- Add DirectoryHelper.defaultPersistenceBaseUrl so both event stores
  share a single source of truth for the platform-specific base
  directory (caches on tvOS, application support elsewhere).
- Make createDefault return optional instead of throwing: log errors
  internally and return nil on failure, removing the single-case
  AdEventStoreError / FeatureEventStoreError enums.
- Update Purchases.swift call site and tests accordingly.

Made-with: Cursor

* Remove unused event_manager_failed_to_initialize string, restore log on else branch

Made-with: Cursor

* Clarify that defaultPersistenceBaseUrl refers to the app's container

Made-with: Cursor

* Include underlying error in FileHandler.failedCreatingDirectory

The error case was discarding the actual FileManager error, making
it impossible to diagnose why directory creation fails on CI.

Made-with: Cursor
2026-03-23 13:29:28 +01:00

269 lines
8.9 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
//
// FeatureEventStore.swift
//
// Created by Nacho Soto on 9/5/23.
import Foundation
protocol FeatureEventStoreType: Sendable {
/// Stores `event` into the store.
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
func store(_ storedEvent: StoredFeatureEvent) async
/// - Returns: the first `count` events from the store.
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
func fetch(_ count: Int) async -> [StoredFeatureEvent]
/// Removes the first `count` events from the store.
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
func clear(_ count: Int) async
}
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
internal actor FeatureEventStore: FeatureEventStoreType {
private let handler: FileHandlerType
init(handler: FileHandlerType) {
self.handler = handler
}
func store(_ storedEvent: StoredFeatureEvent) async {
do {
// Check if store is too big and clear old events if needed
if await self.isEventStoreTooBig() {
Logger.warn(FeatureEventStoreStrings.event_store_size_limit_reached)
await self.clear(Self.eventBatchSizeToClear)
}
if let eventDescription = try? storedEvent.encodedEvent.prettyPrintedJSON {
Logger.verbose(FeatureEventStoreStrings.storing_event(eventDescription))
} else {
Logger.verbose(FeatureEventStoreStrings.storing_event_without_json)
}
let event = try StoredFeatureEventSerializer.encode(storedEvent)
try await self.handler.append(line: event)
} catch {
Logger.error(FeatureEventStoreStrings.error_storing_event(error))
}
}
func fetch(_ count: Int) async -> [StoredFeatureEvent] {
assert(count > 0, "Invalid count: \(count)")
do {
return try await self.handler.readLines()
.prefix(count)
.compactMap { try? StoredFeatureEventSerializer.decode($0) }
.extractValues()
} catch {
Logger.error(FeatureEventStoreStrings.error_fetching_events(error))
return []
}
}
// - Note: If removing these `count` events fails, it will attempt to
// remove the entire file. This ensures that the same events again aren't sent again.
func clear(_ count: Int) async {
assert(count > 0, "Invalid count: \(count)")
do {
try await self.handler.removeFirstLines(count)
} catch {
Logger.error(FeatureEventStoreStrings.error_removing_first_lines(count: count, error))
do {
try await self.handler.emptyFile()
} catch {
Logger.error(FeatureEventStoreStrings.error_emptying_file(error))
}
}
}
private func isEventStoreTooBig() async -> Bool {
do {
return try await self.handler.fileSizeInKB() > Self.maxEventFileSizeInKB
} catch {
Logger.error(FeatureEventStoreStrings.error_checking_file_size(error))
return false
}
}
private static let maxEventFileSizeInKB: Double = 2048
private static let eventBatchSizeToClear = 50
}
@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *)
extension FeatureEventStore {
// See https://nemecek.be/blog/57/making-files-from-your-app-available-in-the-ios-files-app
// We don't want to store events in the documents directory in case app makes their documents
// accessible via the Files app.
static func createDefault(
persistenceDirectory: URL?,
documentsDirectory: URL? = nil
) -> FeatureEventStore? {
guard let directory = persistenceDirectory ?? DirectoryHelper.defaultPersistenceBaseUrl else {
Logger.error(FeatureEventStoreStrings.error_resolving_persistence_directory)
return nil
}
let url = Self.url(in: directory)
Logger.verbose(FeatureEventStoreStrings.initializing(url))
Self.removeLegacyDocumentsStore(overrideDocumentsDirectory: documentsDirectory)
do {
return try .init(handler: FileHandler(url))
} catch {
Logger.error(FeatureEventStoreStrings.error_initializing(error))
return nil
}
}
private static func revenueCatFolder(in container: URL) -> URL {
return container.appendingPathComponent("revenuecat")
}
private static func url(in container: URL) -> URL {
return self.revenueCatFolder(in: container).appendingPathComponent("paywall_event_store")
}
private static func removeLegacyDocumentsStore(overrideDocumentsDirectory: URL?) {
let documentsDirectory: URL
if let overrideDocumentsDirectory {
documentsDirectory = overrideDocumentsDirectory
} else if let resolved = Self.resolveDocumentsDirectory() {
documentsDirectory = resolved
} else {
return
}
let url = Self.revenueCatFolder(in: documentsDirectory)
guard Self.fileManager.fileExists(atPath: url.relativePath) else { return }
Logger.debug(FeatureEventStoreStrings.removing_old_documents_store(url))
do {
try Self.fileManager.removeItem(at: url)
} catch {
Logger.error(FeatureEventStoreStrings.error_removing_old_documents_store(error))
}
}
// swiftlint:disable avoid_using_directory_apis_directly
private static func resolveDocumentsDirectory() -> URL? {
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
return URL.documentsDirectory
} else {
return try? Self.fileManager.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
}
}
// swiftlint:enable avoid_using_directory_apis_directly
private static let fileManager: FileManager = .default
}
// MARK: - Messages
// swiftlint:disable identifier_name
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private enum FeatureEventStoreStrings {
case initializing(URL)
case error_resolving_persistence_directory
case error_initializing(Error)
case removing_old_documents_store(URL)
case error_removing_old_documents_store(Error)
case storing_event(String)
case storing_event_without_json
case error_storing_event(Error)
case error_fetching_events(Error)
case error_removing_first_lines(count: Int, Error)
case error_emptying_file(Error)
case error_checking_file_size(Error)
case event_store_size_limit_reached
}
// swiftlint:enable identifier_name
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
extension FeatureEventStoreStrings: LogMessage {
var description: String {
switch self {
case let .initializing(directory):
return "Initializing FeatureEventStore: \(directory.absoluteString)"
case .error_resolving_persistence_directory:
return "FeatureEventStore: unable to resolve persistence directory"
case let .error_initializing(error):
return "Error initializing FeatureEventStore: \((error as NSError).description)"
case let .removing_old_documents_store(url):
return "Removing old store: \(url)"
case let .error_removing_old_documents_store(error):
return "Failed removing old store: \((error as NSError).description)"
case let .storing_event(eventDescription):
return "Storing event: \(eventDescription)"
case .storing_event_without_json:
return "Storing an event. There was an error trying to print it"
case let .error_storing_event(error):
return "Error storing event: \((error as NSError).description)"
case let .error_fetching_events(error):
return "Error fetching events: \((error as NSError).description)"
case let .error_removing_first_lines(count, error):
return "Error removing first \(count) events: \((error as NSError).description)"
case let .error_emptying_file(error):
return "Error emptying file: \((error as NSError).description)"
case let .error_checking_file_size(error):
return "Error checking file size: \((error as NSError).description)"
case .event_store_size_limit_reached:
return "Event store size limit reached. Clearing oldest events to free up space."
}
}
var category: String { return "paywall_event_store" }
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private extension PaywallEvent {
var debugDescription: String {
return "\(self)"
}
}