diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e57233..0af4291 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,10 @@ name: CI on: - push: - branches: - - "**" pull_request: + push: branches: - - "**" + - main jobs: test: runs-on: macos-latest @@ -14,7 +12,7 @@ jobs: steps: - name: Show Xcode version run: xcodebuild -version - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install xcpretty run: gem install xcpretty - name: Run Tests @@ -29,7 +27,7 @@ jobs: steps: - name: Show Xcode version run: xcodebuild -version - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - 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 8574f49..aa1bd70 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -14,12 +14,12 @@ jobs: VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} steps: - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22.x - name: Show Xcode version run: xcodebuild -version - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - 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 2db53dc..6074fdc 100644 --- a/.github/workflows/docs-prod.yml +++ b/.github/workflows/docs-prod.yml @@ -15,12 +15,12 @@ jobs: VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} steps: - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22.x - name: Show Xcode version run: xcodebuild -version - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - 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 a1a94bf..2aa50c1 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Show Xcode version run: xcodebuild -version - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - 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 f47cc8d..3a73690 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -12,7 +12,14 @@ jobs: steps: - name: Show Xcode version run: xcodebuild -version - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 + - name: Verify release tag + run: | + PODSPEC_VERSION="$(ruby -e "puts File.read('IonicPortals.podspec').match(/s\.version\s*=\s*'([^']+)'/)[1]")" + if [ "$GITHUB_REF_NAME" != "$PODSPEC_VERSION" ]; then + echo "Tag '$GITHUB_REF_NAME' does not match podspec version '$PODSPEC_VERSION'." + exit 1 + fi - name: Install build dependencies run: gem install cocoapods - name: Validate podspec diff --git a/IonicPortals.podspec b/IonicPortals.podspec index f3ab186..2618cd5 100644 --- a/IonicPortals.podspec +++ b/IonicPortals.podspec @@ -11,5 +11,6 @@ Pod::Spec.new do |s| s.source_files = 'Sources/IonicPortals/**/*.swift' s.dependency 'Capacitor', '~> 8.0.0' s.dependency 'IonicLiveUpdates', '>= 0.5.0', '< 0.6.0' + s.dependency 'LiveUpdateProvider', '~> 1.0.0' s.swift_version = '5.7' end diff --git a/Package.swift b/Package.swift index 93f70bd..10356ae 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.5 +// swift-tools-version:5.6 import PackageDescription @@ -14,7 +14,8 @@ let package = Package( dependencies: [ .package(url: "https://github.com/ionic-team/capacitor-swift-pm", .upToNextMajor(from: "8.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")) + .package(url: "https://github.com/pointfreeco/swift-clocks", .upToNextMajor(from: "1.0.2")), + .package(url: "https://github.com/ionic-team/live-update-provider-sdk", .upToNextMajor(from: "1.0.0")) ], targets: [ .target( @@ -22,7 +23,8 @@ let package = Package( dependencies: [ .product(name: "Capacitor", package: "capacitor-swift-pm"), .product(name: "Cordova", package: "capacitor-swift-pm"), - .product(name: "IonicLiveUpdates", package: "ionic-live-updates-releases") + .product(name: "IonicLiveUpdates", package: "ionic-live-updates-releases"), + .product(name: "LiveUpdateProvider", package: "live-update-provider-sdk") ] ), .testTarget( diff --git a/Sources/IonicPortals/AssetMap.swift b/Sources/IonicPortals/AssetMap.swift index 4358b92..9948647 100644 --- a/Sources/IonicPortals/AssetMap.swift +++ b/Sources/IonicPortals/AssetMap.swift @@ -6,7 +6,6 @@ // import Foundation -import IonicLiveUpdates public struct AssetMap { /// The name to index the asset map by. diff --git a/Sources/IonicPortals/Color+PortalBlue.swift b/Sources/IonicPortals/Color+PortalBlue.swift deleted file mode 100644 index 4ef6c29..0000000 --- a/Sources/IonicPortals/Color+PortalBlue.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Color+PortalBlue.swift -// IonicPortals -// -// Created by Steven Sherry on 5/24/22. -// - -import SwiftUI - -extension Color { - static let portalBlue = Color( - red: 0.0, - green: 0.21960784310000001, - blue: 0.78823529410000004 - ) -} diff --git a/Sources/IonicPortals/IonicPortals.docc/Portal.md b/Sources/IonicPortals/IonicPortals.docc/Portal.md index 8e1109f..3f3d3f8 100644 --- a/Sources/IonicPortals/IonicPortals.docc/Portal.md +++ b/Sources/IonicPortals/IonicPortals.docc/Portal.md @@ -4,7 +4,7 @@ ### Create a Portal -- ``init(name:startDir:index:devModeEnabled:bundle:initialContext:assetMaps:plugins:liveUpdateManager:liveUpdateConfig:)`` +- ``init(name:startDir:index:devModeEnabled:bundle:initialContext:assetMaps:plugins:liveUpdateSource:)`` - ``init(stringLiteral:)`` ### Web App Location @@ -26,8 +26,7 @@ ### Live Updates -- ``liveUpdateConfig`` -- ``liveUpdateManager`` +- ``liveUpdateSource`` ### Initial Application State diff --git a/Sources/IonicPortals/Logo.swift b/Sources/IonicPortals/Logo.swift deleted file mode 100644 index f37eb61..0000000 --- a/Sources/IonicPortals/Logo.swift +++ /dev/null @@ -1,187 +0,0 @@ -// -// Logo.swift -// -// -// Created by Steven Sherry on 5/24/22. -// - -import SwiftUI - -struct LogoView: View { - var body: some View { - ZStack { - Background() - .fill(.white.opacity(0.3)) - - RightPost() - .fill( - LinearGradient( - stops: .postGradients, - startPoint: .top, - endPoint: .bottom - ) - ) - - LeftPost() - .fill( - LinearGradient( - stops: .postGradients, - startPoint: .bottom, - endPoint: .top - ) - ) - - TopOval() - .fill(.white, style: FillStyle(eoFill: true)) - - BottomOval() - .fill(.white, style: FillStyle(eoFill: true)) - } - } -} - -private struct Background: Shape { - func path(in rect: CGRect) -> Path { - let width = rect.size.width - let height = rect.size.height - - return Path { path in - path.move(to: CGPoint(x: 0, y: 0.23077*height)) - path.addCurve(to: CGPoint(x: 0.5*width, y: 0.03846*height), control1: CGPoint(x: 0, y: 0.23077*height), control2: CGPoint(x: 0.15385*width, y: 0.03846*height)) - path.addCurve(to: CGPoint(x: width, y: 0.23077*height), control1: CGPoint(x: 0.84615*width, y: 0.03846*height), control2: CGPoint(x: width, y: 0.23077*height)) - path.addLine(to: CGPoint(x: width, y: 0.76923*height)) - path.addCurve(to: CGPoint(x: 0.5*width, y: 0.96154*height), control1: CGPoint(x: width, y: 0.76923*height), control2: CGPoint(x: 0.84615*width, y: 0.96154*height)) - path.addCurve(to: CGPoint(x: 0, y: 0.76923*height), control1: CGPoint(x: 0.15385*width, y: 0.96154*height), control2: CGPoint(x: 0, y: 0.76923*height)) - path.addLine(to: CGPoint(x: 0, y: 0.23077*height)) - } - } -} - -private struct RightPost: Shape { - func path(in rect: CGRect) -> Path { - let width = rect.size.width - let height = rect.size.height - - return Path { path in - path.move(to: CGPoint(x: 0.96154*width, y: 0.80769*height)) - path.addCurve(to: CGPoint(x: 0.92308*width, y: 0.76923*height), control1: CGPoint(x: 0.9403*width, y: 0.80769*height), control2: CGPoint(x: 0.92308*width, y: 0.79047*height)) - path.addLine(to: CGPoint(x: 0.92308*width, y: 0.23077*height)) - path.addCurve(to: CGPoint(x: 0.96154*width, y: 0.19231*height), control1: CGPoint(x: 0.92308*width, y: 0.20953*height), control2: CGPoint(x: 0.9403*width, y: 0.19231*height)) - path.addCurve(to: CGPoint(x: width, y: 0.23077*height), control1: CGPoint(x: 0.98278*width, y: 0.19231*height), control2: CGPoint(x: width, y: 0.20953*height)) - path.addLine(to: CGPoint(x: width, y: 0.76923*height)) - path.addCurve(to: CGPoint(x: 0.96154*width, y: 0.80769*height), control1: CGPoint(x: width, y: 0.79047*height), control2: CGPoint(x: 0.98278*width, y: 0.80769*height)) - } - } -} - -private struct LeftPost: Shape { - func path(in rect: CGRect) -> Path { - let width = rect.size.width - let height = rect.size.height - - return Path { path in - path.move(to: CGPoint(x: 0.03846*width, y: 0.19232*height)) - path.addCurve(to: CGPoint(x: 0, y: 0.23078*height), control1: CGPoint(x: 0.01722*width, y: 0.19232*height), control2: CGPoint(x: 0, y: 0.20954*height)) - path.addLine(to: CGPoint(x: 0, y: 0.76924*height)) - path.addCurve(to: CGPoint(x: 0.03846*width, y: 0.8077*height), control1: CGPoint(x: 0, y: 0.79048*height), control2: CGPoint(x: 0.01722*width, y: 0.8077*height)) - path.addCurve(to: CGPoint(x: 0.07692*width, y: 0.76924*height), control1: CGPoint(x: 0.0597*width, y: 0.8077*height), control2: CGPoint(x: 0.07692*width, y: 0.79048*height)) - path.addLine(to: CGPoint(x: 0.07692*width, y: 0.23078*height)) - path.addCurve(to: CGPoint(x: 0.03846*width, y: 0.19232*height), control1: CGPoint(x: 0.07692*width, y: 0.20954*height), control2: CGPoint(x: 0.0597*width, y: 0.19232*height)) - } - } -} - -private struct TopOval: Shape { - func path(in rect: CGRect) -> Path { - let width = rect.size.width - let height = rect.size.height - - return Path { path in - path.move(to: CGPoint(x: 0.10174*width, y: 0.18331*height)) - path.addCurve(to: CGPoint(x: 0.07694*width, y: 0.23077*height), control1: CGPoint(x: 0.08317*width, y: 0.2016*height), control2: CGPoint(x: 0.07694*width, y: 0.21762*height)) - path.addCurve(to: CGPoint(x: 0.10174*width, y: 0.27822*height), control1: CGPoint(x: 0.07694*width, y: 0.24392*height), control2: CGPoint(x: 0.08317*width, y: 0.25994*height)) - path.addCurve(to: CGPoint(x: 0.18845*width, y: 0.33125*height), control1: CGPoint(x: 0.12044*width, y: 0.29665*height), control2: CGPoint(x: 0.14947*width, y: 0.31501*height)) - path.addCurve(to: CGPoint(x: 0.50001*width, y: 0.38462*height), control1: CGPoint(x: 0.26624*width, y: 0.36366*height), control2: CGPoint(x: 0.37637*width, y: 0.38462*height)) - path.addCurve(to: CGPoint(x: 0.81158*width, y: 0.33125*height), control1: CGPoint(x: 0.62365*width, y: 0.38462*height), control2: CGPoint(x: 0.73379*width, y: 0.36366*height)) - path.addCurve(to: CGPoint(x: 0.89829*width, y: 0.27822*height), control1: CGPoint(x: 0.85056*width, y: 0.31501*height), control2: CGPoint(x: 0.87959*width, y: 0.29665*height)) - path.addCurve(to: CGPoint(x: 0.92309*width, y: 0.23077*height), control1: CGPoint(x: 0.91685*width, y: 0.25994*height), control2: CGPoint(x: 0.92309*width, y: 0.24392*height)) - path.addCurve(to: CGPoint(x: 0.89829*width, y: 0.18331*height), control1: CGPoint(x: 0.92309*width, y: 0.21762*height), control2: CGPoint(x: 0.91685*width, y: 0.2016*height)) - path.addCurve(to: CGPoint(x: 0.81158*width, y: 0.13029*height), control1: CGPoint(x: 0.87959*width, y: 0.16489*height), control2: CGPoint(x: 0.85056*width, y: 0.14653*height)) - path.addCurve(to: CGPoint(x: 0.50001*width, y: 0.07692*height), control1: CGPoint(x: 0.73379*width, y: 0.09788*height), control2: CGPoint(x: 0.62365*width, y: 0.07692*height)) - path.addCurve(to: CGPoint(x: 0.18845*width, y: 0.13029*height), control1: CGPoint(x: 0.37637*width, y: 0.07692*height), control2: CGPoint(x: 0.26624*width, y: 0.09788*height)) - path.addCurve(to: CGPoint(x: 0.10174*width, y: 0.18331*height), control1: CGPoint(x: 0.14947*width, y: 0.14653*height), control2: CGPoint(x: 0.12044*width, y: 0.16489*height)) - path.closeSubpath() - path.move(to: CGPoint(x: 0.15886*width, y: 0.05928*height)) - path.addCurve(to: CGPoint(x: 0.50001*width, y: 0), control1: CGPoint(x: 0.24812*width, y: 0.0221*height), control2: CGPoint(x: 0.36875*width, y: 0)) - path.addCurve(to: CGPoint(x: 0.84116*width, y: 0.05928*height), control1: CGPoint(x: 0.63127*width, y: 0), control2: CGPoint(x: 0.75191*width, y: 0.0221*height)) - path.addCurve(to: CGPoint(x: 0.95227*width, y: 0.12851*height), control1: CGPoint(x: 0.8857*width, y: 0.07784*height), control2: CGPoint(x: 0.92426*width, y: 0.10092*height)) - path.addCurve(to: CGPoint(x: 1.00001*width, y: 0.23077*height), control1: CGPoint(x: 0.98042*width, y: 0.15624*height), control2: CGPoint(x: 1.00001*width, y: 0.19082*height)) - path.addCurve(to: CGPoint(x: 0.95227*width, y: 0.33302*height), control1: CGPoint(x: 1.00001*width, y: 0.27072*height), control2: CGPoint(x: 0.98042*width, y: 0.3053*height)) - path.addCurve(to: CGPoint(x: 0.84116*width, y: 0.40225*height), control1: CGPoint(x: 0.92426*width, y: 0.36062*height), control2: CGPoint(x: 0.8857*width, y: 0.38369*height)) - path.addCurve(to: CGPoint(x: 0.50001*width, y: 0.46154*height), control1: CGPoint(x: 0.75191*width, y: 0.43944*height), control2: CGPoint(x: 0.63127*width, y: 0.46154*height)) - path.addCurve(to: CGPoint(x: 0.15886*width, y: 0.40225*height), control1: CGPoint(x: 0.36875*width, y: 0.46154*height), control2: CGPoint(x: 0.24812*width, y: 0.43944*height)) - path.addCurve(to: CGPoint(x: 0.04775*width, y: 0.33302*height), control1: CGPoint(x: 0.11432*width, y: 0.38369*height), control2: CGPoint(x: 0.07576*width, y: 0.36062*height)) - path.addCurve(to: CGPoint(x: 0.00001*width, y: 0.23077*height), control1: CGPoint(x: 0.01961*width, y: 0.3053*height), control2: CGPoint(x: 0.00001*width, y: 0.27072*height)) - path.addCurve(to: CGPoint(x: 0.04775*width, y: 0.12851*height), control1: CGPoint(x: 0.00001*width, y: 0.19082*height), control2: CGPoint(x: 0.01961*width, y: 0.15624*height)) - path.addCurve(to: CGPoint(x: 0.15886*width, y: 0.05928*height), control1: CGPoint(x: 0.07576*width, y: 0.10092*height), control2: CGPoint(x: 0.11432*width, y: 0.07784*height)) - } - } -} - -private struct BottomOval: Shape { - func path(in rect: CGRect) -> Path { - let width = rect.size.width - let height = rect.size.height - - return Path { path in - path.move(to: CGPoint(x: 0.10172*width, y: 0.72177*height)) - path.addCurve(to: CGPoint(x: 0.07692*width, y: 0.76923*height), control1: CGPoint(x: 0.08316*width, y: 0.74006*height), control2: CGPoint(x: 0.07692*width, y: 0.75607*height)) - path.addCurve(to: CGPoint(x: 0.10172*width, y: 0.81668*height), control1: CGPoint(x: 0.07692*width, y: 0.78238*height), control2: CGPoint(x: 0.08316*width, y: 0.79839*height)) - path.addCurve(to: CGPoint(x: 0.18844*width, y: 0.8697*height), control1: CGPoint(x: 0.12042*width, y: 0.8351*height), control2: CGPoint(x: 0.14946*width, y: 0.85346*height)) - path.addCurve(to: CGPoint(x: 0.5*width, y: 0.92307*height), control1: CGPoint(x: 0.26623*width, y: 0.90212*height), control2: CGPoint(x: 0.37636*width, y: 0.92307*height)) - path.addCurve(to: CGPoint(x: 0.81156*width, y: 0.8697*height), control1: CGPoint(x: 0.62364*width, y: 0.92307*height), control2: CGPoint(x: 0.73377*width, y: 0.90212*height)) - path.addCurve(to: CGPoint(x: 0.89828*width, y: 0.81668*height), control1: CGPoint(x: 0.85054*width, y: 0.85346*height), control2: CGPoint(x: 0.87957*width, y: 0.8351*height)) - path.addCurve(to: CGPoint(x: 0.92308*width, y: 0.76923*height), control1: CGPoint(x: 0.91684*width, y: 0.79839*height), control2: CGPoint(x: 0.92308*width, y: 0.78238*height)) - path.addCurve(to: CGPoint(x: 0.89828*width, y: 0.72177*height), control1: CGPoint(x: 0.92308*width, y: 0.75607*height), control2: CGPoint(x: 0.91684*width, y: 0.74006*height)) - path.addCurve(to: CGPoint(x: 0.81156*width, y: 0.66875*height), control1: CGPoint(x: 0.87957*width, y: 0.70335*height), control2: CGPoint(x: 0.85054*width, y: 0.68499*height)) - path.addCurve(to: CGPoint(x: 0.5*width, y: 0.61538*height), control1: CGPoint(x: 0.73377*width, y: 0.63633*height), control2: CGPoint(x: 0.62364*width, y: 0.61538*height)) - path.addCurve(to: CGPoint(x: 0.18844*width, y: 0.66875*height), control1: CGPoint(x: 0.37636*width, y: 0.61538*height), control2: CGPoint(x: 0.26623*width, y: 0.63633*height)) - path.addCurve(to: CGPoint(x: 0.10172*width, y: 0.72177*height), control1: CGPoint(x: 0.14946*width, y: 0.68499*height), control2: CGPoint(x: 0.12042*width, y: 0.70335*height)) - path.closeSubpath() - path.move(to: CGPoint(x: 0.15885*width, y: 0.59774*height)) - path.addCurve(to: CGPoint(x: 0.5*width, y: 0.53846*height), control1: CGPoint(x: 0.2481*width, y: 0.56055*height), control2: CGPoint(x: 0.36874*width, y: 0.53846*height)) - path.addCurve(to: CGPoint(x: 0.84115*width, y: 0.59774*height), control1: CGPoint(x: 0.63126*width, y: 0.53846*height), control2: CGPoint(x: 0.7519*width, y: 0.56055*height)) - path.addCurve(to: CGPoint(x: 0.95226*width, y: 0.66697*height), control1: CGPoint(x: 0.88569*width, y: 0.6163*height), control2: CGPoint(x: 0.92425*width, y: 0.63938*height)) - path.addCurve(to: CGPoint(x: width, y: 0.76923*height), control1: CGPoint(x: 0.9804*width, y: 0.6947*height), control2: CGPoint(x: width, y: 0.72928*height)) - path.addCurve(to: CGPoint(x: 0.95226*width, y: 0.87148*height), control1: CGPoint(x: width, y: 0.80917*height), control2: CGPoint(x: 0.9804*width, y: 0.84375*height)) - path.addCurve(to: CGPoint(x: 0.84115*width, y: 0.94071*height), control1: CGPoint(x: 0.92425*width, y: 0.89907*height), control2: CGPoint(x: 0.88569*width, y: 0.92215*height)) - path.addCurve(to: CGPoint(x: 0.5*width, y: 0.99999*height), control1: CGPoint(x: 0.7519*width, y: 0.9779*height), control2: CGPoint(x: 0.63126*width, y: 0.99999*height)) - path.addCurve(to: CGPoint(x: 0.15885*width, y: 0.94071*height), control1: CGPoint(x: 0.36874*width, y: 0.99999*height), control2: CGPoint(x: 0.2481*width, y: 0.9779*height)) - path.addCurve(to: CGPoint(x: 0.04774*width, y: 0.87148*height), control1: CGPoint(x: 0.11431*width, y: 0.92215*height), control2: CGPoint(x: 0.07575*width, y: 0.89907*height)) - path.addCurve(to: CGPoint(x: 0, y: 0.76923*height), control1: CGPoint(x: 0.01959*width, y: 0.84375*height), control2: CGPoint(x: 0, y: 0.80917*height)) - path.addCurve(to: CGPoint(x: 0.04774*width, y: 0.66697*height), control1: CGPoint(x: 0, y: 0.72928*height), control2: CGPoint(x: 0.01959*width, y: 0.6947*height)) - path.addCurve(to: CGPoint(x: 0.15885*width, y: 0.59774*height), control1: CGPoint(x: 0.07575*width, y: 0.63938*height), control2: CGPoint(x: 0.11431*width, y: 0.6163*height)) - } - } -} - -private extension Array where Element == Gradient.Stop { - static let postGradients: [Gradient.Stop] = [ - .init(color: .white.opacity(0.7), location: 0.231), - .init(color: .white.opacity(0.0), location: 0.769) - ] -} - -struct LogoView_Previews: PreviewProvider { - static var previews: some View { - GeometryReader { proxy in - HStack { - LogoView() - .frame(width: 64, height: 64) - } - .edgesIgnoringSafeArea(.all) - .frame(width: proxy.size.width, height: proxy.size.height, alignment: .center) - } - .background(Color.portalBlue) - } -} diff --git a/Sources/IonicPortals/Portal+LiveUpdates.swift b/Sources/IonicPortals/Portal+LiveUpdates.swift index b6d218e..1909c11 100644 --- a/Sources/IonicPortals/Portal+LiveUpdates.swift +++ b/Sources/IonicPortals/Portal+LiveUpdates.swift @@ -1,39 +1,72 @@ +import Foundation import IonicLiveUpdates +import LiveUpdateProvider extension Portal { - /// Error thrown if a ``liveUpdateConfig`` is not present on a ``Portal`` when ``sync()`` is called. + /// Error thrown if the required live update source is not present on a ``Portal`` when syncing. 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. + /// Syncs the Ionic Live Updates source if present. + /// - Returns: The result of the synchronization operation. + /// - Throws: If the portal has no Ionic Live Updates source, a ``LiveUpdateNotConfigured`` error will be thrown. + /// Any errors thrown from Ionic Live Updates will be propagated. public func sync() async throws -> LiveUpdateManager.SyncResult { - if let liveUpdateConfig { - return try await liveUpdateManager.sync(appId: liveUpdateConfig.appId) - } else { + guard case .ionic(let manager, let config) = liveUpdateSource else { throw LiveUpdateNotConfigured() } + + return try await manager.sync(appId: config.appId) } - /// Synchronizes the ``liveUpdateConfig``s of the provided ``Portal``s in parallel - /// - Parameter portals: The ``Portal``s to ``sync()`` + /// Syncs the external live update provider source if present. + /// - Returns: The result of the synchronization operation. + /// - Throws: If the portal has no external live update provider source, a ``LiveUpdateNotConfigured`` error will be thrown. + /// Any errors thrown from the live update provider will be propagated. + public func syncProvider() async throws -> (any ProviderSyncResult)? { + guard case .provider(let manager) = liveUpdateSource else { + throw LiveUpdateNotConfigured() + } + + return try await manager.sync() + } + + /// Synchronizes the Ionic Live Updates sources of the provided ``Portal``s in parallel. + /// - Parameter portals: The ``Portal``s to synchronize with ``Portal/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) { + /// for await result in Portal.sync(portals) { /// // do something with result /// } /// ``` public static func sync(_ portals: [Portal]) -> ParallelLiveUpdateSyncGroup { .init(portals) } + + /// Synchronizes the external live update provider sources of the provided ``Portal``s in parallel. + /// - Parameter portals: The ``Portal``s to synchronize with ``Portal/syncProvider()`` + /// - Returns: A ``ParallelLiveUpdateProviderSyncGroup`` of the results of each call to ``Portal/syncProvider()`` + public static func syncProvider(_ portals: [Portal]) -> ParallelLiveUpdateProviderSyncGroup { + .init(portals) + } + + /// The directory of the latest synced web application assets for this portal, if present. + public var latestAppDirectory: URL? { + switch liveUpdateSource { + case .ionic(let manager, let config): + return manager.latestAppDirectory(for: config.appId) + case .provider(let manager): + return manager.latestAppDirectory + case .none: + return nil + } + } } extension Array where Element == Portal { - /// Synchronizes the ``Portal/liveUpdateConfig`` for the elements in the array + /// Synchronizes the Ionic Live Updates sources for the elements in the array. /// - Returns: A ``ParallelLiveUpdateSyncGroup`` of the results of each call to ``Portal/sync()`` /// /// Usage @@ -46,19 +79,44 @@ extension Array where Element == Portal { public func sync() -> ParallelLiveUpdateSyncGroup { .init(self) } + + /// Synchronizes the external live update provider sources for the elements in the array. + /// - Returns: A ``ParallelLiveUpdateProviderSyncGroup`` of the results of each call to ``Portal/syncProvider()`` + public func syncProvider() -> ParallelLiveUpdateProviderSyncGroup { + .init(self) + } } -/// Alias for a parallel sequence of Live Update synchronization results +/// Alias for a parallel sequence of Ionic Live Updates synchronization results public typealias ParallelLiveUpdateSyncGroup = ParallelAsyncSequence> -extension ParallelLiveUpdateSyncGroup { - init(_ portals: [Portal]) { +/// Alias for a parallel sequence of external live update provider synchronization results. +public typealias ParallelLiveUpdateProviderSyncGroup = ParallelAsyncSequence> + +extension ParallelAsyncSequence { + /// Builds the sequence's work list by running `operation` for each portal in parallel. + init( + _ portals: [Portal], + operation: @escaping (Portal) async throws -> Success + ) where T == Result { work = portals.map { portal in - { await Result(catching: portal.sync) } + { await Result(catching: { try await operation(portal) }) } } } } +extension ParallelLiveUpdateSyncGroup { + init(_ portals: [Portal]) { + self.init(portals) { try await $0.sync() } + } +} + +extension ParallelLiveUpdateProviderSyncGroup { + init(_ portals: [Portal]) { + self.init(portals) { try await $0.syncProvider() } + } +} + /// A sequence that executes its tasks in parallel and yields their results as they complete public struct ParallelAsyncSequence: AsyncSequence { public typealias Element = Iterator.Element diff --git a/Sources/IonicPortals/Portal.swift b/Sources/IonicPortals/Portal.swift index 4125300..def4719 100644 --- a/Sources/IonicPortals/Portal.swift +++ b/Sources/IonicPortals/Portal.swift @@ -1,6 +1,7 @@ import Foundation import Capacitor import IonicLiveUpdates +import LiveUpdateProvider /// The configuration of a web application to be embedded in an iOS application public struct Portal { @@ -28,17 +29,15 @@ public struct Portal { /// Any Capacitor plugins to load on the ``Portal`` public var plugins: [Plugin] - /// The `LiveUpdateManager` responsible for locating the latest source for the web application - public var liveUpdateManager: LiveUpdateManager - - /// The `LiveUpdate` configuration used to determine the location of updated application assets. - public var liveUpdateConfig: LiveUpdate? = nil { + /// The ``Portal/LiveUpdateSource`` responsible for locating the latest source for the web application + public var liveUpdateSource: LiveUpdateSource? { didSet { - guard let liveUpdateConfig = liveUpdateConfig else { return } - try? liveUpdateManager.add( - liveUpdateConfig, - existingCacheUrl: bundle.url(forResource: startDir, withExtension: nil) - ) + if case .ionic(let manager, let config) = liveUpdateSource { + try? manager.add( + config, + existingCacheUrl: bundle.url(forResource: startDir, withExtension: nil) + ) + } } } @@ -55,11 +54,10 @@ public struct Portal { /// - index: The initial file to load in the Portal. Defaults to `index.html`. /// - devModeEnabled: Enables web developers to override the Portal content in debug builds. Defaults to `true`. /// - bundle: The `Bundle` that contains the web application. Defaults to `Bundle.main`. - /// - plugins: Any ``Plugin``s to load. Defautls to `[]`. /// - initialContext: Any initial state required by the web application. Defaults to `[:]`. /// - assetMaps: Any ``AssetMap``s needed to share assets with the ``Portal``. Defaults to `[]`. - /// - liveUpdateManager: The `LiveUpdateManager` responsible for locating the source source for the web application. Defaults to `LiveUpdateManager.shared`. - /// - liveUpdateConfig: The `LiveUpdate` configuration used to determine to location of updated application assets. Defaults to `nil`. + /// - plugins: Any ``Plugin``s to load. Defaults to `[]`. + /// - liveUpdateSource: The ``Portal/LiveUpdateSource`` responsible for locating and syncing the latest web application assets. Defaults to `nil`. public init( name: String, startDir: String? = nil, @@ -69,25 +67,43 @@ public struct Portal { initialContext: JSObject = [:], assetMaps: [AssetMap] = [], plugins: [Plugin] = [], - liveUpdateManager: LiveUpdateManager = .shared, - liveUpdateConfig: LiveUpdate? = nil + liveUpdateSource: LiveUpdateSource? = nil ) { self.name = name self.startDir = startDir ?? name - self.devModeEnabled = devModeEnabled self.index = index - self.initialContext = initialContext + self.devModeEnabled = devModeEnabled self.bundle = bundle + self.initialContext = initialContext + self.assetMaps = assetMaps self.plugins = plugins - self.liveUpdateManager = liveUpdateManager - self.liveUpdateConfig = liveUpdateConfig - if let liveUpdateConfig = liveUpdateConfig { - try? liveUpdateManager.add( - liveUpdateConfig, + self.liveUpdateSource = liveUpdateSource + + if case .ionic(let manager, let config) = liveUpdateSource { + try? manager.add( + config, existingCacheUrl: bundle.url(forResource: self.startDir, withExtension: nil) ) } - self.assetMaps = assetMaps + } +} + +extension Portal { + /// The live update source for a ``Portal``. + public enum LiveUpdateSource { + /// Uses Ionic Live Updates to sync and locate the latest web application assets. + /// + /// Portals configured with this case are synchronized with ``Portal/sync()``. + case ionic( + /// The `LiveUpdateManager` responsible for locating updated web application assets. + liveUpdateManager: LiveUpdateManager = .shared, + /// The `LiveUpdate` configuration used to determine the location of updated application assets. + liveUpdateConfig: LiveUpdate) + + /// Uses an external live update provider to sync and locate the latest web application assets. + /// + /// Portals configured with this case are synchronized with ``Portal/syncProvider()``. + case provider(manager: any ProviderManager) } } @@ -150,7 +166,7 @@ extension Portal: ExpressibleByStringLiteral { extension Portal { /// The two ways of registering Capacitor Plugins with a Portal. /// - /// A ``type(_:)`` is initialized by the bridge on the users behalf. + /// A ``type(_:)`` is initialized by the bridge on the user's behalf. /// An ``instance(_:)`` is given directly to the bridge. public enum Plugin { /// Allow the Capacitor runtime to initialize the plugin on your behalf. @@ -233,13 +249,25 @@ extension Portal { self.portal = portal } - /// Configures the `LiveUpdate` configuration + /// Sets the ``Portal/LiveUpdateSource/ionic(liveUpdateManager:liveUpdateConfig:)`` configuration /// - Parameters: - /// - appId: The AppFlow id of the web application associated with the ``IONPortal`` - /// - channel: The AppFlow channel to check for updates from. - /// - syncImmediately: Whether to immediately sync with AppFlow to check for updates. + /// - appId: The Appflow id of the web application associated with the ``IONPortal`` + /// - channel: The Appflow channel to check for updates from. + /// - syncImmediately: Whether to immediately sync with Appflow to check for updates. + /// + /// > Note: No-op if an external live update provider (``Portal/LiveUpdateSource/provider(manager:)``) is already configured. @objc public func setLiveUpdateConfiguration(appId: String, channel: String, syncImmediately: Bool) { - portal.liveUpdateConfig = LiveUpdate(appId: appId, channel: channel, syncOnAdd: syncImmediately) + if case .provider = portal.liveUpdateSource { + CAPLog.print("⚡️ \"\(portal.name)\" already has an external live update provider configured; ignoring Appflow live update configuration.") + return + } + + let config = LiveUpdate(appId: appId, channel: channel, syncOnAdd: syncImmediately) + if case .ionic(let manager, _) = portal.liveUpdateSource { + portal.liveUpdateSource = .ionic(liveUpdateManager: manager, liveUpdateConfig: config) + } else { + portal.liveUpdateSource = .ionic(liveUpdateConfig: config) + } } } @@ -254,8 +282,7 @@ extension IONPortal { let portal = Portal( name: name, startDir: startDir, - initialContext: initialContext.flatMap { JSTypes.coerceDictionaryToJSObject($0) } ?? [:], - liveUpdateConfig: nil + initialContext: initialContext.flatMap { JSTypes.coerceDictionaryToJSObject($0) } ?? [:] ) self.init(portal: portal) diff --git a/Sources/IonicPortals/PortalView/PortalUIView.swift b/Sources/IonicPortals/PortalView/PortalUIView.swift index 22cd996..d6d3555 100644 --- a/Sources/IonicPortals/PortalView/PortalUIView.swift +++ b/Sources/IonicPortals/PortalView/PortalUIView.swift @@ -2,7 +2,6 @@ import Foundation import WebKit import UIKit import Capacitor -import IonicLiveUpdates /// A UIKit UIView to display ``Portal`` content @objc(IONPortalUIView) @@ -83,10 +82,7 @@ public class PortalUIView: UIView { } private func initView () { - if let liveUpdateConfig = portal.liveUpdateConfig { - self.liveUpdatePath = portal.liveUpdateManager.latestAppDirectory(for: liveUpdateConfig.appId) - } - + self.liveUpdatePath = portal.latestAppDirectory addPinnedSubview(webView) } @@ -297,8 +293,7 @@ extension PortalUIView { } /// Reloads the underlying `WKWebView` @objc public func reload() { - if let liveUpdate = portal.liveUpdateConfig, - let latestAppPath = portal.liveUpdateManager.latestAppDirectory(for: liveUpdate.appId), + if let latestAppPath = portal.latestAppDirectory, liveUpdatePath == nil || liveUpdatePath?.path != latestAppPath.path { liveUpdatePath = latestAppPath return setServerBasePath(path: latestAppPath.path) diff --git a/Sources/IonicPortals/PortalsPubSub.swift b/Sources/IonicPortals/PortalsPubSub.swift index 8f965f0..e53bd4b 100644 --- a/Sources/IonicPortals/PortalsPubSub.swift +++ b/Sources/IonicPortals/PortalsPubSub.swift @@ -111,7 +111,7 @@ public struct SubscriptionResult { } } -/// An Objective-C interface that enables marshalling data to and from a ``Portal`` over an event bus. If using Swift, ``PortalsPubSub`` is the perferred interface. +/// An Objective-C interface that enables marshalling data to and from a ``Portal`` over an event bus. If using Swift, ``PortalsPubSub`` is the preferred interface. @objc public class IONPortalsPubSub: NSObject { class Cancellable: NSObject { var cancellable: AnyCancellable @@ -128,7 +128,7 @@ public struct SubscriptionResult { /// - topic: The topic to listen for events on /// - callback: The code to be executed when an event is received for the topic /// - Returns: A subscription reference to use for unsubscribing - /// > Tip: You must retain a reference to the returned to keep the subscription alive. To unsubscribe, set the the reference to nil. + /// > Tip: You must retain the returned reference to keep the subscription alive. To unsubscribe, set the reference to nil. @objc(subscribeToTopic:callback:) public static func subscribe(topic: String, callback: @escaping ([String: Any]) -> Void) -> Any { let cancellable = PortalsPubSub.subscribe(to: topic) { result in callback(result.dictionaryRepresentation as [String: Any]) diff --git a/Sources/IonicPortals/PortalsRegistrationManager.swift b/Sources/IonicPortals/PortalsRegistrationManager.swift deleted file mode 100644 index 4a1ba77..0000000 --- a/Sources/IonicPortals/PortalsRegistrationManager.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation - -/// Registration is no longer required for Ionic Portals. -@available(*, deprecated, message: "Registration is no longer required.") -@objc(IONPortalsRegistrationManager) -public class PortalsRegistrationManager: NSObject { - private override init() {} - - /// The default singleton - @objc public static let shared = PortalsRegistrationManager() - - /// Always `true`. Registration is no longer required. - @objc public var isRegistered: Bool { - true - } - - /// No-op. Registration is no longer required. - /// - Parameter key: Ignored. - @objc public func register(key: String) { - } -} diff --git a/Tests/IonicPortalsTests/PortalLiveUpdateSourceTests.swift b/Tests/IonicPortalsTests/PortalLiveUpdateSourceTests.swift new file mode 100644 index 0000000..dc02869 --- /dev/null +++ b/Tests/IonicPortalsTests/PortalLiveUpdateSourceTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import IonicPortals +import IonicLiveUpdates +import LiveUpdateProvider + +final class PortalLiveUpdateSourceTests: XCTestCase { + func testSyncProvider_returnsProviderSyncResult() async throws { + let manager = MockProviderManager(result: MockSyncResult()) + let portal = Portal(name: "test", liveUpdateSource: .provider(manager: manager)) + + let result = try await portal.syncProvider() + + XCTAssertEqual(manager.syncCallCount, 1) + XCTAssertTrue(result is MockSyncResult) + } + + func testSyncProvider_returnsNilWhenNoUpdateAvailable() async throws { + let manager = MockProviderManager(result: nil) + let portal = Portal(name: "test", liveUpdateSource: .provider(manager: manager)) + + let result = try await portal.syncProvider() + + XCTAssertEqual(manager.syncCallCount, 1) + XCTAssertNil(result) + } + + func testSetLiveUpdateConfiguration_doesNotReplaceExternalProvider() { + let manager = MockProviderManager() + let portal = IONPortal(portal: Portal(name: "test", liveUpdateSource: .provider(manager: manager))) + + portal.setLiveUpdateConfiguration(appId: "app-id", channel: "production", syncImmediately: false) + + guard case .provider = portal.portal.liveUpdateSource else { + XCTFail("Expected external provider configuration to be preserved.") + return + } + } +} + +private struct MockSyncResult: ProviderSyncResult {} + +private final class MockProviderManager: ProviderManager { + var latestAppDirectory: URL? + var syncCallCount = 0 + + private let result: (any ProviderSyncResult)? + + init( + latestAppDirectory: URL? = nil, + result: (any ProviderSyncResult)? = MockSyncResult() + ) { + self.latestAppDirectory = latestAppDirectory + self.result = result + } + + func sync() async throws -> (any ProviderSyncResult)? { + syncCallCount += 1 + return result + } +}