diff --git a/.github/workflows/alpha.yml b/.github/workflows/alpha.yml index f1813baa..5955c824 100644 --- a/.github/workflows/alpha.yml +++ b/.github/workflows/alpha.yml @@ -17,7 +17,7 @@ jobs: bundle_id: "com.SideStore.SideStore" # bundle_id_suffix: ".Alpha" is_beta: true - publish: true + publish: ${{ vars.PUBLISH_ALPHA_UPDATES == 'true' }} is_shared_build_num: false release_tag: "alpha" release_name: "Alpha" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 967f6056..3f4c7952 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -70,7 +70,7 @@ jobs: bundle_id: "com.SideStore.SideStore" # bundle_id_suffix: ".Nightly" is_beta: true - publish: true + publish: ${{ vars.PUBLISH_NIGHTLY_UPDATES == 'true' }} is_shared_build_num: false release_tag: "nightly" release_name: "Nightly" diff --git a/.github/workflows/reusable-build-workflow.yml b/.github/workflows/reusable-build-workflow.yml index 7e51fef3..ee439d2c 100644 --- a/.github/workflows/reusable-build-workflow.yml +++ b/.github/workflows/reusable-build-workflow.yml @@ -1,4 +1,5 @@ name: Reusable SideStore Build + on: workflow_call: inputs: @@ -44,10 +45,28 @@ on: required: false jobs: - build: - name: Build and upload SideStore ${{ inputs.release_tag }} releases + serialize: + name: Wait for other jobs concurrency: group: build-number-increment # serialize for build num cache access + strategy: + fail-fast: false + runs-on: 'macos-15' + steps: + - run: echo "No other contending jobs are running now...Build is ready to start" + - name: Set short commit hash + id: commit-id + run: | + # SHORT_COMMIT="${{ github.sha }}" + SHORT_COMMIT=${GITHUB_SHA:0:7} + echo "Short commit hash: $SHORT_COMMIT" + echo "SHORT_COMMIT=$SHORT_COMMIT" >> $GITHUB_OUTPUT + outputs: + short-commit: ${{ steps.commit-id.outputs.SHORT_COMMIT }} + + build: + name: Build SideStore - ${{ inputs.release_tag }} + needs: serialize strategy: fail-fast: false matrix: @@ -56,8 +75,12 @@ jobs: version: '16.1' runs-on: ${{ matrix.os }} - steps: + outputs: + version: ${{ steps.version.outputs.version }} + marketing-version: ${{ steps.marketing-version.outputs.MARKETING_VERSION }} + release-channel: ${{ steps.release-channel.outputs.RELEASE_CHANNEL }} + steps: - name: Set beta status run: echo "IS_BETA=${{ inputs.is_beta }}" >> $GITHUB_ENV @@ -67,7 +90,8 @@ jobs: submodules: recursive - name: Install dependencies - ldid & xcbeautify - run: brew install ldid xcbeautify + run: | + brew install ldid xcbeautify - name: Set ref based on is_shared_build_num if: ${{ inputs.is_beta }} @@ -87,7 +111,7 @@ jobs: ref: ${{ env.ref }} token: ${{ secrets.CROSS_REPO_PUSH_KEY }} path: 'SideStore/beta-build-num' - + - name: Copy build_number.txt to repo root if: ${{ inputs.is_beta }} run: | @@ -99,13 +123,16 @@ jobs: run: | echo "cat Build.xcconfig" cat Build.xcconfig - + - name: Set Release Channel info for build number bumper + id: release-channel run: | - echo "RELEASE_CHANNEL=${{ inputs.release_tag }}" >> $GITHUB_ENV + RELEASE_CHANNEL="${{ inputs.release_tag }}" + echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}" >> $GITHUB_ENV + echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}" >> $GITHUB_OUTPUT echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}" - + - name: Increase build number for beta builds if: ${{ inputs.is_beta }} run: | @@ -118,15 +145,9 @@ jobs: echo "version=$version" >> $GITHUB_OUTPUT echo "version=$version" - - name: Get short commit hash - run: | - # SHORT_COMMIT="${{ github.sha }}" - SHORT_COMMIT=${GITHUB_SHA:0:7} - echo "Short commit hash: $SHORT_COMMIT" - echo "SHORT_COMMIT=$SHORT_COMMIT" >> $GITHUB_ENV - - name: Set MARKETING_VERSION if: ${{ inputs.is_beta }} + id: marketing-version run: | # Extract version number (e.g., "0.6.0") version=$(echo "${{ steps.version.outputs.version }}" | sed -E 's/^[^0-9]*([0-9]+\.[0-9]+\.[0-9]+).*/\1/') @@ -136,9 +157,10 @@ jobs: build_num=$(echo "${{ steps.version.outputs.version }}" | sed -E 's/.*\.([0-9]+)\+.*/\1/') # Combine them into the final output - MARKETING_VERSION="${version}-${date}.${build_num}+${SHORT_COMMIT}" + MARKETING_VERSION="${version}-${date}.${build_num}+${{ needs.serialize.outputs.short-commit }}" echo "MARKETING_VERSION=$MARKETING_VERSION" >> $GITHUB_ENV + echo "MARKETING_VERSION=$MARKETING_VERSION" >> $GITHUB_OUTPUT echo "MARKETING_VERSION=$MARKETING_VERSION" - name: Echo Updated Build.xcconfig, build_number.txt @@ -152,16 +174,16 @@ jobs: with: xcode-version: ${{ matrix.version }} - - name: Cache Build + - name: (Build) Cache Build uses: irgaly/xcode-cache@v1 with: - key: xcode-cache-deriveddata-${{ github.sha }} - restore-keys: xcode-cache-deriveddata- - swiftpm-cache-key: xcode-cache-sourcedata-${{ github.sha }} + key: xcode-cache-deriveddata-build-${{ github.sha }} + restore-keys: xcode-cache-deriveddata-build- + swiftpm-cache-key: xcode-cache-sourcedata-build-${{ github.sha }} swiftpm-cache-restore-keys: | - xcode-cache-sourcedata- + xcode-cache-sourcedata-build- - - name: Restore Pods from Cache (Exact match) + - name: (Build) Restore Pods from Cache (Exact match) id: pods-restore uses: actions/cache/restore@v3 with: @@ -169,12 +191,12 @@ jobs: ./Podfile.lock ./Pods/ ./AltStore.xcworkspace/ - key: pods-cache-${{ hashFiles('Podfile') }} + key: pods-cache-build-${{ hashFiles('Podfile') }} # restore-keys: | # commented out to strictly check cache for this particular podfile # pods-cache- - - - name: Restore Pods from Cache (Last Available) - if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} + + - name: (Build) Restore Pods from Cache (Last Available) + if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} id: pods-restore-recent uses: actions/cache/restore@v3 with: @@ -182,13 +204,13 @@ jobs: ./Podfile.lock ./Pods/ ./AltStore.xcworkspace/ - key: pods-cache- + key: pods-cache-build- - - name: Install CocoaPods + - name: (Build) Install CocoaPods run: pod install - - name: Save Pods to Cache + - name: (Build) Save Pods to Cache id: save-pods if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} uses: actions/cache/save@v3 @@ -197,9 +219,16 @@ jobs: ./Podfile.lock ./Pods/ ./AltStore.xcworkspace/ - key: pods-cache-${{ hashFiles('Podfile') }} + key: pods-cache-build-${{ hashFiles('Podfile') }} - - name: List Files and derived data + - name: (Build) Clean previous build artifacts + # using 'tee' to intercept stdout and log for detailed build-log + run: | + make clean + mkdir -p build/logs + + - name: (Build) List Files and derived data + if: always() run: | echo ">>>>>>>>> Workdir <<<<<<<<<<" ls -la . @@ -221,23 +250,205 @@ jobs: ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists echo "" - - name: Set BundleID Suffix for Sidestore build run: | echo "BUNDLE_ID_SUFFIX=${{ inputs.bundle_id_suffix }}" >> $GITHUB_ENV - - name: Build SideStore + + - name: Build SideStore.xcarchive # using 'tee' to intercept stdout and log for detailed build-log run: | - NSUnbufferedIO=YES make build 2>&1 | tee build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]} + NSUnbufferedIO=YES make -B build 2>&1 | tee -a build/logs/build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]} - name: Fakesign app - run: make fakesign | tee -a build.log + run: make fakesign | tee -a build/logs/build.log - name: Convert to IPA - run: make ipa | tee -a build.log + run: make ipa | tee -a build/logs/build.log - - name: Encrypt build.log generated from SideStore build for upload + - name: (Build) List Files and Build artifacts + run: | + echo ">>>>>>>>> Workdir <<<<<<<<<<" + ls -la . + echo "" + + echo ">>>>>>>>> Build <<<<<<<<<<" + find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists + echo "" + + echo ">>>>>>>>> SideStore <<<<<<<<<<" + find SideStore -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists + echo "" + + echo ">>>>>>>>> SideStore.xcarchive <<<<<<<<<<" + find SideStore.xcarchive -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists + echo "" + + - name: Encrypt build-logs for upload + id: encrypt-build-log + run: | + DEFAULT_BUILD_LOG_PASSWORD=12345 + + BUILD_LOG_ZIP_PASSWORD=${{ secrets.BUILD_LOG_ZIP_PASSWORD }} + BUILD_LOG_ZIP_PASSWORD=${BUILD_LOG_ZIP_PASSWORD:-$DEFAULT_BUILD_LOG_PASSWORD} + + if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then + echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'." + fi + + pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-build-logs.zip * || popd + echo "::set-output name=encrypted::true" + + - name: Upload encrypted-build-logs.zip + id: attach-encrypted-build-log + if: ${{ always() && steps.encrypt-build-log.outputs.encrypted == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: encrypted-build-logs-${{ steps.version.outputs.version }}.zip + path: encrypted-build-logs.zip + + - name: Upload SideStore.ipa Artifact + uses: actions/upload-artifact@v4 + with: + name: SideStore-${{ steps.version.outputs.version }}.ipa + path: SideStore.ipa + + - name: Zip dSYMs + run: zip -r -9 ./SideStore.dSYMs.zip ./SideStore.xcarchive/dSYMs + + - name: Upload *.dSYM Artifact + uses: actions/upload-artifact@v4 + with: + name: SideStore-${{ steps.version.outputs.version }}-dSYMs.zip + path: SideStore.dSYMs.zip + + - name: Zip beta-beta-build-num & update_apps.py + run: | + zip -r -9 ./beta-build-num.zip ./SideStore/beta-build-num update_apps.py + + - name: Upload beta-build-num artifact + if: ${{ inputs.is_beta }} + uses: actions/upload-artifact@v4 + with: + name: beta-build-num-${{ steps.version.outputs.version }}.zip + path: beta-build-num.zip + + + tests-build: + name: Tests-Build SideStore - ${{ inputs.release_tag }} + needs: serialize + strategy: + fail-fast: false + matrix: + include: + - os: 'macos-15' + version: '16.1' + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install dependencies - xcbeautify + run: | + brew install xcbeautify + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1.6.0 + with: + xcode-version: '16.1' + + - name: (Tests-Build) Cache Build + uses: irgaly/xcode-cache@v1 + with: + key: xcode-cache-deriveddata-test-${{ github.sha }} + restore-keys: xcode-cache-deriveddata-test- + swiftpm-cache-key: xcode-cache-sourcedata-test-${{ github.sha }} + swiftpm-cache-restore-keys: | + xcode-cache-sourcedata-test- + + - name: (Tests-Build) Restore Pods from Cache (Exact match) + id: pods-restore + uses: actions/cache/restore@v3 + with: + path: | + ./Podfile.lock + ./Pods/ + ./AltStore.xcworkspace/ + key: pods-cache-test-${{ hashFiles('Podfile') }} + + - name: (Tests-Build) Restore Pods from Cache (Last Available) + if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} + id: pods-restore-recent + uses: actions/cache/restore@v3 + with: + path: | + ./Podfile.lock + ./Pods/ + ./AltStore.xcworkspace/ + key: pods-cache-test- + + - name: (Tests-Build) Install CocoaPods + run: pod install + + - name: (Tests-Build) Save Pods to Cache + if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v3 + with: + path: | + ./Podfile.lock + ./Pods/ + ./AltStore.xcworkspace/ + key: pods-cache-test-${{ hashFiles('Podfile') }} + + - name: (Tests-Build) Clean previous build artifacts + run: | + make clean + mkdir -p build/logs + + - name: (Tests-Build) List Files and derived data + run: | + echo ">>>>>>>>> Workdir <<<<<<<<<<" + ls -la . + echo "" + + echo ">>>>>>>>> Pods <<<<<<<<<<" + find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists + echo "" + + echo ">>>>>>>>> SideStore <<<<<<<<<<" + find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists + echo "" + + echo ">>>>>>>>> Dependencies <<<<<<<<<<" + find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists + echo "" + + echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<" + ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists + echo "" + + - name: Build SideStore Tests + # using 'tee' to intercept stdout and log for detailed build-log + run: | + NSUnbufferedIO=YES make -B build-tests 2>&1 | tee -a build/logs/tests-build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]} + + - name: (Tests-Build) List Files and Build artifacts + if: always() + run: | + echo ">>>>>>>>> Workdir <<<<<<<<<<" + ls -la . + echo "" + + echo ">>>>>>>>> Build <<<<<<<<<<" + find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists + echo "" + + - name: Encrypt tests-build-logs for upload + id: encrypt-test-log + if: always() run: | DEFAULT_BUILD_LOG_PASSWORD=12345 @@ -248,19 +459,271 @@ jobs: echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'." fi - if [ ! -f build.log ]; then - echo "Warning: build.log is missing, creating a dummy log..." - echo "Error: build.log was missing, This is a dummy placeholder file..." > build.log - fi + pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-tests-build-logs.zip * || popd + echo "::set-output name=encrypted::true" + + - name: Upload encrypted-tests-build-logs.zip + id: attach-encrypted-test-log + if: always() && steps.encrypt-test-log.outputs.encrypted == 'true' + uses: actions/upload-artifact@v4 + with: + name: encrypted-tests-build-logs-${{ needs.serialize.outputs.short-commit }}.zip + path: encrypted-tests-build-logs.zip - zip -e -P "$BUILD_LOG_ZIP_PASSWORD" encrypted-build_log.zip build.log + tests-run: + name: Tests-Run SideStore - ${{ inputs.release_tag }} + needs: [serialize, tests-build] + strategy: + fail-fast: false + matrix: + include: + - os: 'macos-15' + version: '16.1' + runs-on: ${{ matrix.os }} - - name: List Files after SideStore build + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Boot Simulator for testing + run: | + mkdir -p build/logs + make -B boot-sim-async | tee -a build/logs/tests-run.log + exit ${PIPESTATUS[0]} + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1.6.0 + with: + xcode-version: '16.1' + + - name: (Tests-Run) Cache Build + uses: irgaly/xcode-cache@v1 + with: + key: xcode-cache-deriveddata-test-${{ github.sha }} + restore-keys: xcode-cache-deriveddata-test- + swiftpm-cache-key: xcode-cache-sourcedata-test-${{ github.sha }} + swiftpm-cache-restore-keys: | + xcode-cache-sourcedata-test- + + - name: (Tests-Run) Restore Pods from Cache (Exact match) + id: pods-restore + uses: actions/cache/restore@v3 + with: + path: | + ./Podfile.lock + ./Pods/ + ./AltStore.xcworkspace/ + key: pods-cache-test-${{ hashFiles('Podfile') }} + + - name: (Tests-Run) Restore Pods from Cache (Last Available) + if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} + id: pods-restore-recent + uses: actions/cache/restore@v3 + with: + path: | + ./Podfile.lock + ./Pods/ + ./AltStore.xcworkspace/ + key: pods-cache-test- + + - name: (Tests-Run) Install CocoaPods + run: pod install + + - name: (Tests-Run) Save Pods to Cache + if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v3 + with: + path: | + ./Podfile.lock + ./Pods/ + ./AltStore.xcworkspace/ + key: pods-cache-test-${{ hashFiles('Podfile') }} + + - name: (Tests-Run) Clean previous build artifacts + run: | + make clean + mkdir -p build/logs + + - name: (Tests-Run) List Files and derived data run: | echo ">>>>>>>>> Workdir <<<<<<<<<<" ls -la . echo "" + echo ">>>>>>>>> Pods <<<<<<<<<<" + find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists + echo "" + + echo ">>>>>>>>> SideStore <<<<<<<<<<" + find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists + echo "" + + echo ">>>>>>>>> Dependencies <<<<<<<<<<" + find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists + echo "" + + echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<" + ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists + echo "" + + # we expect simulator to have been booted by now, so exit otherwise + - name: Simulator Boot Check + run: | + mkdir -p build/logs + make -B sim-boot-check | tee -a build/logs/tests-run.log + exit ${PIPESTATUS[0]} + + - name: Start Recording UI tests (if DEBUG_RECORD_TESTS is set to 1) + if: ${{ vars.DEBUG_RECORD_TESTS == '1' }} + run: | + nohup xcrun simctl io booted recordVideo -f tests-recording.mp4 --codec h264 tests-recording.log 2>&1 & + RECORD_PID=$! + echo "RECORD_PID=$RECORD_PID" >> $GITHUB_ENV + + - name: Run SideStore Tests + # using 'tee' to intercept stdout and log for detailed build-log + run: | + make run-tests 2>&1 | tee -a build/logs/tests-run.log && exit ${PIPESTATUS[0]} + # NSUnbufferedIO=YES make -B run-tests 2>&1 | tee build/logs/tests-run.log | xcpretty -r junit --output ./build/tests/test-results.xml && exit ${PIPESTATUS[0]} + + - name: Stop Recording tests + if: ${{ always() && env.RECORD_PID != '' }} + run: | + kill -INT ${{ env.RECORD_PID }} + + - name: (Tests-Run) List Files and Build artifacts + if: always() + run: | + echo ">>>>>>>>> Workdir <<<<<<<<<<" + ls -la . + echo "" + + echo ">>>>>>>>> Build <<<<<<<<<<" + find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists + echo "" + + - name: Encrypt tests-run-logs for upload + id: encrypt-test-log + if: always() + run: | + DEFAULT_BUILD_LOG_PASSWORD=12345 + + BUILD_LOG_ZIP_PASSWORD=${{ secrets.BUILD_LOG_ZIP_PASSWORD }} + BUILD_LOG_ZIP_PASSWORD=${BUILD_LOG_ZIP_PASSWORD:-$DEFAULT_BUILD_LOG_PASSWORD} + + if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then + echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'." + fi + + pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-tests-run-logs.zip * || popd + echo "::set-output name=encrypted::true" + + - name: Upload encrypted-tests-run-logs.zip + id: attach-encrypted-test-log + if: always() && steps.encrypt-test-log.outputs.encrypted == 'true' + uses: actions/upload-artifact@v4 + with: + name: encrypted-tests-run-logs-${{ needs.serialize.outputs.short-commit }}.zip + path: encrypted-tests-run-logs.zip + + - name: Print tests-recording.log contents (if exists) + if: ${{ always() && env.RECORD_PID != '' }} + run: | + if [ -f tests-recording.log ]; then + echo "tests-recording.log found. Its contents:" + cat tests-recording.log + else + echo "tests-recording.log not found." + fi + + - name: Check for tests-recording.mp4 presence + id: check-recording + if: ${{ always() && env.RECORD_PID != '' }} + run: | + if [ -f tests-recording.mp4 ]; then + echo "::set-output name=found::true" + echo "tests-recording.mp4 found." + else + echo "tests-recording.mp4 not found, skipping upload." + echo "::set-output name=found::false" + fi + + - name: Upload tests-recording.mp4 + id: upload-recording + if: ${{ always() && steps.check-recording.outputs.found == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: tests-recording-${{ needs.serialize.outputs.short-commit }}.mp4 + path: tests-recording.mp4 + + - name: Zip test-results + run: zip -r -9 ./test-results.zip ./build/tests + + - name: Upload Test Artifacts + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ needs.serialize.outputs.short-commit }}.zip + path: test-results.zip + + deploy: + name: Deploy SideStore - ${{ inputs.release_tag }} + runs-on: macos-15 + # needs: [serialize, build] + needs: [serialize, build, tests-build, tests-run] + steps: + - name: Download IPA artifact + uses: actions/download-artifact@v4 + with: + name: SideStore-${{ needs.build.outputs.version }}.ipa + + - name: Download dSYM artifact + uses: actions/download-artifact@v4 + with: + name: SideStore-${{ needs.build.outputs.version }}-dSYMs.zip + + - name: Download encrypted-build-logs artifact + uses: actions/download-artifact@v4 + with: + name: encrypted-build-logs-${{ needs.build.outputs.version }}.zip + + - name: Download encrypted-tests-build-logs artifact + uses: actions/download-artifact@v4 + with: + name: encrypted-tests-build-logs-${{ needs.serialize.outputs.short-commit }}.zip + + - name: Download encrypted-tests-run-logs artifact + uses: actions/download-artifact@v4 + with: + name: encrypted-tests-run-logs-${{ needs.serialize.outputs.short-commit }}.zip + + - name: Download tests-recording artifact + uses: actions/download-artifact@v4 + with: + name: tests-recording-${{ needs.serialize.outputs.short-commit }}.mp4 + + - name: Download test-results artifact + uses: actions/download-artifact@v4 + with: + name: test-results-${{ needs.serialize.outputs.short-commit }}.zip + + - name: Download beta-build-num artifact + if: ${{ inputs.is_beta }} + uses: actions/download-artifact@v4 + with: + name: beta-build-num-${{ needs.build.outputs.version }}.zip + - name: Un-Zip beta-beta-build-num & update_apps.py + run: | + unzip beta-build-num.zip -d . + + + - name: List files before upload + run: | + echo ">>>>>>>>> Workdir <<<<<<<<<<" + find . -maxdepth 4 -exec ls -ld {} + || true # List contents if directory exists + echo "" + - name: Get current date id: date run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT @@ -269,9 +732,6 @@ jobs: id: date_altstore run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT - - name: Create dSYMs zip - run: zip -r -9 ./SideStore.dSYMs.zip ./SideStore.xcarchive/dSYMs/* - - name: Upload to releases uses: IsaacShelton/update-existing-release@v1.3.1 with: @@ -279,7 +739,7 @@ jobs: release: ${{ inputs.release_name }} tag: ${{ inputs.release_tag }} prerelease: ${{ inputs.is_beta }} - files: SideStore.ipa SideStore.dSYMs.zip encrypted-build_log.zip + files: SideStore.ipa SideStore.dSYMs.zip encrypted-build-logs.zip encrypted-tests-build-logs.zip encrypted-tests-run-logs.zip test-results.zip tests-recording.mp4 body: | This is an ⚠️ **EXPERIMENTAL** ⚠️ ${{ inputs.release_name }} build for commit [${{ github.sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}). @@ -292,14 +752,11 @@ jobs: Built at (UTC): `${{ steps.date.outputs.date }}` Built at (UTC date): `${{ steps.date_altstore.outputs.date }}` Commit SHA: `${{ github.sha }}` - Version: `${{ steps.version.outputs.version }}` + Version: `${{ needs.build.outputs.version }}` - # save it - name: Publish to SideStore/beta-build-num if: ${{ inputs.is_beta }} run: | - rm SideStore/beta-build-num/build_number.txt - mv build_number.txt SideStore/beta-build-num/build_number.txt pushd SideStore/beta-build-num/ echo "Configure Git user (committer details)" @@ -308,33 +765,12 @@ jobs: echo "Adding files to commit" git add --verbose build_number.txt - git commit -m " - updated for ${{ inputs.release_tag }} - $SHORT_COMMIT deployment" || echo "No changes to commit" + git commit -m " - updated for ${{ inputs.release_tag }} - ${{ needs.serialize.outputs.short-commit }} deployment" || echo "No changes to commit" echo "Pushing to remote repo" git push --verbose popd - - name: Add version to IPA file name - run: cp SideStore.ipa SideStore-${{ steps.version.outputs.version }}.ipa - - - name: Upload SideStore.ipa Artifact - uses: actions/upload-artifact@v4 - with: - name: SideStore-${{ steps.version.outputs.version }}.ipa - path: SideStore-${{ steps.version.outputs.version }}.ipa - - - name: Upload *.dSYM Artifact - uses: actions/upload-artifact@v4 - with: - name: SideStore-${{ steps.version.outputs.version }}-dSYM - path: ./SideStore.xcarchive/dSYMs/* - - - name: Upload encrypted-build_log.zip - uses: actions/upload-artifact@v4 - with: - name: encrypted-build_log.zip - path: encrypted-build_log.zip - - name: Get formatted date run: | FORMATTED_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") @@ -364,15 +800,17 @@ jobs: # Format localized description LOCALIZED_DESCRIPTION=$(cat <> $GITHUB_ENV echo "BUNDLE_IDENTIFIER=${{ inputs.bundle_id }}" >> $GITHUB_ENV - echo "VERSION_IPA=$MARKETING_VERSION" >> $GITHUB_ENV + echo "VERSION_IPA=${{ needs.build.outputs.marketing-version }}" >> $GITHUB_ENV echo "VERSION_DATE=$FORMATTED_DATE" >> $GITHUB_ENV + echo "RELEASE_CHANNEL=${{ needs.build.outputs.release-channel }}" >> $GITHUB_ENV echo "SIZE=$IPA_SIZE" >> $GITHUB_ENV echo "SHA256=$SHA256_HASH" >> $GITHUB_ENV echo "DOWNLOAD_URL=https://github.com/SideStore/SideStore/releases/download/${{ inputs.release_tag }}/SideStore.ipa" >> $GITHUB_ENV @@ -391,10 +829,10 @@ jobs: if: ${{ inputs.is_beta && inputs.publish }} uses: actions/checkout@v4 with: - repository: 'SideStore/apps-v2.json' - ref: 'main' # this branch is shared by all beta builds, so beta build workflows are serialized - token: ${{ secrets.CROSS_REPO_PUSH_KEY }} - path: 'SideStore/apps-v2.json' + repository: 'SideStore/apps-v2.json' + ref: 'main' # this branch is shared by all beta builds, so beta build workflows are serialized + token: ${{ secrets.CROSS_REPO_PUSH_KEY }} + path: 'SideStore/apps-v2.json' # for stable builds, let the user manually edit the source.json - name: Publish to SideStore/apps-v2.json @@ -413,7 +851,7 @@ jobs: # Commit changes and push using SSH git add --verbose ./_includes/source.json - git commit -m " - updated for $SHORT_COMMIT deployment" || echo "No changes to commit" + git commit -m " - updated for ${{ needs.serialize.outputs.short-commit }} deployment" || echo "No changes to commit" git push --verbose popd diff --git a/.gitignore b/.gitignore index d7f580cd..9c74f983 100644 --- a/.gitignore +++ b/.gitignore @@ -63,4 +63,10 @@ SideStore/.skip-prebuilt-fetch-em_proxy # Never check-in this package.resolved file # coz SPM then resolves packages using the stale entries in this file *.xcodeproj/**/Package.resolved -*.xcworkspace/**/Package.resolved \ No newline at end of file +*.xcworkspace/**/Package.resolved + +# some more commandline build artifacts +test-recording.mp4 +test-recording.log +altstore-sources.md +local-build.sh \ No newline at end of file diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 6d8fea94..84cd6a2e 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -59,12 +59,21 @@ A80D60D32D3DD85100CEF65D /* ReleaseTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D60D12D3D705F00CEF65D /* ReleaseTrack.swift */; }; A80D790D2D2F20AF00A40F40 /* PaginationIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D790C2D2F20AF00A40F40 /* PaginationIntent.swift */; }; A80D790F2D2F217000A40F40 /* PaginationDataHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D790E2D2F217000A40F40 /* PaginationDataHolder.swift */; }; + A81A8CB92D68B30B0086C96F /* SingletonGenericMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A868CFE32D319988002F1201 /* SingletonGenericMap.swift */; }; + A81A8CBA2D68B3110086C96F /* TreeMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CB02D68B0320086C96F /* TreeMap.swift */; }; + A81A8CBD2D68B43F0086C96F /* LinkedHashMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CBC2D68B43F0086C96F /* LinkedHashMap.swift */; }; + A81A8CC82D68BA610086C96F /* DataStructuresTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CC72D68BA610086C96F /* DataStructuresTests.swift */; }; + A81A8CCE2D68BA8D0086C96F /* LinkedHashMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CBF2D68B4520086C96F /* LinkedHashMapTests.swift */; }; + A81A8CCF2D68BA8D0086C96F /* TreeMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CB42D68B2180086C96F /* TreeMapTests.swift */; }; + A81A8CD02D68BA9B0086C96F /* LinkedHashMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CBC2D68B43F0086C96F /* LinkedHashMap.swift */; }; + A81A8CD12D68BA9B0086C96F /* TreeMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CB02D68B0320086C96F /* TreeMap.swift */; }; + A81A8CD22D68BAA30086C96F /* SingletonGenericMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A868CFE32D319988002F1201 /* SingletonGenericMap.swift */; }; + A81A8CD42D68BAFF0086C96F /* DataStructureTests.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = A81A8CD32D68BAFF0086C96F /* DataStructureTests.xctestplan */; }; A82067842D03DC0600645C0D /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; }; A82067C42D03E0DE00645C0D /* SemanticVersion in Frameworks */ = {isa = PBXBuildFile; productRef = A82067C32D03E0DE00645C0D /* SemanticVersion */; }; A859ED5C2D1EE827003DCC58 /* OpenSSL.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */; }; A859ED5D2D1EE827003DCC58 /* OpenSSL.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; A86315DF2D3EB2DE0048FA40 /* ErrorProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = A86315DE2D3EB2D80048FA40 /* ErrorProcessing.swift */; }; - A868CFE42D31999A002F1201 /* SingletonGenericMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A868CFE32D319988002F1201 /* SingletonGenericMap.swift */; }; A8696EE42D34512C00E96389 /* RemoveAppExtensionsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8696EE32D34512C00E96389 /* RemoveAppExtensionsOperation.swift */; }; A88B8C492D35AD3200F53F9D /* OperationsLoggingContolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88B8C482D35AD3200F53F9D /* OperationsLoggingContolView.swift */; }; A88B8C552D35F1EC00F53F9D /* OperationsLoggingControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88B8C542D35F1EC00F53F9D /* OperationsLoggingControl.swift */; }; @@ -89,6 +98,9 @@ A8C6D5182D1EE95B00DF01F1 /* OpenSSL.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; A8D484D82D0CD306002C691D /* AltBackup.ipa in Resources */ = {isa = PBXBuildFile; fileRef = A8D484D72D0CD306002C691D /* AltBackup.ipa */; }; A8D49F532D3D2F9400844B92 /* ProcessInfo+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8D49F522D3D2F9400844B92 /* ProcessInfo+AltStore.swift */; }; + A8E2DB312D684E2A009E5D31 /* UITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8E2DB2E2D684E2A009E5D31 /* UITests.swift */; }; + A8E2DB322D684E2A009E5D31 /* UITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8E2DB2F2D684E2A009E5D31 /* UITestsLaunchTests.swift */; }; + A8E2DB342D68507F009E5D31 /* SideStoreTests.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = A8E2DB332D68507F009E5D31 /* SideStoreTests.xctestplan */; }; A8EA195F2D4982D600DC6322 /* BaseEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8EA195E2D4982D600DC6322 /* BaseEntity.swift */; }; A8F838922D048E8F00ED425D /* libEmotionalDamage.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 19104DB22909C06C00C49C7B /* libEmotionalDamage.a */; }; A8F838932D048E8F00ED425D /* libminimuxer.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 191E5FAB290A5D92001A3B7C /* libminimuxer.a */; }; @@ -504,6 +516,13 @@ remoteGlobalIDString = BF58047A246A28F7008AE704; remoteInfo = AltBackup; }; + A8E2DB272D684CBD009E5D31 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BFD247622284B9A500981D42 /* Project object */; + proxyType = 1; + remoteGlobalIDString = BFD247692284B9A500981D42; + remoteInfo = SideStore; + }; BF66EE832501AE50007EE018 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = BFD247622284B9A500981D42 /* Project object */; @@ -640,6 +659,13 @@ A80D60D12D3D705F00CEF65D /* ReleaseTrack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReleaseTrack.swift; sourceTree = ""; }; A80D790C2D2F20AF00A40F40 /* PaginationIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIntent.swift; sourceTree = ""; }; A80D790E2D2F217000A40F40 /* PaginationDataHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationDataHolder.swift; sourceTree = ""; }; + A81A8CB02D68B0320086C96F /* TreeMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreeMap.swift; sourceTree = ""; }; + A81A8CB42D68B2180086C96F /* TreeMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreeMapTests.swift; sourceTree = ""; }; + A81A8CBC2D68B43F0086C96F /* LinkedHashMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedHashMap.swift; sourceTree = ""; }; + A81A8CBF2D68B4520086C96F /* LinkedHashMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedHashMapTests.swift; sourceTree = ""; }; + A81A8CC52D68BA610086C96F /* DataStructureTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DataStructureTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A81A8CC72D68BA610086C96F /* DataStructuresTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStructuresTests.swift; sourceTree = ""; }; + A81A8CD32D68BAFF0086C96F /* DataStructureTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DataStructureTests.xctestplan; sourceTree = ""; }; A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */ = {isa = PBXFileReference; expectedSignature = "AppleDeveloperProgram:67RAULRX93:Marcin Krzyzanowski"; lastKnownFileType = wrapper.xcframework; name = OpenSSL.xcframework; path = SideStore/AltSign/Dependencies/OpenSSL/Frameworks/OpenSSL.xcframework; sourceTree = ""; }; A85ACB8E2D1F31C400AA3DE7 /* AltBackup.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltBackup.xcconfig; sourceTree = ""; }; A85ACB8F2D1F31C400AA3DE7 /* AltStore.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltStore.debug.xcconfig; sourceTree = ""; }; @@ -667,6 +693,11 @@ A8C38C372D2084D000E83DBD /* ConsoleLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleLogView.swift; sourceTree = ""; }; A8D484D72D0CD306002C691D /* AltBackup.ipa */ = {isa = PBXFileReference; lastKnownFileType = file; path = AltBackup.ipa; sourceTree = ""; }; A8D49F522D3D2F9400844B92 /* ProcessInfo+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfo+AltStore.swift"; sourceTree = ""; }; + A8E2DB212D684CBD009E5D31 /* UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A8E2DB2C2D684D39009E5D31 /* UITests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = UITests.xcconfig; sourceTree = ""; }; + A8E2DB2E2D684E2A009E5D31 /* UITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITests.swift; sourceTree = ""; }; + A8E2DB2F2D684E2A009E5D31 /* UITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsLaunchTests.swift; sourceTree = ""; }; + A8E2DB332D68507F009E5D31 /* SideStoreTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = SideStoreTests.xctestplan; sourceTree = ""; }; A8EA195E2D4982D600DC6322 /* BaseEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseEntity.swift; sourceTree = ""; }; A8F66C3C2D04D433009689E6 /* em_proxy.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = em_proxy.h; sourceTree = ""; }; A8F66C602D04D464009689E6 /* minimuxer.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = minimuxer.xcodeproj; sourceTree = ""; }; @@ -1066,6 +1097,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A81A8CC22D68BA610086C96F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A8E2DB1E2D684CBD009E5D31 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; BF580478246A28F7008AE704 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1205,6 +1250,24 @@ path = Intents; sourceTree = ""; }; + A81A8CB22D68B2030086C96F /* UnitTests */ = { + isa = PBXGroup; + children = ( + A81A8CB32D68B20F0086C96F /* datastructures */, + ); + path = UnitTests; + sourceTree = ""; + }; + A81A8CB32D68B20F0086C96F /* datastructures */ = { + isa = PBXGroup; + children = ( + A81A8CBF2D68B4520086C96F /* LinkedHashMapTests.swift */, + A81A8CB42D68B2180086C96F /* TreeMapTests.swift */, + A81A8CC72D68BA610086C96F /* DataStructuresTests.swift */, + ); + path = datastructures; + sourceTree = ""; + }; A85ACB942D1F31C400AA3DE7 /* xcconfigs */ = { isa = PBXGroup; children = ( @@ -1216,6 +1279,7 @@ A85ACB902D1F31C400AA3DE7 /* AltStore.release.xcconfig */, A85ACB8E2D1F31C400AA3DE7 /* AltBackup.xcconfig */, A85ACB932D1F31C400AA3DE7 /* AltWidgetExtension.xcconfig */, + A8E2DB2C2D684D39009E5D31 /* UITests.xcconfig */, ); path = xcconfigs; sourceTree = ""; @@ -1266,6 +1330,8 @@ A8AD35572D31BEB2003A28B4 /* datastructures */ = { isa = PBXGroup; children = ( + A81A8CBC2D68B43F0086C96F /* LinkedHashMap.swift */, + A81A8CB02D68B0320086C96F /* TreeMap.swift */, A868CFE32D319988002F1201 /* SingletonGenericMap.swift */, ); path = datastructures; @@ -1322,6 +1388,26 @@ path = common; sourceTree = ""; }; + A8E2DB302D684E2A009E5D31 /* UITests */ = { + isa = PBXGroup; + children = ( + A8E2DB2E2D684E2A009E5D31 /* UITests.swift */, + A8E2DB2F2D684E2A009E5D31 /* UITestsLaunchTests.swift */, + ); + path = UITests; + sourceTree = ""; + }; + A8E2DB352D6850A9009E5D31 /* Tests */ = { + isa = PBXGroup; + children = ( + A81A8CB22D68B2030086C96F /* UnitTests */, + A8E2DB302D684E2A009E5D31 /* UITests */, + A8E2DB332D68507F009E5D31 /* SideStoreTests.xctestplan */, + A81A8CD32D68BAFF0086C96F /* DataStructureTests.xctestplan */, + ); + path = Tests; + sourceTree = ""; + }; A8EA19602D4982E300DC6322 /* DatabaseManager */ = { isa = PBXGroup; children = ( @@ -1350,6 +1436,7 @@ A8F66C072D04C025009689E6 /* SideStore */ = { isa = PBXGroup; children = ( + A8E2DB352D6850A9009E5D31 /* Tests */, A8F66C5C2D04D433009689E6 /* minimuxer */, A8F66C602D04D464009689E6 /* minimuxer.xcodeproj */, A8F66C412D04D433009689E6 /* em_proxy */, @@ -1919,6 +2006,8 @@ 19104DB22909C06C00C49C7B /* libEmotionalDamage.a */, 191E5FAB290A5D92001A3B7C /* libminimuxer.a */, D586D39828EF58B0000E101F /* AltTests.xctest */, + A8E2DB212D684CBD009E5D31 /* UITests.xctest */, + A81A8CC52D68BA610086C96F /* DataStructureTests.xctest */, ); name = Products; sourceTree = ""; @@ -2399,6 +2488,45 @@ productReference = 191E5FAB290A5D92001A3B7C /* libminimuxer.a */; productType = "com.apple.product-type.library.static"; }; + A81A8CC42D68BA610086C96F /* DataStructureTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A81A8CC92D68BA610086C96F /* Build configuration list for PBXNativeTarget "DataStructureTests" */; + buildPhases = ( + A81A8CC12D68BA610086C96F /* Sources */, + A81A8CC22D68BA610086C96F /* Frameworks */, + A81A8CC32D68BA610086C96F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = DataStructureTests; + packageProductDependencies = ( + ); + productName = DataStructuresTests; + productReference = A81A8CC52D68BA610086C96F /* DataStructureTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + A8E2DB202D684CBD009E5D31 /* UITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A8E2DB292D684CBD009E5D31 /* Build configuration list for PBXNativeTarget "UITests" */; + buildPhases = ( + A8E2DB1D2D684CBD009E5D31 /* Sources */, + A8E2DB1E2D684CBD009E5D31 /* Frameworks */, + A8E2DB1F2D684CBD009E5D31 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A8E2DB282D684CBD009E5D31 /* PBXTargetDependency */, + ); + name = UITests; + packageProductDependencies = ( + ); + productName = UITests; + productReference = A8E2DB212D684CBD009E5D31 /* UITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; BF45872A2298D31600BD7491 /* libimobiledevice */ = { isa = PBXNativeTarget; buildConfigurationList = BF4587332298D31600BD7491 /* Build configuration list for PBXNativeTarget "libimobiledevice" */; @@ -2510,7 +2638,7 @@ BFD247622284B9A500981D42 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1400; + LastSwiftUpdateCheck = 1620; LastUpgradeCheck = 1020; ORGANIZATIONNAME = SideStore; TargetAttributes = { @@ -2520,6 +2648,13 @@ 191E5FAA290A5D92001A3B7C = { CreatedOnToolsVersion = 14.0; }; + A81A8CC42D68BA610086C96F = { + CreatedOnToolsVersion = 16.2; + }; + A8E2DB202D684CBD009E5D31 = { + CreatedOnToolsVersion = 16.2; + TestTargetID = BFD247692284B9A500981D42; + }; BF45872A2298D31600BD7491 = { CreatedOnToolsVersion = 10.2.1; }; @@ -2586,6 +2721,8 @@ BF989166250AABF3002ACF50 /* AltWidgetExtension */, 19104DB12909C06C00C49C7B /* EmotionalDamage */, 191E5FAA290A5D92001A3B7C /* minimuxer */, + A8E2DB202D684CBD009E5D31 /* UITests */, + A81A8CC42D68BA610086C96F /* DataStructureTests */, ); }; /* End PBXProject section */ @@ -2643,6 +2780,22 @@ /* End PBXReferenceProxy section */ /* Begin PBXResourcesBuildPhase section */ + A81A8CC32D68BA610086C96F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A8E2DB1F2D684CBD009E5D31 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A81A8CD42D68BAFF0086C96F /* DataStructureTests.xctestplan in Resources */, + A8E2DB342D68507F009E5D31 /* SideStoreTests.xctestplan in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; BF580479246A28F7008AE704 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2814,6 +2967,28 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A81A8CC12D68BA610086C96F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A81A8CC82D68BA610086C96F /* DataStructuresTests.swift in Sources */, + A81A8CD02D68BA9B0086C96F /* LinkedHashMap.swift in Sources */, + A81A8CD22D68BAA30086C96F /* SingletonGenericMap.swift in Sources */, + A81A8CD12D68BA9B0086C96F /* TreeMap.swift in Sources */, + A81A8CCE2D68BA8D0086C96F /* LinkedHashMapTests.swift in Sources */, + A81A8CCF2D68BA8D0086C96F /* TreeMapTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A8E2DB1D2D684CBD009E5D31 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A8E2DB312D684E2A009E5D31 /* UITests.swift in Sources */, + A8E2DB322D684E2A009E5D31 /* UITestsLaunchTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; BF4587282298D31600BD7491 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -3015,6 +3190,7 @@ files = ( D55467C52A8D72C300F4CE90 /* ActiveAppsWidget.swift in Sources */, D577AB7B2A967DF5007FE952 /* AppsTimelineProvider.swift in Sources */, + A81A8CB92D68B30B0086C96F /* SingletonGenericMap.swift in Sources */, D577AB7F2A96878A007FE952 /* AppDetailWidget.swift in Sources */, BF98917E250AAC4F002ACF50 /* Countdown.swift in Sources */, D5151BE22A90363300C96F28 /* RefreshAllAppsWidgetIntent.swift in Sources */, @@ -3023,7 +3199,6 @@ D5FD4EC92A9530C00097BEE8 /* AppSnapshot.swift in Sources */, A8AD35592D31BF2C003A28B4 /* PageInfoManager.swift in Sources */, D5151BE72A90395400C96F28 /* View+AltWidget.swift in Sources */, - A868CFE42D31999A002F1201 /* SingletonGenericMap.swift in Sources */, A8A853AF2D3065A300995795 /* ActiveAppsTimelineProvider.swift in Sources */, BF98917F250AAC4F002ACF50 /* LockScreenWidget.swift in Sources */, A800F7042CE28E3800208744 /* View+AltWidget.swift in Sources */, @@ -3057,6 +3232,7 @@ D5E1E7C128077DE90016FC96 /* UpdateKnownSourcesOperation.swift in Sources */, BFE338DF22F0EADB002E24B9 /* FetchSourceOperation.swift in Sources */, D54DED1428CBC44B008B27A0 /* ErrorLogTableViewCell.swift in Sources */, + A81A8CBD2D68B43F0086C96F /* LinkedHashMap.swift in Sources */, D5390C3C2AC3A43900D17E62 /* AddSourceViewController.swift in Sources */, D5CD805F29CA755E00E591B0 /* SourceDetailViewController.swift in Sources */, D57968CB29CB99EF00539069 /* VibrantButton.swift in Sources */, @@ -3139,6 +3315,7 @@ BF770E5122BB1CF6002A40FE /* InstallAppOperation.swift in Sources */, BF9ABA4B22DD1380008935CF /* NavigationBar.swift in Sources */, A8FD917B2D0472DD00322782 /* DeprecatedAPIs.swift in Sources */, + A81A8CBA2D68B3110086C96F /* TreeMap.swift in Sources */, BF6C8FAC242935ED00125131 /* NSAttributedString+Markdown.m in Sources */, A8C38C382D2084D000E83DBD /* ConsoleLogView.swift in Sources */, BFF00D322501BDA100746320 /* BackgroundRefreshAppsOperation.swift in Sources */, @@ -3202,6 +3379,11 @@ target = BF58047A246A28F7008AE704 /* AltBackup */; targetProxy = A8E00D3D2D0C95B5000DD2C7 /* PBXContainerItemProxy */; }; + A8E2DB282D684CBD009E5D31 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BFD247692284B9A500981D42 /* SideStore */; + targetProxy = A8E2DB272D684CBD009E5D31 /* PBXContainerItemProxy */; + }; BF66EE842501AE50007EE018 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = BF66EE7D2501AE50007EE018 /* AltStoreCore */; @@ -3365,6 +3547,112 @@ }; name = Release; }; + A81A8CCA2D68BA610086C96F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.SideStore.SideStore.DataStructuresTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A81A8CCB2D68BA610086C96F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.SideStore.SideStore.DataStructuresTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + A8E2DB2A2D684CBD009E5D31 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A8E2DB2C2D684D39009E5D31 /* UITests.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "--PRODUCT-BUNDLE-IDENTIFIER-.UITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = SideStore; + }; + name = Debug; + }; + A8E2DB2B2D684CBD009E5D31 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A8E2DB2C2D684D39009E5D31 /* UITests.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "--PRODUCT-BUNDLE-IDENTIFIER-.UITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = SideStore; + }; + name = Release; + }; BF4587342298D31600BD7491 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -3900,6 +4188,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A81A8CC92D68BA610086C96F /* Build configuration list for PBXNativeTarget "DataStructureTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A81A8CCA2D68BA610086C96F /* Debug */, + A81A8CCB2D68BA610086C96F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A8E2DB292D684CBD009E5D31 /* Build configuration list for PBXNativeTarget "UITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A8E2DB2A2D684CBD009E5D31 /* Debug */, + A8E2DB2B2D684CBD009E5D31 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; BF4587332298D31600BD7491 /* Build configuration list for PBXNativeTarget "libimobiledevice" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/AltStore.xcodeproj/xcshareddata/xcschemes/DataStructuresTests.xcscheme b/AltStore.xcodeproj/xcshareddata/xcschemes/DataStructuresTests.xcscheme new file mode 100644 index 00000000..e5c9bc34 --- /dev/null +++ b/AltStore.xcodeproj/xcshareddata/xcschemes/DataStructuresTests.xcscheme @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AltStore.xcodeproj/xcshareddata/xcschemes/SideStore.xcscheme b/AltStore.xcodeproj/xcshareddata/xcschemes/SideStore.xcscheme index 1a73712e..56b21a62 100644 --- a/AltStore.xcodeproj/xcshareddata/xcschemes/SideStore.xcscheme +++ b/AltStore.xcodeproj/xcshareddata/xcschemes/SideStore.xcscheme @@ -28,18 +28,27 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> - + + + + + skipped = "NO"> + + + + diff --git a/AltStore/Sources/AddSourceViewController.swift b/AltStore/Sources/AddSourceViewController.swift index 5e5c01dd..1f756cdb 100644 --- a/AltStore/Sources/AddSourceViewController.swift +++ b/AltStore/Sources/AddSourceViewController.swift @@ -43,10 +43,10 @@ extension AddSourceViewController var sourceAddress: String = "" @Published - var sourceURL: URL? + var sourceURLs: [URL] = [] @Published - var sourcePreviewResult: SourcePreviewResult? + var sourcePreviewResults: [SourcePreviewResult] = [] /* State */ @@ -60,6 +60,8 @@ extension AddSourceViewController class AddSourceViewController: UICollectionViewController { + private var stagedForAdd: LinkedHashMap = LinkedHashMap() + private lazy var dataSource = self.makeDataSource() private lazy var addSourceDataSource = self.makeAddSourceDataSource() private lazy var sourcePreviewDataSource = self.makeSourcePreviewDataSource() @@ -117,6 +119,7 @@ private extension AddSourceViewController layoutConfig.contentInsetsReference = .safeArea let layout = UICollectionViewCompositionalLayout(sectionProvider: { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in + guard let self, let section = Section(rawValue: sectionIndex) else { return nil } switch section { @@ -140,14 +143,19 @@ private extension AddSourceViewController configuration.showsSeparators = false configuration.backgroundColor = .clear - if self.viewModel.sourceURL != nil && self.viewModel.isShowingPreviewStatus + if !self.viewModel.sourceURLs.isEmpty && self.viewModel.isShowingPreviewStatus { - switch self.viewModel.sourcePreviewResult + for result in self.viewModel.sourcePreviewResults { - case (_, .success)?: configuration.footerMode = .none - case (_, .failure)?: configuration.footerMode = .supplementary - case nil where self.viewModel.isLoadingPreview: configuration.footerMode = .supplementary - default: configuration.footerMode = .none + switch result + { + case (_, .success): configuration.footerMode = .none + case (_, .failure): configuration.footerMode = .supplementary + break +// case nil where self.viewModel.isLoadingPreview: configuration.footerMode = .supplementary +// break +// default: configuration.footerMode = .none + } } } else @@ -303,50 +311,71 @@ private extension AddSourceViewController { /* Pipeline */ - // Map UITextField text -> URL + // Map UITextField text -> URLs self.viewModel.$sourceAddress - .map { [weak self] in self?.sourceURL(from: $0) } - .assign(to: &self.viewModel.$sourceURL) - + .map { [weak self] in + guard let self else { return [] } + + // Preserve order of parsed URLs + let lines = $0.split(whereSeparator: { $0.isWhitespace }) + .map(String.init) + .compactMap(self.sourceURL) + + return NSOrderedSet(array: lines).array as! [URL] // de-duplicate while preserving order + } + .assign(to: &self.viewModel.$sourceURLs) + let showPreviewStatusPublisher = self.viewModel.$isShowingPreviewStatus .filter { $0 == true } - let sourceURLPublisher = self.viewModel.$sourceURL + let sourceURLsPublisher = self.viewModel.$sourceURLs .removeDuplicates() .debounce(for: 0.2, scheduler: RunLoop.main) .receive(on: RunLoop.main) - .map { [weak self] sourceURL in + .map { [weak self] sourceURLs in // Only set sourcePreviewResult to nil if sourceURL actually changes. - self?.viewModel.sourcePreviewResult = nil - return sourceURL + self?.viewModel.sourcePreviewResults = [] + return sourceURLs } // Map URL -> Source Preview - Publishers.CombineLatest(sourceURLPublisher, showPreviewStatusPublisher.prepend(false)) + Publishers.CombineLatest(sourceURLsPublisher, showPreviewStatusPublisher.prepend(false)) .receive(on: RunLoop.main) .map { $0.0 } - .compactMap { [weak self] (sourceURL: URL?) -> AnyPublisher? in - guard let self else { return nil } - - guard let sourceURL else { - // Unlike above guard, this continues the pipeline with nil value. - return Just(nil).eraseToAnyPublisher() - } + .flatMap { [weak self] (sourceURLs: [URL]) -> AnyPublisher<[SourcePreviewResult?], Never> in + guard let self else { return Just([]).eraseToAnyPublisher() } self.viewModel.isLoadingPreview = true - return self.fetchSourcePreview(sourceURL: sourceURL).eraseToAnyPublisher() + + // Create publishers maintaining order + let publishers = sourceURLs.enumerated().map { index, sourceURL in + self.fetchSourcePreview(sourceURL: sourceURL) + .map { result in + // Add index to maintain order + (index: index, result: result) + } + .eraseToAnyPublisher() + } + + // since network requests are concurrent, we sort the values when they are received + return publishers.isEmpty + ? Just([]).eraseToAnyPublisher() + : Publishers.MergeMany(publishers) + .collect() // await all publishers to emit the results + .map { results in // perform sorting of the collected results + // Sort by original index before returning + results.sorted { $0.index < $1.index } + .map { $0.result } + } + .eraseToAnyPublisher() } - .switchToLatest() // Cancels previous publisher - .receive(on: RunLoop.main) - .sink { [weak self] sourcePreviewResult in + .sink { [weak self] sourcePreviewResults in self?.viewModel.isLoadingPreview = false - self?.viewModel.sourcePreviewResult = sourcePreviewResult + self?.viewModel.sourcePreviewResults = sourcePreviewResults.compactMap{$0} } .store(in: &self.cancellables) - /* Update UI */ - Publishers.CombineLatest(self.viewModel.$isLoadingPreview.removeDuplicates(), self.viewModel.$isShowingPreviewStatus.removeDuplicates()) .sink { [weak self] _ in @@ -359,7 +388,7 @@ private extension AddSourceViewController if let footerView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath) as? PlaceholderCollectionReusableView { - self.configure(footerView, with: self.viewModel.sourcePreviewResult) + self.configure(footerView, with: self.viewModel.sourcePreviewResults) } let context = UICollectionViewLayoutInvalidationContext() @@ -370,27 +399,38 @@ private extension AddSourceViewController } .store(in: &self.cancellables) - self.viewModel.$sourcePreviewResult - .map { $0?.1 } - .map { result -> Managed? in - switch result - { - case .success(let source): return source - case .failure, nil: return nil + self.viewModel.$sourcePreviewResults + .map { sourcePreviewResults -> [Source] in + // Maintain order based on original sourceURLs array + let orderedSources = self.viewModel.sourceURLs.compactMap { sourceURL -> Source? in + // Find the preview result matching this URL + guard let previewResult = sourcePreviewResults.first(where: { $0.sourceURL == sourceURL }), + case .success(let managedSource) = previewResult.result + else { + return nil + } + + return managedSource.wrappedValue } - } - .removeDuplicates { (sourceA: Managed?, sourceB: Managed?) in - sourceA?.identifier == sourceB?.identifier + + return orderedSources } .receive(on: RunLoop.main) - .sink { [weak self] source in - self?.updateSourcePreview(for: source?.wrappedValue) + .sink { [weak self] sources in + self?.updateSourcesPreview(for: sources) } .store(in: &self.cancellables) + - let addPublisher = NotificationCenter.default.publisher(for: AppManager.didAddSourceNotification) - let removePublisher = NotificationCenter.default.publisher(for: AppManager.didRemoveSourceNotification) - Publishers.Merge(addPublisher, removePublisher) + let mergedNotificationPublisher = Publishers.Merge( + NotificationCenter.default.publisher(for: AppManager.didAddSourceNotification), + NotificationCenter.default.publisher(for: AppManager.didRemoveSourceNotification) + ) + .receive(on: RunLoop.main) + .share() // Shares the upstream publisher with multiple subscribers + + // Update recommended sources section when sources are added/removed + mergedNotificationPublisher .compactMap { notification -> String? in guard let source = notification.object as? Source, let context = source.managedObjectContext @@ -399,7 +439,6 @@ private extension AddSourceViewController let sourceID = context.performAndWait { source.identifier } return sourceID } - .receive(on: RunLoop.main) .compactMap { [dataSource = recommendedSourcesDataSource] sourceID -> IndexPath? in guard let index = dataSource.items.firstIndex(where: { $0.identifier == sourceID }) else { return nil } @@ -411,6 +450,32 @@ private extension AddSourceViewController self?.collectionView.reloadItems(at: [indexPath]) } .store(in: &self.cancellables) + + // Update previews section when sources are added/removed +// mergedNotificationPublisher +// .sink { [weak self] _ in +// // reload the entire of previews section to get latest state +// self?.collectionView.reloadSections(IndexSet(integer: Section.preview.rawValue)) +// } +// .store(in: &self.cancellables) + + mergedNotificationPublisher + .compactMap { notification -> String? in + guard let source = notification.object as? Source, + let context = source.managedObjectContext + else { return nil } + return context.performAndWait { source.identifier } + } + .compactMap { [weak self] sourceID -> IndexPath? in + guard let dataSource = self?.sourcePreviewDataSource, + let index = dataSource.items.firstIndex(where: { $0.identifier == sourceID }) + else { return nil } + return IndexPath(item: index, section: Section.preview.rawValue) + } + .sink { [weak self] indexPath in + self?.collectionView.reloadItems(at: [indexPath]) + } + .store(in: &self.cancellables) } func sourceURL(from address: String) -> URL? @@ -458,35 +523,51 @@ private extension AddSourceViewController }) } - func updateSourcePreview(for source: Source?) - { - let items = [source].compactMap { $0 } + func updateSourcesPreview(for sources: [Source]) { + // Calculate changes needed to go from current items to new items + let currentItemCount = self.sourcePreviewDataSource.items.count + let newItemCount = sources.count - // Have to provide changes in terms of sourcePreviewDataSource. - let indexPath = IndexPath(row: 0, section: 0) + var changes: [RSTCellContentChange] = [] - if !items.isEmpty && self.sourcePreviewDataSource.items.isEmpty - { - let change = RSTCellContentChange(type: .insert, currentIndexPath: nil, destinationIndexPath: indexPath) - self.sourcePreviewDataSource.setItems(items, with: [change]) - } - else if items.isEmpty && !self.sourcePreviewDataSource.items.isEmpty - { - let change = RSTCellContentChange(type: .delete, currentIndexPath: indexPath, destinationIndexPath: nil) - self.sourcePreviewDataSource.setItems(items, with: [change]) - } - else if !items.isEmpty && !self.sourcePreviewDataSource.items.isEmpty - { - let change = RSTCellContentChange(type: .update, currentIndexPath: indexPath, destinationIndexPath: indexPath) - self.sourcePreviewDataSource.setItems(items, with: [change]) + if currentItemCount == 0 && newItemCount > 0 { + // Insert all items if we currently have none + for i in 0.. 0 && newItemCount == 0 { + // Delete all items if we're going to have none + for i in 0..(priority: .userInitiated) { [weak cell] in + guard let cell else { return } + + var isSourceAlreadyPersisted = false + do + { + isSourceAlreadyPersisted = try await source.isAdded + } + catch + { + print("Failed to determine if source is added.", error) + } + + // use the plus icon by default + var buttonIcon = UIImage(systemName: "plus.circle.fill", withConfiguration: config)?.withTintColor(.white, renderingMode: .alwaysOriginal) + + // if the source is already added/staged for adding, use the checkmark icon + let isStagedForAdd = self.stagedForAdd[source] == true + if isStagedForAdd || isSourceAlreadyPersisted + { + buttonIcon = UIImage(systemName: "checkmark.circle.fill", withConfiguration: config)? + .withTintColor(isSourceAlreadyPersisted ? .green : .white, renderingMode: .alwaysOriginal) + } + cell.bannerView.button.setImage(buttonIcon, for: .normal) + cell.bannerView.button.isEnabled = !isSourceAlreadyPersisted + } + } + + // set the icon + setButtonIcon() + let action = UIAction(identifier: .addSource) { [weak self] _ in - self?.add(source) + guard let self else { return } + + self.stagedForAdd[source, default: false].toggle() + + // update the button icon + setButtonIcon() } cell.bannerView.button.addAction(action, for: .primaryActionTriggered) - - Task(priority: .userInitiated) { - do - { - let isAdded = try await source.isAdded - if isAdded - { - cell.bannerView.button.isHidden = true - } - } - catch - { - print("Failed to determine if source is added.", error) - } - } } - func configure(_ footerView: PlaceholderCollectionReusableView, with sourcePreviewResult: SourcePreviewResult?) + func configure(_ footerView: PlaceholderCollectionReusableView, with sourcePreviewResults: [SourcePreviewResult?]) { footerView.placeholderView.stackView.isLayoutMarginsRelativeArrangement = false @@ -552,23 +656,33 @@ private extension AddSourceViewController footerView.placeholderView.detailTextLabel.isHidden = true - switch sourcePreviewResult + var errorText: String? = nil + var isError: Bool = false + for result in sourcePreviewResults { - case (let sourceURL, .failure(let previewError))? where self.viewModel.sourceURL == sourceURL && !self.viewModel.isLoadingPreview: - // The current URL matches the error being displayed, and we're not loading another preview, so show error. - - footerView.placeholderView.textLabel.text = (previewError as NSError).localizedDebugDescription ?? previewError.localizedDescription - footerView.placeholderView.textLabel.isHidden = false - - footerView.placeholderView.activityIndicatorView.stopAnimating() - - default: - // The current URL does not match the URL of the source/error being displayed, so show loading indicator. - - footerView.placeholderView.textLabel.text = nil - footerView.placeholderView.textLabel.isHidden = true - + switch result + { + case (let sourceURL, .failure(let previewError))? where (self.viewModel.sourceURLs.contains(sourceURL) && !self.viewModel.isLoadingPreview): + // The current URL matches the error being displayed, and we're not loading another preview, so show error. + + errorText = (previewError as NSError).localizedDebugDescription ?? previewError.localizedDescription + footerView.placeholderView.textLabel.text = errorText + footerView.placeholderView.textLabel.isHidden = false + + isError = true + + default: + // The current URL does not match the URL of the source/error being displayed, so show loading indicator. + errorText = nil + footerView.placeholderView.textLabel.isHidden = true + } + } + footerView.placeholderView.textLabel.text = errorText + + if !isError{ footerView.placeholderView.activityIndicatorView.startAnimating() + } else{ + footerView.placeholderView.activityIndicatorView.stopAnimating() } } @@ -652,30 +766,60 @@ private extension AddSourceViewController } } - func add(@AsyncManaged _ source: Source) + @IBAction func commitChanges(_ sender: UIBarButtonItem) { - Task { - do - { - let isRecommended = await $source.isRecommended - if isRecommended - { - try await AppManager.shared.add(source, message: nil, presentingViewController: self) - } - else - { - // Use default message - try await AppManager.shared.add(source, presentingViewController: self) - } - - self.dismiss() - + struct StagedSource: Hashable { + @AsyncManaged var source: Source + + // Conformance for Equatable/Hashable by comparing the underlying source + static func == (lhs: StagedSource, rhs: StagedSource) -> Bool { + return lhs.source.identifier == rhs.source.identifier } - catch is CancellationError {} - catch - { - let errorTitle = NSLocalizedString("Unable to Add Source", comment: "") - await self.presentAlert(title: errorTitle, message: error.localizedDescription) + + func hash(into hasher: inout Hasher) { + hasher.combine(source) + } + } + + Task { + var isCancelled = false + // OK: COMMIT the staged changes now + // Convert the stagedForAdd dictionary into an array of StagedSource + let stagedSources: [StagedSource] = self.stagedForAdd.filter { $0.value } + .map { StagedSource(source: $0.key) } + + for staged in stagedSources { + do + { + // Use the projected value to safely access isRecommended asynchronously + let isRecommended = await staged.$source.isRecommended + if isRecommended + { + try await AppManager.shared.add(staged.source, message: nil, presentingViewController: self) + } + else + { + // Use default message + try await AppManager.shared.add(staged.source, presentingViewController: self) + } + + // remove this kv pair + _ = self.stagedForAdd.removeValue(forKey: staged.source) + } + catch is CancellationError { + isCancelled = true + break + } + catch + { + let errorTitle = NSLocalizedString("Unable to Add Source", comment: "") + await self.presentAlert(title: errorTitle, message: error.localizedDescription) + } + } + + if !isCancelled { + // finally dismiss the sheet/viewcontroller + self.dismiss() } } } @@ -737,7 +881,7 @@ extension AddSourceViewController: UICollectionViewDelegateFlowLayout case (.preview, UICollectionView.elementKindSectionFooter): let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ReuseID.placeholderFooter.rawValue, for: indexPath) as! PlaceholderCollectionReusableView - self.configure(footerView, with: self.viewModel.sourcePreviewResult) + self.configure(footerView, with: self.viewModel.sourcePreviewResults) return footerView diff --git a/AltStore/Sources/Components/AddSourceTextFieldCell.swift b/AltStore/Sources/Components/AddSourceTextFieldCell.swift index 1a09959b..1a8af574 100644 --- a/AltStore/Sources/Components/AddSourceTextFieldCell.swift +++ b/AltStore/Sources/Components/AddSourceTextFieldCell.swift @@ -21,7 +21,7 @@ class AddSourceTextFieldCell: UICollectionViewCell self.textField.translatesAutoresizingMaskIntoConstraints = false self.textField.placeholder = "apps.sidestore.io" self.textField.textContentType = .URL - self.textField.keyboardType = .URL +// self.textField.keyboardType = .URL // we can add multiple sources now delimited by spaces/newline so we use normal keyboard not url keyboard self.textField.returnKeyType = .done self.textField.autocapitalizationType = .none self.textField.autocorrectionType = .no diff --git a/AltStore/Sources/Sources.storyboard b/AltStore/Sources/Sources.storyboard index 35cc8703..d521df37 100644 --- a/AltStore/Sources/Sources.storyboard +++ b/AltStore/Sources/Sources.storyboard @@ -1,9 +1,9 @@ - + - + @@ -224,6 +224,11 @@ + + + + + diff --git a/Makefile b/Makefile index 9247cd85..8bd614cd 100755 --- a/Makefile +++ b/Makefile @@ -167,22 +167,86 @@ test: BUILD_CONFIG ?= Release MARKETING_VERSION ?= BUNDLE_ID_SUFFIX ?= +# Common build settings for xcodebuild +COMMON_BUILD_SETTINGS = \ + -workspace AltStore.xcworkspace \ + -scheme SideStore \ + -sdk iphoneos \ + -configuration $(BUILD_CONFIG) \ + CODE_SIGNING_REQUIRED=NO \ + AD_HOC_CODE_SIGNING_ALLOWED=YES \ + CODE_SIGNING_ALLOWED=NO \ + DEVELOPMENT_TEAM=XYZ0123456 \ + ORG_IDENTIFIER=com.SideStore + +# Append MARKETING_VERSION if it’s not empty (coz otherwise the blank entry becomes override) +ifneq ($(strip $(MARKETING_VERSION)),) +COMMON_BUILD_SETTINGS += MARKETING_VERSION=$(MARKETING_VERSION) +endif + +# Append BUNDLE_ID_SUFFIX if it’s not empty (coz otherwise the blank entry becomes override) +ifneq ($(strip $(BUNDLE_ID_SUFFIX)),) +COMMON_BUILD_SETTINGS += BUNDLE_ID_SUFFIX=$(BUNDLE_ID_SUFFIX) +endif + build: @echo ">>>>>>>>> BUILD_CONFIG is set to '$(BUILD_CONFIG)', Building for $(BUILD_CONFIG) mode! <<<<<<<<<<" @echo "" - @xcodebuild -workspace AltStore.xcworkspace \ - -scheme SideStore \ - -sdk iphoneos \ - -configuration $(BUILD_CONFIG) \ - archive -archivePath ./SideStore \ - CODE_SIGNING_REQUIRED=NO \ - AD_HOC_CODE_SIGNING_ALLOWED=YES \ - CODE_SIGNING_ALLOWED=NO \ - DEVELOPMENT_TEAM=XYZ0123456 \ - ORG_IDENTIFIER=com.SideStore \ - MARKETING_VERSION=$(MARKETING_VERSION) \ - BUNDLE_ID_SUFFIX=$(BUNDLE_ID_SUFFIX) -# DWARF_DSYM_FOLDER_PATH="." + @xcodebuild archive -archivePath ./SideStore \ + $(COMMON_BUILD_SETTINGS) + +build-and-test: + @rm -rf build/tests/test-results.xcresult + @echo ">>>>>>>>> BUILD_CONFIG is set to '$(BUILD_CONFIG)', Building for $(BUILD_CONFIG) mode! <<<<<<<<<<" + @echo "" + @echo "Performing a build and running tests..." + @xcodebuild test \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2' \ + -resultBundlePath build/tests/test-results.xcresult \ + -enableCodeCoverage YES \ + $(COMMON_BUILD_SETTINGS) + +build-tests: + @rm -rf build/tests/test-results.xcresult + @echo ">>>>>>>>> BUILD_CONFIG is set to '$(BUILD_CONFIG)', Building Tests for $(BUILD_CONFIG) mode! <<<<<<<<<<" + @echo "" + @echo "Performing a build-for-testing..." + @xcodebuild build-for-testing \ + -enableCodeCoverage YES \ + $(COMMON_BUILD_SETTINGS) + +run-tests: + @rm -rf build/tests/test-results.xcresult + @echo ">>>>>>>>> BUILD_CONFIG is set to '$(BUILD_CONFIG)', Testing for $(BUILD_CONFIG) mode! <<<<<<<<<<" + @echo "" + @echo "Performing a test-without-building..." + @xcodebuild test-without-building \ + -enableCodeCoverage YES \ + -resultBundlePath build/tests/test-results.xcresult \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2' \ + $(COMMON_BUILD_SETTINGS) + +boot-sim-async: + @if xcrun simctl list devices "iPhone 16 Pro" | grep -q "Booted"; then \ + echo "Simulator 'iPhone 16 Pro' is already booted."; \ + else \ + echo "Booting simulator 'iPhone 16 Pro' asynchronously..."; \ + xcrun simctl boot "iPhone 16 Pro" & \ + echo "Simulator boot command dispatched."; \ + fi + +sim-boot-check: + @echo "Checking simulator boot status..." + @if xcrun simctl list devices "iPhone 16 Pro" | grep -q "Booted"; then \ + echo "Simulator 'iPhone 16 Pro' is booted."; \ + else \ + echo "Simulator bootup failed or is not booted yet."; \ + exit 1; \ + fi + +clean-build: + @echo "Cleaning build artifacts..." + @xcodebuild clean -workspace AltStore.xcworkspace -scheme SideStore fakesign-apps: rm -rf SideStore.xcarchive/Products/Applications/SideStore.app/Frameworks/AltStoreCore.framework/Frameworks/ @@ -308,7 +372,7 @@ ipa-altbackup: checkPaths copy-altbackup @mkdir -p "$(ALT_APP_PAYLOAD_DST)/$(TARGET_NAME)" @echo " Copying from $(ALT_APP_SRC) into $(ALT_APP_PAYLOAD_DST)" @cp -R -f "$(ALT_APP_SRC)/." "$(ALT_APP_PAYLOAD_DST)/$(TARGET_NAME)" - @pushd "$(ALT_APP_DST_ARCHIVE)" && zip -r "../../$(ALT_APP_IPA_DST)" Payload && popd + @pushd "$(ALT_APP_DST_ARCHIVE)" && zip -r "../../$(ALT_APP_IPA_DST)" Payload || popd @cp -f "$(ALT_APP_IPA_DST)" AltStore/Resources @echo " IPA created: AltStore/Resources/AltBackup.ipa" @@ -317,11 +381,8 @@ clean-altbackup: @echo "====> Cleaning up AltBackup related artifacts <====" @rm -rf build/altbackup.xcarchive/ @rm -f build/AltBackup.ipa - @rm -f AltStore/Resources/AltBackup.ipa + #@rm -f AltStore/Resources/AltBackup.ipa clean: clean-altbackup - @rm -rf *.xcarchive/ - @rm -rf *.dSYM/ @rm -rf SideStore.ipa @rm -rf build/ - @rm -rf Payload/ diff --git a/SideStore/Tests/DataStructureTests.xctestplan b/SideStore/Tests/DataStructureTests.xctestplan new file mode 100644 index 00000000..1ccf58e8 --- /dev/null +++ b/SideStore/Tests/DataStructureTests.xctestplan @@ -0,0 +1,35 @@ +{ + "configurations" : [ + { + "id" : "93E5E265-DC67-47F3-A214-8082A3421288", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "targetForVariableExpansion" : { + "containerPath" : "container:AltStore.xcodeproj", + "identifier" : "BFD247692284B9A500981D42", + "name" : "SideStore" + } + }, + "testTargets" : [ + { + "skippedTests" : { + "suites" : [ + { + "name" : "DataStructuresTests" + } + ] + }, + "target" : { + "containerPath" : "container:AltStore.xcodeproj", + "identifier" : "A81A8CC42D68BA610086C96F", + "name" : "DataStructureTests" + } + } + ], + "version" : 2 +} diff --git a/SideStore/Tests/SideStoreTests.xctestplan b/SideStore/Tests/SideStoreTests.xctestplan new file mode 100644 index 00000000..d0177c64 --- /dev/null +++ b/SideStore/Tests/SideStoreTests.xctestplan @@ -0,0 +1,33 @@ +{ + "configurations" : [ + { + "id" : "93E5E265-DC67-47F3-A214-8082A3421288", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "targetForVariableExpansion" : { + "containerPath" : "container:AltStore.xcodeproj", + "identifier" : "BFD247692284B9A500981D42", + "name" : "SideStore" + } + }, + "testTargets" : [ + { + "skippedTests" : [ + "UITests\/testLaunchPerformance()", + "UITestsLaunchTests", + "UITestsLaunchTests\/testLaunch()" + ], + "target" : { + "containerPath" : "container:AltStore.xcodeproj", + "identifier" : "A8E2DB202D684CBD009E5D31", + "name" : "UITests" + } + } + ], + "version" : 1 +} diff --git a/SideStore/Tests/UITests/UITests.swift b/SideStore/Tests/UITests/UITests.swift new file mode 100644 index 00000000..0cf187fb --- /dev/null +++ b/SideStore/Tests/UITests/UITests.swift @@ -0,0 +1,459 @@ +// +// UITests.swift +// UITests +// +// Created by Magesh K on 21/02/25. +// Copyright © 2025 SideStore. All rights reserved. +// + +import XCTest +import Foundation + +final class UITests: XCTestCase { + + // Handle to the homescreen UI + private static let springboard_app = XCUIApplication(bundleIdentifier: "com.apple.springboard") + private static let spotlight_app = XCUIApplication(bundleIdentifier: "com.apple.Spotlight") + + private static let searchBar = spotlight_app.textFields["SpotlightSearchField"] + + private static let APP_NAME = "SideStore" + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. +// Self.dismissSpotlight() +// Self.deleteMyApp() + Self.deleteMyApp2() + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + super.tearDown() + } + + +// @MainActor // Xcode 16.2 bug: UITest Record Button Disabled with @MainActor, see: https://stackoverflow.com/a/79445950/11971304 + func testBulkAddRecommendedSources() throws { + + let app = XCUIApplication() + app.launch() + + let systemAlert = Self.springboard_app.alerts["“\(Self.APP_NAME)” Would Like to Send You Notifications"] + + // if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence + XCTAssertTrue(systemAlert.exists || systemAlert.waitForExistence(timeout: 5), "Notifications alert did not appear") + systemAlert.scrollViews.otherElements.buttons["Allow"].tap() + + // Do the actual validation + try performBulkAddingRecommendedSources(for: app) + } + + func testBulkAddInputSources() throws { + + let app = XCUIApplication() + app.launch() + + let systemAlert = Self.springboard_app.alerts["“\(Self.APP_NAME)” Would Like to Send You Notifications"] + + // if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence + XCTAssertTrue(systemAlert.exists || systemAlert.waitForExistence(timeout: 5), "Notifications alert did not appear") + systemAlert.scrollViews.otherElements.buttons["Allow"].tap() + + // Do the actual validation + try performBulkAddingInputSources(for: app) + } + + func testRepeatabilityForStagingInputSources() throws { + + let app = XCUIApplication() + app.launch() + + let systemAlert = Self.springboard_app.alerts["“\(Self.APP_NAME)” Would Like to Send You Notifications"] + + // if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence + XCTAssertTrue(systemAlert.exists || systemAlert.waitForExistence(timeout: 5), "Notifications alert did not appear") + systemAlert.scrollViews.otherElements.buttons["Allow"].tap() + + // Do the actual validation + try performRepeatabilityForStagingInputSources(for: app) + } + + func testRepeatabilityForStagingRecommendedSources() throws { + + let app = XCUIApplication() + app.launch() + + let systemAlert = Self.springboard_app.alerts["“\(Self.APP_NAME)” Would Like to Send You Notifications"] + + // if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence + XCTAssertTrue(systemAlert.exists || systemAlert.waitForExistence(timeout: 5), "Notifications alert did not appear") + systemAlert.scrollViews.otherElements.buttons["Allow"].tap() + + // Do the actual validation + try performRepeatabilityForStagingRecommendedSources(for: app) + } + + +// @MainActor +// func testLaunchPerformance() throws { +// if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { +// // This measures how long it takes to launch your application. +// measure(metrics: [XCTApplicationLaunchMetric()]) { +// XCUIApplication().launch() +// } +// } +// } +} + +// Helpers +private extension UITests { + + class func dismissSpotlight(){ + // ignore spotlight if it was shown + if searchBar.exists { + let clearButton = searchBar.buttons["Clear text"] + if clearButton.exists{ + clearButton.tap() + } + } + springboard_app.tap() + } + + class func deleteMyApp() { + XCUIApplication().terminate() + dismissSpringboardAlerts() + +// XCUIDevice.shared.press(.home) + springboard_app.swipeDown() + + let searchBar = Self.searchBar + _ = searchBar.exists || searchBar.waitForExistence(timeout: 5) + searchBar.typeText(APP_NAME) + + // Rest of the deletion flow... + let appIcon = spotlight_app.icons[APP_NAME] + // if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence + if appIcon.exists || appIcon.waitForExistence(timeout: 5) { + appIcon.press(forDuration: 1) + + let deleteAppButton = spotlight_app.buttons["Delete App"] + _ = deleteAppButton.exists || deleteAppButton.waitForExistence(timeout: 5) + deleteAppButton.tap() + + let confirmDeleteButton = springboard_app.alerts["Delete “\(APP_NAME)”?"] + _ = confirmDeleteButton.exists || confirmDeleteButton.waitForExistence(timeout: 5) + confirmDeleteButton.scrollViews.otherElements.buttons["Delete"].tap() + } + + let clearButton = searchBar.buttons["Clear text"] + _ = clearButton.exists || clearButton.waitForExistence(timeout: 5) + clearButton.tap() + + springboard_app.tap() + } + + class func deleteMyApp2() { + XCUIApplication().terminate() + dismissSpringboardAlerts() + + // Rest of the deletion flow... + let appIcon = springboard_app.icons[APP_NAME] + // if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence + if appIcon.exists || appIcon.waitForExistence(timeout: 5) { + appIcon.press(forDuration: 1) + + do { + let button = springboard_app.buttons["Remove App"] + _ = button.exists || button.waitForExistence(timeout: 5) + button.tap() + } + do { + let button = springboard_app.buttons["Delete App"] + _ = button.waitForExistence(timeout: 0.3) + button.tap() + } + do { + let button = springboard_app.buttons["Delete"] + _ = button.waitForExistence(timeout: 0.3) + button.tap() + } + +// // Press home once to make the icons stop wiggling +// XCUIDevice.shared.press(.home) + } + } + + + + class func dismissSpringboardAlerts() { + for alert in springboard_app.alerts.allElementsBoundByIndex { + if alert.exists { + // If there's a "Cancel" button, tap it; otherwise, tap the first button. + if alert.buttons["Cancel"].exists { + alert.buttons["Cancel"].tap() + } else if let firstButton = alert.buttons.allElementsBoundByIndex.first { + firstButton.tap() + } + } + } + } +} + + +struct SeededGenerator: RandomNumberGenerator { + var seed: UInt64 + + mutating func next() -> UInt64 { + // A basic LCG (not cryptographically secure, but fine for testing) + seed = 6364136223846793005 &* seed &+ 1 + return seed + } +} + + +// Test guts (definition) +private extension UITests { + + + private func performBulkAdd( + app: XCUIApplication, + sourceMappings: [(identifier: String, alertTitle: String, requiresSwipe: Bool)], + cellsQuery: XCUIElementQuery + ) throws { + + // Tap on each sourceMappings source's "add" button. + try tapAddForThesePickedSources(app: app, sourceMappings: sourceMappings, cellsQuery: cellsQuery) + + // Commit the changes by tapping "Done". + app.navigationBars["Add Source"].buttons["Done"].tap() + + // Accept each source addition via alert. + for source in sourceMappings { + let alertIdentifier = "Would you like to add the source “\(source.alertTitle)”?" + let addSourceButton = app.alerts[alertIdentifier] + .scrollViews.otherElements.buttons["Add Source"] + _ = addSourceButton.exists || addSourceButton.waitForExistence(timeout: 0.3) + addSourceButton.tap() + } + } + + + + private func performBulkAddingInputSources(for app: XCUIApplication) throws { + + // set content into clipboard (for bulk add (paste)) + // NOTE: THIS IS AN ORDERED SEQUENCE AND MUST MATCH THE ORDER in textInputSources BELOW (Remember to take this into account when adding more entries) + UIPasteboard.general.string = """ + https://alts.lao.sb + https://taurine.app/altstore/taurinestore.json + https://randomblock1.com/altstore/apps.json + https://burritosoftware.github.io/altstore/channels/burritosource.json + https://bit.ly/40Isul6 + https://bit.ly/wuxuslibraryplus + https://bit.ly/Quantumsource-plus + https://bit.ly/Altstore-complete + https://bit.ly/Quantumsource + """.trimmedIndentation + + let app = XCUIApplication() + app.tabBars["Tab Bar"].buttons["Sources"].tap() + app.navigationBars["Sources"].buttons["Add"].tap() + + let collectionViewsQuery = app.collectionViews + let appsSidestoreIoTextField = collectionViewsQuery.textFields["apps.sidestore.io"] + _ = appsSidestoreIoTextField.exists || appsSidestoreIoTextField.waitForExistence(timeout: 5) + appsSidestoreIoTextField.tap() + appsSidestoreIoTextField.tap() + collectionViewsQuery.staticTexts["Paste"].tap() + +// if app.keyboards.buttons["Return"].exists { +// app.keyboards.buttons["Return"].tap() +// } else if app.keyboards.buttons["Done"].exists { +// app.keyboards.buttons["Done"].tap() +// } else { +// // if still exists try tapping outside of text field focus +// app.tap() +// } + + if app.keyboards.count > 0 { + appsSidestoreIoTextField.typeText("\n") // Fallback to newline so that soft kb is dismissed + } + + let cellsQuery = collectionViewsQuery.cells + + // Data model for recommended sources. NOTE: This list order is required to be the same as that of "Add Source" Screen + let textInputSources: [(identifier: String, alertTitle: String, requiresSwipe: Bool)] = [ + ("Laoalts\nalts.lao.sb", "Laoalts", false), + ("Taurine\ntaurine.app/altstore/taurinestore.json", "Taurine", false), + ("RandomSource\nrandomblock1.com/altstore/apps.json", "RandomSource", false), + ("Burrito's AltStore\nburritosoftware.github.io/altstore/channels/burritosource.json", "Burrito's AltStore", true), + ("Qn_'s AltStore Repo\nbit.ly/40Isul6", "Qn_'s AltStore Repo", false), + ("WuXu's Library++\nThe Most Up-To-Date IPA Library on AltStore.", "WuXu's Library++", false), + ("Quantum Source++\nContains tweaked apps, free streaming, cracked apps, and more.", "Quantum Source++", false), + ("AltStore Complete\nContains tweaked apps, free streaming, cracked apps, and more.", "AltStore Complete", false), + ("Quantum Source\nContains all of your favorite emulators, games, jailbreaks, utilities, and more.", "Quantum Source", false), + ] + + try performBulkAdd(app: app, sourceMappings: textInputSources, cellsQuery: cellsQuery) + } + + + private func performRepeatabilityForStagingInputSources(for app: XCUIApplication) throws { + + // set content into clipboard (for bulk add (paste)) + // NOTE: THIS IS AN ORDERED SEQUENCE AND MUST MATCH THE ORDER in textInputSources BELOW (Remember to take this into account when adding more entries) + UIPasteboard.general.string = """ + https://alts.lao.sb + https://taurine.app/altstore/taurinestore.json + https://randomblock1.com/altstore/apps.json + https://burritosoftware.github.io/altstore/channels/burritosource.json + https://bit.ly/40Isul6 + """.trimmedIndentation + + let app = XCUIApplication() + app.tabBars["Tab Bar"].buttons["Sources"].tap() + app.navigationBars["Sources"].buttons["Add"].tap() + + let collectionViewsQuery = app.collectionViews + let appsSidestoreIoTextField = collectionViewsQuery.textFields["apps.sidestore.io"] + _ = appsSidestoreIoTextField.exists || appsSidestoreIoTextField.waitForExistence(timeout: 5) + appsSidestoreIoTextField.tap() + appsSidestoreIoTextField.tap() + _ = appsSidestoreIoTextField.exists || appsSidestoreIoTextField.waitForExistence(timeout: 5) + collectionViewsQuery.staticTexts["Paste"].tap() + + if app.keyboards.count > 0 { + appsSidestoreIoTextField.typeText("\n") // Fallback to newline so that soft kb is dismissed + } + + let cellsQuery = collectionViewsQuery.cells + + // Data model for recommended sources. NOTE: This list order is required to be the same as that of "Add Source" Screen + let textInputSources: [(identifier: String, alertTitle: String, requiresSwipe: Bool)] = [ + ("Laoalts\nalts.lao.sb", "Laoalts", false), + ("Taurine\ntaurine.app/altstore/taurinestore.json", "Taurine", false), + ("RandomSource\nrandomblock1.com/altstore/apps.json", "RandomSource", false), + ("Burrito's AltStore\nburritosoftware.github.io/altstore/channels/burritosource.json", "Burrito's AltStore", false), + ("Qn_'s AltStore Repo\nbit.ly/40Isul6", "Qn_'s AltStore Repo", false), + ] + + let repeatCount = 3 // number of times to run the entire sequence + let timeSeed = UInt64(Date().timeIntervalSince1970) // time is unique (upto microseconds) - uncomment this to use non-deterministic seed based RNG (random number generator) + + try repeatabilityTest(app: app, sourceMappings: textInputSources, cellsQuery: cellsQuery, repeatCount: repeatCount, seed: timeSeed) + } + + private func repeatabilityTest( + app: XCUIApplication, + sourceMappings: [(identifier: String, alertTitle: String, requiresSwipe: Bool)], + cellsQuery: XCUIElementQuery, + repeatCount: Int = 1, // number of times to run the entire sequence + seed: UInt64 = 42 // default = fixed seed for deterministic start of this generator + ) throws { + let seededGenerator = SeededGenerator(seed: seed) + + for _ in 0.. Void)? + init(id: Int, onDeinit: (() -> Void)? = nil) { + self.id = id + self.onDeinit = onDeinit + } + deinit { + onDeinit?() + } +} + +final class LinkedHashMapTests: XCTestCase { + + // Test that insertion preserves order and that iteration returns items in insertion order. + func testInsertionAndOrder() { + let map = LinkedHashMap() + map.put(key: "one", value: 1) + map.put(key: "two", value: 2) + map.put(key: "three", value: 3) + + XCTAssertEqual(map.count, 3) + XCTAssertEqual(map.keys, ["one", "two", "three"], "Insertion order should be preserved") + + var iteratedKeys = [String]() + for (key, _) in map { + iteratedKeys.append(key) + } + XCTAssertEqual(iteratedKeys, ["one", "two", "three"], "Iterator should follow insertion order") + } + + // Test that updating a key does not change its order. + func testUpdateDoesNotChangeOrder() { + let map = LinkedHashMap() + map.put(key: "a", value: 1) + map.put(key: "b", value: 2) + map.put(key: "c", value: 3) + // Update key "b" + map.put(key: "b", value: 20) + XCTAssertEqual(map.get(key: "b"), 20) + + XCTAssertEqual(map.keys, ["a", "b", "c"], "Order should not change on update") + } + + // Test removal functionality and behavior when removing a non-existent key. + func testRemoval() { + let map = LinkedHashMap() + map.put(key: 1, value: "one") + map.put(key: 2, value: "two") + map.put(key: 3, value: "three") + + let removed = map.remove(key: 2) + XCTAssertEqual(removed, "two") + XCTAssertEqual(map.count, 2) + XCTAssertEqual(map.keys, [1, 3]) + + // Removing a key that doesn't exist should return nil. + let removedNil = map.remove(key: 4) + XCTAssertNil(removedNil) + } + + // Test clearing the map. + func testClear() { + let map = LinkedHashMap() + map.put(key: "x", value: 100) + map.put(key: "y", value: 200) + XCTAssertEqual(map.count, 2) + + map.clear() + XCTAssertEqual(map.count, 0) + XCTAssertTrue(map.isEmpty) + XCTAssertEqual(map.keys, []) + XCTAssertEqual(map.values, []) + } + + // Test subscript access for getting, updating, and removal. + func testSubscript() { + let map = LinkedHashMap() + map["alpha"] = 10 + XCTAssertEqual(map["alpha"], 10) + + map["alpha"] = 20 + XCTAssertEqual(map["alpha"], 20) + + // Setting a key to nil should remove the mapping. + map["alpha"] = nil + XCTAssertNil(map["alpha"]) + } + + // Test containsKey and containsValue. + func testContains() { + let map = LinkedHashMap() + map.put(key: "key1", value: 1) + map.put(key: "key2", value: 2) + + XCTAssertTrue(map.containsKey("key1")) + XCTAssertFalse(map.containsKey("key3")) + + XCTAssertTrue(map.containsValue(1)) + XCTAssertFalse(map.containsValue(99)) + } + + // Test initialization from a dictionary. + func testInitializationFromDictionary() { + // Note: Swift dictionaries preserve insertion order for literals. + let dictionary: [String: Int] = ["a": 1, "b": 2, "c": 3] + let map = LinkedHashMap(dictionary) + XCTAssertEqual(map.count, 3) + // Order may differ since Dictionary order is not strictly defined – here we verify membership. + XCTAssertEqual(Set(map.keys), Set(["a", "b", "c"])) + } + + // Revised test that iterates over the map and compares key-value pairs element by element. + func testIteration() { + let map = LinkedHashMap() + let pairs = [(1, "one"), (2, "two"), (3, "three")] + for (key, value) in pairs { + map.put(key: key, value: value) + } + + var iteratedPairs = [(Int, String)]() + for (key, value) in map { + iteratedPairs.append((key, value)) + } + + XCTAssertEqual(iteratedPairs.count, pairs.count, "Iterated count should match inserted count") + for (iter, expected) in zip(iteratedPairs, pairs) { + XCTAssertEqual(iter.0, expected.0, "Keys should match in order") + XCTAssertEqual(iter.1, expected.1, "Values should match in order") + } + } + + // Test that the values stored in the map are deallocated when the map is deallocated. + func testMemoryLeak() { + weak var weakMap: LinkedHashMap? + var deinitCalled = false + + do { + let map = LinkedHashMap() + let tester = LeakTester(id: 1) { deinitCalled = true } + map.put(key: 1, value: tester) + weakMap = map + XCTAssertNotNil(map.get(key: 1)) + } + // At this point the map (and its stored objects) should be deallocated. + XCTAssertNil(weakMap, "LinkedHashMap should be deallocated when out of scope") + XCTAssertTrue(deinitCalled, "LeakTester should be deallocated, indicating no memory leak") + } + + // Test that removal from the map correctly frees stored objects. + func testMemoryLeakOnRemoval() { + var deinitCalledForTester1 = false + var deinitCalledForTester2 = false + + let map = LinkedHashMap() + autoreleasepool { + let tester1 = LeakTester(id: 1) { deinitCalledForTester1 = true } + let tester2 = LeakTester(id: 2) { deinitCalledForTester2 = true } + map.put(key: 1, value: tester1) + map.put(key: 2, value: tester2) + + XCTAssertNotNil(map.get(key: 1)) + XCTAssertNotNil(map.get(key: 2)) + + // Remove tester1; it should be deallocated if no retain cycle exists. + _ = map.remove(key: 1) + } + // tester1 should be deallocated immediately after removal. + XCTAssertTrue(deinitCalledForTester1, "Tester1 should be deallocated after removal") + // tester2 is still in the map. + XCTAssertNotNil(map.get(key: 2)) + + // Clear the map and tester2 should be deallocated. + map.clear() + XCTAssertTrue(deinitCalledForTester2, "Tester2 should be deallocated after clearing the map") + } + + func testDefaultSubscriptExtension() { + // Create an instance of LinkedHashMap with String keys and Bool values. + let map = LinkedHashMap() + + // Verify that accessing a non-existent key returns the default value (false). + XCTAssertEqual(map["testKey", default: false], false) + + // Use the default subscript setter to assign 'true' for the key. + map["testKey", default: false] = true + XCTAssertEqual(map["testKey", default: false], true) + + // Simulate in-place toggle: read the value, toggle it, then write it back. + var current = map["testKey", default: false] + current.toggle() // now false + map["testKey", default: false] = current + XCTAssertEqual(map["testKey", default: false], false) + } +} diff --git a/SideStore/Tests/UnitTests/datastructures/TreeMapTests.swift b/SideStore/Tests/UnitTests/datastructures/TreeMapTests.swift new file mode 100644 index 00000000..11881d08 --- /dev/null +++ b/SideStore/Tests/UnitTests/datastructures/TreeMapTests.swift @@ -0,0 +1,154 @@ +// +// TreeMapTests.swift +// AltStore +// +// Created by Magesh K on 21/02/25. +// Copyright © 2025 SideStore. All rights reserved. +// + + +import XCTest + +class TreeMapTests: XCTestCase { + + func testInsertionAndRetrieval() { + let map = TreeMap() + XCTAssertNil(map[10]) + map[10] = "ten" + XCTAssertEqual(map[10], "ten") + + map[5] = "five" + map[15] = "fifteen" + XCTAssertEqual(map.count, 3) + XCTAssertEqual(map[5], "five") + XCTAssertEqual(map[15], "fifteen") + } + + func testUpdateValue() { + let map = TreeMap() + map[10] = "ten" + let oldValue = map.insert(key: 10, value: "TEN") + XCTAssertEqual(oldValue, "ten") + XCTAssertEqual(map[10], "TEN") + XCTAssertEqual(map.count, 1) + } + + func testDeletion() { + let map = TreeMap() + // Setup: Inserting three nodes. + map[20] = "twenty" + map[10] = "ten" + map[30] = "thirty" + + // Remove a leaf node. + let removedLeaf = map.remove(key: 10) + XCTAssertEqual(removedLeaf, "ten") + XCTAssertNil(map[10]) + XCTAssertEqual(map.count, 2) + + // Setup additional nodes to create a one-child scenario. + map[25] = "twenty-five" + map[27] = "twenty-seven" // Right child for 25. + // Remove a node with one child. + let removedOneChild = map.remove(key: 25) + XCTAssertEqual(removedOneChild, "twenty-five") + XCTAssertNil(map[25]) + XCTAssertEqual(map.count, 3) + + // Setup for a node with two children. + map[40] = "forty" + map[35] = "thirty-five" + map[45] = "forty-five" + // Remove a node with two children. + let removedTwoChildren = map.remove(key: 40) + XCTAssertEqual(removedTwoChildren, "forty") + XCTAssertNil(map[40]) + XCTAssertEqual(map.count, 5) + } + + func testDeletionOfRoot() { + let map = TreeMap() + map[50] = "fifty" + map[30] = "thirty" + map[70] = "seventy" + + // Delete the root node. + let removedRoot = map.remove(key: 50) + XCTAssertEqual(removedRoot, "fifty") + XCTAssertNil(map[50]) + // After deletion, remaining keys should be in sorted order. + XCTAssertEqual(map.keys, [30, 70]) + } + + func testSortedIteration() { + let map = TreeMap() + let keys = [20, 10, 30, 5, 15, 25, 35] + for key in keys { + map[key] = "\(key)" + } + let sortedKeys = map.keys + XCTAssertEqual(sortedKeys, keys.sorted()) + + // Verify in-order traversal. + var previous: Int? = nil + for (key, value) in map { + if let prev = previous { + XCTAssertLessThanOrEqual(prev, key) + } + previous = key + XCTAssertEqual(value, "\(key)") + } + } + + func testRemoveAll() { + let map = TreeMap() + for i in 0..<100 { + map[i] = "\(i)" + } + XCTAssertEqual(map.count, 100) + map.removeAll() + XCTAssertEqual(map.count, 0) + XCTAssertTrue(map.isEmpty) + } + + func testBalancing() { + let map = TreeMap() + // Insert elements in ascending order to challenge the balancing. + for i in 1...1000 { + map[i] = i + } + // Verify in-order traversal produces sorted order. + var expected = 1 + for (key, value) in map { + XCTAssertEqual(key, expected) + XCTAssertEqual(value, expected) + expected += 1 + } + XCTAssertEqual(expected - 1, 1000) + + // Remove odd keys to force rebalancing. + for i in stride(from: 1, through: 1000, by: 2) { + _ = map.remove(key: i) + } + let expectedEvenKeys = (1...1000).filter { $0 % 2 == 0 } + XCTAssertEqual(map.keys, expectedEvenKeys) + } + + func testNonExistentDeletion() { + let map = TreeMap() + map[10] = "ten" + let removed = map.remove(key: 20) + XCTAssertNil(removed) + XCTAssertEqual(map.count, 1) + } + + func testDuplicateInsertion() { + let map = TreeMap() + map["a"] = "first" + XCTAssertEqual(map["a"], "first") + let oldValue = map.insert(key: "a", value: "second") + XCTAssertEqual(oldValue, "first") + XCTAssertEqual(map["a"], "second") + XCTAssertEqual(map.count, 1) + } +} diff --git a/SideStore/Utils/datastructures/LinkedHashMap.swift b/SideStore/Utils/datastructures/LinkedHashMap.swift new file mode 100644 index 00000000..70d1819e --- /dev/null +++ b/SideStore/Utils/datastructures/LinkedHashMap.swift @@ -0,0 +1,226 @@ +// +// LinkedHashMap.swift +// SideStore +// +// Created by Magesh K on 21/02/25. +// Copyright © 2025 SideStore. All rights reserved. +// + + +/// A generic LinkedHashMap implementation in Swift. +/// It provides constant-time lookup along with predictable (insertion) ordering. +public final class LinkedHashMap: Sequence { + + /// Internal doubly-linked list node + fileprivate final class Node { + let key: Key + var value: Value + var next: Node? + weak var prev: Node? // weak to avoid strong reference cycle + + init(key: Key, value: Value) { + self.key = key + self.value = value + } + } + + // MARK: - Storage + + /// Dictionary for fast lookup from key to node. + private var dict: [Key: Node] = [:] + + /// Head and tail of the doubly-linked list to maintain order. + private var head: Node? + private var tail: Node? + + // MARK: - Initialization + + /// Creates an empty LinkedHashMap. + public init() { } + + /// Creates a LinkedHashMap from a standard dictionary. + public init(_ dictionary: [Key: Value]) { + for (key, value) in dictionary { + _ = self.put(key: key, value: value) + } + } + + // MARK: - Public API + + /// The number of key-value pairs in the map. + public var count: Int { + return dict.count + } + + /// A Boolean value indicating whether the map is empty. + public var isEmpty: Bool { + return dict.isEmpty + } + + /// Returns the value for the given key, or `nil` if the key is not found. + public func get(key: Key) -> Value? { + return dict[key]?.value + } + + /// Inserts or updates the value for the given key. + /// - Returns: The previous value for the key if it existed; otherwise, `nil`. + @discardableResult + public func put(key: Key, value: Value) -> Value? { + if let node = dict[key] { + let oldValue = node.value + node.value = value + return oldValue + } else { + let newNode = Node(key: key, value: value) + dict[key] = newNode + appendNode(newNode) + return nil + } + } + + /// Removes the value for the given key. + /// - Returns: The removed value if it existed; otherwise, `nil`. + @discardableResult + public func remove(key: Key) -> Value? { + guard let node = dict.removeValue(forKey: key) else { return nil } + removeNode(node) + return node.value + } + + /// Removes all key-value pairs from the map. + public func clear() { + dict.removeAll() + head = nil + tail = nil + } + + /// Determines whether the map contains the given key. + public func containsKey(_ key: Key) -> Bool { + return dict[key] != nil + } + + /// Determines whether the map contains the given value. + /// Note: This method requires that Value conforms to Equatable. + public func containsValue(_ value: Value) -> Bool where Value: Equatable { + var current = head + while let node = current { + if node.value == value { + return true + } + current = node.next + } + return false + } + + /// Returns all keys in insertion order. + public var keys: [Key] { + var result = [Key]() + var current = head + while let node = current { + result.append(node.key) + current = node.next + } + return result + } + + /// Returns all values in insertion order. + public var values: [Value] { + var result = [Value]() + var current = head + while let node = current { + result.append(node.value) + current = node.next + } + return result + } + + /// Subscript for getting and setting values. + public subscript(key: Key) -> Value? { + get { + return get(key: key) + } + set { + if let newValue = newValue { + _ = put(key: key, value: newValue) + } else { + _ = remove(key: key) + } + } + } + + // MARK: - Sequence Conformance + + /// Iterator that yields key-value pairs in insertion order. + public struct Iterator: IteratorProtocol { + private var current: Node? + + fileprivate init(start: Node?) { + self.current = start + } + + public mutating func next() -> (key: Key, value: Value)? { + guard let node = current else { return nil } + current = node.next + return (node.key, node.value) + } + } + + public func makeIterator() -> Iterator { + return Iterator(start: head) + } + + // MARK: - Private Helpers + + /// Appends a new node to the end of the linked list. + private func appendNode(_ node: Node) { + if let tailNode = tail { + tailNode.next = node + node.prev = tailNode + tail = node + } else { + head = node + tail = node + } + } + + /// Removes the given node from the linked list. + private func removeNode(_ node: Node) { + let prevNode = node.prev + let nextNode = node.next + + if let prevNode = prevNode { + prevNode.next = nextNode + } else { + head = nextNode + } + + if let nextNode = nextNode { + nextNode.prev = prevNode + } else { + tail = prevNode + } + + // Disconnect node's pointers. + node.prev = nil + node.next = nil + } + + public func removeValue(forKey key: Key) -> Value? { + return remove(key: key) + } +} + +extension LinkedHashMap { + public subscript(key: Key, default defaultValue: @autoclosure () -> Value) -> Value { + get { + if let value = self[key] { + return value + } else { + return defaultValue() + } + } + set { + self[key] = newValue + } + } +} diff --git a/SideStore/Utils/datastructures/TreeMap.swift b/SideStore/Utils/datastructures/TreeMap.swift new file mode 100644 index 00000000..7afca202 --- /dev/null +++ b/SideStore/Utils/datastructures/TreeMap.swift @@ -0,0 +1,397 @@ +// +// TreeMap.swift +// SideStore +// +// Created by Magesh K on 21/02/25. +// Copyright © 2025 SideStore. All rights reserved. +// + +public class TreeMap: Sequence { + + // MARK: - Node and Color Definitions + + fileprivate enum Color { + case red + case black + } + + fileprivate class Node { + var key: Key + var value: Value + var left: Node? + var right: Node? + weak var parent: Node? + var color: Color + + init(key: Key, value: Value, color: Color = .red, parent: Node? = nil) { + self.key = key + self.value = value + self.color = color + self.parent = parent + } + } + + // MARK: - TreeMap Properties and Initializer + + private var root: Node? + public private(set) var count: Int = 0 + + public init() {} + + // MARK: - Public Dictionary-like API + + /// Subscript: Get or set value for a given key. + public subscript(key: Key) -> Value? { + get { return get(key: key) } + set { + if let newValue = newValue { + _ = insert(key: key, value: newValue) + } else { + _ = remove(key: key) + } + } + } + + /// Returns the value associated with the given key. + public func get(key: Key) -> Value? { + guard let node = getNode(forKey: key) else { return nil } + return node.value + } + + /// Inserts (or updates) the key with the given value. + /// Returns the old value if the key was already present. + @discardableResult + public func insert(key: Key, value: Value) -> Value? { + if let node = getNode(forKey: key) { + let oldValue = node.value + node.value = value + return oldValue + } + // Create new node + let newNode = Node(key: key, value: value) + var parent: Node? = nil + var current = root + while let cur = current { + parent = cur + if newNode.key < cur.key { + current = cur.left + } else { + current = cur.right + } + } + newNode.parent = parent + if parent == nil { + root = newNode + } else if newNode.key < parent!.key { + parent!.left = newNode + } else { + parent!.right = newNode + } + count += 1 + fixAfterInsertion(newNode) + return nil + } + + /// Removes the node with the given key. + /// Returns the removed value if it existed. + @discardableResult + public func remove(key: Key) -> Value? { + guard let node = getNode(forKey: key) else { return nil } + let removedValue = node.value + deleteNode(node) + count -= 1 + return removedValue + } + + /// Returns true if the map is empty. + public var isEmpty: Bool { + return count == 0 + } + + /// Returns all keys in sorted order. + public var keys: [Key] { + var result = [Key]() + for (k, _) in self { result.append(k) } + return result + } + + /// Returns all values in order of their keys. + public var values: [Value] { + var result = [Value]() + for (_, v) in self { result.append(v) } + return result + } + + /// Removes all entries. + public func removeAll() { + root = nil + count = 0 + } + + // MARK: - Internal Helper Methods + + /// Standard BST search for a node matching the key. + private func getNode(forKey key: Key) -> Node? { + var current = root + while let node = current { + if key == node.key { + return node + } else if key < node.key { + current = node.left + } else { + current = node.right + } + } + return nil + } + + /// Returns the minimum node in the subtree rooted at `node`. + private func minimum(_ node: Node) -> Node { + var current = node + while let next = current.left { + current = next + } + return current + } + + // MARK: - Rotation Methods + + private func rotateLeft(_ x: Node) { + guard let y = x.right else { return } + x.right = y.left + if let leftChild = y.left { + leftChild.parent = x + } + y.parent = x.parent + if x.parent == nil { + root = y + } else if x === x.parent?.left { + x.parent?.left = y + } else { + x.parent?.right = y + } + y.left = x + x.parent = y + } + + private func rotateRight(_ x: Node) { + guard let y = x.left else { return } + x.left = y.right + if let rightChild = y.right { + rightChild.parent = x + } + y.parent = x.parent + if x.parent == nil { + root = y + } else if x === x.parent?.right { + x.parent?.right = y + } else { + x.parent?.left = y + } + y.right = x + x.parent = y + } + + // MARK: - Insertion Fix-Up + + /// Restores red–black properties after insertion. + private func fixAfterInsertion(_ x: Node) { + var node = x + node.color = .red + while node !== root, let parent = node.parent, parent.color == .red { + if parent === parent.parent?.left { + if let uncle = parent.parent?.right, uncle.color == .red { + parent.color = .black + uncle.color = .black + parent.parent?.color = .red + if let grandparent = parent.parent { + node = grandparent + } + } else { + if node === parent.right { + node = parent + rotateLeft(node) + } + node.parent?.color = .black + node.parent?.parent?.color = .red + if let grandparent = node.parent?.parent { + rotateRight(grandparent) + } + } + } else { + if let uncle = parent.parent?.left, uncle.color == .red { + parent.color = .black + uncle.color = .black + parent.parent?.color = .red + if let grandparent = parent.parent { + node = grandparent + } + } else { + if node === parent.left { + node = parent + rotateRight(node) + } + node.parent?.color = .black + node.parent?.parent?.color = .red + if let grandparent = node.parent?.parent { + rotateLeft(grandparent) + } + } + } + } + root?.color = .black + } + + // MARK: - Deletion Helpers + + /// Replaces subtree rooted at u with subtree rooted at v. + private func transplant(_ u: Node, _ v: Node?) { + if u.parent == nil { + root = v + } else if u === u.parent?.left { + u.parent?.left = v + } else { + u.parent?.right = v + } + if let vNode = v { + vNode.parent = u.parent + } + } + + /// Deletes node z and fixes red–black properties. + private func deleteNode(_ z: Node) { + var y = z + let originalColor = y.color + var x: Node? + + if z.left == nil { + x = z.right + transplant(z, z.right) + } else if z.right == nil { + x = z.left + transplant(z, z.left) + } else { + y = minimum(z.right!) + let yOriginalColor = y.color + x = y.right + if y.parent === z { + if x != nil { x!.parent = y } + } else { + transplant(y, y.right) + y.right = z.right + y.right?.parent = y + } + transplant(z, y) + y.left = z.left + y.left?.parent = y + y.color = z.color + if yOriginalColor == .black { + fixAfterDeletion(x, parent: y.parent) + } + return + } + if originalColor == .black { + fixAfterDeletion(x, parent: z.parent) + } + } + + /// Restores red–black properties after deletion. + private func fixAfterDeletion(_ x: Node?, parent: Node?) { + var x = x + var parent = parent + while (x == nil || x!.color == .black) && (x !== root) { + if x === parent?.left { + var w = parent?.right + if w?.color == .red { + w?.color = .black + parent?.color = .red + rotateLeft(parent!) + w = parent?.right + } + if (w?.left == nil || w?.left?.color == .black) && + (w?.right == nil || w?.right?.color == .black) { + w?.color = .red + x = parent + parent = x?.parent + } else { + if w?.right == nil || w?.right?.color == .black { + w?.left?.color = .black + w?.color = .red + if let wUnwrapped = w { rotateRight(wUnwrapped) } + w = parent?.right + } + w?.color = parent?.color ?? .black + parent?.color = .black + w?.right?.color = .black + rotateLeft(parent!) + x = root + parent = nil + } + } else { + var w = parent?.left + if w?.color == .red { + w?.color = .black + parent?.color = .red + rotateRight(parent!) + w = parent?.left + } + if (w?.left == nil || w?.left?.color == .black) && + (w?.right == nil || w?.right?.color == .black) { + w?.color = .red + x = parent + parent = x?.parent + } else { + if w?.left == nil || w?.left?.color == .black { + w?.right?.color = .black + w?.color = .red + if let wUnwrapped = w { rotateLeft(wUnwrapped) } + w = parent?.left + } + w?.color = parent?.color ?? .black + parent?.color = .black + w?.left?.color = .black + rotateRight(parent!) + x = root + parent = nil + } + } + } + x?.color = .black + } + + // Convenience overload if parent is not separately tracked. + private func fixAfterDeletion(_ x: Node?) { + fixAfterDeletion(x, parent: x?.parent) + } + + // MARK: - Sequence Conformance (In-Order Traversal) + + public struct Iterator: IteratorProtocol { + private var stack: [Node] = [] + + // Marked as private because Node is a private type. + fileprivate init(root: Node?) { + var current = root + while let node = current { + stack.append(node) + current = node.left + } + } + + public mutating func next() -> (Key, Value)? { + if stack.isEmpty { return nil } + let node = stack.removeLast() + let result = (node.key, node.value) + var current = node.right + while let n = current { + stack.append(n) + current = n.left + } + return result + } + } + + public func makeIterator() -> Iterator { + return Iterator(root: root) + } +} diff --git a/xcconfigs/UITests.xcconfig b/xcconfigs/UITests.xcconfig new file mode 100644 index 00000000..94c175d2 --- /dev/null +++ b/xcconfigs/UITests.xcconfig @@ -0,0 +1,3 @@ +#include "../Build.xcconfig" + +PRODUCT_BUNDLE_IDENTIFIER = $(PRODUCT_BUNDLE_IDENTIFIER).UITests