Merge pull request #888 from SideStore/develop-alpha

Feature: Bulk sources-add in sources screen with capability to stage them before persisting into database
This commit is contained in:
Magesh K
2025-02-26 11:47:46 +05:30
committed by GitHub
21 changed files with 2837 additions and 244 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -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 </dev/null > 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 <<EOF
This is release for:
- version: "${{ steps.version.outputs.version }}"
- revision: "$SHORT_COMMIT"
- version: "${{ needs.build.outputs.version }}"
- revision: "${{ needs.serialize.outputs.short-commit }}"
- timestamp: "${{ steps.date.outputs.date }}"
EOF
)
echo "IS_BETA=${{ inputs.is_beta }}" >> $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

8
.gitignore vendored
View File

@@ -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
*.xcworkspace/**/Package.resolved
# some more commandline build artifacts
test-recording.mp4
test-recording.log
altstore-sources.md
local-build.sh

View File

@@ -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 = "<group>"; };
A80D790C2D2F20AF00A40F40 /* PaginationIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIntent.swift; sourceTree = "<group>"; };
A80D790E2D2F217000A40F40 /* PaginationDataHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationDataHolder.swift; sourceTree = "<group>"; };
A81A8CB02D68B0320086C96F /* TreeMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreeMap.swift; sourceTree = "<group>"; };
A81A8CB42D68B2180086C96F /* TreeMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreeMapTests.swift; sourceTree = "<group>"; };
A81A8CBC2D68B43F0086C96F /* LinkedHashMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedHashMap.swift; sourceTree = "<group>"; };
A81A8CBF2D68B4520086C96F /* LinkedHashMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedHashMapTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
A81A8CD32D68BAFF0086C96F /* DataStructureTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DataStructureTests.xctestplan; sourceTree = "<group>"; };
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 = "<group>"; };
A85ACB8E2D1F31C400AA3DE7 /* AltBackup.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltBackup.xcconfig; sourceTree = "<group>"; };
A85ACB8F2D1F31C400AA3DE7 /* AltStore.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltStore.debug.xcconfig; sourceTree = "<group>"; };
@@ -667,6 +693,11 @@
A8C38C372D2084D000E83DBD /* ConsoleLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleLogView.swift; sourceTree = "<group>"; };
A8D484D72D0CD306002C691D /* AltBackup.ipa */ = {isa = PBXFileReference; lastKnownFileType = file; path = AltBackup.ipa; sourceTree = "<group>"; };
A8D49F522D3D2F9400844B92 /* ProcessInfo+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfo+AltStore.swift"; sourceTree = "<group>"; };
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 = "<group>"; };
A8E2DB2E2D684E2A009E5D31 /* UITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITests.swift; sourceTree = "<group>"; };
A8E2DB2F2D684E2A009E5D31 /* UITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsLaunchTests.swift; sourceTree = "<group>"; };
A8E2DB332D68507F009E5D31 /* SideStoreTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = SideStoreTests.xctestplan; sourceTree = "<group>"; };
A8EA195E2D4982D600DC6322 /* BaseEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseEntity.swift; sourceTree = "<group>"; };
A8F66C3C2D04D433009689E6 /* em_proxy.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = em_proxy.h; sourceTree = "<group>"; };
A8F66C602D04D464009689E6 /* minimuxer.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = minimuxer.xcodeproj; sourceTree = "<group>"; };
@@ -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 = "<group>";
};
A81A8CB22D68B2030086C96F /* UnitTests */ = {
isa = PBXGroup;
children = (
A81A8CB32D68B20F0086C96F /* datastructures */,
);
path = UnitTests;
sourceTree = "<group>";
};
A81A8CB32D68B20F0086C96F /* datastructures */ = {
isa = PBXGroup;
children = (
A81A8CBF2D68B4520086C96F /* LinkedHashMapTests.swift */,
A81A8CB42D68B2180086C96F /* TreeMapTests.swift */,
A81A8CC72D68BA610086C96F /* DataStructuresTests.swift */,
);
path = datastructures;
sourceTree = "<group>";
};
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 = "<group>";
@@ -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 = "<group>";
};
A8E2DB302D684E2A009E5D31 /* UITests */ = {
isa = PBXGroup;
children = (
A8E2DB2E2D684E2A009E5D31 /* UITests.swift */,
A8E2DB2F2D684E2A009E5D31 /* UITestsLaunchTests.swift */,
);
path = UITests;
sourceTree = "<group>";
};
A8E2DB352D6850A9009E5D31 /* Tests */ = {
isa = PBXGroup;
children = (
A81A8CB22D68B2030086C96F /* UnitTests */,
A8E2DB302D684E2A009E5D31 /* UITests */,
A8E2DB332D68507F009E5D31 /* SideStoreTests.xctestplan */,
A81A8CD32D68BAFF0086C96F /* DataStructureTests.xctestplan */,
);
path = Tests;
sourceTree = "<group>";
};
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 = "<group>";
@@ -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 = (

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<TestPlans>
<TestPlanReference
reference = "container:SideStore/Tests/DataStructureTests.xctestplan"
default = "YES">
</TestPlanReference>
</TestPlans>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A81A8CC42D68BA610086C96F"
BuildableName = "DataStructureTests.xctest"
BlueprintName = "DataStructureTests"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -28,18 +28,27 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<!-- shouldAutocreateTestPlan = "YES"> -->
<TestPlans>
<TestPlanReference
reference = "container:SideStore/Tests/SideStoreTests.xctestplan"
default = "YES">
</TestPlanReference>
</TestPlans>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D586D39728EF58B0000E101F"
BuildableName = "AltTests.xctest"
BlueprintName = "AltTests"
BlueprintIdentifier = "A8E2DB202D684CBD009E5D31"
BuildableName = "UITests.xctest"
BlueprintName = "UITests"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
<SelectedTests>
<Test
Identifier = "UITests/testExample()">
</Test>
</SelectedTests>
</TestableReference>
</Testables>
</TestAction>

View File

@@ -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<Source, Bool> = 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<SourcePreviewResult?, Never>? 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<Source>? 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<Source>?, sourceB: Managed<Source>?) 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..<newItemCount {
let indexPath = IndexPath(row: i, section: 0)
let change = RSTCellContentChange(type: .insert,
currentIndexPath: nil,
destinationIndexPath: indexPath)
changes.append(change)
}
} else if currentItemCount > 0 && newItemCount == 0 {
// Delete all items if we're going to have none
for i in 0..<currentItemCount {
let indexPath = IndexPath(row: i, section: 0)
let change = RSTCellContentChange(type: .delete,
currentIndexPath: indexPath,
destinationIndexPath: nil)
changes.append(change)
}
} else if currentItemCount != newItemCount {
// If counts differ, do a section update
let change = RSTCellContentChange(type: .update, sectionIndex: 0)
changes = [change]
} else {
// Update existing items in place
for i in 0..<newItemCount {
let indexPath = IndexPath(row: i, section: 0)
let change = RSTCellContentChange(type: .update,
currentIndexPath: indexPath,
destinationIndexPath: indexPath)
changes.append(change)
}
}
if source == nil
{
self.sourcePreviewDataSource.setItems(sources, with: changes)
if sources.isEmpty {
self.collectionView.reloadSections([Section.preview.rawValue])
}
else
{
} else {
self.collectionView.collectionViewLayout.invalidateLayout()
}
}
@@ -510,9 +591,6 @@ private extension AddSourceViewController
cell.bannerView.iconImageView.isIndicatingActivity = true
let config = UIImage.SymbolConfiguration(scale: .medium)
let image = UIImage(systemName: "plus.circle.fill", withConfiguration: config)?.withTintColor(.white, renderingMode: .alwaysOriginal)
cell.bannerView.button.setImage(image, for: .normal)
cell.bannerView.button.setImage(image, for: .highlighted)
cell.bannerView.button.setTitle(nil, for: .normal)
cell.bannerView.button.imageView?.contentMode = .scaleAspectFit
cell.bannerView.button.contentHorizontalAlignment = .fill // Fill entire button with imageView
@@ -521,28 +599,54 @@ private extension AddSourceViewController
cell.bannerView.button.tintColor = .clear
cell.bannerView.button.isHidden = false
// mark the button with label (useful for accessibility and for UITests)
cell.bannerView.button.accessibilityIdentifier = "add"
func setButtonIcon()
{
Task<Void, Never>(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<Void, Never>(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<Void, Never> {
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<Void, Never> {
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

View File

@@ -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

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="22155" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="7We-99-yEv">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="7We-99-yEv">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22131"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="collection view cell content view" minToolsVersion="11.0"/>
@@ -224,6 +224,11 @@
<segue destination="qr8-ss-Ghz" kind="unwind" unwindAction="unwindFromAddSource:" id="Pba-Kh-qfH"/>
</connections>
</barButtonItem>
<barButtonItem key="rightBarButtonItem" systemItem="done" id="oza-rj-JhC">
<connections>
<action selector="commitChanges:" destination="bbz-wy-kaK" id="4FB-Sj-E14"/>
</connections>
</barButtonItem>
</navigationItem>
<connections>
<segue destination="7XE-Wv-lf9" kind="show" identifier="showSourceDetails" destinationCreationSelector="makeSourceDetailViewController:sender:" id="LO9-iP-zZC"/>

View File

@@ -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 its 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 its 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/

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 its 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..<repeatCount {
// The same fixed seeded generator will yield the same permutation if not advanced, so you might want to reinitialize or use a fresh copy for each iteration:
var seededGenerator = seededGenerator // uncomment this for repeats to use same(shuffled once due to inital seed) order for all repeats
// let sourceMappings = sourceMappings.shuffled() // use this for non-deterministic shuffling
let sourceMappings = sourceMappings.shuffled(using: &seededGenerator) // use this for deterministic shuffling based on seed
try tapAddForThesePickedSources(app: app, sourceMappings: sourceMappings, cellsQuery: cellsQuery)
}
}
private func tapAddForThesePickedSources(
app: XCUIApplication,
sourceMappings: [(identifier: String, alertTitle: String, requiresSwipe: Bool)],
cellsQuery: XCUIElementQuery
) throws {
// Tap on each sourceMappings source's "add" button.
for source in sourceMappings {
let sourceButton = cellsQuery.otherElements
.containing(.button, identifier: source.identifier)
.children(matching: .button)[source.identifier]
XCTAssert(sourceButton.exists || sourceButton.waitForExistence(timeout: 10), "Source preview for id: '\(source.alertTitle)' not found in the view")
// let addButton = sourceButton.children(matching: .button).firstMatch
let addButton = sourceButton.children(matching: .button)["add"]
XCTAssert(addButton.exists || addButton.waitForExistence(timeout: 0.3), " `+` button for id: '\(source.alertTitle)' not found in the preview container")
addButton.tap()
if source.requiresSwipe {
sourceButton.swipeUp(velocity: .slow) // Swipe up if needed.
}
}
}
private func performBulkAddingRecommendedSources(for app: XCUIApplication) throws {
// Navigate to the Sources screen and open the Add Source view.
app.tabBars["Tab Bar"].buttons["Sources"].tap()
app.navigationBars["Sources"].buttons["Add"].tap()
let cellsQuery = app.collectionViews.cells
// Data model for recommended sources. NOTE: This list order is required to be the same as that of "Add Source" Screen
let recommendedSources: [(identifier: String, alertTitle: String, requiresSwipe: Bool)] = [
("SideStore Team Picks\ncommunity-apps.sidestore.io/sidecommunity.json", "SideStore Team Picks", false),
("Provenance EMU\nprovenance-emu.com/apps.json", "Provenance EMU", false),
("Countdown Respository\nneoarz.github.io/Countdown-App/Countdown.json", "Countdown Respository", false),
("OatmealDome's AltStore Source\naltstore.oatmealdome.me", "OatmealDome's AltStore Source", true),
("UTM Repository\nVirtual machines for iOS", "UTM Repository", false),
("Flyinghead\nflyinghead.github.io/flycast-builds/altstore.json", "Flyinghead", false),
// ("PojavLauncher Repository\nalt.crystall1ne.dev", "PojavLauncher Repository", false), // not a stable source, sometimes becomes unreachable, so disabled
("PokeMMO\npokemmo.eu/altstore/", "PokeMMO", true),
("Odyssey\ntheodyssey.dev/altstore/odysseysource.json", "Odyssey", false),
("Yattee\nrepos.yattee.stream/alt/apps.json", "Yattee", false),
("ThatStella7922 Source\nThe home for all apps ThatStella7922", "ThatStella7922 Source", false)
]
try performBulkAdd(app: app, sourceMappings: recommendedSources, cellsQuery: cellsQuery)
}
private func performRepeatabilityForStagingRecommendedSources(for app: XCUIApplication) throws {
// Navigate to the Sources screen and open the Add Source view.
app.tabBars["Tab Bar"].buttons["Sources"].tap()
app.navigationBars["Sources"].buttons["Add"].tap()
let cellsQuery = app.collectionViews.cells
// Data model for recommended sources. NOTE: This list order is required to be the same as that of "Add Source" Screen
let recommendedSources: [(identifier: String, alertTitle: String, requiresSwipe: Bool)] = [
("SideStore Team Picks\ncommunity-apps.sidestore.io/sidecommunity.json", "SideStore Team Picks", false),
("Provenance EMU\nprovenance-emu.com/apps.json", "Provenance EMU", false),
("Countdown Respository\nneoarz.github.io/Countdown-App/Countdown.json", "Countdown Respository", false),
("OatmealDome's AltStore Source\naltstore.oatmealdome.me", "OatmealDome's AltStore Source", false),
("UTM Repository\nVirtual machines for iOS", "UTM Repository", 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: recommendedSources, cellsQuery: cellsQuery, repeatCount: repeatCount, seed: timeSeed)
}
}
extension String {
var trimmedIndentation: String {
let lines = self.split(separator: "\n", omittingEmptySubsequences: false)
let minIndent = lines
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } // Ignore empty lines
.map { $0.prefix { $0.isWhitespace }.count }
.min() ?? 0
return lines.map { line in
String(line.dropFirst(minIndent))
}.joined(separator: "\n")
}
}

View File

@@ -0,0 +1,34 @@
//
// UITestsLaunchTests.swift
// UITests
//
// Created by Magesh K on 21/02/25.
// Copyright © 2025 SideStore. All rights reserved.
//
import XCTest
final class UITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}
override func setUpWithError() throws {
continueAfterFailure = false
}
// @MainActor
// func testLaunch() throws {
// let app = XCUIApplication()
// app.launch()
//
// // Insert steps here to perform after app launch but before taking a screenshot,
// // such as logging into a test account or navigating somewhere in the app
//
// let attachment = XCTAttachment(screenshot: app.screenshot())
// attachment.name = "Launch Screen"
// attachment.lifetime = .keepAlways
// add(attachment)
// }
}

View File

@@ -0,0 +1,17 @@
//
// DataStructuresTests.swift
// DataStructuresTests
//
// Created by Magesh K on 21/02/25.
// Copyright © 2025 SideStore. All rights reserved.
//
import Testing
struct DataStructuresTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
}
}

