mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-10 07:13:28 +01:00
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:
2
.github/workflows/alpha.yml
vendored
2
.github/workflows/alpha.yml
vendored
@@ -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"
|
||||
|
||||
2
.github/workflows/nightly.yml
vendored
2
.github/workflows/nightly.yml
vendored
@@ -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"
|
||||
|
||||
600
.github/workflows/reusable-build-workflow.yml
vendored
600
.github/workflows/reusable-build-workflow.yml
vendored
@@ -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
8
.gitignore
vendored
@@ -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
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
97
Makefile
97
Makefile
@@ -167,22 +167,86 @@ test:
|
||||
BUILD_CONFIG ?= Release
|
||||
MARKETING_VERSION ?=
|
||||
BUNDLE_ID_SUFFIX ?=
|
||||
# Common build settings for xcodebuild
|
||||
COMMON_BUILD_SETTINGS = \
|
||||
-workspace AltStore.xcworkspace \
|
||||
-scheme SideStore \
|
||||
-sdk iphoneos \
|
||||
-configuration $(BUILD_CONFIG) \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
AD_HOC_CODE_SIGNING_ALLOWED=YES \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
DEVELOPMENT_TEAM=XYZ0123456 \
|
||||
ORG_IDENTIFIER=com.SideStore
|
||||
|
||||
# Append MARKETING_VERSION if it’s not empty (coz otherwise the blank entry becomes override)
|
||||
ifneq ($(strip $(MARKETING_VERSION)),)
|
||||
COMMON_BUILD_SETTINGS += MARKETING_VERSION=$(MARKETING_VERSION)
|
||||
endif
|
||||
|
||||
# Append BUNDLE_ID_SUFFIX if it’s not empty (coz otherwise the blank entry becomes override)
|
||||
ifneq ($(strip $(BUNDLE_ID_SUFFIX)),)
|
||||
COMMON_BUILD_SETTINGS += BUNDLE_ID_SUFFIX=$(BUNDLE_ID_SUFFIX)
|
||||
endif
|
||||
|
||||
build:
|
||||
@echo ">>>>>>>>> BUILD_CONFIG is set to '$(BUILD_CONFIG)', Building for $(BUILD_CONFIG) mode! <<<<<<<<<<"
|
||||
@echo ""
|
||||
@xcodebuild -workspace AltStore.xcworkspace \
|
||||
-scheme SideStore \
|
||||
-sdk iphoneos \
|
||||
-configuration $(BUILD_CONFIG) \
|
||||
archive -archivePath ./SideStore \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
AD_HOC_CODE_SIGNING_ALLOWED=YES \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
DEVELOPMENT_TEAM=XYZ0123456 \
|
||||
ORG_IDENTIFIER=com.SideStore \
|
||||
MARKETING_VERSION=$(MARKETING_VERSION) \
|
||||
BUNDLE_ID_SUFFIX=$(BUNDLE_ID_SUFFIX)
|
||||
# DWARF_DSYM_FOLDER_PATH="."
|
||||
@xcodebuild archive -archivePath ./SideStore \
|
||||
$(COMMON_BUILD_SETTINGS)
|
||||
|
||||
build-and-test:
|
||||
@rm -rf build/tests/test-results.xcresult
|
||||
@echo ">>>>>>>>> BUILD_CONFIG is set to '$(BUILD_CONFIG)', Building for $(BUILD_CONFIG) mode! <<<<<<<<<<"
|
||||
@echo ""
|
||||
@echo "Performing a build and running tests..."
|
||||
@xcodebuild test \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2' \
|
||||
-resultBundlePath build/tests/test-results.xcresult \
|
||||
-enableCodeCoverage YES \
|
||||
$(COMMON_BUILD_SETTINGS)
|
||||
|
||||
build-tests:
|
||||
@rm -rf build/tests/test-results.xcresult
|
||||
@echo ">>>>>>>>> BUILD_CONFIG is set to '$(BUILD_CONFIG)', Building Tests for $(BUILD_CONFIG) mode! <<<<<<<<<<"
|
||||
@echo ""
|
||||
@echo "Performing a build-for-testing..."
|
||||
@xcodebuild build-for-testing \
|
||||
-enableCodeCoverage YES \
|
||||
$(COMMON_BUILD_SETTINGS)
|
||||
|
||||
run-tests:
|
||||
@rm -rf build/tests/test-results.xcresult
|
||||
@echo ">>>>>>>>> BUILD_CONFIG is set to '$(BUILD_CONFIG)', Testing for $(BUILD_CONFIG) mode! <<<<<<<<<<"
|
||||
@echo ""
|
||||
@echo "Performing a test-without-building..."
|
||||
@xcodebuild test-without-building \
|
||||
-enableCodeCoverage YES \
|
||||
-resultBundlePath build/tests/test-results.xcresult \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2' \
|
||||
$(COMMON_BUILD_SETTINGS)
|
||||
|
||||
boot-sim-async:
|
||||
@if xcrun simctl list devices "iPhone 16 Pro" | grep -q "Booted"; then \
|
||||
echo "Simulator 'iPhone 16 Pro' is already booted."; \
|
||||
else \
|
||||
echo "Booting simulator 'iPhone 16 Pro' asynchronously..."; \
|
||||
xcrun simctl boot "iPhone 16 Pro" & \
|
||||
echo "Simulator boot command dispatched."; \
|
||||
fi
|
||||
|
||||
sim-boot-check:
|
||||
@echo "Checking simulator boot status..."
|
||||
@if xcrun simctl list devices "iPhone 16 Pro" | grep -q "Booted"; then \
|
||||
echo "Simulator 'iPhone 16 Pro' is booted."; \
|
||||
else \
|
||||
echo "Simulator bootup failed or is not booted yet."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
clean-build:
|
||||
@echo "Cleaning build artifacts..."
|
||||
@xcodebuild clean -workspace AltStore.xcworkspace -scheme SideStore
|
||||
|
||||
fakesign-apps:
|
||||
rm -rf SideStore.xcarchive/Products/Applications/SideStore.app/Frameworks/AltStoreCore.framework/Frameworks/
|
||||
@@ -308,7 +372,7 @@ ipa-altbackup: checkPaths copy-altbackup
|
||||
@mkdir -p "$(ALT_APP_PAYLOAD_DST)/$(TARGET_NAME)"
|
||||
@echo " Copying from $(ALT_APP_SRC) into $(ALT_APP_PAYLOAD_DST)"
|
||||
@cp -R -f "$(ALT_APP_SRC)/." "$(ALT_APP_PAYLOAD_DST)/$(TARGET_NAME)"
|
||||
@pushd "$(ALT_APP_DST_ARCHIVE)" && zip -r "../../$(ALT_APP_IPA_DST)" Payload && popd
|
||||
@pushd "$(ALT_APP_DST_ARCHIVE)" && zip -r "../../$(ALT_APP_IPA_DST)" Payload || popd
|
||||
@cp -f "$(ALT_APP_IPA_DST)" AltStore/Resources
|
||||
@echo " IPA created: AltStore/Resources/AltBackup.ipa"
|
||||
|
||||
@@ -317,11 +381,8 @@ clean-altbackup:
|
||||
@echo "====> Cleaning up AltBackup related artifacts <===="
|
||||
@rm -rf build/altbackup.xcarchive/
|
||||
@rm -f build/AltBackup.ipa
|
||||
@rm -f AltStore/Resources/AltBackup.ipa
|
||||
#@rm -f AltStore/Resources/AltBackup.ipa
|
||||
|
||||
clean: clean-altbackup
|
||||
@rm -rf *.xcarchive/
|
||||
@rm -rf *.dSYM/
|
||||
@rm -rf SideStore.ipa
|
||||
@rm -rf build/
|
||||
@rm -rf Payload/
|
||||
|
||||
35
SideStore/Tests/DataStructureTests.xctestplan
Normal file
35
SideStore/Tests/DataStructureTests.xctestplan
Normal 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
|
||||
}
|
||||
33
SideStore/Tests/SideStoreTests.xctestplan
Normal file
33
SideStore/Tests/SideStoreTests.xctestplan
Normal 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
|
||||
}
|
||||
459
SideStore/Tests/UITests/UITests.swift
Normal file
459
SideStore/Tests/UITests/UITests.swift
Normal 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 it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
|
||||
// @MainActor // Xcode 16.2 bug: UITest Record Button Disabled with @MainActor, see: https://stackoverflow.com/a/79445950/11971304
|
||||
func testBulkAddRecommendedSources() throws {
|
||||
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
let systemAlert = Self.springboard_app.alerts["“\(Self.APP_NAME)” Would Like to Send You Notifications"]
|
||||
|
||||
// if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence
|
||||
XCTAssertTrue(systemAlert.exists || systemAlert.waitForExistence(timeout: 5), "Notifications alert did not appear")
|
||||
systemAlert.scrollViews.otherElements.buttons["Allow"].tap()
|
||||
|
||||
// Do the actual validation
|
||||
try performBulkAddingRecommendedSources(for: app)
|
||||
}
|
||||
|
||||
func testBulkAddInputSources() throws {
|
||||
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
let systemAlert = Self.springboard_app.alerts["“\(Self.APP_NAME)” Would Like to Send You Notifications"]
|
||||
|
||||
// if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence
|
||||
XCTAssertTrue(systemAlert.exists || systemAlert.waitForExistence(timeout: 5), "Notifications alert did not appear")
|
||||
systemAlert.scrollViews.otherElements.buttons["Allow"].tap()
|
||||
|
||||
// Do the actual validation
|
||||
try performBulkAddingInputSources(for: app)
|
||||
}
|
||||
|
||||
func testRepeatabilityForStagingInputSources() throws {
|
||||
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
let systemAlert = Self.springboard_app.alerts["“\(Self.APP_NAME)” Would Like to Send You Notifications"]
|
||||
|
||||
// if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence
|
||||
XCTAssertTrue(systemAlert.exists || systemAlert.waitForExistence(timeout: 5), "Notifications alert did not appear")
|
||||
systemAlert.scrollViews.otherElements.buttons["Allow"].tap()
|
||||
|
||||
// Do the actual validation
|
||||
try performRepeatabilityForStagingInputSources(for: app)
|
||||
}
|
||||
|
||||
func testRepeatabilityForStagingRecommendedSources() throws {
|
||||
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
let systemAlert = Self.springboard_app.alerts["“\(Self.APP_NAME)” Would Like to Send You Notifications"]
|
||||
|
||||
// if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence
|
||||
XCTAssertTrue(systemAlert.exists || systemAlert.waitForExistence(timeout: 5), "Notifications alert did not appear")
|
||||
systemAlert.scrollViews.otherElements.buttons["Allow"].tap()
|
||||
|
||||
// Do the actual validation
|
||||
try performRepeatabilityForStagingRecommendedSources(for: app)
|
||||
}
|
||||
|
||||
|
||||
// @MainActor
|
||||
// func testLaunchPerformance() throws {
|
||||
// if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
|
||||
// // This measures how long it takes to launch your application.
|
||||
// measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||
// XCUIApplication().launch()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
// Helpers
|
||||
private extension UITests {
|
||||
|
||||
class func dismissSpotlight(){
|
||||
// ignore spotlight if it was shown
|
||||
if searchBar.exists {
|
||||
let clearButton = searchBar.buttons["Clear text"]
|
||||
if clearButton.exists{
|
||||
clearButton.tap()
|
||||
}
|
||||
}
|
||||
springboard_app.tap()
|
||||
}
|
||||
|
||||
class func deleteMyApp() {
|
||||
XCUIApplication().terminate()
|
||||
dismissSpringboardAlerts()
|
||||
|
||||
// XCUIDevice.shared.press(.home)
|
||||
springboard_app.swipeDown()
|
||||
|
||||
let searchBar = Self.searchBar
|
||||
_ = searchBar.exists || searchBar.waitForExistence(timeout: 5)
|
||||
searchBar.typeText(APP_NAME)
|
||||
|
||||
// Rest of the deletion flow...
|
||||
let appIcon = spotlight_app.icons[APP_NAME]
|
||||
// if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence
|
||||
if appIcon.exists || appIcon.waitForExistence(timeout: 5) {
|
||||
appIcon.press(forDuration: 1)
|
||||
|
||||
let deleteAppButton = spotlight_app.buttons["Delete App"]
|
||||
_ = deleteAppButton.exists || deleteAppButton.waitForExistence(timeout: 5)
|
||||
deleteAppButton.tap()
|
||||
|
||||
let confirmDeleteButton = springboard_app.alerts["Delete “\(APP_NAME)”?"]
|
||||
_ = confirmDeleteButton.exists || confirmDeleteButton.waitForExistence(timeout: 5)
|
||||
confirmDeleteButton.scrollViews.otherElements.buttons["Delete"].tap()
|
||||
}
|
||||
|
||||
let clearButton = searchBar.buttons["Clear text"]
|
||||
_ = clearButton.exists || clearButton.waitForExistence(timeout: 5)
|
||||
clearButton.tap()
|
||||
|
||||
springboard_app.tap()
|
||||
}
|
||||
|
||||
class func deleteMyApp2() {
|
||||
XCUIApplication().terminate()
|
||||
dismissSpringboardAlerts()
|
||||
|
||||
// Rest of the deletion flow...
|
||||
let appIcon = springboard_app.icons[APP_NAME]
|
||||
// if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence
|
||||
if appIcon.exists || appIcon.waitForExistence(timeout: 5) {
|
||||
appIcon.press(forDuration: 1)
|
||||
|
||||
do {
|
||||
let button = springboard_app.buttons["Remove App"]
|
||||
_ = button.exists || button.waitForExistence(timeout: 5)
|
||||
button.tap()
|
||||
}
|
||||
do {
|
||||
let button = springboard_app.buttons["Delete App"]
|
||||
_ = button.waitForExistence(timeout: 0.3)
|
||||
button.tap()
|
||||
}
|
||||
do {
|
||||
let button = springboard_app.buttons["Delete"]
|
||||
_ = button.waitForExistence(timeout: 0.3)
|
||||
button.tap()
|
||||
}
|
||||
|
||||
// // Press home once to make the icons stop wiggling
|
||||
// XCUIDevice.shared.press(.home)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class func dismissSpringboardAlerts() {
|
||||
for alert in springboard_app.alerts.allElementsBoundByIndex {
|
||||
if alert.exists {
|
||||
// If there's a "Cancel" button, tap it; otherwise, tap the first button.
|
||||
if alert.buttons["Cancel"].exists {
|
||||
alert.buttons["Cancel"].tap()
|
||||
} else if let firstButton = alert.buttons.allElementsBoundByIndex.first {
|
||||
firstButton.tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct SeededGenerator: RandomNumberGenerator {
|
||||
var seed: UInt64
|
||||
|
||||
mutating func next() -> UInt64 {
|
||||
// A basic LCG (not cryptographically secure, but fine for testing)
|
||||
seed = 6364136223846793005 &* seed &+ 1
|
||||
return seed
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Test guts (definition)
|
||||
private extension UITests {
|
||||
|
||||
|
||||
private func performBulkAdd(
|
||||
app: XCUIApplication,
|
||||
sourceMappings: [(identifier: String, alertTitle: String, requiresSwipe: Bool)],
|
||||
cellsQuery: XCUIElementQuery
|
||||
) throws {
|
||||
|
||||
// Tap on each sourceMappings source's "add" button.
|
||||
try tapAddForThesePickedSources(app: app, sourceMappings: sourceMappings, cellsQuery: cellsQuery)
|
||||
|
||||
// Commit the changes by tapping "Done".
|
||||
app.navigationBars["Add Source"].buttons["Done"].tap()
|
||||
|
||||
// Accept each source addition via alert.
|
||||
for source in sourceMappings {
|
||||
let alertIdentifier = "Would you like to add the source “\(source.alertTitle)”?"
|
||||
let addSourceButton = app.alerts[alertIdentifier]
|
||||
.scrollViews.otherElements.buttons["Add Source"]
|
||||
_ = addSourceButton.exists || addSourceButton.waitForExistence(timeout: 0.3)
|
||||
addSourceButton.tap()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private func performBulkAddingInputSources(for app: XCUIApplication) throws {
|
||||
|
||||
// set content into clipboard (for bulk add (paste))
|
||||
// NOTE: THIS IS AN ORDERED SEQUENCE AND MUST MATCH THE ORDER in textInputSources BELOW (Remember to take this into account when adding more entries)
|
||||
UIPasteboard.general.string = """
|
||||
https://alts.lao.sb
|
||||
https://taurine.app/altstore/taurinestore.json
|
||||
https://randomblock1.com/altstore/apps.json
|
||||
https://burritosoftware.github.io/altstore/channels/burritosource.json
|
||||
https://bit.ly/40Isul6
|
||||
https://bit.ly/wuxuslibraryplus
|
||||
https://bit.ly/Quantumsource-plus
|
||||
https://bit.ly/Altstore-complete
|
||||
https://bit.ly/Quantumsource
|
||||
""".trimmedIndentation
|
||||
|
||||
let app = XCUIApplication()
|
||||
app.tabBars["Tab Bar"].buttons["Sources"].tap()
|
||||
app.navigationBars["Sources"].buttons["Add"].tap()
|
||||
|
||||
let collectionViewsQuery = app.collectionViews
|
||||
let appsSidestoreIoTextField = collectionViewsQuery.textFields["apps.sidestore.io"]
|
||||
_ = appsSidestoreIoTextField.exists || appsSidestoreIoTextField.waitForExistence(timeout: 5)
|
||||
appsSidestoreIoTextField.tap()
|
||||
appsSidestoreIoTextField.tap()
|
||||
collectionViewsQuery.staticTexts["Paste"].tap()
|
||||
|
||||
// if app.keyboards.buttons["Return"].exists {
|
||||
// app.keyboards.buttons["Return"].tap()
|
||||
// } else if app.keyboards.buttons["Done"].exists {
|
||||
// app.keyboards.buttons["Done"].tap()
|
||||
// } else {
|
||||
// // if still exists try tapping outside of text field focus
|
||||
// app.tap()
|
||||
// }
|
||||
|
||||
if app.keyboards.count > 0 {
|
||||
appsSidestoreIoTextField.typeText("\n") // Fallback to newline so that soft kb is dismissed
|
||||
}
|
||||
|
||||
let cellsQuery = collectionViewsQuery.cells
|
||||
|
||||
// Data model for recommended sources. NOTE: This list order is required to be the same as that of "Add Source" Screen
|
||||
let textInputSources: [(identifier: String, alertTitle: String, requiresSwipe: Bool)] = [
|
||||
("Laoalts\nalts.lao.sb", "Laoalts", false),
|
||||
("Taurine\ntaurine.app/altstore/taurinestore.json", "Taurine", false),
|
||||
("RandomSource\nrandomblock1.com/altstore/apps.json", "RandomSource", false),
|
||||
("Burrito's AltStore\nburritosoftware.github.io/altstore/channels/burritosource.json", "Burrito's AltStore", true),
|
||||
("Qn_'s AltStore Repo\nbit.ly/40Isul6", "Qn_'s AltStore Repo", false),
|
||||
("WuXu's Library++\nThe Most Up-To-Date IPA Library on AltStore.", "WuXu's Library++", false),
|
||||
("Quantum Source++\nContains tweaked apps, free streaming, cracked apps, and more.", "Quantum Source++", false),
|
||||
("AltStore Complete\nContains tweaked apps, free streaming, cracked apps, and more.", "AltStore Complete", false),
|
||||
("Quantum Source\nContains all of your favorite emulators, games, jailbreaks, utilities, and more.", "Quantum Source", false),
|
||||
]
|
||||
|
||||
try performBulkAdd(app: app, sourceMappings: textInputSources, cellsQuery: cellsQuery)
|
||||
}
|
||||
|
||||
|
||||
private func performRepeatabilityForStagingInputSources(for app: XCUIApplication) throws {
|
||||
|
||||
// set content into clipboard (for bulk add (paste))
|
||||
// NOTE: THIS IS AN ORDERED SEQUENCE AND MUST MATCH THE ORDER in textInputSources BELOW (Remember to take this into account when adding more entries)
|
||||
UIPasteboard.general.string = """
|
||||
https://alts.lao.sb
|
||||
https://taurine.app/altstore/taurinestore.json
|
||||
https://randomblock1.com/altstore/apps.json
|
||||
https://burritosoftware.github.io/altstore/channels/burritosource.json
|
||||
https://bit.ly/40Isul6
|
||||
""".trimmedIndentation
|
||||
|
||||
let app = XCUIApplication()
|
||||
app.tabBars["Tab Bar"].buttons["Sources"].tap()
|
||||
app.navigationBars["Sources"].buttons["Add"].tap()
|
||||
|
||||
let collectionViewsQuery = app.collectionViews
|
||||
let appsSidestoreIoTextField = collectionViewsQuery.textFields["apps.sidestore.io"]
|
||||
_ = appsSidestoreIoTextField.exists || appsSidestoreIoTextField.waitForExistence(timeout: 5)
|
||||
appsSidestoreIoTextField.tap()
|
||||
appsSidestoreIoTextField.tap()
|
||||
_ = appsSidestoreIoTextField.exists || appsSidestoreIoTextField.waitForExistence(timeout: 5)
|
||||
collectionViewsQuery.staticTexts["Paste"].tap()
|
||||
|
||||
if app.keyboards.count > 0 {
|
||||
appsSidestoreIoTextField.typeText("\n") // Fallback to newline so that soft kb is dismissed
|
||||
}
|
||||
|
||||
let cellsQuery = collectionViewsQuery.cells
|
||||
|
||||
// Data model for recommended sources. NOTE: This list order is required to be the same as that of "Add Source" Screen
|
||||
let textInputSources: [(identifier: String, alertTitle: String, requiresSwipe: Bool)] = [
|
||||
("Laoalts\nalts.lao.sb", "Laoalts", false),
|
||||
("Taurine\ntaurine.app/altstore/taurinestore.json", "Taurine", false),
|
||||
("RandomSource\nrandomblock1.com/altstore/apps.json", "RandomSource", false),
|
||||
("Burrito's AltStore\nburritosoftware.github.io/altstore/channels/burritosource.json", "Burrito's AltStore", false),
|
||||
("Qn_'s AltStore Repo\nbit.ly/40Isul6", "Qn_'s AltStore Repo", false),
|
||||
]
|
||||
|
||||
let repeatCount = 3 // number of times to run the entire sequence
|
||||
let timeSeed = UInt64(Date().timeIntervalSince1970) // time is unique (upto microseconds) - uncomment this to use non-deterministic seed based RNG (random number generator)
|
||||
|
||||
try repeatabilityTest(app: app, sourceMappings: textInputSources, cellsQuery: cellsQuery, repeatCount: repeatCount, seed: timeSeed)
|
||||
}
|
||||
|
||||
private func repeatabilityTest(
|
||||
app: XCUIApplication,
|
||||
sourceMappings: [(identifier: String, alertTitle: String, requiresSwipe: Bool)],
|
||||
cellsQuery: XCUIElementQuery,
|
||||
repeatCount: Int = 1, // number of times to run the entire sequence
|
||||
seed: UInt64 = 42 // default = fixed seed for deterministic start of this generator
|
||||
) throws {
|
||||
let seededGenerator = SeededGenerator(seed: seed)
|
||||
|
||||
for _ in 0..<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")
|
||||
}
|
||||
}
|
||||
34
SideStore/Tests/UITests/UITestsLaunchTests.swift
Normal file
34
SideStore/Tests/UITests/UITestsLaunchTests.swift
Normal 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)
|
||||
// }
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
154
SideStore/Tests/UnitTests/datastructures/TreeMapTests.swift
Normal file
154
SideStore/Tests/UnitTests/datastructures/TreeMapTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
226
SideStore/Utils/datastructures/LinkedHashMap.swift
Normal file
226
SideStore/Utils/datastructures/LinkedHashMap.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
397
SideStore/Utils/datastructures/TreeMap.swift
Normal file
397
SideStore/Utils/datastructures/TreeMap.swift
Normal 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 red–black properties after insertion.
|
||||
private func fixAfterInsertion(_ x: Node) {
|
||||
var node = x
|
||||
node.color = .red
|
||||
while node !== root, let parent = node.parent, parent.color == .red {
|
||||
if parent === parent.parent?.left {
|
||||
if let uncle = parent.parent?.right, uncle.color == .red {
|
||||
parent.color = .black
|
||||
uncle.color = .black
|
||||
parent.parent?.color = .red
|
||||
if let grandparent = parent.parent {
|
||||
node = grandparent
|
||||
}
|
||||
} else {
|
||||
if node === parent.right {
|
||||
node = parent
|
||||
rotateLeft(node)
|
||||
}
|
||||
node.parent?.color = .black
|
||||
node.parent?.parent?.color = .red
|
||||
if let grandparent = node.parent?.parent {
|
||||
rotateRight(grandparent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let uncle = parent.parent?.left, uncle.color == .red {
|
||||
parent.color = .black
|
||||
uncle.color = .black
|
||||
parent.parent?.color = .red
|
||||
if let grandparent = parent.parent {
|
||||
node = grandparent
|
||||
}
|
||||
} else {
|
||||
if node === parent.left {
|
||||
node = parent
|
||||
rotateRight(node)
|
||||
}
|
||||
node.parent?.color = .black
|
||||
node.parent?.parent?.color = .red
|
||||
if let grandparent = node.parent?.parent {
|
||||
rotateLeft(grandparent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
root?.color = .black
|
||||
}
|
||||
|
||||
// MARK: - Deletion Helpers
|
||||
|
||||
/// Replaces subtree rooted at u with subtree rooted at v.
|
||||
private func transplant(_ u: Node, _ v: Node?) {
|
||||
if u.parent == nil {
|
||||
root = v
|
||||
} else if u === u.parent?.left {
|
||||
u.parent?.left = v
|
||||
} else {
|
||||
u.parent?.right = v
|
||||
}
|
||||
if let vNode = v {
|
||||
vNode.parent = u.parent
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes node z and fixes red–black properties.
|
||||
private func deleteNode(_ z: Node) {
|
||||
var y = z
|
||||
let originalColor = y.color
|
||||
var x: Node?
|
||||
|
||||
if z.left == nil {
|
||||
x = z.right
|
||||
transplant(z, z.right)
|
||||
} else if z.right == nil {
|
||||
x = z.left
|
||||
transplant(z, z.left)
|
||||
} else {
|
||||
y = minimum(z.right!)
|
||||
let yOriginalColor = y.color
|
||||
x = y.right
|
||||
if y.parent === z {
|
||||
if x != nil { x!.parent = y }
|
||||
} else {
|
||||
transplant(y, y.right)
|
||||
y.right = z.right
|
||||
y.right?.parent = y
|
||||
}
|
||||
transplant(z, y)
|
||||
y.left = z.left
|
||||
y.left?.parent = y
|
||||
y.color = z.color
|
||||
if yOriginalColor == .black {
|
||||
fixAfterDeletion(x, parent: y.parent)
|
||||
}
|
||||
return
|
||||
}
|
||||
if originalColor == .black {
|
||||
fixAfterDeletion(x, parent: z.parent)
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores red–black properties after deletion.
|
||||
private func fixAfterDeletion(_ x: Node?, parent: Node?) {
|
||||
var x = x
|
||||
var parent = parent
|
||||
while (x == nil || x!.color == .black) && (x !== root) {
|
||||
if x === parent?.left {
|
||||
var w = parent?.right
|
||||
if w?.color == .red {
|
||||
w?.color = .black
|
||||
parent?.color = .red
|
||||
rotateLeft(parent!)
|
||||
w = parent?.right
|
||||
}
|
||||
if (w?.left == nil || w?.left?.color == .black) &&
|
||||
(w?.right == nil || w?.right?.color == .black) {
|
||||
w?.color = .red
|
||||
x = parent
|
||||
parent = x?.parent
|
||||
} else {
|
||||
if w?.right == nil || w?.right?.color == .black {
|
||||
w?.left?.color = .black
|
||||
w?.color = .red
|
||||
if let wUnwrapped = w { rotateRight(wUnwrapped) }
|
||||
w = parent?.right
|
||||
}
|
||||
w?.color = parent?.color ?? .black
|
||||
parent?.color = .black
|
||||
w?.right?.color = .black
|
||||
rotateLeft(parent!)
|
||||
x = root
|
||||
parent = nil
|
||||
}
|
||||
} else {
|
||||
var w = parent?.left
|
||||
if w?.color == .red {
|
||||
w?.color = .black
|
||||
parent?.color = .red
|
||||
rotateRight(parent!)
|
||||
w = parent?.left
|
||||
}
|
||||
if (w?.left == nil || w?.left?.color == .black) &&
|
||||
(w?.right == nil || w?.right?.color == .black) {
|
||||
w?.color = .red
|
||||
x = parent
|
||||
parent = x?.parent
|
||||
} else {
|
||||
if w?.left == nil || w?.left?.color == .black {
|
||||
w?.right?.color = .black
|
||||
w?.color = .red
|
||||
if let wUnwrapped = w { rotateLeft(wUnwrapped) }
|
||||
w = parent?.left
|
||||
}
|
||||
w?.color = parent?.color ?? .black
|
||||
parent?.color = .black
|
||||
w?.left?.color = .black
|
||||
rotateRight(parent!)
|
||||
x = root
|
||||
parent = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
x?.color = .black
|
||||
}
|
||||
|
||||
// Convenience overload if parent is not separately tracked.
|
||||
private func fixAfterDeletion(_ x: Node?) {
|
||||
fixAfterDeletion(x, parent: x?.parent)
|
||||
}
|
||||
|
||||
// MARK: - Sequence Conformance (In-Order Traversal)
|
||||
|
||||
public struct Iterator: IteratorProtocol {
|
||||
private var stack: [Node] = []
|
||||
|
||||
// Marked as private because Node is a private type.
|
||||
fileprivate init(root: Node?) {
|
||||
var current = root
|
||||
while let node = current {
|
||||
stack.append(node)
|
||||
current = node.left
|
||||
}
|
||||
}
|
||||
|
||||
public mutating func next() -> (Key, Value)? {
|
||||
if stack.isEmpty { return nil }
|
||||
let node = stack.removeLast()
|
||||
let result = (node.key, node.value)
|
||||
var current = node.right
|
||||
while let n = current {
|
||||
stack.append(n)
|
||||
current = n.left
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
public func makeIterator() -> Iterator {
|
||||
return Iterator(root: root)
|
||||
}
|
||||
}
|
||||
3
xcconfigs/UITests.xcconfig
Normal file
3
xcconfigs/UITests.xcconfig
Normal file
@@ -0,0 +1,3 @@
|
||||
#include "../Build.xcconfig"
|
||||
|
||||
PRODUCT_BUNDLE_IDENTIFIER = $(PRODUCT_BUNDLE_IDENTIFIER).UITests
|
||||
Reference in New Issue
Block a user