From 72e7a6d910cbc8771875d10da1a161efac358621 Mon Sep 17 00:00:00 2001 From: Steven Sherry Date: Mon, 12 Feb 2024 13:56:42 -0600 Subject: [PATCH] feat: Adds convenience Live Update APIs on Portal directly --- Package.resolved | 27 ++++ Package.swift | 5 + Sources/IonicPortals/Portal+LiveUpdates.swift | 136 ++++++++++++++++++ .../ParallelAsyncSequenceTests.swift | 70 +++++++++ 4 files changed, 238 insertions(+) create mode 100644 Sources/IonicPortals/Portal+LiveUpdates.swift create mode 100644 Tests/ParallelAsyncSequenceTests/ParallelAsyncSequenceTests.swift diff --git a/Package.resolved b/Package.resolved index 11829a8..3777745 100644 --- a/Package.resolved +++ b/Package.resolved @@ -18,6 +18,33 @@ "revision": "1b04d7e2afe633302dd4f4c4560eb3cd8d3719f9", "version": "0.5.0" } + }, + { + "package": "swift-clocks", + "repositoryURL": "https://github.com/pointfreeco/swift-clocks", + "state": { + "branch": null, + "revision": "a8421d68068d8f45fbceb418fbf22c5dad4afd33", + "version": "1.0.2" + } + }, + { + "package": "swift-concurrency-extras", + "repositoryURL": "https://github.com/pointfreeco/swift-concurrency-extras", + "state": { + "branch": null, + "revision": "bb5059bde9022d69ac516803f4f227d8ac967f71", + "version": "1.1.0" + } + }, + { + "package": "xctest-dynamic-overlay", + "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state": { + "branch": null, + "revision": "b58e6627149808b40634c4552fcf2f44d0b3ca87", + "version": "1.1.0" + } } ] }, diff --git a/Package.swift b/Package.swift index 629b645..74f5990 100644 --- a/Package.swift +++ b/Package.swift @@ -14,6 +14,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/ionic-team/capacitor-swift-pm", .upToNextMajor(from: "5.0.0")), .package(url: "https://github.com/ionic-team/ionic-live-updates-releases", "0.5.0"..<"0.6.0"), + .package(url: "https://github.com/pointfreeco/swift-clocks", .upToNextMajor(from: "1.0.2")) ], targets: [ .target( @@ -31,6 +32,10 @@ let package = Package( .testTarget( name: "IonicPortalsObjcTests", dependencies: [ "IonicPortals" ] + ), + .testTarget( + name: "ParallelAsyncSequenceTests", + dependencies: [ "IonicPortals", .product(name: "Clocks", package: "swift-clocks") ] ) ] ) diff --git a/Sources/IonicPortals/Portal+LiveUpdates.swift b/Sources/IonicPortals/Portal+LiveUpdates.swift new file mode 100644 index 0000000..b6d218e --- /dev/null +++ b/Sources/IonicPortals/Portal+LiveUpdates.swift @@ -0,0 +1,136 @@ +import IonicLiveUpdates + +extension Portal { + /// Error thrown if a ``liveUpdateConfig`` is not present on a ``Portal`` when ``sync()`` is called. + public struct LiveUpdateNotConfigured: Error {} + + /// Syncs the ``liveUpdateConfig`` if present + /// - Returns: The result of the synchronization operation + /// - Throws: If the portal has no ``liveUpdateConfig``, a ``LiveUpdateNotConfigured`` error will be thrown. + /// Any errors thrown from ``liveUpdateManager`` will be propogated. + public func sync() async throws -> LiveUpdateManager.SyncResult { + if let liveUpdateConfig { + return try await liveUpdateManager.sync(appId: liveUpdateConfig.appId) + } else { + throw LiveUpdateNotConfigured() + } + } + + /// Synchronizes the ``liveUpdateConfig``s of the provided ``Portal``s in parallel + /// - Parameter portals: The ``Portal``s to ``sync()`` + /// - Returns: A ``ParallelLiveUpdateSyncGroup`` of the results of each call to ``Portal/sync()`` + /// + /// Usage + /// ```swift + /// let portals = [portal1, portal2, portal3] + /// for await result in Portals.sync(portals) { + /// // do something with result + /// } + /// ``` + public static func sync(_ portals: [Portal]) -> ParallelLiveUpdateSyncGroup { + .init(portals) + } +} + +extension Array where Element == Portal { + /// Synchronizes the ``Portal/liveUpdateConfig`` for the elements in the array + /// - Returns: A ``ParallelLiveUpdateSyncGroup`` of the results of each call to ``Portal/sync()`` + /// + /// Usage + /// ```swift + /// let portals = [portal1, portal2, portal3] + /// for await result in portals.sync() { + /// // do something with result + /// } + /// ``` + public func sync() -> ParallelLiveUpdateSyncGroup { + .init(self) + } +} + +/// Alias for a parallel sequence of Live Update synchronization results +public typealias ParallelLiveUpdateSyncGroup = ParallelAsyncSequence> + +extension ParallelLiveUpdateSyncGroup { + init(_ portals: [Portal]) { + work = portals.map { portal in + { await Result(catching: portal.sync) } + } + } +} + +/// A sequence that executes its tasks in parallel and yields their results as they complete +public struct ParallelAsyncSequence: AsyncSequence { + public typealias Element = Iterator.Element + private var work: [() async -> T] + + init(work: [() async -> T]) { + self.work = work + } + + /// Creates an asynchronous iterator for this sequence + public func makeAsyncIterator() -> Iterator { + Iterator(work) + } +} + +extension ParallelAsyncSequence { + /// An iterator that executes its tasks in parallel and yields their results as they complete + public struct Iterator: AsyncIteratorProtocol { + private let storage: Storage + private let tasks: [Task] + private var currentIndex = 0 + + fileprivate init(_ work: [() async -> T]) { + let storage = Storage(anticipatedSize: work.count) + self.storage = storage + tasks = work.map { run in + Task { [storage] in + await storage.append(await run()) + } + } + } + + /// Advances the iterator and returns the next value, or `nil` if there are no more values + mutating public func next() async -> T? { + defer { currentIndex += 1 } + guard currentIndex < tasks.endIndex else { return nil } + + while currentIndex >= (await storage.results.count) { + await Task.yield() + if Task.isCancelled { + for task in tasks { + task.cancel() + } + return nil + } + } + + return await storage.results[currentIndex] + } + } + + private actor Storage { + var results: [T] + + init(anticipatedSize: Int) { + results = [] + results.reserveCapacity(anticipatedSize) + } + + func append(_ element: T) { + results.append(element) + } + } +} + +extension Result { + init(catching body: @escaping () async throws -> Success) async where Failure == any Error { + do { + let result = try await body() + self = .success(result) + } catch { + self = .failure(error) + } + } +} diff --git a/Tests/ParallelAsyncSequenceTests/ParallelAsyncSequenceTests.swift b/Tests/ParallelAsyncSequenceTests/ParallelAsyncSequenceTests.swift new file mode 100644 index 0000000..8166d45 --- /dev/null +++ b/Tests/ParallelAsyncSequenceTests/ParallelAsyncSequenceTests.swift @@ -0,0 +1,70 @@ +// +// ParallelAsyncSequenceTests.swift +// +// +// Created by Steven Sherry on 2/12/24. +// + +@testable import IonicPortals +import XCTest +import Clocks + + +@available(iOS 16, *) +final class ParallelAsyncSequenceTests: XCTestCase { + func testParallelAsyncSequence_runsTasksInParallel_andCompletesSuccessfullyWhenNoCancellationOccurs() async throws { + let clock = TestClock() + let sequence = ParallelAsyncSequence(work: [1, 2, 3, 4, 5] + .map { number in + return { + try? await clock.sleep(for: .seconds(number)) + return number + } + } + ) + + let task = Task { + await sequence.reduce(0, +) + } + + // If the work was not being done in parallel, advancing the clock by 5 seconds would + // not be enough to ensure the work would be completed. + await clock.advance(by: .seconds(5)) + // This will throw if anything is currently awaiting the clock so the test will + // fail instead of hanging indefinitely + try await clock.checkSuspension() + + let result = await task.value + XCTAssertEqual(result, 15) + } + + func testParallelAsyncSequence_finishesAndCancelsTasks_whenTopLevelTaskIsCancelled() async throws { + let clock = TestClock() + let sequence = ParallelAsyncSequence(work: [1, 2, 3, 4, 5] + .map { number in + return { + do { + try await clock.sleep(for: .seconds(number)) + return number + } catch is CancellationError { + return 0 + } catch { + XCTFail("Unexpected error thrown from call to clock.sleep") + return 0 + } + } + } + ) + + let task = Task { + await sequence.reduce(0, +) + } + + await clock.advance(by: .seconds(2)) + task.cancel() + let value = await task.value + // The reduction should have only had a chance to reduce the first two elements before the task was cancelled + XCTAssertEqual(value, 3) + } + +}