Skip to content

Commit

Permalink
feat: Live Update sync extension (#84)
Browse files Browse the repository at this point in the history
  • Loading branch information
Steven0351 authored Feb 12, 2024
1 parent 32609b1 commit 7e0a801
Show file tree
Hide file tree
Showing 10 changed files with 266 additions and 28 deletions.
18 changes: 9 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,31 @@ 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
run: |
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
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/docs-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/docs-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/pre-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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)
}

}
Loading

0 comments on commit 7e0a801

Please sign in to comment.