diff --git a/.env b/.env deleted file mode 120000 index f54419c1..00000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -Configuration/.env \ No newline at end of file diff --git a/.github/workflows/nightlies.yaml b/.github/workflows/nightlies.yaml new file mode 100644 index 00000000..f9dcc248 --- /dev/null +++ b/.github/workflows/nightlies.yaml @@ -0,0 +1,38 @@ +--- +name: Nightlies + +on: # yamllint disable-line rule:truthy + push: + branches: [main] + +jobs: + deliver-demo-nightlies: + name: "🌙 Nightlies" + runs-on: macos-latest + strategy: + matrix: + platform: [ios, tvos] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Add Apple certificate + run: | + Scripts/add-apple-certificate.sh \ + $RUNNER_TEMP \ + ${{ secrets.KEYCHAIN_PASSWORD }} \ + ${{ secrets.SRGSSR_APPLE_DEV_CERTIFICATE_B64 }} + + - name: Configure environment + run: | + Scripts/configure-environment.sh \ + ${{ secrets.APP_STORE_CONNECT_API_KEY }} + + - name: Archive the demo + run: | + make deliver-demo-nightly-${{ matrix.platform }} + env: + TEAM_ID: ${{ secrets.TEAM_ID }} + KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ISSUER_ID }} + TESTFLIGHT_GROUPS: ${{ vars.TESTFLIGHT_GROUPS }} diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 00000000..c0e72790 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,69 @@ +--- +name: Pull Request + +on: pull_request # yamllint disable-line rule:truthy + +jobs: + check-quality: + name: "🔎 Check quality" + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run the quality check + run: make check-quality + + build-documentation: + name: "📚 Build documentation" + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build the documentation + run: make doc + + tests: + name: "🧪 Tests" + runs-on: macos-latest + strategy: + matrix: + platform: [ios, tvos] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run tests + run: make test-${{ matrix.platform }} + + archive-demos: + name: "📦 Archives" + runs-on: macos-latest + strategy: + matrix: + platform: [ios, tvos] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Add Apple certificate + run: | + Scripts/add-apple-certificate.sh \ + $RUNNER_TEMP \ + ${{ secrets.KEYCHAIN_PASSWORD }} \ + ${{ secrets.APPLE_DEV_CERTIFICATE }} \ + ${{ secrets.APPLE_DEV_CERTIFICATE_PASSWORD }} + + - name: Configure environment + run: | + Scripts/configure-environment.sh \ + ${{ secrets.APP_STORE_CONNECT_API_KEY }} + + - name: Archive the demo + run: make archive-demo-${{ matrix.platform }} + env: + TEAM_ID: ${{ secrets.TEAM_ID }} + KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ISSUER_ID }} + TESTFLIGHT_GROUPS: ${{ vars.TESTFLIGHT_GROUPS }} diff --git a/Demo/Sources/Players/PlaybackView.swift b/Demo/Sources/Players/PlaybackView.swift index edb4d9e8..a2e02a3b 100644 --- a/Demo/Sources/Players/PlaybackView.swift +++ b/Demo/Sources/Players/PlaybackView.swift @@ -51,6 +51,14 @@ private struct MainView: View { MetricsView(metricsCollector: metricsCollector) } .statusBarHidden(isFullScreen ? isUserInterfaceHidden : false) + .onContinuousHover { phase in + switch phase { + case .active: + visibilityTracker.reset() + case .ended: + break + } + } .bind(visibilityTracker, to: player) .bind(metricsCollector, to: player) } @@ -352,6 +360,7 @@ private struct SkipBackwardButton: View { .opacity(player.canSkipBackward() ? 1 : 0) .animation(.defaultLinear, value: player.canSkipBackward()) .keyboardShortcut("s", modifiers: []) + .hoverEffect() } private func skipBackward() { @@ -375,6 +384,7 @@ private struct SkipForwardButton: View { .opacity(player.canSkipForward() ? 1 : 0) .animation(.defaultLinear, value: player.canSkipForward()) .keyboardShortcut("d", modifiers: []) + .hoverEffect() } private func skipForward() { @@ -394,6 +404,7 @@ private struct FullScreenButton: View { .font(.system(size: 20)) } .keyboardShortcut("f", modifiers: []) + .hoverEffect() } } @@ -431,6 +442,7 @@ private struct VolumeButton: View { .font(.system(size: 20)) } .keyboardShortcut("m", modifiers: []) + .hoverEffect() } private var imageName: String { @@ -457,6 +469,7 @@ private struct SettingsMenu: View { .tint(.white) } .menuOrder(.fixed) + .hoverEffect() } @ViewBuilder @@ -560,6 +573,7 @@ private struct LiveButton: View { .fontWeight(.ultraLight) .font(.system(size: 20)) } + .hoverEffect() .accessibilityLabel("Jump to live") } } @@ -829,12 +843,13 @@ private struct PlaybackButton: View { .resizable() .tint(.white) } -#if os(iOS) - .keyboardShortcut(.space, modifiers: []) -#endif .aspectRatio(contentMode: .fit) .frame(minWidth: 120, maxHeight: 90) .accessibilityLabel(accessibilityLabel) +#if os(iOS) + .keyboardShortcut(.space, modifiers: []) + .hoverEffect() +#endif } private func play() { diff --git a/Demo/Sources/Showcase/Playlist/PlaylistView.swift b/Demo/Sources/Showcase/Playlist/PlaylistView.swift index 43219c5f..211f72e0 100644 --- a/Demo/Sources/Showcase/Playlist/PlaylistView.swift +++ b/Demo/Sources/Showcase/Playlist/PlaylistView.swift @@ -76,6 +76,7 @@ private struct Toolbar: View { Button(action: player.returnToPrevious) { Image(systemName: "arrow.left") } + .hoverEffect() .accessibilityLabel("Previous") .disabled(!player.canReturnToPrevious()) } @@ -86,22 +87,26 @@ private struct Toolbar: View { Button(action: toggleRepeatMode) { Image(systemName: repeatModeImageName) } + .hoverEffect() .accessibilityLabel(repeatModeAccessibilityLabel) Button(action: model.shuffle) { Image(systemName: "shuffle") } + .hoverEffect() .accessibilityLabel("Shuffle") .disabled(model.isEmpty) Button(action: add) { Image(systemName: "plus") } + .hoverEffect() .accessibilityLabel("Add") Button(action: model.trash) { Image(systemName: "trash") } + .hoverEffect() .accessibilityLabel("Delete all") .disabled(model.isEmpty) } @@ -112,6 +117,7 @@ private struct Toolbar: View { Button(action: player.advanceToNext) { Image(systemName: "arrow.right") } + .hoverEffect() .accessibilityLabel("Next") .disabled(!player.canAdvanceToNext()) } diff --git a/Demo/Sources/Views/CloseButton.swift b/Demo/Sources/Views/CloseButton.swift index 5a40210c..65b01211 100644 --- a/Demo/Sources/Views/CloseButton.swift +++ b/Demo/Sources/Views/CloseButton.swift @@ -22,6 +22,7 @@ struct CloseButton: View { #if os(iOS) .keyboardShortcut(.escape, modifiers: []) #endif + .hoverEffect() } init(topBarStyle: Bool = false) { diff --git a/Gemfile b/Gemfile index d002b4c3..eb64a720 100644 --- a/Gemfile +++ b/Gemfile @@ -3,8 +3,6 @@ source 'https://rubygems.org' gem 'fastlane' -gem 'rubocop' -gem 'xcode-install' plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/Gemfile.lock b/Gemfile.lock index e61f2de9..a1ebc7d6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,19 +8,18 @@ GEM addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) artifactory (3.0.17) - ast (2.4.2) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.998.0) - aws-sdk-core (3.211.0) + aws-partitions (1.1014.0) + aws-sdk-core (3.214.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.95.0) + aws-sdk-kms (1.96.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.169.0) + aws-sdk-s3 (1.174.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -162,10 +161,9 @@ GEM domain_name (~> 0.5) httpclient (2.8.3) jmespath (1.6.2) - json (2.7.5) + json (2.8.2) jwt (2.9.3) base64 - language_server-protocol (3.17.0.3) mini_magick (4.13.2) mini_mime (1.1.5) multi_json (1.15.0) @@ -173,18 +171,11 @@ GEM nanaimo (0.4.0) naturally (2.2.1) nkf (0.2.0) - optparse (0.5.0) + optparse (0.6.0) os (1.1.4) - parallel (1.26.3) - parser (3.3.5.0) - ast (~> 2.4.1) - racc plist (3.7.1) public_suffix (6.0.1) - racc (1.8.1) - rainbow (3.1.1) rake (13.2.1) - regexp_parser (2.9.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -192,19 +183,6 @@ GEM retriable (3.1.2) rexml (3.3.9) rouge (2.0.7) - rubocop (1.67.0) - json (~> 2.3) - language_server-protocol (>= 3.17.0) - parallel (~> 1.10) - parser (>= 3.3.0.2) - rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rubocop-ast (>= 1.32.2, < 2.0) - ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.33.0) - parser (>= 3.3.1.0) - ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) rubyzip (2.3.2) security (0.1.5) @@ -228,10 +206,7 @@ GEM uber (0.1.0) unicode-display_width (2.6.0) word_wrap (1.0.0) - xcode-install (2.8.1) - claide (>= 0.9.1) - fastlane (>= 2.1.0, < 3.0.0) - xcodeproj (1.26.0) + xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) @@ -248,6 +223,7 @@ PLATFORMS arm64-darwin-22 arm64-darwin-23 arm64-darwin-24 + ruby x86_64-darwin-21 x86_64-darwin-22 @@ -255,8 +231,6 @@ DEPENDENCIES fastlane fastlane-plugin-badge fastlane-plugin-xcconfig - rubocop - xcode-install BUNDLED WITH - 2.3.7 + 2.5.23 diff --git a/Makefile b/Makefile index 46424af1..3429dd37 100644 --- a/Makefile +++ b/Makefile @@ -1,90 +1,93 @@ #!/usr/bin/xcrun make -f -CONFIGURATION_REPOSITORY_URL=https://github.com/SRGSSR/pillarbox-apple-configuration.git -CONFIGURATION_COMMIT_SHA1=dad52a4242c7997c179073caec03b8d6e718fc03 - .PHONY: all all: help -.PHONY: setup -setup: - @echo "Setting up the project..." - @bundle install > /dev/null - @Scripts/checkout-configuration.sh "${CONFIGURATION_REPOSITORY_URL}" "${CONFIGURATION_COMMIT_SHA1}" Configuration +.PHONY: install-pkgx +install-pkgx: + @echo "Installing pkgx..." + @curl -Ssf https://pkgx.sh | sh &> /dev/null + @echo "... done.\n" + +.PHONY: install-bundler +install-bundler: + @echo "Installing bundler..." + @pkgx bundle config set path '.bundle' + @pkgx bundle install @echo "... done.\n" .PHONY: fastlane -fastlane: setup - @bundle exec fastlane +fastlane: install-pkgx install-bundler + @pkgx bundle exec fastlane .PHONY: archive-demo-ios -archive-demo-ios: setup - @bundle exec fastlane archive_demo_ios +archive-demo-ios: install-pkgx install-bundler + @pkgx bundle exec fastlane archive_demo_ios .PHONY: archive-demo-tvos -archive-demo-tvos: setup - @bundle exec fastlane archive_demo_tvos +archive-demo-tvos: install-pkgx install-bundler + @pkgx bundle exec fastlane archive_demo_tvos .PHONY: deliver-demo-nightly-ios -deliver-demo-nightly-ios: setup +deliver-demo-nightly-ios: install-pkgx install-bundler @echo "Delivering demo nightly build for iOS..." - @bundle exec fastlane deliver_demo_nightly_ios + @pkgx +magick +rsvg-convert bundle exec fastlane deliver_demo_nightly_ios @echo "... done.\n" .PHONY: deliver-demo-nightly-tvos -deliver-demo-nightly-tvos: setup +deliver-demo-nightly-tvos: install-pkgx install-bundler @echo "Delivering demo nightly build for tvOS..." - @bundle exec fastlane deliver_demo_nightly_tvos + @pkgx +magick +rsvg-convert bundle exec fastlane deliver_demo_nightly_tvos @echo "... done.\n" .PHONY: deliver-demo-release-ios -deliver-demo-release-ios: setup +deliver-demo-release-ios: install-pkgx @echo "Delivering demo release build for iOS..." @bundle exec fastlane deliver_demo_release_ios @echo "... done.\n" .PHONY: deliver-demo-release-tvos -deliver-demo-release-tvos: setup +deliver-demo-release-tvos: install-pkgx @echo "Delivering demo release build for tvOS..." @bundle exec fastlane deliver_demo_release_tvos @echo "... done.\n" .PHONY: test-streams-start -test-streams-start: +test-streams-start: install-pkgx @echo "Starting test streams" @Scripts/test-streams.sh -s @echo "... done.\n" .PHONY: test-streams-stop -test-streams-stop: +test-streams-stop: install-pkgx @echo "Stopping test streams" @Scripts/test-streams.sh -k @echo "... done.\n" .PHONY: test-ios -test-ios: setup +test-ios: install-pkgx install-bundler @echo "Running unit tests..." @Scripts/test-streams.sh -s - @bundle exec fastlane test_ios + @pkgx bundle exec fastlane test_ios @Scripts/test-streams.sh -k @echo "... done.\n" .PHONY: test-tvos -test-tvos: setup +test-tvos: install-pkgx install-bundler @echo "Running unit tests..." @Scripts/test-streams.sh -s - @bundle exec fastlane test_tvos + @pkgx bundle exec fastlane test_tvos @Scripts/test-streams.sh -k @echo "... done.\n" .PHONY: check-quality -check-quality: setup +check-quality: install-pkgx @echo "Checking quality..." @Scripts/check-quality.sh @echo "... done.\n" .PHONY: fix-quality -fix-quality: setup +fix-quality: install-pkgx @echo "Fixing quality..." @Scripts/fix-quality.sh @echo "... done.\n" @@ -115,9 +118,9 @@ clean-imports: @echo "Cleaning imports..." @mkdir -p .build @xcodebuild -scheme Pillarbox -destination generic/platform=ios > ./.build/xcodebuild.log - @swiftlint analyze --fix --compiler-log-path ./.build/xcodebuild.log + @pkgx swiftlint analyze --fix --compiler-log-path ./.build/xcodebuild.log @xcodebuild -scheme Pillarbox-demo -project ./Demo/Pillarbox-demo.xcodeproj -destination generic/platform=iOS > ./.build/xcodebuild.log - @swiftlint analyze --fix --compiler-log-path ./.build/xcodebuild.log + @pkgx swiftlint analyze --fix --compiler-log-path ./.build/xcodebuild.log @echo "... done.\n" .PHONY: find-dead-code @@ -125,15 +128,15 @@ find-dead-code: @echo "Start checking dead code..." @mkdir -p .build @xcodebuild -scheme Pillarbox -destination generic/platform=iOS -derivedDataPath ./.build/derived-data clean build &> /dev/null - @periphery scan --retain-public --skip-build --index-store-path ./.build/derived-data/Index.noindex/DataStore/ + @pkgx periphery scan --retain-public --skip-build --index-store-path ./.build/derived-data/Index.noindex/DataStore/ @xcodebuild -scheme Pillarbox-demo -project ./Demo/Pillarbox-demo.xcodeproj -destination generic/platform=iOS -derivedDataPath ./.build/derived-data clean build &> /dev/null - @periphery scan --project ./Demo/Pillarbox-demo.xcodeproj --schemes Pillarbox-demo --targets Pillarbox-demo --skip-build --index-store-path ./.build/derived-data/Index.noindex/DataStore/ + @pkgx periphery scan --project ./Demo/Pillarbox-demo.xcodeproj --schemes Pillarbox-demo --targets Pillarbox-demo --skip-build --index-store-path ./.build/derived-data/Index.noindex/DataStore/ @echo "... done.\n" .PHONY: doc -doc: setup +doc: install-pkgx @echo "Generating documentation sets..." - @bundle exec fastlane doc + @pkgx fastlane doc @echo "... done.\n" .PHONY: help @@ -141,7 +144,6 @@ help: @echo "The following targets are available:" @echo "" @echo " all Default target" - @echo " setup Setup project" @echo "" @echo " fastlane Run fastlane" @echo "" diff --git a/Package.resolved b/Package.resolved index 1fa702e5..3d04c774 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "commanders-act-apple", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SRGSSR/commanders-act-apple.git", + "state" : { + "revision" : "e06657ceae07237a08e04ca2d34c2ec7612483b8", + "version" : "5.4.12" + } + }, { "identity" : "comscore-swift-package-manager", "kind" : "remoteSourceControl", @@ -36,15 +45,6 @@ "version" : "1.0.1" } }, - { - "identity" : "iosv5", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CommandersAct/iOSV5.git", - "state" : { - "revision" : "24ffbad1da8467b615958be9d0dd1e92c4396699", - "version" : "5.4.12" - } - }, { "identity" : "nimble", "kind" : "remoteSourceControl", diff --git a/Scripts/add-apple-certificate.sh b/Scripts/add-apple-certificate.sh new file mode 100755 index 00000000..bada240a --- /dev/null +++ b/Scripts/add-apple-certificate.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +root_dir="$1" +keychain_password="$2" +apple_certificate_base64="$3" +apple_certificate_password="$4" + + +if [[ -z $root_dir || -z $keychain_password || -z $apple_certificate_base64 || -z $apple_certificate_password ]] +then + echo "[!] Usage: $0 " + exit 1 +fi + +keychain_path="$root_dir/app-signing.keychain-db" +apple_certificate="$root_dir/certificate.p12" + +echo -n "$apple_certificate_base64" | base64 --decode -o "$apple_certificate" + +# Create a temporary keychain (https://docs.github.com/en/actions/using-github-hosted-runners/using-github-hosted-runners/about-github-hosted-runners) +security create-keychain -p "$keychain_password" "$keychain_path" +security set-keychain-settings -lut 21600 "$keychain_path" +security unlock-keychain -p "$keychain_password" "$keychain_path" + +# Import certificate +security import "$apple_certificate" -k "$keychain_path" -P "$apple_certificate_password" -A -t cert -f pkcs12 +# Authorize access to certificate private key +security set-key-partition-list -S apple-tool:,apple: -k "$keychain_password" "$keychain_path" +# Set the default keychain +security list-keychain -d user -s "$keychain_path" \ No newline at end of file diff --git a/Scripts/check-quality.sh b/Scripts/check-quality.sh index 92257992..e31a467b 100755 --- a/Scripts/check-quality.sh +++ b/Scripts/check-quality.sh @@ -2,6 +2,9 @@ set -e +eval "$(pkgx --shellcode)" +env +swiftlint +rubocop +shellcheck +markdownlint +yamllint + echo "... checking Swift code..." if [ $# -eq 0 ]; then swiftlint --quiet --strict @@ -9,7 +12,7 @@ elif [[ "$1" == "only-changes" ]]; then git diff --staged --name-only | grep ".swift$" | xargs swiftlint lint --quiet --strict fi echo "... checking Ruby scripts..." -bundle exec rubocop --format quiet +rubocop --format quiet echo "... checking Shell scripts..." shellcheck Scripts/*.sh hooks/* Artifacts/**/*.sh echo "... checking Markdown documentation..." diff --git a/Scripts/checkout-configuration.sh b/Scripts/checkout-configuration.sh deleted file mode 100755 index fdc5a858..00000000 --- a/Scripts/checkout-configuration.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash - -# This script attempts to checkout a configuration repository URL and to switch to a specific commit. - -CONFIGURATION_REPOSITORY_URL=$1 -CONFIGURATION_COMMIT_SHA1=$2 -CONFIGURATION_FOLDER=$3 - -if [[ -z "$CONFIGURATION_REPOSITORY_URL" ]]; then - echo "A configuration repository URL must be provided." - exit 1 -fi - -if [[ -z "$CONFIGURATION_COMMIT_SHA1" ]]; then - echo "A configuration commit SHA1 must be provided." - exit 1 -fi - -if [[ -z "$CONFIGURATION_FOLDER" ]]; then - echo "A configuration destination folder must be provided." - exit 1 -fi - -if [[ ! -d "$CONFIGURATION_FOLDER" ]]; then - if git clone "$CONFIGURATION_REPOSITORY_URL" "$CONFIGURATION_FOLDER" &> /dev/null; then - echo "Private configuration details were successfully cloned under the '$CONFIGURATION_FOLDER' folder." - else - echo "Your GitHub account cannot access private project configuration details. Skipped." - exit 0 - fi -else - echo "A '$CONFIGURATION_FOLDER' folder is already available." -fi - -pushd "$CONFIGURATION_FOLDER" > /dev/null || exit - -if ! git status &> /dev/null; then - echo "The '$CONFIGURATION_FOLDER' folder is not a valid git repository." - exit 1 -fi - -if [[ $(git status --porcelain 2> /dev/null) ]]; then - echo "The repository '$CONFIGURATION_FOLDER' contains changes. Please commit or discard these changes and retry." - exit 1 -fi - -git fetch &> /dev/null - -if git checkout -q "$CONFIGURATION_COMMIT_SHA1" &> /dev/null; then - echo "The '$CONFIGURATION_FOLDER' repository has been switched to commit $CONFIGURATION_COMMIT_SHA1." -else - echo "The repository '$CONFIGURATION_FOLDER' could not be switched to commit $CONFIGURATION_COMMIT_SHA1. Does this commit exist?" - exit 1 -fi - -popd > /dev/null || exit - -ln -fs "$CONFIGURATION_FOLDER/.env" . diff --git a/Scripts/configure-environment.sh b/Scripts/configure-environment.sh new file mode 100755 index 00000000..11f14161 --- /dev/null +++ b/Scripts/configure-environment.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +apple_api_key_b64="$1" + +if [[ -z $apple_api_key_b64 ]] +then + echo "[!] Usage: $0 " + exit 1 +fi + +mkdir -p Configuration +echo "$apple_api_key_b64" | base64 --decode > Configuration/AppStoreConnect_API_Key.p8 diff --git a/Scripts/test-streams.sh b/Scripts/test-streams.sh index a1d13df7..d3c29283 100755 --- a/Scripts/test-streams.sh +++ b/Scripts/test-streams.sh @@ -3,6 +3,9 @@ SCRIPT_NAME=$(basename "$0") SCRIPT_DIR=$(dirname "$0") +eval "$(pkgx --shellcode)" +env +python +ffmpeg +packager + GENERATED_DIR="/tmp/pillarbox" METADATA_DIR="$SCRIPT_DIR/../metadata" @@ -14,8 +17,8 @@ function serve_test_streams { kill_test_streams "$dest_dir" - if ! command -v python3 &> /dev/null; then - echo "python3 could not be found" + if ! command -v python &> /dev/null; then + echo "python could not be found" exit 1 fi @@ -89,7 +92,7 @@ function generate_packaged_streams { mkdir -p "$dest_dir" local on_demand_with_options_dir="$dest_dir/on_demand_with_options" - shaka-packager \ + packager \ "in=$src_dir/source_640x360.mp4,stream=video,segment_template=$on_demand_with_options_dir/640x360/\$Number\$.ts" \ "in=$src_dir/source_audio_eng.mp4,stream=audio,segment_template=$on_demand_with_options_dir/audio_eng/\$Number\$.ts,lang=en,hls_name=English" \ "in=$src_dir/source_audio_fre.mp4,stream=audio,segment_template=$on_demand_with_options_dir/audio_fre/\$Number\$.ts,lang=fr,hls_name=Français" \ @@ -100,19 +103,19 @@ function generate_packaged_streams { --hls_master_playlist_output "$on_demand_with_options_dir/master.m3u8" > /dev/null 2>&1 local on_demand_without_options_dir="$dest_dir/on_demand_without_options" - shaka-packager \ + packager \ "in=$src_dir/source_640x360.mp4,stream=video,segment_template=$on_demand_without_options_dir/640x360/\$Number\$.ts" \ --hls_master_playlist_output "$on_demand_without_options_dir/master.m3u8" > /dev/null 2>&1 local on_demand_with_single_audible_option_dir="$dest_dir/on_demand_with_single_audible_option" - shaka-packager \ + packager \ "in=$src_dir/source_640x360.mp4,stream=video,segment_template=$on_demand_with_single_audible_option_dir/640x360/\$Number\$.ts" \ "in=$src_dir/source_audio_eng.mp4,stream=audio,segment_template=$on_demand_with_single_audible_option_dir/audio_eng/\$Number\$.ts,lang=en,hls_name=English" \ --hls_master_playlist_output "$on_demand_with_single_audible_option_dir/master.m3u8" > /dev/null 2>&1 } function serve_directory { - python3 -m http.server 8123 --directory "$1" > /dev/null 2>&1 & + python -m http.server 8123 --directory "$1" > /dev/null 2>&1 & } function kill_test_streams { diff --git a/Sources/Analytics/Analytics.docc/events-article.md b/Sources/Analytics/Analytics.docc/events-article.md index e5cea504..b49c1765 100644 --- a/Sources/Analytics/Analytics.docc/events-article.md +++ b/Sources/Analytics/Analytics.docc/events-article.md @@ -10,7 +10,7 @@ Better understand how your app functionalities are used. As a product team you need to better understand which features are popular and which ones aren't. -The PillarboxAnalytics framework provides a way to send arbitrary events so that your analysts can better understand your users and help you lead your product in the right direction. +The ``PillarboxAnalytics`` framework provides a way to send arbitrary events so that your analysts can better understand your users and help you lead your product in the right direction. > Important: Tracking must be properly setup first. Please refer to for more information. diff --git a/Sources/Analytics/Analytics.docc/page-views-article.md b/Sources/Analytics/Analytics.docc/page-views-article.md index dc52dffc..0f907199 100644 --- a/Sources/Analytics/Analytics.docc/page-views-article.md +++ b/Sources/Analytics/Analytics.docc/page-views-article.md @@ -8,7 +8,7 @@ Identify where users navigate within your app. ## Overview -As a product team you need to better understand where users navigate within your app. The PillarboxAnalytics framework provides a way to track views as they are brought on screen. This makes it possible to improve on user journeys and make your content more discoverable. +As a product team you need to better understand where users navigate within your app. The ``PillarboxAnalytics`` framework provides a way to track views as they are brought on screen. This makes it possible to improve on user journeys and make your content more discoverable. > Important: Tracking must be properly setup first. Please refer to for more information. @@ -64,7 +64,7 @@ private extension HomeView { ### Track page views in UIKit -View controllers commonly represent screens in a UIKit application. The PillarboxAnalytics framework provides a streamlined way to associate page view data with a view controller by having it conform to the ``PageViewTracking`` protocol: +View controllers commonly represent screens in a UIKit application. The ``PillarboxAnalytics`` framework provides a streamlined way to associate page view data with a view controller by having it conform to the ``PageViewTracking`` protocol: ```swift final class HomeViewController: UIViewController { @@ -103,7 +103,7 @@ Only a container can namely decide for which child (or children) page views shou - If page views must be automatically forwarded to all children of a container no additional work is required. - If page views must be automatically forwarded to only selected children, though, then a container must conform to the `ContainerPageViewTracking` protocol to declare which children must be considered active for measurements. -> Tip: The PillarboxAnalytics framework provides native support for standard UIKit containers without any additional work. +> Tip: The ``PillarboxAnalytics`` framework provides native support for standard UIKit containers without any additional work. ### Trigger page views manually diff --git a/Sources/Analytics/Analytics.docc/user-consent-article.md b/Sources/Analytics/Analytics.docc/user-consent-article.md index b2cf019c..73931f61 100644 --- a/Sources/Analytics/Analytics.docc/user-consent-article.md +++ b/Sources/Analytics/Analytics.docc/user-consent-article.md @@ -8,9 +8,9 @@ Take into account user choices about the data they are willing to share. ## Overview -The PillarboxAnalytics framework does not directly implement user consent management but provides a way to forward user consent choices to the Commanders Act and comScore SDKs so that user wishes can be properly taken into account at the data processing level. +The ``PillarboxAnalytics`` framework does not directly implement user consent management but provides a way to forward user consent choices to the Commanders Act and comScore SDKs so that user wishes can be properly taken into account at the data processing level. -> Note: Do not worry if you observe analytics-related network traffic with a proxy tool like [Charles](https://www.charlesproxy.com). The PillarboxAnalytics framework still sends data but user consent is transmitted in each request payload for server-side processing. +> Note: Do not worry if you observe analytics-related network traffic with a proxy tool like [Charles](https://www.charlesproxy.com). The ``PillarboxAnalytics`` framework still sends data but user consent is transmitted in each request payload for server-side processing. ### Setup an analytics data source diff --git a/Sources/Core/View.swift b/Sources/Core/View.swift index bc2a4d11..3c54926b 100644 --- a/Sources/Core/View.swift +++ b/Sources/Core/View.swift @@ -14,16 +14,30 @@ public extension View { /// - publisher: The publisher to subscribe to. /// - keyPath: The key path to extract. /// - binding: The binding to which values must be assigned. - /// - Returns: A view that fills the given binding when the `publisher` emits an event. func onReceive( _ publisher: P, assign keyPath: KeyPath, to binding: Binding ) -> some View where P: Publisher, P.Failure == Never, T: Equatable { - onReceive(publisher.slice(at: keyPath).receiveOnMainThread()) { output in - if binding.wrappedValue != output { - binding.wrappedValue = output - } + onReceive(publisher.slice(at: keyPath).receiveOnMainThread()) { output in + if binding.wrappedValue != output { + binding.wrappedValue = output } + } + } + + /// Observes values emitted by the given publisher at the specified key path. + /// + /// - Parameters: + /// - publisher: The publisher to subscribe to. + /// - keyPath: The key path to extract. + /// - action: A closure to run when the value changes, executed on the main thread. The action is not called for + /// the first value that the publisher might provide upon subscription. + func onReceive( + _ publisher: P, + at keyPath: KeyPath, + perform action: @escaping (T) -> Void + ) -> some View where P: Publisher, P.Failure == Never, T: Equatable { + onReceive(publisher.slice(at: keyPath).dropFirst().receiveOnMainThread(), perform: action) } } diff --git a/Sources/Player/Player.docc/Articles/optimization/optimization-article.md b/Sources/Player/Player.docc/Articles/optimization/optimization-article.md new file mode 100644 index 00000000..15cef357 --- /dev/null +++ b/Sources/Player/Player.docc/Articles/optimization/optimization-article.md @@ -0,0 +1,62 @@ +# Optimization + +@Metadata { + @PageColor(purple) + @PageImage(purpose: card, source: optimization-card, alt: "An image depicting a speedometer.") +} + +Avoid wasting system resources unnecessarily. + +## Overview + +Applications must use system resources—such as CPU, memory, and network bandwidth—efficiently to avoid excessive memory consumption (which could lead to app termination) and to conserve battery life. + +Media playback is frequently highlighted in product specifications as a key indicator of battery performance. This is no coincidence, as activities like video streaming involve significant resource demands: the network interface must wake periodically to download content, the processor must decode it, and the screen must remain active to display it to the user. + +By ensuring your application manages resources responsibly, especially when handling video or audio playback, you enhance the user experience. Not only will users be able to enjoy your content for longer, but they’ll also extend their device’s battery life, enabling more use on a single charge. + +This article discusses a few strategies to reduce resource consumption associated with ``PillarboxPlayer`` in your application. + +## Profile your application + +"We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%." +– Donald Knuth + +Use Instruments to identify optimization opportunities. Focus on the following areas: + +- **Allocations Instrument**: Analyze memory usage to identify excessive consumption associated with your application process. You can filter allocations, such as with the keyword _player_, to pinpoint playback-related resources and verify that their count aligns with your expectations. +- **Time Profiler Instrument**: Detect unusual CPU activity and identify potential bottlenecks in your application's performance. +- **Animation Hitches Instrument**: Investigate frame rate hiccups, particularly when players are displayed in scrollable views, to ensure smooth user interactions. +- **Activity Monitor Instrument**: +Since media playback occurs out-of-process through dedicated media services daemons (e.g., _mediaplaybackd_), use Activity Monitor to analyze their CPU and memory usage. Filter for daemons with names containing _media_ to focus on relevant processes. + +To gain a comprehensive understanding of your application's memory and CPU usage, you should therefore evaluate not only its own process but also the media service daemons it interacts with. + +## Restrict the number of players loaded with content + +An empty ``Player`` instance is lightweight, but once loaded with content, it interacts with media service daemons to handle playback. The more ``Player`` instances your application loads simultaneously, the more CPU, memory, and potentially network resources are therefore consumed. + +To minimize resource usage, aim to keep the number of ``Player`` instances loaded with content as low as possible. Consider these strategies: + +- **Implement a Player Pool**: Instead of creating a new player instance for every need, maintain a pool of reusable players. Borrow a player from the pool when needed and return it when done. +- **Clear Unused Players**: Use ``Player/removeAllItems()`` to empty a player's item queue without destroying the player instance. To reload previously played content, use ``PlayerItemConfiguration/position`` to resume playback from where it was last interrupted. +- **Leverage Thumbnails**: Display thumbnails representing the first frame or video content to create the illusion of instant playback without loading the actual video. This approach is especially effective in scrollable lists with autoplay functionality. +- **Limit Buffering**: Control the player's buffering behavior by setting ``PlayerItemConfiguration/preferredForwardBufferDuration`` in a ``PlayerItem`` configuration. While the default buffering can be quite aggressive, reducing the buffer duration lowers memory usage but increases the likelihood of playback stalling and re-buffering. Use this setting judiciously to balance resource usage and playback stability. + +## Implement autoplay wisely + +Autoplay is a common feature, but its implementation requires careful consideration, as it can lead to unnecessary resource consumption. While ``PillarboxPlayer`` does not provide dedicated APIs for autoplay, if you plan to implement this functionality, consider the following best practices to enhance the user experience: + +- **Make Autoplay Optional**: Always provide a setting to disable autoplay. Some users may find this feature intrusive and prefer to turn it off. Offering this option is not only user-friendly but also environmentally conscious, as it helps conserve resources. +- **Disable Autoplay in Poor Conditions**: Automatically disable autoplay when the user is connected to a mobile network or when [Low Data](https://support.apple.com/en-is/102433) or [Low Power](https://support.apple.com/en-us/101604) modes are enabled. This ensures a better experience by reducing resource consumption in constrained conditions. + +## Configure players to minimize resource usage + +``PillarboxPlayer`` provides settings to help reduce resource usage. Consider the following available options: + +- **Disable Non-Essential Players in Low Data Mode**: Players that serve a purely decorative purpose can be automatically disabled when [Low Data Mode](https://support.apple.com/en-is/102433) is enabled. To do this, set ``PlayerConfiguration/allowsConstrainedNetworkAccess`` to `false` in the configuration provided at player creation. +- **Provide a Quality Selector**: Users may want to reduce network bandwidth usage for economical or environmental reasons. Consider offering a range of ``PlayerLimits`` that users can choose from, allowing them to select the best option for their needs. + +## Optimize user interface refreshes + +``PillarboxPlayer`` offers various tools to optimize view layouts in response to . For detailed guidance, please refer to the tutorial. diff --git a/Sources/Player/Player.docc/Articles/optimization/optimization-card.jpg b/Sources/Player/Player.docc/Articles/optimization/optimization-card.jpg new file mode 100644 index 00000000..e2406bf2 Binary files /dev/null and b/Sources/Player/Player.docc/Articles/optimization/optimization-card.jpg differ diff --git a/Sources/Player/Player.docc/Articles/playback/playback-article.md b/Sources/Player/Player.docc/Articles/playback/playback-article.md index ff77c469..74347278 100644 --- a/Sources/Player/Player.docc/Articles/playback/playback-article.md +++ b/Sources/Player/Player.docc/Articles/playback/playback-article.md @@ -15,7 +15,7 @@ Use a ``Player`` to play of one or several items sequentially and be automatical ## Create a Player -You create a player with or without associated items to be played. Since ``Player`` is an [`ObservableObject`](https://developer.apple.com/documentation/combine/observableobject) you usually store an instance as a [`StateObject`](https://developer.apple.com/documentation/swiftui/stateobject) belonging to some [SwiftUI](https://developer.apple.com/documentation/swiftui) view. This not only ensures that the instance remains available for the duration of the view, but also that the view body is automatically updated when the state of the player changes. +You can create a player with or without associated items to be played. Since ``Player`` is an [`ObservableObject`](https://developer.apple.com/documentation/combine/observableobject) you must store an instance as a [`StateObject`](https://developer.apple.com/documentation/swiftui/stateobject) belonging to some [SwiftUI](https://developer.apple.com/documentation/swiftui) view. This not only ensures that the instance remains available for the lifetime of the view, but also that the view body is automatically updated when the state of the player changes. @TabNavigator { @Tab("Empty") { @@ -58,7 +58,7 @@ You create a player with or without associated items to be played. Since ``Playe ## Configure the player -The player can be customized during the instantiation phase by providing a dedicated ``PlayerConfiguration`` object. It is important to note that the configuration is set only at the time of instantiation and remains constant throughout the player's entire life cycle. +The player can be customized during the instantiation phase by providing a dedicated ``PlayerConfiguration`` object. The configuration is set at creation time and cannot be changed afterwards. ## Load custom content diff --git a/Sources/Player/Player.docc/Articles/state-observation/state-observation-article.md b/Sources/Player/Player.docc/Articles/state-observation/state-observation-article.md index b8584c0a..9e0c7201 100644 --- a/Sources/Player/Player.docc/Articles/state-observation/state-observation-article.md +++ b/Sources/Player/Player.docc/Articles/state-observation/state-observation-article.md @@ -9,7 +9,7 @@ Learn how to observe state associated with a player. ## Overview -The PillarboxPlayer framework heavily relies on [Combine](https://developer.apple.com/documentation/combine), [`ObservableObject`](https://developer.apple.com/documentation/combine/observableobject) and published properties. This makes it possible for SwiftUI views to automatically observe and respond to change. +The ``PillarboxPlayer`` framework heavily relies on [Combine](https://developer.apple.com/documentation/combine), [`ObservableObject`](https://developer.apple.com/documentation/combine/observableobject) and published properties. This makes it possible for SwiftUI views to automatically observe and respond to change. Unsupervised property publishing can lead to an explosion of updates sent from an observable object, though, leading to potentially unnecessary SwiftUI view body refreshes, poor layout performance and energy consumption issues. @@ -47,7 +47,7 @@ For this reason ``Player`` does not publish time updates automatically. Explicit - Use ``Player/periodicTimePublisher(forInterval:queue:)`` for periodic time updates. - Use ``Player/boundaryTimePublisher(for:queue:)`` to detect time traversal. -When implementing a user interface, you should rather use ``ProgressTracker`` to observe progress changes without the need for explicit time update subscription. +When implementing a user interface you should rather use ``ProgressTracker`` to observe progress changes without the need for explicit time update subscription. ### Explicitly subscribe to frequent updates @@ -76,6 +76,28 @@ struct PlaybackView: View { Check ``PlayerProperties`` for the list of all properties that are available for explicit observation. +### Respond to state updates + +You can respond to state updates at the view level using the ``SwiftUICore/View/onReceive(player:at:perform:)`` modifier. This can be useful to trigger actions when some specific state is reached, for example when ending playback of an item: + +```swift +struct PlaybackView: View { + @StateObject private var player = Player( + item: .simple(url: URL(string: "https://www.server.com/master.m3u8")!) + ) + + var body: some View { + ZStack { + VideoView(player: player) + } + .onReceive(player: player, at: \.playbackState) { playbackState in + guard playbackState == .ended else { return } + // ... + } + } +} +``` + ### Use SwiftUI property wrappers wisely With SwiftUI it is especially important to [properly annotate](https://developer.apple.com/documentation/swiftui/model-data) properties so that changes to your models correctly drive updates to your user interface. diff --git a/Sources/Player/Player.docc/Articles/stream-encoding-and-packaging-advice-article/stream-encoding-and-packaging-advice-article.md b/Sources/Player/Player.docc/Articles/stream-encoding-and-packaging-advice-article/stream-encoding-and-packaging-advice-article.md index aaad662f..9ab7b319 100644 --- a/Sources/Player/Player.docc/Articles/stream-encoding-and-packaging-advice-article/stream-encoding-and-packaging-advice-article.md +++ b/Sources/Player/Player.docc/Articles/stream-encoding-and-packaging-advice-article/stream-encoding-and-packaging-advice-article.md @@ -4,13 +4,13 @@ @PageColor(purple) } -Encode and package streams for optimal compatibility with the PillarboxPlayer framework. +Encode and package streams for optimal compatibility with the ``PillarboxPlayer`` framework. ## Overview Apple provides HLS [authoring specifications](https://developer.apple.com/documentation/http_live_streaming/hls_authoring_specification_for_apple_devices/) regarding encoding and packaging best practices for compatibility with Apple devices. These specifications cover compatible codecs, encoding profiles or recommended encoding ladders, among many other topics. -For optimal playback experience with the PillarboxPlayer framework some of these specifications need to be followed rigorously. This article covers these specific requirements in more detail and provides more information about how streams can be tested for compatibility with PillarboxPlayer. +For optimal playback experience with the ``PillarboxPlayer`` framework some of these specifications need to be followed rigorously. This article covers these specific requirements in more detail and provides more information about how streams can be tested for compatibility with ``PillarboxPlayer``. > Note: More information about automatic media selection is available from . @@ -60,7 +60,7 @@ Note that I-frame playlists are a [must-have](https://developer.apple.com/docume ### Inspecting and testing streams -Several tools are available for stream encoding and packaging teams to check that the streams they deliver work well with PillarboxPlayer. +Several tools are available for stream encoding and packaging teams to check that the streams they deliver work well with ``PillarboxPlayer``. #### HTTP Live Streaming Tools @@ -88,7 +88,7 @@ HLS streams can always be tested with a variety of native players, most notably: - Safari on iOS / iPadOS. - QuickTime Player on macOS. -> Important: Streams that cannot be correctly played with Apple official players will almost certainly fail to play correctly with PillarboxPlayer. +> Important: Streams that cannot be correctly played with Apple official players will almost certainly fail to play correctly with ``PillarboxPlayer``. #### 3rd party players diff --git a/Sources/Player/Player.docc/Articles/tracking/tracking-article.md b/Sources/Player/Player.docc/Articles/tracking/tracking-article.md index 8061eba8..6849c599 100644 --- a/Sources/Player/Player.docc/Articles/tracking/tracking-article.md +++ b/Sources/Player/Player.docc/Articles/tracking/tracking-article.md @@ -9,7 +9,7 @@ Track player items during playback. ## Overview -The PillarboxPlayer framework offers a way to track an item during playback. This mechanism is mostly useful to gather analytics, perform Quality of Experience (QoE) and Quality of Service (QoS) monitoring or save the current playback position into a local history, for example. +The ``PillarboxPlayer`` framework offers a way to track an item during playback. This mechanism is mostly useful to gather analytics, perform Quality of Experience (QoE) and Quality of Service (QoS) monitoring or save the current playback position into a local history, for example. You define which data is required by a tracker as well as its life cycle by creating a new class type and conforming it to the ``PlayerItemTracker`` protocol. This can be achieved in a few steps discussed below. diff --git a/Sources/Player/Player.docc/PillarboxPlayer.md b/Sources/Player/Player.docc/PillarboxPlayer.md index 6319d630..326b7052 100644 --- a/Sources/Player/Player.docc/PillarboxPlayer.md +++ b/Sources/Player/Player.docc/PillarboxPlayer.md @@ -26,6 +26,7 @@ The PillarboxPlayer framework fully integrates with SwiftUI, embracing its decla - - - + - } ### Asset Resource Loading diff --git a/Sources/Player/UserInterface/PictureInPictureButton.swift b/Sources/Player/UserInterface/PictureInPictureButton.swift index 3b3dd5b3..c688e991 100644 --- a/Sources/Player/UserInterface/PictureInPictureButton.swift +++ b/Sources/Player/UserInterface/PictureInPictureButton.swift @@ -25,6 +25,7 @@ public struct PictureInPictureButton: View where Content: View { Button(action: PictureInPicture.shared.custom.toggle) { content(isActive) } + .hoverEffect() .onReceive(PictureInPicture.shared.custom.$isActive) { isActive = $0 } } } diff --git a/Sources/Player/UserInterface/View.swift b/Sources/Player/UserInterface/View.swift index d2acad79..9b0933de 100644 --- a/Sources/Player/UserInterface/View.swift +++ b/Sources/Player/UserInterface/View.swift @@ -13,8 +13,6 @@ public extension View { /// - player: The player. /// - keyPath: The key path to extract. /// - binding: The binding to which the value must be assigned. - /// - Returns: A view that fills the given binding when the player's publisher emits an - /// event. /// /// > Warning: Be careful to associate often updated state to local view scopes to avoid unnecessary view body refreshes. Please /// refer to for more information. @@ -28,6 +26,24 @@ public extension View { } } + /// Observes values emitted by the given player's publisher. + /// + /// - Parameters: + /// - player: The player. + /// - keyPath: The key path to extract. + /// - action: A closure to run when the value changes. + @ViewBuilder + func onReceive(player: Player?, at keyPath: KeyPath, perform action: @escaping (T) -> Void) -> some View where T: Equatable { + if let player { + onReceive(player.propertiesPublisher, at: keyPath, perform: action) + } + else { + self + } + } +} + +public extension View { /// Enable in-app Picture in Picture support. /// /// - Parameter persistable: The object to persist during Picture in Picture. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index da9a2d4a..fbbe0a84 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -19,7 +19,7 @@ TESTFLIGHT_PLATFORMS = { }.freeze DEVICES = { - ios: 'iPhone 15', + ios: 'iPhone 16', tvos: 'Apple TV' }.freeze @@ -176,8 +176,9 @@ def deliver_demo_nightly(platform_id) add_version_badge(platform_id, last_git_tag, build_number, 'orange') build_and_sign_app(platform_id, :nightly) reset_git_repo(skip_clean: true) - upload_app_to_testflight - distribute_app_to_testers(platform_id, :nightly, build_number) + login_to_app_store_connect + # upload_app_to_testflight + # distribute_app_to_testers(platform_id, :nightly, build_number) end def deliver_demo_release(platform_id) diff --git a/hooks/pre-commit b/hooks/pre-commit index 0c60fb90..b9a31b50 100755 --- a/hooks/pre-commit +++ b/hooks/pre-commit @@ -1,11 +1,6 @@ #!/bin/sh -#================================================================ # Quality check -#================================================================ - -PATH="$(which swiftlint):$(which ruby):$PATH" - if Scripts/check-quality.sh only-changes; then echo "✅ Quality checked" else