View File

@@ -0,0 +1,206 @@
//
// LinkedHashMapTests.swift
// SideStore
//
// Created by Magesh K on 21/02/25.
// Copyright © 2025 SideStore. All rights reserved.
//
import XCTest
// A helper class that signals when it is deallocated.
class LeakTester {
let id: Int
var onDeinit: (() -> 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<String, Int>()
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<String, Int>()
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<Int, String>()
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<String, Int>()
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<String, Int>()
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<String, Int>()
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<Int, String>()
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<Int, LeakTester>?
var deinitCalled = false
do {
let map = LinkedHashMap<Int, LeakTester>()
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<Int, LeakTester>()
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<String, Bool>()
// 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)
}
}

View File

@@ -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<Int, String>()
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<Int, String>()
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<Int, String>()
// 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<Int, String>()
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<Int, String>()
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<Int, String>()
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<Int, Int>()
// 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<Int, String>()
map[10] = "ten"
let removed = map.remove(key: 20)
XCTAssertNil(removed)
XCTAssertEqual(map.count, 1)
}
func testDuplicateInsertion() {
let map = TreeMap<String, String>()
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)
}
}

View File

@@ -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<Key: Hashable, Value>: 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
}
}
}

View File

@@ -0,0 +1,397 @@
//
// TreeMap.swift
// SideStore
//
// Created by Magesh K on 21/02/25.
// Copyright © 2025 SideStore. All rights reserved.
//
public class TreeMap<Key: Comparable, Value>: 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 redblack 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 redblack 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 redblack 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)
}
}

View File

@@ -0,0 +1,3 @@
#include "../Build.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER = $(PRODUCT_BUNDLE_IDENTIFIER).UITests