Skip to content

Commit

Permalink
feat: Adds convenience Live Update APIs on Portal directly
Browse files Browse the repository at this point in the history
  • Loading branch information
Steven0351 committed Feb 12, 2024
1 parent 32609b1 commit 72e7a6d
Show file tree
Hide file tree
Showing 4 changed files with 238 additions and 0 deletions.
27 changes: 27 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
]
},
Expand Down
5 changes: 5 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -31,6 +32,10 @@ let package = Package(
.testTarget(
name: "IonicPortalsObjcTests",
dependencies: [ "IonicPortals" ]
),
.testTarget(
name: "ParallelAsyncSequenceTests",
dependencies: [ "IonicPortals", .product(name: "Clocks", package: "swift-clocks") ]
)
]
)
136 changes: 136 additions & 0 deletions Sources/IonicPortals/Portal+LiveUpdates.swift
Original file line number Diff line number Diff line change
@@ -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<Result<LiveUpdateManager.SyncResult, any Error>>

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<T>: 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<Void, Never>]
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)
}
}
}
70 changes: 70 additions & 0 deletions Tests/ParallelAsyncSequenceTests/ParallelAsyncSequenceTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}

}

0 comments on commit 72e7a6d

Please sign in to comment.