From 7e0a80176ed0e25b8bea0e02274703d4e37dd3ae Mon Sep 17 00:00:00 2001 From: Steven Sherry Date: Mon, 12 Feb 2024 14:58:04 -0600 Subject: [PATCH] feat: Live Update sync extension (#84) --- .github/workflows/ci.yml | 18 +-- .github/workflows/docs-preview.yml | 10 +- .github/workflows/docs-prod.yml | 10 +- .github/workflows/pre-release.yml | 8 +- .github/workflows/publish.yaml | 8 +- Package.resolved | 27 ++++ Package.swift | 5 + Sources/IonicPortals/Portal+LiveUpdates.swift | 136 ++++++++++++++++++ .../ParallelAsyncSequenceTests.swift | 70 +++++++++ package.json | 2 +- 10 files changed, 266 insertions(+), 28 deletions(-) create mode 100644 Sources/IonicPortals/Portal+LiveUpdates.swift create mode 100644 Tests/ParallelAsyncSequenceTests/ParallelAsyncSequenceTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cee273..0300b5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,17 +3,17 @@ name: CI on: push: branches: - - '**' + - "**" pull_request: branches: - - '**' + - "**" jobs: test: - runs-on: macos-12 + runs-on: macos-14 timeout-minutes: 30 steps: - - run: sudo xcode-select --switch /Applications/Xcode_14.1.app - - uses: actions/checkout@v3 + - run: sudo xcode-select --switch /Applications/Xcode_15.1.app + - uses: actions/checkout@v4 - name: Install xcpretty run: gem install xcpretty - name: Run Tests @@ -21,13 +21,13 @@ jobs: set -eo pipefail xcodebuild test \ -scheme IonicPortals \ - -destination 'platform=iOS Simulator,name=iPhone 13' | xcpretty + -destination 'platform=iOS Simulator,name=iPhone 15' | xcpretty validate-podspec: - runs-on: macos-12 + runs-on: macos-14 timeout-minutes: 30 steps: - - run: sudo xcode-select --switch /Applications/Xcode_14.1.app - - uses: actions/checkout@v3 + - run: sudo xcode-select --switch /Applications/Xcode_15.1.app + - uses: actions/checkout@v4 - name: Install cocoapods run: gem install cocoapods - name: Lint Podspec diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index ba622bb..103d3cb 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -3,22 +3,22 @@ name: Publish Docs Preview on: push: branches: - - '**' + - "**" jobs: publish-preview-docs: - runs-on: macos-12 + runs-on: macos-14 timeout-minutes: 30 env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} steps: - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18.x - - run: sudo xcode-select --switch /Applications/Xcode_14.1.app - - uses: actions/checkout@v2 + - run: sudo xcode-select --switch /Applications/Xcode_15.1.app + - uses: actions/checkout@v4 - name: Install Vercel CLI run: npm install -g vercel - name: Pull Build Configuration diff --git a/.github/workflows/docs-prod.yml b/.github/workflows/docs-prod.yml index b962242..9bcf41e 100644 --- a/.github/workflows/docs-prod.yml +++ b/.github/workflows/docs-prod.yml @@ -3,23 +3,23 @@ name: Publish Docs on: push: tags: - - '*' + - "*" workflow_dispatch: jobs: publish-docs: - runs-on: macos-12 + runs-on: macos-14 timeout-minutes: 30 env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} steps: - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18.x - - run: sudo xcode-select --switch /Applications/Xcode_14.1.app - - uses: actions/checkout@v3 + - run: sudo xcode-select --switch /Applications/Xcode_15.1.app + - uses: actions/checkout@v4 - name: Install Vercel CLI run: npm install -g vercel - name: Pull Build Configuration diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 237e98e..75e1408 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -3,15 +3,15 @@ name: Prerelease on: push: branches: - - 'release/**' + - "release/**" jobs: update-version: - runs-on: macos-12 + runs-on: macos-14 timeout-minutes: 30 steps: - - run: sudo xcode-select --switch /Applications/Xcode_14.1.app - - uses: actions/checkout@v3 + - run: sudo xcode-select --switch /Applications/Xcode_15.1.app + - uses: actions/checkout@v4 - name: Install build dependencies run: gem install cocoapods xcpretty fastlane - name: Assign version to RELEASE_VERSION environment variable diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 5b8116d..80ab855 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -3,15 +3,15 @@ name: Publish to Cocoapods on: push: tags: - - '*' + - "*" jobs: publish-to-cocoapods: - runs-on: macos-12 + runs-on: macos-14 timeout-minutes: 30 steps: - - run: sudo xcode-select --switch /Applications/Xcode_14.1.app - - uses: actions/checkout@v3 + - run: sudo xcode-select --switch /Applications/Xcode_15.1.app + - uses: actions/checkout@v4 - name: Install build dependencies run: gem install cocoapods - name: Validate podspec 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) + } + +} diff --git a/package.json b/package.json index e70a7df..2551db5 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "scripts": { "build": "npm run build:docs && npm run build:transform", - "build:docs": "xcodebuild docbuild -scheme IonicPortals -derivedDataPath DerivedData -destination 'platform=iOS Simulator,name=iPhone 13'", + "build:docs": "xcodebuild docbuild -scheme IonicPortals -derivedDataPath DerivedData -destination 'platform=iOS Simulator,name=iPhone 15'", "build:transform": "mv DerivedData/Build/Products/Debug-iphonesimulator/IonicPortals.doccarchive public" } }