mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-11 22:55:55 +05:45
* Add workflows network layer for multipage paywalls - Add `getWorkflows` and `getWorkflow` endpoint paths - Add `WorkflowsListResponse` and `PublishedWorkflow` response models - Add `WorkflowDetailProcessor` to handle `inline`/`use_cdn` response actions - Add `WorkflowCdnFetcher` protocol and `DirectWorkflowCdnFetcher` implementation - Add `GetWorkflowsOperation` and `GetWorkflowOperation` cacheable network operations - Add `WorkflowsAPI` facade and wire into `Backend` - Add unit tests for all new components iOS equivalent of https://github.com/RevenueCat/purchases-android/pull/3300 Made-with: Cursor * fix xcodeproj * reset Package.resolved * Update WorkflowStep models to match actual backend response - Add WorkflowTrigger struct (name, type, action_id, component_id) - Add triggers, outputs, and metadata fields to WorkflowStep - Add metadata field to PublishedWorkflow - Remove value field from WorkflowTriggerAction (backend uses step_id only) Made-with: Cursor * Update WorkflowResponseTests to match updated models - Replace value/resolvedTargetStepId assertions with stepId - Remove testDecodeWorkflowTriggerActionValueTakesPrecedence (value field removed) - Add testDecodeWorkflowTrigger for new WorkflowTrigger struct - Add testDecodePublishedWorkflowWithMetadata - Update testDecodeWorkflowStepDefaults to cover triggers, outputs, metadata - Add testDecodeWorkflowStepMatchingActualBackendResponse with real backend payload Made-with: Cursor * Disable file_length lint rule in HTTPRequestPath.swift Made-with: Cursor * Fix GetWorkflowOperation to compute result once before distributing to callbacks CDN fetch and JSON decoding were running once per deduplicated callback. Compute the Result<WorkflowFetchResult, BackendError> once outside the performOnAllItemsAndRemoveFromCache loop, matching the pattern used by GetOfferingsOperation and other operations in the codebase. Made-with: Cursor * Fix CDN fetcher: use URLSession instead of Data(contentsOf:), classify errors correctly - Replace Data(contentsOf:) with URLSession.shared.dataTask + DispatchSemaphore in DirectWorkflowCdnFetcher; gets URLSession timeout, HTTP status validation, and proper network stack semantics - Add WorkflowDetailProcessingError.cdnFetchFailed typed error so CDN I/O failures are distinguishable from envelope parsing failures - Catch cdnFetchFailed in GetWorkflowOperation and map to NetworkError.networkError instead of NetworkError.decoding, fixing misleading error classification - Update WorkflowDetailProcessorTests to assert the typed error is thrown Made-with: Cursor * Replace semaphore CDN fetch with async/await using withCheckedThrowingContinuation - Make WorkflowCdnFetcher.fetchCompiledWorkflowData async throws; use withCheckedThrowingContinuation to bridge URLSession.dataTask into async, avoiding any thread-blocking - Make WorkflowDetailProcessor.process async throws to propagate async - Bridge into async in GetWorkflowOperation via Task {}; completion() is called inside the Task after CDN fetch and decoding complete - Update WorkflowDetailProcessorTests to async throws with await Made-with: Cursor * Make CDN fetcher and processor completion-handler based for consistency Avoids Task{} in GetWorkflowOperation and keeps all operations calling completion() synchronously from within the HTTP callback, matching every other operation in the codebase. - WorkflowCdnFetcher.fetchCompiledWorkflowData now takes a completion handler; DirectWorkflowCdnFetcher uses URLSession.dataTask (non-blocking, no semaphore) - WorkflowDetailProcessor.process now takes a completion handler; inline action completes synchronously, use_cdn fans out to the fetcher callback - GetWorkflowOperation splits into getWorkflow/handleResponse/backendResult/ distribute helpers to stay within line-length limits - WorkflowDetailProcessorTests updated to use waitUntilValue pattern Made-with: Cursor * Fix ambiguous cache key delimiter in GetWorkflowOperation Space-separated appUserID+workflowId could collide (e.g. user 'a b' + workflow 'c' == user 'a' + workflow 'b c'). Use newline as delimiter, matching the precedent set by GetWebBillingProductsOperation. Made-with: Cursor * PR comments * remove cdn fetcher * fix response in BackendGetWorkflowsTests.swift * fix WorkflowResponseTests * fix error * [skip ci] Generating new test snapshots (#6584) * [skip ci] Generating new test snapshots (#6585) * [skip ci] Generating new test snapshots (#6586) * [skip ci] Generating new test snapshots (#6587) * [skip ci] Generating new test snapshots (#6588) * [skip ci] Generating new test snapshots (#6589) * [skip ci] Generating new test snapshots (#6590) * Test CDN mock is not re-assignable per test * [skip ci] Generating new test snapshots (#6597) * [skip ci] Generating new test snapshots (#6598) * [skip ci] Generating new test snapshots (#6599) * [skip ci] Generating new test snapshots (#6600) * [skip ci] Generating new test snapshots (#6601) * [skip ci] Generating new test snapshots (#6602) * [skip ci] Generating new test snapshots (#6603) * getWorkflow is signed * add type parameter * add response verification for CDN response * remove workflows list * add value to WorkflowTriggerAction * step id * hash and filerepo * missingCdnHash * linter and project * fix project * revert Package.resolved * @unchecked Sendable * use hash in generateOrGetCachedFileURL * fix compilation * skip responseVerificationMode in cdn * change basePath of caches * remove reserialization * Use explicit type in GetWorkflowOperation.createFactory for greppability Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix swiftlint identifier_name violation in WorkflowDetailProcessorTests Rename short variable `d` to `doubleValue` in two `if case .double(let d)` patterns to satisfy the identifier_name rule requiring names >= 3 characters. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * remove default FileRepositoryType * fix project * fix test * test preserves camel case * Generating new test snapshots for `feat/workflows-network-layer` - ios-15 (#6654) * Generating new test snapshots for `feat/workflows-network-layer` - ios-14 (#6656) --------- Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com> Co-authored-by: Facundo Menzella <facumenzella@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Facundo Menzella <facumenzella@users.noreply.github.com>
200 lines
6.9 KiB
Swift
200 lines
6.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
|
|
//
|
|
// FileRepository.swift
|
|
//
|
|
// Created by Jacob Zivan Rakidzich on 8/13/25.
|
|
|
|
import Foundation
|
|
|
|
/// A file repository
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, visionOS 1.0, watchOS 8.0, *)
|
|
@_spi(Internal) public final class FileRepository: FileRepositoryType, @unchecked Sendable {
|
|
/// A shared file repository
|
|
@_spi(Internal) public static let shared = FileRepository()
|
|
|
|
private static let defaultBasePath = "RevenueCat"
|
|
|
|
let networkService: SimpleNetworkServiceType
|
|
|
|
private let store = KeyedDeferredValueStore<JobKey, OutputURL>()
|
|
private let fileManager: LargeItemCacheType
|
|
private let cacheURL: URL?
|
|
|
|
/// Create a file repository
|
|
/// - Parameters:
|
|
/// - networkService: A service capable of fetching data from a URL
|
|
/// - fileManager: A service capable of storing data and returning the URL where that stored data exists
|
|
init(
|
|
networkService: SimpleNetworkServiceType = URLSession.shared,
|
|
fileManager: LargeItemCacheType = FileManager.default,
|
|
basePath: String = FileRepository.defaultBasePath
|
|
) {
|
|
self.networkService = networkService
|
|
self.fileManager = fileManager
|
|
|
|
self.cacheURL = fileManager.createCacheDirectoryIfNeeded(
|
|
basePath: basePath,
|
|
/*
|
|
In order to use the app specific directory structure the existing
|
|
cached files will have to be moved first.
|
|
Until that happens we'll keep using the existing 'RevenueCat' directory
|
|
*/
|
|
inAppSpecificDirectory: false
|
|
)
|
|
}
|
|
|
|
/// Create a file repository
|
|
@_spi(Internal) public convenience init() {
|
|
self.init(
|
|
networkService: URLSession.shared,
|
|
fileManager: FileManager.default
|
|
)
|
|
}
|
|
|
|
/// Prefetch files at the given urls
|
|
/// - Parameter urls: An array of URL to fetch data from
|
|
@_spi(Internal) public func prefetch(urls: [InputURL]) {
|
|
for url in urls {
|
|
Task { [weak self] in
|
|
try await self?.generateOrGetCachedFileURL(for: url, withChecksum: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create and/or get the cached file url
|
|
/// - Parameters:
|
|
/// - url: The url for the remote data to cache into a file
|
|
@_spi(Internal) public func generateOrGetCachedFileURL(
|
|
for url: InputURL,
|
|
withChecksum checksum: Checksum?
|
|
) async throws -> OutputURL {
|
|
return try await store.getOrPut(
|
|
Task { [weak self] in
|
|
guard let self,
|
|
let cachedUrl = self.generateLocalFilesystemURL(
|
|
forRemoteURL: url,
|
|
withChecksum: checksum
|
|
)
|
|
else {
|
|
Logger.error(Strings.fileRepository.failedToCreateCacheDirectory(url))
|
|
throw Error.failedToCreateCacheDirectory(url.absoluteString)
|
|
}
|
|
|
|
if self.fileManager.cachedContentExists(at: cachedUrl) {
|
|
return cachedUrl
|
|
}
|
|
|
|
let bytes = try await self.getBytes(from: url)
|
|
|
|
try await self.saveCachedFile(url: cachedUrl, fromBytes: bytes, withChecksum: checksum)
|
|
return cachedUrl
|
|
},
|
|
forKey: JobKey(url, checksum)
|
|
).value
|
|
}
|
|
|
|
/// Get the cached file url (if it exists)
|
|
/// - Parameters:
|
|
/// - url: The url for the remote data to cache into a file
|
|
@_spi(Internal) public func getCachedFileURL(for url: InputURL, withChecksum checksum: Checksum?) -> OutputURL? {
|
|
let cachedUrl = self.generateLocalFilesystemURL(forRemoteURL: url, withChecksum: checksum)
|
|
|
|
if let cachedUrl, self.fileManager.cachedContentExists(at: cachedUrl) {
|
|
return cachedUrl
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func getBytes(from url: URL) async throws -> AsyncThrowingStream<UInt8, Swift.Error> {
|
|
do {
|
|
return try await networkService.bytes(from: url)
|
|
} catch {
|
|
let message = Strings.fileRepository.failedToFetchFileFromRemoteSource(url, error)
|
|
Logger.error(message)
|
|
throw Error.failedToFetchFileFromRemoteSource(message.description)
|
|
}
|
|
}
|
|
|
|
private func saveCachedFile(
|
|
url: URL,
|
|
fromBytes bytes: AsyncThrowingStream<UInt8, Swift.Error>,
|
|
withChecksum checksum: Checksum?
|
|
) async throws {
|
|
do {
|
|
try await fileManager.saveData(bytes, to: url, checksum: checksum)
|
|
} catch is Checksum.ChecksumValidationFailure {
|
|
throw Error.checksumMismatch
|
|
} catch {
|
|
let message = Strings.fileRepository.failedToSaveCachedFile(url, error)
|
|
Logger.error(message)
|
|
throw Error.failedToSaveCachedFile(message.description)
|
|
}
|
|
}
|
|
|
|
func generateLocalFilesystemURL(forRemoteURL url: URL, withChecksum checksum: Checksum?) -> URL? {
|
|
let path = checksum?.value ?? url.absoluteString.asData.md5String
|
|
return cacheURL?
|
|
.appendingPathComponent(path + url.lastPathComponent)
|
|
}
|
|
}
|
|
|
|
/// A file cache
|
|
@_spi(Internal) public protocol FileRepositoryType: Sendable {
|
|
|
|
/// Prefetch files at the given urls
|
|
/// - Parameter urls: An array of URL to fetch data from
|
|
func prefetch(urls: [InputURL])
|
|
|
|
/// Create and/or get the cached file url
|
|
/// - Parameters:
|
|
/// - url: The url for the remote data to cache into a file
|
|
/// - checksum: A checksum of the remote file if there is any
|
|
func generateOrGetCachedFileURL(
|
|
for url: InputURL,
|
|
withChecksum checksum: Checksum?
|
|
) async throws -> OutputURL
|
|
}
|
|
|
|
/// The input URL is the URL that the repository will read remote data from
|
|
@_spi(Internal) public typealias InputURL = URL
|
|
|
|
/// The output URL is the local file's URL where the data can be found after caching is complete
|
|
@_spi(Internal) public typealias OutputURL = URL
|
|
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, visionOS 1.0, watchOS 8.0, *)
|
|
extension FileRepository {
|
|
|
|
/// File repository error cases
|
|
@_spi(Internal) public enum Error: Swift.Error {
|
|
/// Used when creating the folder on disk fails
|
|
case failedToCreateCacheDirectory(String)
|
|
|
|
/// Used when saving the file on disk fails
|
|
case failedToSaveCachedFile(String)
|
|
|
|
/// Used when fetching the data fails
|
|
case failedToFetchFileFromRemoteSource(String)
|
|
|
|
/// Used when the downloaded file's checksum does not match the expected value
|
|
case checksumMismatch
|
|
}
|
|
}
|
|
|
|
private struct JobKey: Hashable {
|
|
let url: InputURL
|
|
let checksum: Checksum?
|
|
|
|
init(_ url: InputURL, _ checksum: Checksum?) {
|
|
self.url = url
|
|
self.checksum = checksum
|
|
}
|
|
}
|