Compare commits
23 Commits
a6be43da53
...
users/june
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbce8725c7 | ||
|
|
8cfbbf8768 | ||
|
|
b3357049b0 | ||
|
|
a7f01956e8 | ||
|
|
1cc0b11aa6 | ||
|
|
9a598f701a | ||
|
|
af26262b6b | ||
|
|
9ece114f7a | ||
|
|
ed0f83d0cb | ||
|
|
2a8e1d8888 | ||
|
|
61077fa5b1 | ||
|
|
7fd9639351 | ||
|
|
eb06d05d63 | ||
|
|
853de98326 | ||
|
|
bb9760f31d | ||
|
|
60411cabb4 | ||
|
|
e07c4aac37 | ||
|
|
a9bef300a0 | ||
|
|
2024e0ad32 | ||
|
|
3527a4b8bc | ||
|
|
41c7c161d6 | ||
|
|
c3a199c2d0 | ||
|
|
ea00f2904b |
63
.github/maintenance/cache.py
vendored
@@ -1,63 +0,0 @@
|
||||
import requests
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Your GitHub Personal Access Token
|
||||
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
|
||||
|
||||
# Repository details
|
||||
REPO_OWNER = "SideStore"
|
||||
REPO_NAME = "SideStore"
|
||||
|
||||
|
||||
API_URL = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/actions/caches"
|
||||
|
||||
# Common headers for GitHub API calls
|
||||
HEADERS = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {GITHUB_TOKEN}"
|
||||
}
|
||||
|
||||
def list_caches():
|
||||
response = requests.get(API_URL, headers=HEADERS)
|
||||
if response.status_code != 200:
|
||||
print(f"Failed to list caches. HTTP {response.status_code}")
|
||||
print("Response:", response.text)
|
||||
sys.exit(1)
|
||||
data = response.json()
|
||||
return data.get("actions_caches", [])
|
||||
|
||||
def delete_cache(cache_id):
|
||||
delete_url = f"{API_URL}/{cache_id}"
|
||||
response = requests.delete(delete_url, headers=HEADERS)
|
||||
return response.status_code
|
||||
|
||||
def main():
|
||||
caches = list_caches()
|
||||
if not caches:
|
||||
print("No caches found.")
|
||||
return
|
||||
|
||||
print("Found caches:")
|
||||
for cache in caches:
|
||||
print(f"ID: {cache.get('id')}, Key: {cache.get('key')}")
|
||||
|
||||
print("\nDeleting caches...")
|
||||
for cache in caches:
|
||||
cache_id = cache.get("id")
|
||||
status = delete_cache(cache_id)
|
||||
if status == 204:
|
||||
print(f"Successfully deleted cache with ID: {cache_id}")
|
||||
else:
|
||||
print(f"Failed to delete cache with ID: {cache_id}. HTTP status code: {status}")
|
||||
|
||||
print("All caches processed.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
### How to use
|
||||
'''
|
||||
just export the GITHUB_TOKEN and then run this script via `python3 cache.py' to delete the caches
|
||||
'''
|
||||
300
.github/workflows/alpha.yml
vendored
@@ -2,27 +2,283 @@ name: Alpha SideStore build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop-alpha
|
||||
|
||||
# cancel duplicate run if from same branch
|
||||
concurrency:
|
||||
group: ${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
# - alpha
|
||||
- rebase-2.0-wip
|
||||
|
||||
jobs:
|
||||
Reusable-build:
|
||||
uses: ./.github/workflows/reusable-sidestore-build.yml
|
||||
with:
|
||||
# bundle_id: "com.SideStore.SideStore.Alpha"
|
||||
bundle_id: "com.SideStore.SideStore"
|
||||
# bundle_id_suffix: ".Alpha"
|
||||
is_beta: true
|
||||
publish: ${{ vars.PUBLISH_ALPHA_UPDATES == 'true' }}
|
||||
is_shared_build_num: false
|
||||
release_tag: "alpha"
|
||||
release_name: "Alpha"
|
||||
upstream_tag: "nightly"
|
||||
upstream_name: "Nightly"
|
||||
secrets:
|
||||
CROSS_REPO_PUSH_KEY: ${{ secrets.CROSS_REPO_PUSH_KEY }}
|
||||
BUILD_LOG_ZIP_PASSWORD: ${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
|
||||
build:
|
||||
name: Build and upload SideStore Alpha releases
|
||||
concurrency:
|
||||
group: ${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: 'macos-14'
|
||||
version: '16.1'
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
|
||||
- name: Set current build as ALPHA
|
||||
run: echo "IS_ALPHA=1" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: brew install ldid
|
||||
|
||||
- name: Install xcbeautify
|
||||
run: brew install xcbeautify
|
||||
|
||||
- name: Cache .alpha-build-num
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .alpha-build-num
|
||||
key: alpha-build-num
|
||||
|
||||
- name: Get version
|
||||
id: version-marketing
|
||||
run: echo "VERSION_IPA=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_ENV
|
||||
|
||||
- name: Increase alpha build number and set as version
|
||||
run: bash .github/workflows/increase-alpha-build-num.sh
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: echo "version=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Echo version
|
||||
run: echo "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1.6.0
|
||||
with:
|
||||
xcode-version: ${{ matrix.version }}
|
||||
|
||||
- name: 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 }}
|
||||
swiftpm-cache-restore-keys: |
|
||||
xcode-cache-sourcedata-
|
||||
|
||||
|
||||
- name: Restore Pods from Cache (Exact match)
|
||||
id: pods-restore
|
||||
uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: |
|
||||
./Podfile.lock
|
||||
./Pods/
|
||||
./AltStore.xcworkspace/
|
||||
key: pods-cache-${{ 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' }}
|
||||
id: pods-restore-recent
|
||||
uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: |
|
||||
./Podfile.lock
|
||||
./Pods/
|
||||
./AltStore.xcworkspace/
|
||||
key: pods-cache-
|
||||
|
||||
- name: Install CocoaPods
|
||||
# if: ${{ steps.pods-restore.outputs.cache-hit != 'true'}}
|
||||
id: pods-install
|
||||
run: |
|
||||
pod install
|
||||
|
||||
- name: Save Pods to Cache
|
||||
id: save-pods
|
||||
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
|
||||
uses: actions/cache/save@v3
|
||||
with:
|
||||
path: |
|
||||
./Podfile.lock
|
||||
./Pods/
|
||||
./AltStore.xcworkspace/
|
||||
key: pods-cache-${{ hashFiles('Podfile') }}
|
||||
|
||||
- name: 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
|
||||
run: NSUnbufferedIO=YES make build 2>&1 | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
|
||||
|
||||
- name: Fakesign app
|
||||
run: make fakesign
|
||||
|
||||
- name: Convert to IPA
|
||||
run: make ipa
|
||||
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get current date in AltStore date form
|
||||
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 alpha release
|
||||
uses: IsaacShelton/update-existing-release@v1.3.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
release: "Alpha"
|
||||
tag: "alpha"
|
||||
prerelease: true
|
||||
files: SideStore.ipa SideStore.dSYMs.zip
|
||||
body: |
|
||||
This is an ⚠️ **EXPERIMENTAL** ⚠️ alpha build for commit [${{ github.sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}).
|
||||
|
||||
Alpha builds are **extremely experimental builds only meant to be used by developers and alpha testers. They often contain bugs and experimental features. Use at your own risk!**
|
||||
|
||||
If you want to try out new features early but want a lower chance of bugs, you can look at [SideStore Stable](https://github.com/${{ github.repository }}/releases?q=stable).
|
||||
|
||||
## Build Info
|
||||
|
||||
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 }}`
|
||||
|
||||
- name: Add version to IPA file name
|
||||
run: mv 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/*
|
||||
|
||||
# Check if PUBLISH_ALPHA_UPDATES secret is set to non-zero
|
||||
- name: Check if PUBLISH_ALPHA_UPDATES is set
|
||||
id: check_publish
|
||||
run: |
|
||||
if [[ "${{ secrets.PUBLISH_ALPHA_UPDATES }}" != "__YES__" ]]; then
|
||||
echo "PUBLISH_ALPHA_UPDATES is not set. Skipping deployment."
|
||||
exit 1 # Exit with 1 to indicate no deployment
|
||||
else
|
||||
echo "PUBLISH_ALPHA_UPDATES is set. Proceeding with deployment."
|
||||
exit 0 # Exit with 0 to indicate deployment should proceed
|
||||
fi
|
||||
continue-on-error: true # Continue even if exit code is 1
|
||||
|
||||
- name: Get short commit hash
|
||||
if: ${{ steps.check_publish.outcome == 'success' }}
|
||||
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: Get formatted date
|
||||
if: ${{ steps.check_publish.outcome == 'success' }}
|
||||
run: |
|
||||
FORMATTED_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
echo "Formatted date: $FORMATTED_DATE"
|
||||
echo "FORMATTED_DATE=$FORMATTED_DATE" >> $GITHUB_ENV
|
||||
|
||||
- name: Get size of IPA in bytes (macOS/Linux)
|
||||
if: ${{ steps.check_publish.outcome == 'success' }}
|
||||
run: |
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
# macOS
|
||||
IPA_SIZE=$(stat -f %z SideStore-${{ steps.version.outputs.version }}.ipa)
|
||||
else
|
||||
# Linux
|
||||
IPA_SIZE=$(stat -c %s SideStore-${{ steps.version.outputs.version }}.ipa)
|
||||
fi
|
||||
echo "IPA size in bytes: $IPA_SIZE"
|
||||
echo "IPA_SIZE=$IPA_SIZE" >> $GITHUB_ENV
|
||||
|
||||
- name: Compute SHA-256 of IPA
|
||||
if: ${{ steps.check_publish.outcome == 'success' }}
|
||||
run: |
|
||||
SHA256_HASH=$(shasum -a 256 SideStore-${{ steps.version.outputs.version }}.ipa | awk '{ print $1 }')
|
||||
echo "SHA-256 Hash: $SHA256_HASH"
|
||||
echo "SHA256_HASH=$SHA256_HASH" >> $GITHUB_ENV
|
||||
|
||||
- name: Set environment variables dynamically
|
||||
if: ${{ steps.check_publish.outcome == 'success' }}
|
||||
run: |
|
||||
echo "VERSION_IPA=$VERSION_IPA" >> $GITHUB_ENV
|
||||
echo "VERSION_DATE=$FORMATTED_DATE" >> $GITHUB_ENV
|
||||
echo "BETA=true" >> $GITHUB_ENV
|
||||
echo "COMMIT_ID=$SHORT_COMMIT" >> $GITHUB_ENV
|
||||
echo "SIZE=$IPA_SIZE" >> $GITHUB_ENV
|
||||
echo "SHA256=$SHA256_HASH" >> $GITHUB_ENV
|
||||
echo "LOCALIZED_DESCRIPTION=This is alpha release for revision: ${{ github.sha }}" >> $GITHUB_ENV
|
||||
echo "DOWNLOAD_URL=https://github.com/SideStore/SideStore/releases/download/alpha/SideStore.ipa" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout SideStore/apps-v2.json
|
||||
if: ${{ steps.check_publish.outcome == 'success' }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Repository name with owner. For example, actions/checkout
|
||||
# Default: ${{ github.repository }}
|
||||
repository: 'SideStore/apps-v2.json'
|
||||
ref: 'main'
|
||||
# token: ${{ github.token }}
|
||||
token: ${{ secrets.APPS_DEPLOY_KEY }}
|
||||
path: 'SideStore/apps-v2.json'
|
||||
|
||||
- name: Publish to SideStore/apps-v2.json
|
||||
if: ${{ steps.check_publish.outcome == 'success' }}
|
||||
run: |
|
||||
# Copy and execute the update script
|
||||
pushd SideStore/apps-v2.json/
|
||||
|
||||
# Configure Git user (committer details)
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email "github-actions@github.com"
|
||||
|
||||
# Make the update script executable and run it
|
||||
python3 ../../update_apps.py "./_includes/source.json"
|
||||
|
||||
# Commit changes and push using SSH
|
||||
git add ./_includes/source.json
|
||||
git commit -m " - updated for $SHORT_COMMIT deployment" || echo "No changes to commit"
|
||||
|
||||
git status
|
||||
git push origin HEAD:main
|
||||
popd
|
||||
|
||||
28
.github/workflows/increase-alpha-build-num.sh
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Ensure we are in root directory
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
DATE=`date -u +'%Y.%m.%d'`
|
||||
BUILD_NUM=1
|
||||
|
||||
write() {
|
||||
sed -e "/MARKETING_VERSION = .*/s/$/-alpha.$DATE.$BUILD_NUM+$(git rev-parse --short HEAD)/" -i '' Build.xcconfig
|
||||
echo "$DATE,$BUILD_NUM" > .alpha-build-num
|
||||
}
|
||||
|
||||
if [ ! -f ".alpha-build-num" ]; then
|
||||
write
|
||||
exit 0
|
||||
fi
|
||||
|
||||
LAST_DATE=`cat .alpha-build-num | perl -n -e '/([^,]*),([^ ]*)$/ && print $1'`
|
||||
LAST_BUILD_NUM=`cat .alpha-build-num | perl -n -e '/([^,]*),([^ ]*)$/ && print $2'`
|
||||
|
||||
if [[ "$DATE" != "$LAST_DATE" ]]; then
|
||||
write
|
||||
else
|
||||
BUILD_NUM=`expr $LAST_BUILD_NUM + 1`
|
||||
write
|
||||
fi
|
||||
|
||||
34
.github/workflows/increase-beta-build-num.sh
vendored
@@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Ensure we are in root directory
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
DATE=`date -u +'%Y.%m.%d'`
|
||||
BUILD_NUM=1
|
||||
|
||||
# Use RELEASE_CHANNEL from the environment variable or default to "beta"
|
||||
RELEASE_CHANNEL=${RELEASE_CHANNEL:-"beta"}
|
||||
|
||||
write() {
|
||||
sed -e "/MARKETING_VERSION = .*/s/$/-$RELEASE_CHANNEL.$DATE.$BUILD_NUM+$(git rev-parse --short HEAD)/" -i '' Build.xcconfig
|
||||
echo "$DATE,$BUILD_NUM" > build_number.txt
|
||||
}
|
||||
|
||||
if [ ! -f "build_number.txt" ]; then
|
||||
write
|
||||
exit 0
|
||||
fi
|
||||
|
||||
LAST_DATE=`cat build_number.txt | perl -n -e '/([^,]*),([^ ]*)$/ && print $1'`
|
||||
LAST_BUILD_NUM=`cat build_number.txt | perl -n -e '/([^,]*),([^ ]*)$/ && print $2'`
|
||||
|
||||
# if [[ "$DATE" != "$LAST_DATE" ]]; then
|
||||
# write
|
||||
# else
|
||||
# BUILD_NUM=`expr $LAST_BUILD_NUM + 1`
|
||||
# write
|
||||
# fi
|
||||
|
||||
# Build number is always incremental
|
||||
BUILD_NUM=`expr $LAST_BUILD_NUM + 1`
|
||||
write
|
||||
28
.github/workflows/increase-nightly-build-num.sh
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Ensure we are in root directory
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
DATE=`date -u +'%Y.%m.%d'`
|
||||
BUILD_NUM=1
|
||||
|
||||
write() {
|
||||
sed -e "/MARKETING_VERSION = .*/s/$/-nightly.$DATE.$BUILD_NUM+$(git rev-parse --short HEAD)/" -i '' Build.xcconfig
|
||||
echo "$DATE,$BUILD_NUM" > .nightly-build-num
|
||||
}
|
||||
|
||||
if [ ! -f ".nightly-build-num" ]; then
|
||||
write
|
||||
exit 0
|
||||
fi
|
||||
|
||||
LAST_DATE=`cat .nightly-build-num | perl -n -e '/([^,]*),([^ ]*)$/ && print $1'`
|
||||
LAST_BUILD_NUM=`cat .nightly-build-num | perl -n -e '/([^,]*),([^ ]*)$/ && print $2'`
|
||||
|
||||
if [[ "$DATE" != "$LAST_DATE" ]]; then
|
||||
write
|
||||
else
|
||||
BUILD_NUM=`expr $LAST_BUILD_NUM + 1`
|
||||
write
|
||||
fi
|
||||
|
||||
363
.github/workflows/nightly.yml
vendored
@@ -1,82 +1,317 @@
|
||||
name: Nightly SideStore Build
|
||||
name: Nightly SideStore build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Runs every night at midnight UTC
|
||||
workflow_dispatch: # Allows manual trigger
|
||||
|
||||
# cancel duplicate run if from same branch
|
||||
concurrency:
|
||||
group: ${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-changes:
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
has_changes: ${{ steps.check.outputs.has_changes }}
|
||||
build:
|
||||
name: Build and upload SideStore Nightly releases
|
||||
concurrency:
|
||||
group: ${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: 'macos-14'
|
||||
version: '16.1'
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
- name: Set current build as BETA
|
||||
run: |
|
||||
echo "IS_BETA=1" >> $GITHUB_ENV
|
||||
echo "RELEASE_CHANNEL=beta" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Ensure full history
|
||||
submodules: recursive
|
||||
|
||||
- name: Get last successful workflow run
|
||||
id: get_last_success
|
||||
run: |
|
||||
LAST_SUCCESS=$(gh run list --workflow "Nightly SideStore Build" --json createdAt,conclusion \
|
||||
--jq '[.[] | select(.conclusion=="success")][0].createdAt' || echo "")
|
||||
echo "Last successful run: $LAST_SUCCESS"
|
||||
echo "last_success=$LAST_SUCCESS" >> $GITHUB_ENV
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Install dependencies
|
||||
run: brew install ldid
|
||||
|
||||
- name: Check for new commits since last successful build
|
||||
id: check
|
||||
- name: Install xcbeautify
|
||||
run: brew install xcbeautify
|
||||
|
||||
- name: Cache .nightly-build-num
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .nightly-build-num
|
||||
key: nightly-build-num
|
||||
|
||||
- name: Get version
|
||||
id: version-marketing
|
||||
run: echo "VERSION_IPA=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_ENV
|
||||
|
||||
- name: Increase nightly build number and set as version
|
||||
run: bash .github/workflows/increase-nightly-build-num.sh
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: echo "version=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Echo version
|
||||
run: echo "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1.6.0
|
||||
with:
|
||||
xcode-version: ${{ matrix.version }}
|
||||
|
||||
- name: 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 }}
|
||||
swiftpm-cache-restore-keys: |
|
||||
xcode-cache-sourcedata-
|
||||
|
||||
|
||||
- name: Restore Pods from Cache (Exact match)
|
||||
id: pods-restore
|
||||
uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: |
|
||||
./Podfile.lock
|
||||
./Pods/
|
||||
./AltStore.xcworkspace/
|
||||
key: pods-cache-${{ 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' }}
|
||||
id: pods-restore-recent
|
||||
uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: |
|
||||
./Podfile.lock
|
||||
./Pods/
|
||||
./AltStore.xcworkspace/
|
||||
key: pods-cache-
|
||||
|
||||
- name: Install CocoaPods
|
||||
# if: ${{ steps.pods-restore.outputs.cache-hit != 'true'}}
|
||||
id: pods-install
|
||||
run: |
|
||||
if [ -n "$LAST_SUCCESS" ]; then
|
||||
NEW_COMMITS=$(git rev-list --count --since="$LAST_SUCCESS" origin/develop)
|
||||
COMMIT_LOG=$(git log --since="$LAST_SUCCESS" --pretty=format:"%h %s" origin/develop)
|
||||
else
|
||||
NEW_COMMITS=1
|
||||
COMMIT_LOG=$(git log -n 10 --pretty=format:"%h %s" origin/develop) # Show last 10 commits if no history
|
||||
pod install
|
||||
|
||||
- name: Save Pods to Cache
|
||||
id: save-pods
|
||||
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
|
||||
uses: actions/cache/save@v3
|
||||
with:
|
||||
path: |
|
||||
./Podfile.lock
|
||||
./Pods/
|
||||
./AltStore.xcworkspace/
|
||||
key: pods-cache-${{ hashFiles('Podfile') }}
|
||||
|
||||
- name: 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
|
||||
# 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]}
|
||||
|
||||
- name: Fakesign app
|
||||
run: make fakesign | tee -a build.log
|
||||
|
||||
- name: Convert to IPA
|
||||
run: make ipa | tee -a build.log
|
||||
|
||||
- name: Encrypt build.log generated from SideStore build for upload
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
echo "Has changes: $NEW_COMMITS"
|
||||
echo "New commits since last successful build:"
|
||||
echo "$COMMIT_LOG"
|
||||
|
||||
if [ "$NEW_COMMITS" -gt 0 ]; then
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
zip -e -P "$BUILD_LOG_ZIP_PASSWORD" encrypted-build_log.zip build.log
|
||||
|
||||
- name: List Files after SideStore build
|
||||
run: |
|
||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||
ls -la .
|
||||
echo ""
|
||||
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get current date in AltStore date form
|
||||
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 nightly release
|
||||
uses: IsaacShelton/update-existing-release@v1.3.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
release: "Nightly"
|
||||
tag: "nightly"
|
||||
prerelease: true
|
||||
files: SideStore.ipa SideStore.dSYMs.zip encrypted-build_log.zip
|
||||
body: |
|
||||
This is an ⚠️ **EXPERIMENTAL** ⚠️ nightly build for commit [${{ github.sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}).
|
||||
|
||||
Nightly builds are **extremely experimental builds only meant to be used by developers and beta testers. They often contain bugs and experimental features. Use at your own risk!**
|
||||
|
||||
If you want to try out new features early but want a lower chance of bugs, you can look at [SideStore Stable](https://github.com/${{ github.repository }}/releases?q=stable).
|
||||
|
||||
## Build Info
|
||||
|
||||
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 }}`
|
||||
|
||||
- name: Add version to IPA file name
|
||||
run: mv 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
|
||||
|
||||
# Check if PUBLISH_BETA_UPDATES secret is set to non-zero
|
||||
- name: Check if PUBLISH_BETA_UPDATES is set
|
||||
id: check_publish
|
||||
run: |
|
||||
if [[ "${{ secrets.PUBLISH_BETA_UPDATES }}" != "__YES__" ]]; then
|
||||
echo "PUBLISH_BETA_UPDATES is not set. Skipping deployment."
|
||||
exit 1 # Exit with 1 to indicate no deployment
|
||||
else
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "PUBLISH_BETA_UPDATES is set. Proceeding with deployment."
|
||||
exit 0 # Exit with 0 to indicate deployment should proceed
|
||||
fi
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
LAST_SUCCESS: ${{ env.last_success }}
|
||||
continue-on-error: true # Continue even if exit code is 1
|
||||
|
||||
Reusable-build:
|
||||
if: |
|
||||
always() &&
|
||||
(github.event_name == 'push' ||
|
||||
(github.event_name == 'schedule' && needs.check-changes.result == 'success' && needs.check-changes.outputs.has_changes == 'true'))
|
||||
needs: check-changes
|
||||
uses: ./.github/workflows/reusable-sidestore-build.yml
|
||||
with:
|
||||
# bundle_id: "com.SideStore.SideStore.Nightly"
|
||||
bundle_id: "com.SideStore.SideStore"
|
||||
# bundle_id_suffix: ".Nightly"
|
||||
is_beta: true
|
||||
publish: ${{ vars.PUBLISH_NIGHTLY_UPDATES == 'true' }}
|
||||
is_shared_build_num: false
|
||||
release_tag: "nightly"
|
||||
release_name: "Nightly"
|
||||
upstream_tag: "0.5.10"
|
||||
upstream_name: "Stable"
|
||||
secrets:
|
||||
CROSS_REPO_PUSH_KEY: ${{ secrets.CROSS_REPO_PUSH_KEY }}
|
||||
BUILD_LOG_ZIP_PASSWORD: ${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
|
||||
- name: Get short commit hash
|
||||
if: ${{ steps.check_publish.outcome == 'success' }}
|
||||
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: Get formatted date
|
||||
if: ${{ steps.check_publish.outcome == 'success' }}
|
||||
run: |
|
||||
FORMATTED_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
echo "Formatted date: $FORMATTED_DATE"
|
||||
echo "FORMATTED_DATE=$FORMATTED_DATE" >> $GITHUB_ENV
|
||||
|
||||
- name: Get size of IPA in bytes (macOS/Linux)
|
||||
if: ${{ steps.check_publish.outcome == 'success' }}
|
||||
run: |
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
# macOS
|
||||
IPA_SIZE=$(stat -f %z SideStore-${{ steps.version.outputs.version }}.ipa)
|
||||
else
|
||||
# Linux
|
||||
IPA_SIZE=$(stat -c %s SideStore-${{ steps.version.outputs.version }}.ipa)
|
||||
fi
|
||||
echo "IPA size in bytes: $IPA_SIZE"
|
||||
echo "IPA_SIZE=$IPA_SIZE" >> $GITHUB_ENV
|
||||
|
||||
- name: Compute SHA-256 of IPA
|
||||
if: ${{ steps.check_publish.outcome == 'success' }}
|
||||
run: |
|
||||
SHA256_HASH=$(shasum -a 256 SideStore-${{ steps.version.outputs.version }}.ipa | awk '{ print $1 }')
|
||||
echo "SHA-256 Hash: $SHA256_HASH"
|
||||
echo "SHA256_HASH=$SHA256_HASH" >> $GITHUB_ENV
|
||||
|
||||
- name: Set environment variables dynamically
|
||||
if: ${{ steps.check_publish.outcome == 'success' }}
|
||||
run: |
|
||||
echo "VERSION_IPA=$VERSION_IPA" >> $GITHUB_ENV
|
||||
echo "VERSION_DATE=$FORMATTED_DATE" >> $GITHUB_ENV
|
||||
echo "COMMIT_ID=$SHORT_COMMIT" >> $GITHUB_ENV
|
||||
echo "SIZE=$IPA_SIZE" >> $GITHUB_ENV
|
||||
echo "SHA256=$SHA256_HASH" >> $GITHUB_ENV
|
||||
echo "LOCALIZED_DESCRIPTION=This is nightly release for revision: ${{ github.sha }}" >> $GITHUB_ENV
|
||||
echo "DOWNLOAD_URL=https://github.com/SideStore/SideStore/releases/download/nightly/SideStore.ipa" >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout SideStore/apps-v2.json
|
||||
if: ${{ steps.check_publish.outcome == 'success' }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Repository name with owner. For example, actions/checkout
|
||||
# Default: ${{ github.repository }}
|
||||
repository: 'SideStore/apps-v2.json'
|
||||
ref: 'main' # TODO: use branches for alpha and beta tracks? so as to avoid push collision?
|
||||
# ref: 'nightly' # TODO: use branches for alpha and beta tracks? so as to avoid push collision?
|
||||
# token: ${{ github.token }}
|
||||
token: ${{ secrets.APPS_DEPLOY_KEY }}
|
||||
path: 'SideStore/apps-v2.json'
|
||||
|
||||
- name: Publish to SideStore/apps-v2.json
|
||||
if: ${{ steps.check_publish.outcome == 'success' }}
|
||||
run: |
|
||||
# Copy and execute the update script
|
||||
pushd SideStore/apps-v2.json/
|
||||
|
||||
# Configure Git user (committer details)
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email "github-actions@github.com"
|
||||
|
||||
# update the source.json
|
||||
python3 ../../update_apps.py "./_includes/source.json"
|
||||
|
||||
# Commit changes and push using SSH
|
||||
git add ./_includes/source.json
|
||||
git commit -m " - updated for $SHORT_COMMIT deployment" || echo "No changes to commit"
|
||||
|
||||
git status
|
||||
git push origin HEAD:main
|
||||
popd
|
||||
|
||||
48
.github/workflows/pr.yml
vendored
@@ -1,13 +1,10 @@
|
||||
name: Pull Request SideStore build
|
||||
on:
|
||||
pull_request:
|
||||
# types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and upload SideStore
|
||||
if: ${{ github.event.pull_request.draft == false }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -54,12 +51,57 @@ jobs:
|
||||
swiftpm-cache-restore-keys: |
|
||||
xcode-cache-sourcedata-
|
||||
|
||||
|
||||
- name: Restore Pods from Cache (Exact match)
|
||||
id: pods-restore
|
||||
uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: |
|
||||
./Podfile.lock
|
||||
./Pods/
|
||||
./AltStore.xcworkspace/
|
||||
key: pods-cache-${{ 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' }}
|
||||
id: pods-restore-recent
|
||||
uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: |
|
||||
./Podfile.lock
|
||||
./Pods/
|
||||
./AltStore.xcworkspace/
|
||||
key: pods-cache-
|
||||
|
||||
- name: Install CocoaPods
|
||||
# if: ${{ steps.pods-restore.outputs.cache-hit != 'true'}}
|
||||
id: pods-install
|
||||
run: |
|
||||
pod install
|
||||
|
||||
- name: Save Pods to Cache
|
||||
id: save-pods
|
||||
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
|
||||
uses: actions/cache/save@v3
|
||||
with:
|
||||
path: |
|
||||
./Podfile.lock
|
||||
./Pods/
|
||||
./AltStore.xcworkspace/
|
||||
key: pods-cache-${{ hashFiles('Podfile') }}
|
||||
|
||||
- name: 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 ""
|
||||
|
||||
105
.github/workflows/reusable-sidestore-build.yml
vendored
@@ -1,105 +0,0 @@
|
||||
name: Reusable SideStore Build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
is_beta:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
publish:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
is_shared_build_num:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
release_name:
|
||||
required: true
|
||||
type: string
|
||||
release_tag:
|
||||
required: true
|
||||
type: string
|
||||
upstream_tag:
|
||||
required: true
|
||||
type: string
|
||||
upstream_name:
|
||||
required: true
|
||||
type: string
|
||||
bundle_id:
|
||||
default: com.SideStore.SideStore
|
||||
required: true
|
||||
type: string
|
||||
bundle_id_suffix:
|
||||
default: ''
|
||||
required: false
|
||||
type: string
|
||||
|
||||
secrets:
|
||||
# GITHUB_TOKEN:
|
||||
# required: true
|
||||
CROSS_REPO_PUSH_KEY:
|
||||
required: true
|
||||
BUILD_LOG_ZIP_PASSWORD:
|
||||
required: false
|
||||
|
||||
|
||||
# since build cache, test-build cache, test-run cache are involved, out of order exec if serialization is on individual jobs will wreak all sorts of havoc
|
||||
# so we serialize on the entire workflow
|
||||
concurrency:
|
||||
group: serialize-workflow
|
||||
|
||||
jobs:
|
||||
shared:
|
||||
uses: ./.github/workflows/sidestore-shared.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
needs: shared
|
||||
uses: ./.github/workflows/sidestore-build.yml
|
||||
with:
|
||||
is_beta: ${{ inputs.is_beta }}
|
||||
is_shared_build_num: ${{ inputs.is_shared_build_num }}
|
||||
release_tag: ${{ inputs.release_tag }}
|
||||
short_commit: ${{ needs.shared.outputs.short-commit }}
|
||||
bundle_id: ${{ inputs.bundle_id }}
|
||||
bundle_id_suffix: ${{ inputs.bundle_id_suffix }}
|
||||
secrets: inherit
|
||||
|
||||
# tests-build:
|
||||
# if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_BUILD == '1' }}
|
||||
# needs: shared
|
||||
# uses: ./.github/workflows/sidestore-tests-build.yml
|
||||
# with:
|
||||
# release_tag: ${{ inputs.release_tag }}
|
||||
# short_commit: ${{ needs.shared.outputs.short-commit }}
|
||||
# secrets: inherit
|
||||
|
||||
# tests-run:
|
||||
# if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
|
||||
# needs: [shared, tests-build]
|
||||
# uses: ./.github/workflows/sidestore-tests-run.yml
|
||||
# with:
|
||||
# release_tag: ${{ inputs.release_tag }}
|
||||
# short_commit: ${{ needs.shared.outputs.short-commit }}
|
||||
# secrets: inherit
|
||||
|
||||
deploy:
|
||||
# needs: [shared, build, tests-build, tests-run] # Keep tests-run in needs
|
||||
needs: [shared, build] # Keep tests-run in needs
|
||||
if: ${{ always() && (needs.tests-run.result == 'skipped' || needs.tests-run.result == 'success') }}
|
||||
uses: ./.github/workflows/sidestore-deploy.yml
|
||||
with:
|
||||
is_beta: ${{ inputs.is_beta }}
|
||||
publish: ${{ inputs.publish }}
|
||||
release_name: ${{ inputs.release_name }}
|
||||
release_tag: ${{ inputs.release_tag }}
|
||||
upstream_tag: ${{ inputs.upstream_tag }}
|
||||
upstream_name: ${{ inputs.upstream_name }}
|
||||
version: ${{ needs.build.outputs.version }}
|
||||
short_commit: ${{ needs.shared.outputs.short-commit }}
|
||||
release_channel: ${{ needs.build.outputs.release-channel }}
|
||||
marketing_version: ${{ needs.build.outputs.marketing-version }}
|
||||
bundle_id: ${{ inputs.bundle_id }}
|
||||
secrets: inherit
|
||||
358
.github/workflows/sidestore-build.yml
vendored
@@ -1,358 +0,0 @@
|
||||
name: SideStore Build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
is_beta:
|
||||
type: boolean
|
||||
is_shared_build_num:
|
||||
type: boolean
|
||||
release_tag:
|
||||
type: string
|
||||
bundle_id:
|
||||
type: string
|
||||
bundle_id_suffix:
|
||||
type: string
|
||||
short_commit:
|
||||
type: string
|
||||
secrets:
|
||||
CROSS_REPO_PUSH_KEY:
|
||||
required: true
|
||||
BUILD_LOG_ZIP_PASSWORD:
|
||||
required: false
|
||||
outputs:
|
||||
version:
|
||||
value: ${{ jobs.build.outputs.version }}
|
||||
marketing-version:
|
||||
value: ${{ jobs.build.outputs.marketing-version }}
|
||||
release-channel:
|
||||
value: ${{ jobs.build.outputs.release-channel }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build SideStore - ${{ inputs.release_tag }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: 'macos-26'
|
||||
version: '26.0'
|
||||
runs-on: ${{ matrix.os }}
|
||||
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
|
||||
shell: bash
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies - ldid & xcbeautify
|
||||
run: |
|
||||
brew install ldid xcbeautify
|
||||
|
||||
- name: Set ref based on is_shared_build_num
|
||||
if: ${{ inputs.is_beta }}
|
||||
id: set_ref
|
||||
run: |
|
||||
if [ "${{ inputs.is_shared_build_num }}" == "true" ]; then
|
||||
echo "ref=main" >> $GITHUB_ENV
|
||||
else
|
||||
echo "ref=${{ inputs.release_tag }}" >> $GITHUB_ENV
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Checkout SideStore/beta-build-num repo
|
||||
if: ${{ inputs.is_beta }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'SideStore/beta-build-num'
|
||||
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: |
|
||||
cp SideStore/beta-build-num/build_number.txt .
|
||||
echo "cat build_number.txt"
|
||||
cat build_number.txt
|
||||
shell: bash
|
||||
|
||||
- name: Echo Build.xcconfig
|
||||
run: |
|
||||
echo "cat Build.xcconfig"
|
||||
cat Build.xcconfig
|
||||
shell: bash
|
||||
|
||||
- name: Set Release Channel info for build number bumper
|
||||
id: release-channel
|
||||
run: |
|
||||
RELEASE_CHANNEL="${{ inputs.release_tag }}"
|
||||
echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}" >> $GITHUB_ENV
|
||||
echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}" >> $GITHUB_OUTPUT
|
||||
echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}"
|
||||
shell: bash
|
||||
|
||||
- name: Increase build number for beta builds
|
||||
if: ${{ inputs.is_beta }}
|
||||
run: |
|
||||
bash .github/workflows/increase-beta-build-num.sh
|
||||
shell: bash
|
||||
|
||||
- name: Extract MARKETING_VERSION from Build.xcconfig
|
||||
id: version
|
||||
run: |
|
||||
version=$(grep MARKETING_VERSION Build.xcconfig | sed -e 's/MARKETING_VERSION = //g')
|
||||
echo "version=$version" >> $GITHUB_OUTPUT
|
||||
echo "version=$version"
|
||||
shell: bash
|
||||
|
||||
- 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/')
|
||||
# Extract date (YYYYMMDD) (e.g., "20250205")
|
||||
date=$(echo "${{ steps.version.outputs.version }}" | sed -E 's/.*\.([0-9]{4})\.([0-9]{2})\.([0-9]{2})\..*/\1\2\3/')
|
||||
# Extract build number (e.g., "2")
|
||||
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}+${{ inputs.short_commit }}"
|
||||
|
||||
echo "MARKETING_VERSION=$MARKETING_VERSION" >> $GITHUB_ENV
|
||||
echo "MARKETING_VERSION=$MARKETING_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "MARKETING_VERSION=$MARKETING_VERSION"
|
||||
shell: bash
|
||||
|
||||
- name: Echo Updated Build.xcconfig, build_number.txt
|
||||
if: ${{ inputs.is_beta }}
|
||||
run: |
|
||||
cat Build.xcconfig
|
||||
cat build_number.txt
|
||||
shell: bash
|
||||
|
||||
- name: Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1.6.0
|
||||
with:
|
||||
xcode-version: ${{ matrix.version }}
|
||||
|
||||
- name: (Build) Restore Xcode & SwiftPM Cache (Exact match)
|
||||
id: xcode-cache-restore
|
||||
uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: |
|
||||
~/Library/Developer/Xcode/DerivedData
|
||||
~/Library/Caches/org.swift.swiftpm
|
||||
key: xcode-cache-build-${{ github.ref_name }}-${{ github.sha }}
|
||||
|
||||
- name: (Build) Restore Xcode & SwiftPM Cache (Last Available)
|
||||
id: xcode-cache-restore-recent
|
||||
uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: |
|
||||
~/Library/Developer/Xcode/DerivedData
|
||||
~/Library/Caches/org.swift.swiftpm
|
||||
key: xcode-cache-build-${{ github.ref_name }}-
|
||||
|
||||
# - name: (Build) Cache Build
|
||||
# uses: irgaly/xcode-cache@v1.8.1
|
||||
# with:
|
||||
# key: xcode-cache-deriveddata-build-${{ github.ref_name }}-${{ github.sha }}
|
||||
# restore-keys: xcode-cache-deriveddata-build-${{ github.ref_name }}-
|
||||
# swiftpm-cache-key: xcode-cache-sourcedata-build-${{ github.ref_name }}-${{ github.sha }}
|
||||
# swiftpm-cache-restore-keys: |
|
||||
# xcode-cache-sourcedata-build-${{ github.ref_name }}-
|
||||
|
||||
- name: (Build) Clean previous build artifacts
|
||||
# using 'tee' to intercept stdout and log for detailed build-log
|
||||
run: |
|
||||
make clean
|
||||
mkdir -p build/logs
|
||||
shell: bash
|
||||
|
||||
- name: (Build) List Files and derived data
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||
ls -la .
|
||||
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: Set BundleID Suffix for Sidestore build
|
||||
run: |
|
||||
echo "BUNDLE_ID_SUFFIX=${{ inputs.bundle_id_suffix }}" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
|
||||
|
||||
- name: Build SideStore.xcarchive
|
||||
# using 'tee' to intercept stdout and log for detailed build-log
|
||||
run: |
|
||||
NSUnbufferedIO=YES make -B build 2>&1 | tee -a build/logs/build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
|
||||
shell: bash
|
||||
|
||||
- name: Fakesign app
|
||||
run: make fakesign | tee -a build/logs/build.log
|
||||
shell: bash
|
||||
|
||||
- name: Convert to IPA
|
||||
run: make ipa | tee -a build/logs/build.log
|
||||
shell: bash
|
||||
|
||||
- name: (Build) Save Xcode & SwiftPM Cache
|
||||
id: cache-save
|
||||
if: ${{ steps.xcode-cache-restore.outputs.cache-hit != 'true' }}
|
||||
uses: actions/cache/save@v3
|
||||
with:
|
||||
path: |
|
||||
~/Library/Developer/Xcode/DerivedData
|
||||
~/Library/Caches/org.swift.swiftpm
|
||||
key: xcode-cache-build-${{ github.ref_name }}-${{ github.sha }}
|
||||
|
||||
- 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 ""
|
||||
|
||||
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
||||
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
|
||||
echo ""
|
||||
shell: bash
|
||||
|
||||
- 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"
|
||||
shell: bash
|
||||
|
||||
- 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
|
||||
shell: bash
|
||||
|
||||
- name: Upload *.dSYM Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: SideStore-${{ steps.version.outputs.version }}-dSYMs.zip
|
||||
path: SideStore.dSYMs.zip
|
||||
|
||||
- name: Keep rolling the build numbers for each successful build
|
||||
if: ${{ inputs.is_beta }}
|
||||
run: |
|
||||
pushd SideStore/beta-build-num/
|
||||
|
||||
echo "Configure Git user (committer details)"
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email "github-actions@github.com"
|
||||
|
||||
echo "Adding files to commit"
|
||||
git add --verbose build_number.txt
|
||||
git commit -m " - updated for ${{ inputs.release_tag }} - ${{ inputs.short_commit }} deployment" || echo "No changes to commit"
|
||||
|
||||
echo "Pushing to remote repo"
|
||||
git push --verbose
|
||||
popd
|
||||
shell: bash
|
||||
|
||||
- name: Get last successful commit
|
||||
id: get_last_commit
|
||||
run: |
|
||||
# Try to get the last successful workflow run commit
|
||||
LAST_SUCCESS_SHA=$(gh run list --branch "${{ github.ref_name }}" --status success --json headSha --jq '.[0].headSha')
|
||||
echo "LAST_SUCCESS_SHA=$LAST_SUCCESS_SHA" >> $GITHUB_OUTPUT
|
||||
echo "LAST_SUCCESS_SHA=$LAST_SUCCESS_SHA" >> $GITHUB_ENV
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
|
||||
- name: Create release notes
|
||||
run: |
|
||||
LAST_SUCCESS_SHA=${{ steps.get_last_commit.outputs.LAST_SUCCESS_SHA}}
|
||||
echo "Last successful commit SHA: $LAST_SUCCESS_SHA"
|
||||
|
||||
FROM_COMMIT=$LAST_SUCCESS_SHA
|
||||
# Check if we got a valid SHA
|
||||
if [ -z "$LAST_SUCCESS_SHA" ] || [ "$LAST_SUCCESS_SHA" = "null" ]; then
|
||||
echo "No successful run found, using initial commit of branch"
|
||||
# Get the first commit of the branch (initial commit)
|
||||
FROM_COMMIT=$(git rev-list --max-parents=0 HEAD)
|
||||
fi
|
||||
|
||||
python3 update_release_notes.py $FROM_COMMIT ${{ inputs.release_tag }} ${{ github.ref_name }}
|
||||
# cat release-notes.md
|
||||
shell: bash
|
||||
|
||||
- name: Upload release-notes.md
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-notes-${{ inputs.short_commit }}.md
|
||||
path: release-notes.md
|
||||
|
||||
- name: Upload update_release_notes.py
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: update_release_notes-${{ inputs.short_commit }}.py
|
||||
path: update_release_notes.py
|
||||
|
||||
- name: Upload update_apps.py
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: update_apps-${{ inputs.short_commit }}.py
|
||||
path: update_apps.py
|
||||
281
.github/workflows/sidestore-deploy.yml
vendored
@@ -1,281 +0,0 @@
|
||||
name: SideStore Deploy
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
is_beta:
|
||||
type: boolean
|
||||
publish:
|
||||
type: boolean
|
||||
release_name:
|
||||
type: string
|
||||
release_tag:
|
||||
type: string
|
||||
upstream_tag:
|
||||
type: string
|
||||
upstream_name:
|
||||
type: string
|
||||
version:
|
||||
type: string
|
||||
short_commit:
|
||||
type: string
|
||||
marketing_version:
|
||||
type: string
|
||||
release_channel:
|
||||
type: string
|
||||
bundle_id:
|
||||
type: string
|
||||
secrets:
|
||||
CROSS_REPO_PUSH_KEY:
|
||||
required: true
|
||||
# GITHUB_TOKEN:
|
||||
# required: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy SideStore - ${{ inputs.release_tag }}
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- name: Download IPA artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: SideStore-${{ inputs.version }}.ipa
|
||||
|
||||
- name: Download dSYM artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: SideStore-${{ inputs.version }}-dSYMs.zip
|
||||
|
||||
- name: Download encrypted-build-logs artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: encrypted-build-logs-${{ inputs.version }}.zip
|
||||
|
||||
- name: Download encrypted-tests-build-logs artifact
|
||||
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_BUILD == '1' }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: encrypted-tests-build-logs-${{ inputs.short_commit }}.zip
|
||||
|
||||
- name: Download encrypted-tests-run-logs artifact
|
||||
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: encrypted-tests-run-logs-${{ inputs.short_commit }}.zip
|
||||
|
||||
- name: Download tests-recording artifact
|
||||
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tests-recording-${{ inputs.short_commit }}.mp4
|
||||
|
||||
- name: Download test-results artifact
|
||||
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: test-results-${{ inputs.short_commit }}.zip
|
||||
|
||||
- name: Download release-notes.md
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-notes-${{ inputs.short_commit }}.md
|
||||
|
||||
- name: Download update_release_notes.py
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: update_release_notes-${{ inputs.short_commit }}.py
|
||||
|
||||
- name: Download update_apps.py
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: update_apps-${{ inputs.short_commit }}.py
|
||||
|
||||
- name: Read release notes
|
||||
id: release_notes
|
||||
run: |
|
||||
CONTENT=$(python3 update_release_notes.py --retrieve ${{ inputs.release_tag }})
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
shell: bash
|
||||
|
||||
- name: List files before upload
|
||||
run: |
|
||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||
find . -maxdepth 4 -exec ls -ld {} + || true # List contents if directory exists
|
||||
echo ""
|
||||
shell: bash
|
||||
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
|
||||
shell: bash
|
||||
|
||||
- name: Get current date in AltStore date form
|
||||
id: date_altstore
|
||||
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
shell: bash
|
||||
|
||||
|
||||
- name: List files to upload
|
||||
id: list_uploads
|
||||
run: |
|
||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||
find . -maxdepth 4 -exec ls -ld {} + || true # List contents if directory exists
|
||||
echo ""
|
||||
|
||||
FILES="SideStore.ipa SideStore.dSYMs.zip encrypted-build-logs.zip"
|
||||
|
||||
if [[ "${{ vars.ENABLE_TESTS }}" == "1" && "${{ vars.ENABLE_TESTS_BUILD }}" == "1" ]]; then
|
||||
FILES="$FILES encrypted-tests-build-logs.zip"
|
||||
fi
|
||||
|
||||
if [[ "${{ vars.ENABLE_TESTS }}" == "1" && "${{ vars.ENABLE_TESTS_RUN }}" == "1" ]]; then
|
||||
FILES="$FILES encrypted-tests-run-logs.zip test-results.zip tests-recording.mp4"
|
||||
fi
|
||||
|
||||
echo "Final upload list:"
|
||||
for f in $FILES; do
|
||||
if [[ -f "$f" ]]; then
|
||||
echo " ✓ $f"
|
||||
else
|
||||
echo " - $f (missing)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "files=$FILES" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set Upstream Recommendation
|
||||
id: upstream_recommendation
|
||||
run: |
|
||||
UPSTREAM_NAME=$(echo "${{ inputs.upstream_name }}" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$UPSTREAM_NAME" != "nightly" ]]; then
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "If you want to try out new features early but want a lower chance of bugs, you can look at [SideStore ${{ inputs.upstream_name }}](https://github.com/${{ github.repository }}/releases?q=${{ inputs.upstream_tag }})." >> $GITHUB_OUTPUT
|
||||
echo "" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "content=" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Upload to releases
|
||||
uses: IsaacShelton/update-existing-release@v1.3.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
release: ${{ inputs.release_name }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
prerelease: ${{ inputs.is_beta }}
|
||||
files: ${{ steps.list_uploads.outputs.files }}
|
||||
body: |
|
||||
This is an ⚠️ **EXPERIMENTAL** ⚠️ ${{ inputs.release_name }} build for commit [${{ github.sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}).
|
||||
|
||||
${{ inputs.release_name }} builds are **extremely experimental builds only meant to be used by developers and beta testers. They often contain bugs and experimental features. Use at your own risk!**
|
||||
|
||||
${{ steps.upstream_recommendation.outputs.content }}
|
||||
## Build Info
|
||||
|
||||
Built at (UTC): `${{ steps.date.outputs.date }}`
|
||||
Built at (UTC date): `${{ steps.date_altstore.outputs.date }}`
|
||||
Commit SHA: `${{ github.sha }}`
|
||||
Version: `${{ inputs.version }}`
|
||||
|
||||
${{ steps.release_notes.outputs.content }}
|
||||
|
||||
- name: Get formatted date
|
||||
run: |
|
||||
FORMATTED_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
echo "Formatted date: $FORMATTED_DATE"
|
||||
echo "FORMATTED_DATE=$FORMATTED_DATE" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
|
||||
- name: Get size of IPA in bytes (macOS/Linux)
|
||||
run: |
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
# macOS
|
||||
IPA_SIZE=$(stat -f %z SideStore.ipa)
|
||||
else
|
||||
# Linux
|
||||
IPA_SIZE=$(stat -c %s SideStore.ipa)
|
||||
fi
|
||||
echo "IPA size in bytes: $IPA_SIZE"
|
||||
echo "IPA_SIZE=$IPA_SIZE" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
|
||||
- name: Compute SHA-256 of IPA
|
||||
run: |
|
||||
SHA256_HASH=$(shasum -a 256 SideStore.ipa | awk '{ print $1 }')
|
||||
echo "SHA-256 Hash: $SHA256_HASH"
|
||||
echo "SHA256_HASH=$SHA256_HASH" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
|
||||
- name: Set Release Info variables
|
||||
run: |
|
||||
echo "IS_BETA=${{ inputs.is_beta }}" >> $GITHUB_ENV
|
||||
echo "BUNDLE_IDENTIFIER=${{ inputs.bundle_id }}" >> $GITHUB_ENV
|
||||
echo "VERSION_IPA=${{ inputs.marketing_version }}" >> $GITHUB_ENV
|
||||
echo "VERSION_DATE=$FORMATTED_DATE" >> $GITHUB_ENV
|
||||
echo "RELEASE_CHANNEL=${{ inputs.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
|
||||
|
||||
# Format localized description
|
||||
get_description() {
|
||||
cat <<EOF
|
||||
This is release for:
|
||||
- version: "${{ inputs.version }}"
|
||||
- revision: "${{ inputs.short_commit }}"
|
||||
- timestamp: "${{ steps.date.outputs.date }}"
|
||||
|
||||
Release Notes:
|
||||
${{ steps.release_notes.outputs.content }}
|
||||
EOF
|
||||
}
|
||||
|
||||
LOCALIZED_DESCRIPTION=$(get_description)
|
||||
echo "$LOCALIZED_DESCRIPTION"
|
||||
|
||||
# multiline strings
|
||||
echo "LOCALIZED_DESCRIPTION<<EOF" >> $GITHUB_ENV
|
||||
echo "$LOCALIZED_DESCRIPTION" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
|
||||
- name: Check if Publish updates is set
|
||||
id: check_publish
|
||||
run: |
|
||||
echo "Publish updates to source.json = ${{ inputs.publish }}"
|
||||
shell: bash
|
||||
|
||||
- name: Checkout SideStore/apps-v2.json
|
||||
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'
|
||||
|
||||
# for stable builds, let the user manually edit the source.json
|
||||
- name: Publish to SideStore/apps-v2.json
|
||||
if: ${{ inputs.is_beta && inputs.publish }}
|
||||
id: publish-release
|
||||
shell: bash
|
||||
run: |
|
||||
# Copy and execute the update script
|
||||
pushd SideStore/apps-v2.json/
|
||||
|
||||
# Configure Git user (committer details)
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email "github-actions@github.com"
|
||||
|
||||
# update the source.json
|
||||
python3 ../../update_apps.py "./_includes/source.json"
|
||||
|
||||
# Commit changes and push using SSH
|
||||
git add --verbose ./_includes/source.json
|
||||
git commit -m " - updated for ${{ inputs.short_commit }} deployment" || echo "No changes to commit"
|
||||
|
||||
git push --verbose
|
||||
popd
|
||||
24
.github/workflows/sidestore-shared.yml
vendored
@@ -1,24 +0,0 @@
|
||||
name: SideStore Shared
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
short-commit:
|
||||
value: ${{ jobs.shared.outputs.short-commit }}
|
||||
|
||||
jobs:
|
||||
shared:
|
||||
name: Shared Steps
|
||||
strategy:
|
||||
fail-fast: false
|
||||
runs-on: 'macos-15'
|
||||
steps:
|
||||
- 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 }}
|
||||
165
.github/workflows/sidestore-tests-build.yml
vendored
@@ -1,165 +0,0 @@
|
||||
name: SideStore Tests Build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
type: string
|
||||
short_commit:
|
||||
type: string
|
||||
secrets:
|
||||
BUILD_LOG_ZIP_PASSWORD:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
tests-build:
|
||||
name: Tests-Build SideStore - ${{ inputs.release_tag }}
|
||||
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_BUILD == '1' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: 'macos-26'
|
||||
version: '26.0'
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install dependencies - xcbeautify
|
||||
run: |
|
||||
brew install xcbeautify
|
||||
shell: bash
|
||||
|
||||
- name: Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1.6.0
|
||||
with:
|
||||
xcode-version: '26.0'
|
||||
|
||||
# - name: (Tests-Build) Cache Build
|
||||
# uses: irgaly/xcode-cache@v1.8.1
|
||||
# with:
|
||||
# key: xcode-cache-deriveddata-test-${{ github.ref_name }}-${{ github.sha }}
|
||||
# # tests shouldn't restore cache unless it is same build
|
||||
# # restore-keys: xcode-cache-deriveddata-test-${{ github.ref_name }}-
|
||||
# swiftpm-cache-key: xcode-cache-sourcedata-test-${{ github.ref_name }}-${{ github.sha }}
|
||||
# swiftpm-cache-restore-keys: |
|
||||
# xcode-cache-sourcedata-test-${{ github.ref_name }}-
|
||||
# delete-used-deriveddata-cache: true
|
||||
|
||||
- name: (Tests-Build) Restore Xcode & SwiftPM Cache (Exact match)
|
||||
id: xcode-cache-restore
|
||||
uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: |
|
||||
~/Library/Developer/Xcode/DerivedData
|
||||
~/Library/Caches/org.swift.swiftpm
|
||||
key: xcode-cache-tests-${{ github.ref_name }}-${{ github.sha }}
|
||||
|
||||
- name: (Tests-Build) Restore Xcode & SwiftPM Cache (Last Available)
|
||||
id: xcode-cache-restore-recent
|
||||
uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: |
|
||||
~/Library/Developer/Xcode/DerivedData
|
||||
~/Library/Caches/org.swift.swiftpm
|
||||
key: xcode-cache-tests-${{ github.ref_name }}-
|
||||
|
||||
- name: Clean Derived Data (if required)
|
||||
if: ${{ vars.PERFORM_CLEAN_TESTS_BUILD == '1' }}
|
||||
run: |
|
||||
rm -rf ~/Library/Developer/Xcode/DerivedData/
|
||||
make clean
|
||||
xcodebuild clean
|
||||
shell: bash
|
||||
|
||||
- name: (Tests-Build) Clean previous build artifacts
|
||||
run: |
|
||||
make clean
|
||||
mkdir -p build/logs
|
||||
shell: bash
|
||||
|
||||
- name: (Tests-Build) List Files and derived data
|
||||
shell: bash
|
||||
run: |
|
||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||
ls -la .
|
||||
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
|
||||
shell: bash
|
||||
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) Save Xcode & SwiftPM Cache
|
||||
id: cache-save
|
||||
if: ${{ steps.xcode-cache-restore.outputs.cache-hit != 'true' }}
|
||||
uses: actions/cache/save@v3
|
||||
with:
|
||||
path: |
|
||||
~/Library/Developer/Xcode/DerivedData
|
||||
~/Library/Caches/org.swift.swiftpm
|
||||
key: xcode-cache-tests-${{ github.ref_name }}-${{ github.sha }}
|
||||
|
||||
- name: (Tests-Build) List Files and Build artifacts
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||
ls -la .
|
||||
echo ""
|
||||
|
||||
echo ">>>>>>>>> Build <<<<<<<<<<"
|
||||
find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
|
||||
echo ""
|
||||
|
||||
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
||||
find ~/Library/Developer/Xcode/DerivedData -maxdepth 8 -exec ls -ld {} + | grep "Build/Products" >> tests-build-deriveddata.txt || true
|
||||
echo ""
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: tests-build-deriveddata-${{ inputs.short_commit }}.txt
|
||||
path: tests-build-deriveddata.txt
|
||||
|
||||
- name: Encrypt tests-build-logs for upload
|
||||
id: encrypt-test-log
|
||||
if: always()
|
||||
shell: bash
|
||||
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-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-${{ inputs.short_commit }}.zip
|
||||
path: encrypted-tests-build-logs.zip
|
||||
196
.github/workflows/sidestore-tests-run.yml
vendored
@@ -1,196 +0,0 @@
|
||||
name: SideStore Tests Run
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
type: string
|
||||
short_commit:
|
||||
type: string
|
||||
secrets:
|
||||
BUILD_LOG_ZIP_PASSWORD:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
tests-run:
|
||||
name: Tests-Run SideStore - ${{ inputs.release_tag }}
|
||||
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: 'macos-26'
|
||||
version: '26.0'
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Boot Simulator async(nohup) for testing
|
||||
run: |
|
||||
mkdir -p build/logs
|
||||
nohup make -B boot-sim-async </dev/null >> build/logs/tests-run.log 2>&1 &
|
||||
shell: bash
|
||||
|
||||
- name: Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1.6.0
|
||||
with:
|
||||
xcode-version: '26.0'
|
||||
|
||||
# - name: (Tests-Run) Cache Build
|
||||
# uses: irgaly/xcode-cache@v1.8.1
|
||||
# with:
|
||||
# # This comes from
|
||||
# key: xcode-cache-deriveddata-test-${{ github.ref_name }}-${{ github.sha }}
|
||||
# swiftpm-cache-key: xcode-cache-sourcedata-test-${{ github.ref_name }}-${{ github.sha }}
|
||||
|
||||
- name: (Tests-Build) Restore Xcode & SwiftPM Cache (Exact match) [from tests-build job]
|
||||
id: xcode-cache-restore
|
||||
uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: |
|
||||
~/Library/Developer/Xcode/DerivedData
|
||||
~/Library/Caches/org.swift.swiftpm
|
||||
key: xcode-cache-tests-${{ github.ref_name }}-${{ github.sha }}
|
||||
|
||||
- name: (Tests-Run) Clean previous build artifacts
|
||||
run: |
|
||||
make clean
|
||||
mkdir -p build/logs
|
||||
shell: bash
|
||||
|
||||
- name: (Tests-Run) List Files and derived data
|
||||
shell: bash
|
||||
run: |
|
||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||
ls -la .
|
||||
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 <<<<<<<<<<"
|
||||
find ~/Library/Developer/Xcode/DerivedData -maxdepth 8 -exec ls -ld {} + | grep "Build/Products" >> tests-run-deriveddata.txt || true
|
||||
echo ""
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: tests-run-deriveddata-${{ inputs.short_commit }}.txt
|
||||
path: tests-run-deriveddata.txt
|
||||
|
||||
# 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]}
|
||||
shell: bash
|
||||
|
||||
- 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
|
||||
shell: bash
|
||||
|
||||
- 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]}
|
||||
shell: bash
|
||||
|
||||
- name: Stop Recording tests
|
||||
if: ${{ always() && env.RECORD_PID != '' }}
|
||||
run: |
|
||||
kill -INT ${{ env.RECORD_PID }}
|
||||
shell: bash
|
||||
|
||||
- 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 ""
|
||||
shell: bash
|
||||
|
||||
- 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"
|
||||
shell: bash
|
||||
|
||||
- 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-${{ inputs.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
|
||||
shell: bash
|
||||
|
||||
- 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
|
||||
shell: bash
|
||||
|
||||
- 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-${{ inputs.short_commit }}.mp4
|
||||
path: tests-recording.mp4
|
||||
|
||||
- name: Zip test-results
|
||||
run: zip -r -9 ./test-results.zip ./build/tests
|
||||
shell: bash
|
||||
|
||||
- name: Upload Test Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results-${{ inputs.short_commit }}.zip
|
||||
path: test-results.zip
|
||||
249
.github/workflows/stable.yml
vendored
@@ -7,236 +7,97 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build SideStore - stable (on tag push)
|
||||
name: Build and upload SideStore
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: 'macos-26'
|
||||
version: '26.0'
|
||||
runs-on: ${{ matrix.os }}
|
||||
- os: 'macos-14'
|
||||
version: '15.4'
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Echo Build.xcconfig
|
||||
run: |
|
||||
echo "cat Build.xcconfig"
|
||||
cat Build.xcconfig
|
||||
shell: bash
|
||||
- name: Install dependencies
|
||||
run: brew install ldid
|
||||
|
||||
# - name: Change MARKETING_VERSION to the pushed tag that triggered this build
|
||||
# run: sed -e '/MARKETING_VERSION = .*/s/= .*/= ${{ github.ref_name }}/' -i '' Build.xcconfig
|
||||
- name: Change version to tag
|
||||
run: sed -e '/MARKETING_VERSION = .*/s/= .*/= ${{ github.ref_name }}/' -i '' Build.xcconfig
|
||||
|
||||
- name: Echo Updated Build.xcconfig
|
||||
run: |
|
||||
cat Build.xcconfig
|
||||
shell: bash
|
||||
|
||||
- name: Extract MARKETING_VERSION from Build.xcconfig
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
version=$(grep MARKETING_VERSION Build.xcconfig | sed -e 's/MARKETING_VERSION = //g')
|
||||
echo "version=$version" >> $GITHUB_OUTPUT
|
||||
echo "version=$version"
|
||||
run: echo "version=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "MARKETING_VERSION=$version" >> $GITHUB_ENV
|
||||
echo "MARKETING_VERSION=$version" >> $GITHUB_OUTPUT
|
||||
echo "MARKETING_VERSION=$version"
|
||||
|
||||
shell: bash
|
||||
|
||||
- name: Fail the build if pushed tag and embedded MARKETING_VERSION in Build.xcconfig are mismatching
|
||||
run: |
|
||||
if [ "$MARKETING_VERSION" != "${{ github.ref_name }}" ]; then
|
||||
echo 'Version mismatch: $tag != $marketing_version ... '
|
||||
echo " expected-tag : $MARKETING_VERSION"
|
||||
echo " pushed-tag : ${{ github.ref_name }}"
|
||||
exit 1
|
||||
fi
|
||||
echo 'Version matches: $tag == $marketing_version ... '
|
||||
echo " expected-tag : $MARKETING_VERSION"
|
||||
echo " pushed-tag : ${{ github.ref_name }}"
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies - ldid & xcbeautify
|
||||
run: |
|
||||
brew install ldid xcbeautify
|
||||
- name: Echo version
|
||||
run: echo "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1.6.0
|
||||
with:
|
||||
xcode-version: ${{ matrix.version }}
|
||||
|
||||
- name: (Build) Restore Xcode & SwiftPM Cache (Exact match)
|
||||
id: xcode-cache-restore
|
||||
uses: actions/cache/restore@v3
|
||||
- name: Cache Build
|
||||
uses: irgaly/xcode-cache@v1
|
||||
with:
|
||||
path: |
|
||||
~/Library/Developer/Xcode/DerivedData
|
||||
~/Library/Caches/org.swift.swiftpm
|
||||
key: xcode-cache-build-stable-${{ github.sha }}
|
||||
key: xcode-cache-deriveddata-${{ github.sha }}
|
||||
restore-keys: xcode-cache-deriveddata-
|
||||
swiftpm-cache-key: xcode-cache-sourcedata-${{ github.sha }}
|
||||
swiftpm-cache-restore-keys: |
|
||||
xcode-cache-sourcedata-
|
||||
|
||||
- name: (Build) Restore Xcode & SwiftPM Cache (Last Available)
|
||||
id: xcode-cache-restore-recent
|
||||
uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: |
|
||||
~/Library/Developer/Xcode/DerivedData
|
||||
~/Library/Caches/org.swift.swiftpm
|
||||
key: xcode-cache-build-stable-
|
||||
|
||||
- name: (Build) Clean previous build artifacts
|
||||
run: |
|
||||
make clean
|
||||
mkdir -p build/logs
|
||||
shell: bash
|
||||
|
||||
- name: (Build) List Files and derived data
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||
ls -la .
|
||||
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.xcarchive
|
||||
# using 'tee' to intercept stdout and log for detailed build-log
|
||||
run: |
|
||||
NSUnbufferedIO=YES make -B build 2>&1 | tee -a build/logs/build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
|
||||
shell: bash
|
||||
- name: Build SideStore
|
||||
run: NSUnbufferedIO=YES make build | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
|
||||
|
||||
- name: Fakesign app
|
||||
run: make fakesign | tee -a build/logs/build.log
|
||||
shell: bash
|
||||
run: make fakesign
|
||||
|
||||
- name: Convert to IPA
|
||||
run: make ipa | tee -a build/logs/build.log
|
||||
shell: bash
|
||||
run: make ipa
|
||||
|
||||
- name: (Build) Save Xcode & SwiftPM Cache
|
||||
id: cache-save
|
||||
if: ${{ steps.xcode-cache-restore.outputs.cache-hit != 'true' }}
|
||||
uses: actions/cache/save@v3
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get current date in AltStore date form
|
||||
id: date_altstore
|
||||
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload to new stable release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
path: |
|
||||
~/Library/Developer/Xcode/DerivedData
|
||||
~/Library/Caches/org.swift.swiftpm
|
||||
key: xcode-cache-build-stable-${{ github.sha }}
|
||||
|
||||
- name: (Build) List Files and Build artifacts
|
||||
run: |
|
||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
||||
ls -la .
|
||||
echo ""
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
name: ${{ steps.version.outputs.version }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
draft: true
|
||||
files: SideStore.ipa
|
||||
body: |
|
||||
<!-- NOTE: to reset SideSource cache, go to `https://apps.sidestore.io/reset-cache/nightly/<sidesource key>`. This is not included in the GitHub Action since it makes draft releases so they can be edited and have a changelog. -->
|
||||
## Changelog
|
||||
|
||||
- TODO
|
||||
|
||||
## Build Info
|
||||
|
||||
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 }}`
|
||||
|
||||
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 ""
|
||||
|
||||
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
||||
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
|
||||
echo ""
|
||||
shell: bash
|
||||
|
||||
- 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"
|
||||
shell: bash
|
||||
|
||||
- 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: Add version to IPA file name
|
||||
run: mv 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.ipa
|
||||
|
||||
- name: Zip dSYMs
|
||||
run: zip -r -9 ./SideStore.dSYMs.zip ./SideStore.xcarchive/dSYMs
|
||||
shell: bash
|
||||
path: SideStore-${{ steps.version.outputs.version }}.ipa
|
||||
|
||||
- name: Upload *.dSYM Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: SideStore-${{ steps.version.outputs.version }}-dSYMs.zip
|
||||
path: SideStore.dSYMs.zip
|
||||
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
|
||||
shell: bash
|
||||
|
||||
- name: Get current date in AltStore date form
|
||||
id: date_altstore
|
||||
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
shell: bash
|
||||
|
||||
- name: Upload to releases
|
||||
uses: IsaacShelton/update-existing-release@v1.3.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
draft: true
|
||||
release: ${{ github.ref_name }} # name
|
||||
tag: ${{ github.ref_name }}
|
||||
# stick with what the user pushed, do not use latest commit or anything,
|
||||
# ex: if we want to go back to previous release due to hot issue, dev can create a new tag pointing to that older working tag/commit so as to keep it as an update (to revert major issue)
|
||||
# in this case we do not want the tag to be auto-updated to latest
|
||||
updateTag: false
|
||||
prerelease: false
|
||||
files: >
|
||||
SideStore.ipa
|
||||
SideStore.dSYMs.zip
|
||||
encrypted-build-logs.zip
|
||||
body: |
|
||||
<!-- NOTE: to reset SideSource cache, go to `https://apps.sidestore.io/reset-cache/nightly/<sidesource key>`. This is not included in the GitHub Action since it makes draft releases so they can be edited and have a changelog. -->
|
||||
## Changelog
|
||||
|
||||
- TODO
|
||||
|
||||
## Build Info
|
||||
|
||||
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 }}`
|
||||
name: SideStore-${{ steps.version.outputs.version }}-dSYM
|
||||
path: ./*.dSYM/
|
||||
|
||||
8
.gitignore
vendored
@@ -63,10 +63,4 @@ 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
|
||||
|
||||
# some more commandline build artifacts
|
||||
test-recording.mp4
|
||||
test-recording.log
|
||||
altstore-sources.md
|
||||
local-build.sh
|
||||
*.xcworkspace/**/Package.resolved
|
||||
6
.gitmodules
vendored
@@ -30,7 +30,7 @@
|
||||
url = https://github.com/rileytestut/Roxas.git
|
||||
[submodule "Dependencies/libimobiledevice"]
|
||||
path = Dependencies/libimobiledevice
|
||||
url = https://github.com/SideStore/libimobiledevice
|
||||
url = https://github.com/libimobiledevice/libimobiledevice
|
||||
[submodule "Dependencies/libusbmuxd"]
|
||||
path = Dependencies/libusbmuxd
|
||||
url = https://github.com/libimobiledevice/libusbmuxd.git
|
||||
@@ -51,8 +51,8 @@
|
||||
url = https://github.com/SideStore/minimuxer
|
||||
branch = master
|
||||
[submodule "SideStore/em_proxy"]
|
||||
path = SideStore/em_proxy
|
||||
url = https://github.com/SideStore/em_proxy
|
||||
path = SideStore/em_proxy
|
||||
url = https://github.com/SideStore/em_proxy
|
||||
branch = master
|
||||
[submodule "SideStore/libfragmentzip"]
|
||||
path = SideStore/libfragmentzip
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.$(GROUP_ID)</string>
|
||||
<string>group.$(APP_GROUP_IDENTIFIER)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>BuildRevision</key>
|
||||
<string>$(BUILD_REVISION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<?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,27 +28,18 @@
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<TestPlans>
|
||||
<TestPlanReference
|
||||
reference = "container:SideStore/Tests/SideStoreTests.xctestplan"
|
||||
default = "YES">
|
||||
</TestPlanReference>
|
||||
</TestPlans>
|
||||
<!-- shouldAutocreateTestPlan = "YES"> -->
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A8E2DB202D684CBD009E5D31"
|
||||
BuildableName = "UITests.xctest"
|
||||
BlueprintName = "UITests"
|
||||
BlueprintIdentifier = "D586D39728EF58B0000E101F"
|
||||
BuildableName = "AltTests.xctest"
|
||||
BlueprintName = "AltTests"
|
||||
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||
</BuildableReference>
|
||||
<SelectedTests>
|
||||
<Test
|
||||
Identifier = "UITests/testExample()">
|
||||
</Test>
|
||||
</SelectedTests>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
|
||||
3
AltStore.xcworkspace/contents.xcworkspacedata
generated
@@ -10,4 +10,7 @@
|
||||
<FileRef
|
||||
location = "group:Dependencies/Roxas/Roxas.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
@@ -2,14 +2,24 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<!-- <key>com.apple.security.files.user-selected.read-write</key>
|
||||
<array>
|
||||
<string></string>
|
||||
</array>
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
<array>
|
||||
<string></string>
|
||||
</array> -->
|
||||
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.kernel.increased-debugging-memory-limit</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.kernel.increased-memory-limit</key>
|
||||
<true/>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.siri</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.$(APP_GROUP_IDENTIFIER)</string>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.$(APP_GROUP_IDENTIFIER)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
115
AltStore/Analytics/AnalyticsManager.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// AnalyticsManager.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 3/31/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import AltStoreCore
|
||||
|
||||
import AppCenter
|
||||
import AppCenterAnalytics
|
||||
import AppCenterCrashes
|
||||
|
||||
#if DEBUG
|
||||
private let appCenterAppSecret = "73532d3e-e573-4693-99a4-9f85840bbb44"
|
||||
#elseif RELEASE
|
||||
private let appCenterAppSecret = "73532d3e-e573-4693-99a4-9f85840bbb44"
|
||||
#else
|
||||
private let appCenterAppSecret = "73532d3e-e573-4693-99a4-9f85840bbb44"
|
||||
#endif
|
||||
|
||||
extension AnalyticsManager
|
||||
{
|
||||
enum EventProperty: String
|
||||
{
|
||||
case name
|
||||
case bundleIdentifier
|
||||
case developerName
|
||||
case version
|
||||
case buildVersion
|
||||
case size
|
||||
case tintColor
|
||||
case sourceIdentifier
|
||||
case sourceURL
|
||||
case patreonURL
|
||||
case pledgeAmount
|
||||
case pledgeCurrency
|
||||
}
|
||||
|
||||
enum Event
|
||||
{
|
||||
case installedApp(InstalledApp)
|
||||
case updatedApp(InstalledApp)
|
||||
case refreshedApp(InstalledApp)
|
||||
|
||||
var name: String {
|
||||
switch self
|
||||
{
|
||||
case .installedApp: return "installed_app"
|
||||
case .updatedApp: return "updated_app"
|
||||
case .refreshedApp: return "refreshed_app"
|
||||
}
|
||||
}
|
||||
|
||||
var properties: [EventProperty: String] {
|
||||
let properties: [EventProperty: String?]
|
||||
|
||||
switch self
|
||||
{
|
||||
case .installedApp(let app), .updatedApp(let app), .refreshedApp(let app):
|
||||
let appBundleURL = InstalledApp.fileURL(for: app)
|
||||
let appBundleSize = FileManager.default.directorySize(at: appBundleURL)
|
||||
|
||||
properties = [
|
||||
.name: app.name,
|
||||
.bundleIdentifier: app.bundleIdentifier,
|
||||
.developerName: app.storeApp?.developerName,
|
||||
.version: app.version,
|
||||
.buildVersion: app.buildVersion,
|
||||
.size: appBundleSize?.description,
|
||||
.tintColor: app.storeApp?.tintColor?.hexString,
|
||||
.sourceIdentifier: app.storeApp?.sourceIdentifier,
|
||||
.sourceURL: app.storeApp?.source?.sourceURL.absoluteString,
|
||||
.patreonURL: app.storeApp?.source?.patreonURL?.absoluteString,
|
||||
.pledgeAmount: app.storeApp?.pledgeAmount?.description,
|
||||
.pledgeCurrency: app.storeApp?.pledgeCurrency
|
||||
]
|
||||
}
|
||||
|
||||
return properties.compactMapValues { $0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class AnalyticsManager
|
||||
{
|
||||
static let shared = AnalyticsManager()
|
||||
|
||||
private init()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
extension AnalyticsManager
|
||||
{
|
||||
func start()
|
||||
{
|
||||
AppCenter.start(withAppSecret: appCenterAppSecret, services: [
|
||||
Analytics.self,
|
||||
Crashes.self
|
||||
])
|
||||
}
|
||||
|
||||
func trackEvent(_ event: Event)
|
||||
{
|
||||
let properties = event.properties.reduce(into: [:]) { (properties, item) in
|
||||
properties[item.key.rawValue] = item.value
|
||||
}
|
||||
|
||||
Analytics.trackEvent(event.name, withProperties: properties)
|
||||
}
|
||||
}
|
||||
@@ -43,10 +43,8 @@ final class AppContentViewController: UITableViewController
|
||||
}()
|
||||
|
||||
@IBOutlet private var subtitleLabel: UILabel!
|
||||
// @IBOutlet private var descriptionTextView: CollapsingTextView!
|
||||
@IBOutlet private var descriptionTextView: CollapsingMarkdownView!
|
||||
// @IBOutlet private var versionDescriptionTextView: CollapsingTextView!
|
||||
@IBOutlet private var versionDescriptionTextView: CollapsingMarkdownView!
|
||||
@IBOutlet private var descriptionTextView: CollapsingTextView!
|
||||
@IBOutlet private var versionDescriptionTextView: CollapsingTextView!
|
||||
@IBOutlet private var versionLabel: UILabel!
|
||||
@IBOutlet private var versionDateLabel: UILabel!
|
||||
@IBOutlet private var sizeLabel: UILabel!
|
||||
@@ -57,32 +55,35 @@ final class AppContentViewController: UITableViewController
|
||||
@IBOutlet private(set) var appDetailCollectionViewController: AppDetailCollectionViewController!
|
||||
@IBOutlet private var appDetailCollectionViewHeightConstraint: NSLayoutConstraint!
|
||||
|
||||
override func viewDidLoad() {
|
||||
override func viewDidLoad()
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
self.tableView.contentInset.bottom = 20
|
||||
|
||||
self.subtitleLabel.text = self.app.subtitle
|
||||
let desc = self.app.localizedDescription
|
||||
self.descriptionTextView.text = desc
|
||||
self.descriptionTextView.text = self.app.localizedDescription
|
||||
|
||||
if let version = self.app.latestAvailableVersion {
|
||||
self.versionDescriptionTextView.text = version.localizedDescription ?? "nil"
|
||||
if let version = self.app.latestAvailableVersion
|
||||
{
|
||||
self.versionDescriptionTextView.text = version.localizedDescription
|
||||
self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), version.localizedVersion)
|
||||
self.versionDateLabel.text = Date().relativeDateString(since: version.date)
|
||||
self.sizeLabel.text = ByteCountFormatter.string(fromByteCount: version.size, countStyle: .file)
|
||||
} else {
|
||||
self.versionDescriptionTextView.text = "nil"
|
||||
self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: version.size)
|
||||
}
|
||||
else
|
||||
{
|
||||
self.versionDescriptionTextView.text = nil
|
||||
self.versionLabel.text = nil
|
||||
self.versionDateLabel.text = nil
|
||||
self.sizeLabel.text = ByteCountFormatter.string(fromByteCount: 0, countStyle: .file)
|
||||
self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: 0)
|
||||
}
|
||||
|
||||
self.descriptionTextView.maximumNumberOfLines = 5
|
||||
self.versionDescriptionTextView.maximumNumberOfLines = 5
|
||||
self.descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
|
||||
|
||||
self.descriptionTextView.toggleButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
|
||||
self.versionDescriptionTextView.toggleButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
|
||||
self.versionDescriptionTextView.maximumNumberOfLines = 3
|
||||
self.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews()
|
||||
@@ -161,12 +162,8 @@ private extension AppContentViewController
|
||||
|
||||
switch sender
|
||||
{
|
||||
case self.descriptionTextView.toggleButton:
|
||||
indexPath = IndexPath(row: Row.description.rawValue, section: 0)
|
||||
|
||||
case self.versionDescriptionTextView.toggleButton:
|
||||
indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0)
|
||||
|
||||
case self.descriptionTextView.moreButton: indexPath = IndexPath(row: Row.description.rawValue, section: 0)
|
||||
case self.versionDescriptionTextView.moreButton: indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0)
|
||||
default: return
|
||||
}
|
||||
|
||||
@@ -189,7 +186,7 @@ extension AppContentViewController
|
||||
switch Row.allCases[indexPath.row]
|
||||
{
|
||||
case .screenshots:
|
||||
guard !self.app.allScreenshots.isEmpty else { return 0.0 }
|
||||
guard !self.app.screenshots.isEmpty else { return 0.0 }
|
||||
return UITableView.automaticDimension
|
||||
|
||||
case .permissions:
|
||||
|
||||
@@ -67,11 +67,6 @@ final class AppViewController: UIViewController
|
||||
self.navigationBarTitleView.sizeToFit()
|
||||
self.navigationItem.titleView = self.navigationBarTitleView
|
||||
|
||||
// spacing in storyboard wasn't working, so had to do programatically
|
||||
if let stackView = self.navigationBarTitleView as? UIStackView {
|
||||
stackView.spacing = 8
|
||||
}
|
||||
|
||||
self.contentViewControllerShadowView = UIView()
|
||||
self.contentViewControllerShadowView.backgroundColor = .white
|
||||
self.contentViewControllerShadowView.layer.cornerRadius = 38
|
||||
@@ -392,8 +387,7 @@ private extension AppViewController
|
||||
{
|
||||
var buttonAction: AppBannerView.AppAction?
|
||||
|
||||
// if let installedApp = self.app.installedApp, let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion), !self.app.isPledgeRequired || self.app.isPledged
|
||||
if let installedApp = self.app.installedApp, installedApp.hasUpdate
|
||||
if let installedApp = self.app.installedApp, let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion), !self.app.isPledgeRequired || self.app.isPledged
|
||||
{
|
||||
// Explicitly set button action to .update if there is an update available, even if it's not supported.
|
||||
buttonAction = .update
|
||||
@@ -543,8 +537,7 @@ extension AppViewController
|
||||
{
|
||||
if let installedApp = self.app.installedApp
|
||||
{
|
||||
// if let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion), !self.app.isPledgeRequired || self.app.isPledged
|
||||
if let latestVersion = self.app.latestAvailableVersion, installedApp.hasUpdate
|
||||
if let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion), !self.app.isPledgeRequired || self.app.isPledged
|
||||
{
|
||||
self.updateApp(installedApp, to: latestVersion)
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ extension AppIDsViewController: UICollectionViewDelegateFlowLayout
|
||||
|
||||
// NOTE: double dequeue of cell has been discontinued
|
||||
// TODO: Using harcoded value until this is fixed
|
||||
return CGSize(width: collectionView.bounds.width, height: 200)
|
||||
return CGSize(width: collectionView.bounds.width, height: 260)
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize
|
||||
|
||||
@@ -27,12 +27,10 @@ extension AppDelegate
|
||||
static let addSourceDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".AddSourceDeepLinkNotification")
|
||||
|
||||
static let appBackupDidFinish = Notification.Name(Bundle.Info.appbundleIdentifier + ".AppBackupDidFinish")
|
||||
static let exportCertificateNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".ExportCertificateNotification")
|
||||
|
||||
static let importAppDeepLinkURLKey = "fileURL"
|
||||
static let appBackupResultKey = "result"
|
||||
static let addSourceDeepLinkURLKey = "sourceURL"
|
||||
static let exportCertificateCallbackTemplateKey = "callback"
|
||||
}
|
||||
|
||||
@UIApplicationMain
|
||||
@@ -70,17 +68,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// Register default settings before doing anything else.
|
||||
UserDefaults.registerDefaults()
|
||||
|
||||
|
||||
// Recreate Database if requested
|
||||
// NOTE: Userdefaults are local to the SideStore.app sandbox and are not shared
|
||||
if UserDefaults.standard.recreateDatabaseOnNextStart{
|
||||
// reset the state
|
||||
UserDefaults.standard.recreateDatabaseOnNextStart = false
|
||||
|
||||
// re-create database
|
||||
DatabaseManager.recreateDatabase()
|
||||
}
|
||||
|
||||
|
||||
|
||||
DatabaseManager.shared.start { (error) in
|
||||
if let error = error
|
||||
@@ -93,14 +81,13 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
self.setTintColor()
|
||||
AnalyticsManager.shared.start()
|
||||
|
||||
self.setTintColor()
|
||||
self.prepareImageCache()
|
||||
|
||||
// TODO: @mahee96: find if we need to start em_proxy as in altstore?
|
||||
if UserDefaults.standard.enableEMPforWireguard {
|
||||
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
||||
}
|
||||
// start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
||||
|
||||
SecureValueTransformer.register()
|
||||
|
||||
@@ -112,7 +99,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
UserDefaults.standard.preferredServerID = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.serverID) as? String
|
||||
|
||||
#if DEBUG && targetEnvironment(simulator)
|
||||
#if DEBUG && (targetEnvironment(simulator) || BETA)
|
||||
UserDefaults.standard.isDebugModeEnabled = true
|
||||
#endif
|
||||
|
||||
@@ -125,9 +112,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
{
|
||||
// Make sure to update SceneDelegate.sceneDidEnterBackground() as well.
|
||||
// TODO: @mahee96: find if we need to stop em_proxy as in altstore?
|
||||
if UserDefaults.standard.enableEMPforWireguard {
|
||||
stop_em_proxy()
|
||||
}
|
||||
// stop_em_proxy()
|
||||
guard let oneMonthAgo = Calendar.current.date(byAdding: .month, value: -1, to: Date()) else { return }
|
||||
|
||||
let midnightOneMonthAgo = Calendar.current.startOfDay(for: oneMonthAgo)
|
||||
@@ -144,9 +129,9 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func applicationWillEnterForeground(_ application: UIApplication)
|
||||
{
|
||||
AppManager.shared.update()
|
||||
if UserDefaults.standard.enableEMPforWireguard {
|
||||
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
||||
}
|
||||
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
||||
|
||||
PatreonAPI.shared.refreshPatreonAccount()
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) -> Bool
|
||||
@@ -297,26 +282,6 @@ private extension AppDelegate
|
||||
|
||||
return true
|
||||
|
||||
case "pairing":
|
||||
let queryItems = components.queryItems?.reduce(into: [String: String]()) { $0[$1.name.lowercased()] = $1.value } ?? [:]
|
||||
guard let callbackTemplate = queryItems["urlName"]?.removingPercentEncoding else { return false }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
exportPairingFile(callbackTemplate)
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
case "certificate":
|
||||
let queryItems = components.queryItems?.reduce(into: [String: String]()) { $0[$1.name.lowercased()] = $1.value } ?? [:]
|
||||
guard let callbackTemplate = queryItems["callback_template"]?.removingPercentEncoding else { return false }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: AppDelegate.exportCertificateNotification, object: nil, userInfo: [AppDelegate.exportCertificateCallbackTemplateKey: callbackTemplate])
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
@@ -459,8 +424,6 @@ private extension AppDelegate
|
||||
|
||||
try context.save()
|
||||
|
||||
|
||||
|
||||
let updatesFetchRequest = InstalledApp.supportedUpdatesFetchRequest()
|
||||
let newsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NewsItem>
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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">
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22684"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
@@ -14,7 +14,7 @@
|
||||
<objects>
|
||||
<navigationController storyboardIdentifier="navigationController" id="ZTo-53-dSL" sceneMemberID="viewController">
|
||||
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" barStyle="black" largeTitles="YES" id="Aej-RF-PfV" customClass="NavigationBar" customModule="SideStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="20" width="375" height="96"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="96"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<color key="barTintColor" name="SettingsBackground"/>
|
||||
@@ -42,13 +42,13 @@
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<view hidden="YES" userInteractionEnabled="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="oyW-Fd-ojD" userLabel="Sizing View">
|
||||
<rect key="frame" x="0.0" y="64" width="375" height="603"/>
|
||||
<rect key="frame" x="0.0" y="44" width="375" height="623"/>
|
||||
</view>
|
||||
<scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" alwaysBounceVertical="YES" indicatorStyle="white" keyboardDismissMode="onDrag" translatesAutoresizingMaskIntoConstraints="NO" id="WXx-hX-AXv">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" translatesAutoresizingMaskIntoConstraints="NO" id="2wp-qG-f0Z">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="603"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="623"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" axis="vertical" spacing="50" translatesAutoresizingMaskIntoConstraints="NO" id="YmX-7v-pxh">
|
||||
<rect key="frame" x="16" y="6" width="343" height="359.5"/>
|
||||
@@ -57,13 +57,13 @@
|
||||
<rect key="frame" x="0.0" y="0.0" width="343" height="67.5"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Welcome to SideStore." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="EI2-V3-zQZ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="343" height="41"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="333.5" height="41"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="34"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Sign in with your Apple ID to get started." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="SNU-tv-8Au">
|
||||
<rect key="frame" x="0.0" y="47" width="306.5" height="20.5"/>
|
||||
<rect key="frame" x="0.0" y="47" width="308.5" height="20.5"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
@@ -160,7 +160,7 @@
|
||||
</stackView>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="2N5-zd-fUj">
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="2N5-zd-fUj">
|
||||
<rect key="frame" x="0.0" y="191" width="343" height="51"/>
|
||||
<color key="backgroundColor" name="SettingsHighlighted"/>
|
||||
<constraints>
|
||||
@@ -179,7 +179,7 @@
|
||||
</subviews>
|
||||
</stackView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="250" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="DBk-rT-ZE8">
|
||||
<rect key="frame" x="16" y="498.5" width="343" height="96.5"/>
|
||||
<rect key="frame" x="16" y="518.5" width="343" height="96.5"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Why do we need this?" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="p9U-0q-Kn8">
|
||||
<rect key="frame" x="0.0" y="0.0" width="343" height="20.5"/>
|
||||
@@ -198,10 +198,6 @@
|
||||
</stackView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="DBk-rT-ZE8" firstAttribute="leading" secondItem="2wp-qG-f0Z" secondAttribute="leadingMargin" id="5AT-nV-ZP9"/>
|
||||
<constraint firstAttribute="bottomMargin" secondItem="DBk-rT-ZE8" secondAttribute="bottom" id="HgY-oY-8KM"/>
|
||||
<constraint firstAttribute="trailingMargin" secondItem="DBk-rT-ZE8" secondAttribute="trailing" id="VCf-bW-2K4"/>
|
||||
<constraint firstItem="YmX-7v-pxh" firstAttribute="top" secondItem="2wp-qG-f0Z" secondAttribute="top" constant="6" id="iUr-Nd-tkt"/>
|
||||
<constraint firstItem="DBk-rT-ZE8" firstAttribute="top" relation="greaterThanOrEqual" secondItem="YmX-7v-pxh" secondAttribute="bottom" constant="8" symbolic="YES" id="zTU-eY-DWd"/>
|
||||
</constraints>
|
||||
</view>
|
||||
@@ -219,15 +215,19 @@
|
||||
<constraints>
|
||||
<constraint firstAttribute="bottom" secondItem="WXx-hX-AXv" secondAttribute="bottom" id="0jL-Ky-ju6"/>
|
||||
<constraint firstAttribute="leadingMargin" secondItem="YmX-7v-pxh" secondAttribute="leading" id="2PO-lG-dmB"/>
|
||||
<constraint firstItem="DBk-rT-ZE8" firstAttribute="leading" secondItem="2wp-qG-f0Z" secondAttribute="leadingMargin" id="5AT-nV-ZP9"/>
|
||||
<constraint firstItem="oyW-Fd-ojD" firstAttribute="top" secondItem="zMn-DV-fpy" secondAttribute="top" id="730-db-ukB"/>
|
||||
<constraint firstItem="2wp-qG-f0Z" firstAttribute="bottomMargin" secondItem="DBk-rT-ZE8" secondAttribute="bottom" id="HgY-oY-8KM"/>
|
||||
<constraint firstItem="zMn-DV-fpy" firstAttribute="trailing" secondItem="oyW-Fd-ojD" secondAttribute="trailing" id="KGE-CN-SWf"/>
|
||||
<constraint firstItem="WXx-hX-AXv" firstAttribute="top" secondItem="mjy-4S-hyH" secondAttribute="top" id="LPQ-bF-ic0"/>
|
||||
<constraint firstItem="zMn-DV-fpy" firstAttribute="trailing" secondItem="WXx-hX-AXv" secondAttribute="trailing" id="MG7-A6-pKp"/>
|
||||
<constraint firstAttribute="trailingMargin" secondItem="YmX-7v-pxh" secondAttribute="trailing" id="O4T-nu-o3e"/>
|
||||
<constraint firstItem="zMn-DV-fpy" firstAttribute="bottom" secondItem="oyW-Fd-ojD" secondAttribute="bottom" id="PuX-ab-cEq"/>
|
||||
<constraint firstItem="oyW-Fd-ojD" firstAttribute="leading" secondItem="zMn-DV-fpy" secondAttribute="leading" id="SzC-gC-Nvi"/>
|
||||
<constraint firstItem="2wp-qG-f0Z" firstAttribute="trailingMargin" secondItem="DBk-rT-ZE8" secondAttribute="trailing" id="VCf-bW-2K4"/>
|
||||
<constraint firstItem="WXx-hX-AXv" firstAttribute="leading" secondItem="zMn-DV-fpy" secondAttribute="leading" id="d08-zF-5X6"/>
|
||||
<constraint firstItem="2wp-qG-f0Z" firstAttribute="height" secondItem="oyW-Fd-ojD" secondAttribute="height" id="dFN-pw-TWt"/>
|
||||
<constraint firstItem="YmX-7v-pxh" firstAttribute="top" secondItem="2wp-qG-f0Z" secondAttribute="top" constant="6" id="iUr-Nd-tkt"/>
|
||||
<constraint firstItem="2wp-qG-f0Z" firstAttribute="width" secondItem="oyW-Fd-ojD" secondAttribute="width" id="rYO-GN-0Lk"/>
|
||||
</constraints>
|
||||
</view>
|
||||
@@ -264,7 +264,7 @@
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" axis="vertical" distribution="equalSpacing" translatesAutoresizingMaskIntoConstraints="NO" id="bp6-55-IG2">
|
||||
<rect key="frame" x="0.0" y="64" width="375" height="544"/>
|
||||
<rect key="frame" x="0.0" y="44" width="375" height="564"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="FjP-tm-w7K">
|
||||
<rect key="frame" x="16" y="35" width="343" height="95.5"/>
|
||||
@@ -298,7 +298,7 @@
|
||||
</subviews>
|
||||
</stackView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="LpI-Jt-SzX">
|
||||
<rect key="frame" x="16" y="161" width="343" height="95.5"/>
|
||||
<rect key="frame" x="16" y="168" width="343" height="95.5"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="2" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="0LW-eE-qHa">
|
||||
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
|
||||
@@ -310,7 +310,7 @@
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="dMu-eg-gIO">
|
||||
<rect key="frame" x="79" y="16" width="264" height="64"/>
|
||||
<rect key="frame" x="79" y="15.5" width="264" height="64"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Connect to Wi-Fi and VPN" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="esj-pD-D4A">
|
||||
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
||||
@@ -318,7 +318,7 @@
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Enable LocalDevVPN and use Sidestore on the go." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="4rk-ge-FSj">
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Enable SideStore VPN in Wireguard and be able to use Sidestore on the go." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="4rk-ge-FSj">
|
||||
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
||||
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
@@ -329,7 +329,7 @@
|
||||
</subviews>
|
||||
</stackView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="tfb-ja-9UC">
|
||||
<rect key="frame" x="16" y="287.5" width="343" height="95.5"/>
|
||||
<rect key="frame" x="16" y="300.5" width="343" height="95.5"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="3" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="nVr-El-Csi">
|
||||
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
|
||||
@@ -341,7 +341,7 @@
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="z6Y-zi-teL">
|
||||
<rect key="frame" x="79" y="15.5" width="264" height="64"/>
|
||||
<rect key="frame" x="79" y="16" width="264" height="64"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Download Apps" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="JeJ-bk-UCA">
|
||||
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
||||
@@ -360,7 +360,7 @@
|
||||
</subviews>
|
||||
</stackView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="X3r-G1-vf2">
|
||||
<rect key="frame" x="16" y="413.5" width="343" height="95.5"/>
|
||||
<rect key="frame" x="16" y="433.5" width="343" height="95.5"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="4" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="i2U-NL-plG">
|
||||
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
|
||||
@@ -372,7 +372,7 @@
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="Xs6-pJ-PUz">
|
||||
<rect key="frame" x="79" y="17" width="264" height="62"/>
|
||||
<rect key="frame" x="79" y="16" width="264" height="64"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Apps Refresh Automatically" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="nvb-Aq-sYa">
|
||||
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
||||
@@ -381,7 +381,7 @@
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Apps are refreshed in the background while you are on SideStore VPN!" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="HU5-Hv-E3d">
|
||||
<rect key="frame" x="0.0" y="25.5" width="264" height="36.5"/>
|
||||
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
||||
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
@@ -431,7 +431,7 @@
|
||||
</objects>
|
||||
<point key="canvasLocation" x="1353" y="736"/>
|
||||
</scene>
|
||||
<!--Refresh SideStore-->
|
||||
<!--Refresh AltStore-->
|
||||
<scene sceneID="9Vh-dM-OqX">
|
||||
<objects>
|
||||
<viewController storyboardIdentifier="refreshAltStoreViewController" id="aoK-yE-UVT" customClass="RefreshAltStoreViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
@@ -485,7 +485,7 @@
|
||||
<constraint firstItem="tDQ-ao-1Jg" firstAttribute="leading" secondItem="R83-kV-365" secondAttribute="leadingMargin" id="zEt-Xr-kJx"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<navigationItem key="navigationItem" title="Refresh SideStore" largeTitleDisplayMode="always" id="5nk-NR-jtV"/>
|
||||
<navigationItem key="navigationItem" title="Refresh AltStore" largeTitleDisplayMode="always" id="5nk-NR-jtV"/>
|
||||
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
|
||||
<connections>
|
||||
<outlet property="placeholderView" destination="fpO-Bf-gFY" id="q7d-au-d94"/>
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="98"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" text="Hello Me" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="Pyt-8D-BZA" customClass="CollapsingMarkdownView" customModule="SideStore" customModuleProvider="target">
|
||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" text="Hello Me" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="Pyt-8D-BZA" customClass="CollapsingTextView" customModule="SideStore" customModuleProvider="target">
|
||||
<rect key="frame" x="20" y="20" width="335" height="34"/>
|
||||
<color key="backgroundColor" name="Background"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="15"/>
|
||||
@@ -353,7 +353,7 @@
|
||||
</stackView>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="wQF-WY-Gdz" customClass="CollapsingMarkdownView" customModule="SideStore" customModuleProvider="target">
|
||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="wQF-WY-Gdz" customClass="CollapsingTextView" customModule="SideStore" customModuleProvider="target">
|
||||
<rect key="frame" x="20" y="59.5" width="335" height="34"/>
|
||||
<color key="backgroundColor" name="Background"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="15"/>
|
||||
@@ -532,7 +532,6 @@
|
||||
<rect key="frame" x="0.0" y="20" width="375" height="96"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<color key="tintColor" name="Primary"/>
|
||||
<edgeInsets key="layoutMargins" top="8" left="20" bottom="8" right="8"/>
|
||||
</navigationBar>
|
||||
<nil name="viewControllers"/>
|
||||
<connections>
|
||||
@@ -562,7 +561,6 @@
|
||||
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="CzO-Kt-BlZ" customClass="NavigationBar" customModule="SideStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="20" width="375" height="96"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<edgeInsets key="layoutMargins" top="8" left="20" bottom="8" right="8"/>
|
||||
</navigationBar>
|
||||
<nil name="viewControllers"/>
|
||||
<connections>
|
||||
@@ -915,7 +913,6 @@
|
||||
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="9sB-f3-Fnk">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="108"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<edgeInsets key="layoutMargins" top="8" left="20" bottom="8" right="8"/>
|
||||
</navigationBar>
|
||||
<nil name="viewControllers"/>
|
||||
<connections>
|
||||
|
||||
@@ -538,8 +538,7 @@ private extension BrowseViewController
|
||||
|
||||
let app = self.dataSource.item(at: indexPath)
|
||||
|
||||
// if let installedApp = app.installedApp, !installedApp.isUpdateAvailable
|
||||
if let installedApp = app.installedApp, !installedApp.hasUpdate
|
||||
if let installedApp = app.installedApp, !installedApp.isUpdateAvailable
|
||||
{
|
||||
self.open(installedApp)
|
||||
}
|
||||
@@ -564,8 +563,7 @@ private extension BrowseViewController
|
||||
}
|
||||
|
||||
Task<Void, Never>(priority: .userInitiated) { @MainActor in
|
||||
// if let installedApp = app.installedApp, installedApp.isUpdateAvailable
|
||||
if let installedApp = app.installedApp, installedApp.hasUpdate
|
||||
if let installedApp = app.installedApp, installedApp.isUpdateAvailable
|
||||
{
|
||||
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
|
||||
}
|
||||
|
||||
@@ -482,8 +482,7 @@ private extension FeaturedViewController
|
||||
|
||||
let storeApp = self.dataSource.item(at: indexPath)
|
||||
|
||||
// if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable
|
||||
if let installedApp = storeApp.installedApp, !installedApp.hasUpdate
|
||||
if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable
|
||||
{
|
||||
self.open(installedApp)
|
||||
}
|
||||
@@ -501,8 +500,7 @@ private extension FeaturedViewController
|
||||
return
|
||||
}
|
||||
|
||||
// if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable
|
||||
if let installedApp = storeApp.installedApp, installedApp.hasUpdate
|
||||
if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable
|
||||
{
|
||||
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
|
||||
}
|
||||
|
||||
@@ -138,12 +138,11 @@ extension AppBannerView
|
||||
init(app: AppProtocol)
|
||||
{
|
||||
self.name = app.name
|
||||
|
||||
|
||||
guard let storeApp = (app as? StoreApp) ?? (app as? InstalledApp)?.storeApp else { return }
|
||||
self.developerName = storeApp.developerName
|
||||
|
||||
if let track = storeApp.latestSupportedVersion?.channel,
|
||||
ReleaseTracks.betaTracks.contains(track)
|
||||
|
||||
if storeApp.isBeta
|
||||
{
|
||||
self.name = String(format: NSLocalizedString("%@ beta", comment: ""), app.name)
|
||||
self.isBeta = true
|
||||
@@ -234,8 +233,7 @@ extension AppBannerView
|
||||
{
|
||||
// App is installed
|
||||
|
||||
// if installedApp.isUpdateAvailable
|
||||
if installedApp.hasUpdate
|
||||
if installedApp.isUpdateAvailable
|
||||
{
|
||||
buttonAction = .update
|
||||
}
|
||||
|
||||
@@ -13,18 +13,21 @@ final class CollapsingTextView: UITextView
|
||||
var isCollapsed = true {
|
||||
didSet {
|
||||
guard self.isCollapsed != oldValue else { return }
|
||||
self.shouldResetLayout = true
|
||||
self.setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
var maximumNumberOfLines = 2 {
|
||||
didSet {
|
||||
self.shouldResetLayout = true
|
||||
self.setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
var lineSpacing: Double = 2 {
|
||||
didSet {
|
||||
self.shouldResetLayout = true
|
||||
|
||||
if #available(iOS 16, *)
|
||||
{
|
||||
@@ -39,6 +42,7 @@ final class CollapsingTextView: UITextView
|
||||
|
||||
override var text: String! {
|
||||
didSet {
|
||||
self.shouldResetLayout = true
|
||||
|
||||
guard #available(iOS 16, *) else { return }
|
||||
self.updateText()
|
||||
@@ -47,6 +51,9 @@ final class CollapsingTextView: UITextView
|
||||
|
||||
let moreButton = UIButton(type: .system)
|
||||
|
||||
private var shouldResetLayout: Bool = false
|
||||
private var previousSize: CGSize?
|
||||
|
||||
override init(frame: CGRect, textContainer: NSTextContainer?)
|
||||
{
|
||||
super.init(frame: frame, textContainer: textContainer)
|
||||
@@ -108,39 +115,45 @@ final class CollapsingTextView: UITextView
|
||||
height: font.lineHeight)
|
||||
self.moreButton.frame = moreButtonFrame
|
||||
|
||||
if self.isCollapsed
|
||||
if self.shouldResetLayout || self.previousSize != self.bounds.size
|
||||
{
|
||||
let boundingSize = self.attributedText.boundingRect(with: CGSize(width: self.textContainer.size.width, height: .infinity), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
|
||||
let maximumCollapsedHeight = font.lineHeight * Double(self.maximumNumberOfLines) + self.lineSpacing * Double(self.maximumNumberOfLines - 1)
|
||||
|
||||
if boundingSize.height.rounded() > maximumCollapsedHeight.rounded()
|
||||
if self.isCollapsed
|
||||
{
|
||||
self.textContainer.maximumNumberOfLines = self.maximumNumberOfLines
|
||||
let boundingSize = self.attributedText.boundingRect(with: CGSize(width: self.textContainer.size.width, height: .infinity), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
|
||||
let maximumCollapsedHeight = font.lineHeight * Double(self.maximumNumberOfLines) + self.lineSpacing * Double(self.maximumNumberOfLines - 1)
|
||||
|
||||
var exclusionFrame = moreButtonFrame
|
||||
exclusionFrame.origin.y += self.moreButton.bounds.midY
|
||||
exclusionFrame.size.width = self.bounds.width // Extra wide to make sure it wraps to next line.
|
||||
self.textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)]
|
||||
|
||||
self.moreButton.isHidden = false
|
||||
if boundingSize.height.rounded() > maximumCollapsedHeight.rounded()
|
||||
{
|
||||
self.textContainer.maximumNumberOfLines = self.maximumNumberOfLines
|
||||
|
||||
var exclusionFrame = moreButtonFrame
|
||||
exclusionFrame.origin.y += self.moreButton.bounds.midY
|
||||
exclusionFrame.size.width = self.bounds.width // Extra wide to make sure it wraps to next line.
|
||||
self.textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)]
|
||||
|
||||
self.moreButton.isHidden = false
|
||||
}
|
||||
else
|
||||
{
|
||||
self.textContainer.maximumNumberOfLines = 0 // Fixes last line having slightly smaller line spacing.
|
||||
self.textContainer.exclusionPaths = []
|
||||
|
||||
self.moreButton.isHidden = true
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
self.textContainer.maximumNumberOfLines = 0 // Fixes last line having slightly smaller line spacing.
|
||||
self.textContainer.maximumNumberOfLines = 0
|
||||
self.textContainer.exclusionPaths = []
|
||||
|
||||
self.moreButton.isHidden = true
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
self.textContainer.maximumNumberOfLines = 0
|
||||
self.textContainer.exclusionPaths = []
|
||||
|
||||
self.moreButton.isHidden = true
|
||||
self.invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
self.invalidateIntrinsicContentSize()
|
||||
self.shouldResetLayout = false
|
||||
self.previousSize = self.bounds.size
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,23 +65,13 @@ class ToastView: RSTToastView
|
||||
self.opensErrorLog = opensLog
|
||||
}
|
||||
|
||||
enum InfoMode: String {
|
||||
case fullError
|
||||
case localizedDescription
|
||||
}
|
||||
|
||||
convenience init(error: Error){
|
||||
self.init(error: error, mode: .localizedDescription)
|
||||
}
|
||||
|
||||
convenience init(error: Error, mode: InfoMode)
|
||||
convenience init(error: Error)
|
||||
{
|
||||
let error = error as NSError
|
||||
let mode = mode == .fullError ? ErrorProcessing.InfoMode.fullError : ErrorProcessing.InfoMode.localizedDescription
|
||||
|
||||
let text = error.localizedTitle ?? NSLocalizedString("Operation Failed", comment: "")
|
||||
let detailText = ErrorProcessing(mode).getDescription(error: error)
|
||||
|
||||
let detailText = ErrorProcessing(.fullError).getDescription(error: error)
|
||||
|
||||
self.init(text: text, detailText: detailText)
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>BuildRevision</key>
|
||||
<string>$(BUILD_REVISION)</string>
|
||||
<key>INIntentsSupported</key>
|
||||
<array>
|
||||
<string>RefreshAllIntent</string>
|
||||
|
||||
@@ -12,73 +12,84 @@ import EmotionalDamage
|
||||
import minimuxer
|
||||
import WidgetKit
|
||||
|
||||
import AltSign
|
||||
import AltStoreCore
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
let pairingFileName = "ALTPairingFile.mobiledevicepairing"
|
||||
|
||||
final class LaunchViewController: UIViewController, UIDocumentPickerDelegate {
|
||||
final class LaunchViewController: RSTLaunchViewController, UIDocumentPickerDelegate
|
||||
{
|
||||
private var didFinishLaunching = false
|
||||
private var retries = 0
|
||||
private var maxRetries = 3
|
||||
private var splashView: SplashView!
|
||||
private var destinationViewController: TabBarController?
|
||||
private var startTime: Date!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
splashView = SplashView(frame: view.bounds, appName: "SideStore")
|
||||
destinationViewController = storyboard!.instantiateViewController(withIdentifier: "tabBarController") as? TabBarController
|
||||
view.addSubview(splashView)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
guard !didFinishLaunching else { return }
|
||||
Task {
|
||||
startTime = Date()
|
||||
await runLaunchSequence()
|
||||
doPostLaunch()
|
||||
|
||||
private var destinationViewController: TabBarController!
|
||||
|
||||
override var launchConditions: [RSTLaunchCondition] {
|
||||
let isDatabaseStarted = RSTLaunchCondition(condition: { DatabaseManager.shared.isStarted }) { (completionHandler) in
|
||||
DatabaseManager.shared.start(completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
return [isDatabaseStarted]
|
||||
}
|
||||
|
||||
private func runLaunchSequence() async {
|
||||
guard retries < maxRetries else { return }
|
||||
retries += 1
|
||||
|
||||
await Task.detached {
|
||||
if !DatabaseManager.shared.isStarted {
|
||||
await withCheckedContinuation { continuation in
|
||||
DatabaseManager.shared.start { error in
|
||||
if let error {
|
||||
Task { await self.handleLaunchError(error, retryCallback: self.runLaunchSequence) }
|
||||
} else {
|
||||
Task { await self.finishLaunching() }
|
||||
|
||||
override var childForStatusBarStyle: UIViewController? {
|
||||
return self.children.first
|
||||
}
|
||||
|
||||
override var childForStatusBarHidden: UIViewController? {
|
||||
return self.children.first
|
||||
}
|
||||
|
||||
override func viewDidLoad()
|
||||
{
|
||||
defer {
|
||||
// Create destinationViewController now so view controllers can register for receiving Notifications.
|
||||
self.destinationViewController = self.storyboard!.instantiateViewController(withIdentifier: "tabBarController") as! TabBarController
|
||||
}
|
||||
super.viewDidLoad()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(true)
|
||||
if #available(iOS 17, *), !UserDefaults.standard.sidejitenable {
|
||||
DispatchQueue.global().async {
|
||||
self.isSideJITServerDetected() { result in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success():
|
||||
let dialogMessage = UIAlertController(title: "SideJITServer Detected", message: "Would you like to enable SideJITServer", preferredStyle: .alert)
|
||||
|
||||
// Create OK button with action handler
|
||||
let ok = UIAlertAction(title: "OK", style: .default, handler: { (action) -> Void in
|
||||
UserDefaults.standard.sidejitenable = true
|
||||
})
|
||||
|
||||
let cancel = UIAlertAction(title: "Cancel", style: .cancel)
|
||||
//Add OK button to a dialog message
|
||||
dialogMessage.addAction(ok)
|
||||
dialogMessage.addAction(cancel)
|
||||
|
||||
// Present Alert to
|
||||
self.present(dialogMessage, animated: true, completion: nil)
|
||||
case .failure(_):
|
||||
print("Cannot find sideJITServer")
|
||||
}
|
||||
continuation.resume(returning: ())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await self.finishLaunching()
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
private func doPostLaunch() {
|
||||
SideJITManager.shared.checkAndPromptIfNeeded(presentingVC: self)
|
||||
}
|
||||
|
||||
if #available(iOS 17, *), UserDefaults.standard.sidejitenable {
|
||||
DispatchQueue.global().async { SideJITManager.shared.askForNetwork() }
|
||||
DispatchQueue.global().async {
|
||||
self.askfornetwork()
|
||||
}
|
||||
print("SideJITServer Enabled")
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
||||
|
||||
detectAndImportAccountFile()
|
||||
|
||||
if UserDefaults.standard.enableEMPforWireguard {
|
||||
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
||||
}
|
||||
guard let pf = fetchPairingFile() else {
|
||||
displayError("Device pairing file not found.")
|
||||
return
|
||||
@@ -86,189 +97,281 @@ final class LaunchViewController: UIViewController, UIDocumentPickerDelegate {
|
||||
start_minimuxer_threads(pf)
|
||||
#endif
|
||||
}
|
||||
|
||||
func start_minimuxer_threads(_ pairing_file: String) {
|
||||
target_minimuxer_address()
|
||||
let documentsDirectory = FileManager.default.documentsDirectory.absoluteString
|
||||
do {
|
||||
let loggingEnabled = UserDefaults.standard.isMinimuxerConsoleLoggingEnabled
|
||||
try minimuxer.startWithLogger(pairing_file, documentsDirectory, loggingEnabled)
|
||||
} catch {
|
||||
try! FileManager.default.removeItem(at: FileManager.default.documentsDirectory.appendingPathComponent(pairingFileName))
|
||||
displayError("minimuxer failed to start, please restart SideStore. \((error as? LocalizedError)?.failureReason ?? "UNKNOWN ERROR")")
|
||||
|
||||
func askfornetwork() {
|
||||
let address = UserDefaults.standard.textInputSideJITServerurl ?? ""
|
||||
|
||||
var SJSURL = address
|
||||
|
||||
if (UserDefaults.standard.textInputSideJITServerurl ?? "").isEmpty {
|
||||
SJSURL = "http://sidejitserver._http._tcp.local:8080"
|
||||
}
|
||||
start_auto_mounter(documentsDirectory)
|
||||
|
||||
// Create a network operation at launch to Refresh SideJITServer
|
||||
let url = URL(string: "\(SJSURL)/re/")!
|
||||
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
|
||||
print(data)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
||||
func isSideJITServerDetected(completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let address = UserDefaults.standard.textInputSideJITServerurl ?? ""
|
||||
|
||||
var SJSURL = address
|
||||
|
||||
if (UserDefaults.standard.textInputSideJITServerurl ?? "").isEmpty {
|
||||
SJSURL = "http://sidejitserver._http._tcp.local:8080"
|
||||
}
|
||||
|
||||
// Create a network operation at launch to Refresh SideJITServer
|
||||
let url = URL(string: SJSURL)!
|
||||
let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
|
||||
if let error = error {
|
||||
print("No SideJITServer on Network")
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
completion(.success(()))
|
||||
}
|
||||
task.resume()
|
||||
return
|
||||
}
|
||||
|
||||
func fetchPairingFile() -> String? {
|
||||
let filename = "ALTPairingFile.mobiledevicepairing"
|
||||
let fm = FileManager.default
|
||||
let documentsPath = fm.documentsDirectory.appendingPathComponent("/\(filename)")
|
||||
if fm.fileExists(atPath: documentsPath.path), let contents = try? String(contentsOf: documentsPath), !contents.isEmpty {
|
||||
print("Loaded ALTPairingFile from \(documentsPath.path)")
|
||||
return contents
|
||||
} else if
|
||||
let appResourcePath = Bundle.main.url(forResource: "ALTPairingFile", withExtension: "mobiledevicepairing"),
|
||||
fm.fileExists(atPath: appResourcePath.path),
|
||||
let data = fm.contents(atPath: appResourcePath.path),
|
||||
let contents = String(data: data, encoding: .utf8),
|
||||
!contents.isEmpty,
|
||||
!UserDefaults.standard.isPairingReset {
|
||||
print("Loaded ALTPairingFile from \(appResourcePath.path)")
|
||||
return contents
|
||||
} else if let plistString = Bundle.main.object(forInfoDictionaryKey: "ALTPairingFile") as? String, !plistString.isEmpty, !plistString.contains("insert pairing file here"), !UserDefaults.standard.isPairingReset{
|
||||
print("Loaded ALTPairingFile from Info.plist")
|
||||
return plistString
|
||||
} else {
|
||||
// Show an alert explaining the pairing file
|
||||
// Create new Alert
|
||||
let dialogMessage = UIAlertController(title: "Pairing File", message: "Select the pairing file or select \"Help\" for help.", preferredStyle: .alert)
|
||||
|
||||
// Create OK button with action handler
|
||||
let ok = UIAlertAction(title: "OK", style: .default, handler: { (action) -> Void in
|
||||
// Try to load it from a file picker
|
||||
var types = UTType.types(tag: "plist", tagClass: UTTagClass.filenameExtension, conformingTo: nil)
|
||||
types.append(contentsOf: UTType.types(tag: "mobiledevicepairing", tagClass: UTTagClass.filenameExtension, conformingTo: UTType.data))
|
||||
types.append(.xml)
|
||||
let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: types)
|
||||
documentPickerController.shouldShowFileExtensions = true
|
||||
documentPickerController.delegate = self
|
||||
self.present(documentPickerController, animated: true, completion: nil)
|
||||
UserDefaults.standard.isPairingReset = false
|
||||
})
|
||||
|
||||
//Add "help" button to take user to wiki
|
||||
let wikiOption = UIAlertAction(title: "Help", style: .default) { (action) in
|
||||
let wikiURL: String = "https://docs.sidestore.io/docs/getting-started/pairing-file"
|
||||
if let url = URL(string: wikiURL) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
sleep(2)
|
||||
exit(0)
|
||||
}
|
||||
|
||||
//Add buttons to dialog message
|
||||
dialogMessage.addAction(wikiOption)
|
||||
dialogMessage.addAction(ok)
|
||||
|
||||
func fetchPairingFile() -> String? { PairingFileManager.shared.fetchPairingFile(presentingVC: self) }
|
||||
// Present Alert to
|
||||
self.present(dialogMessage, animated: true, completion: nil)
|
||||
|
||||
let dialogMessage2 = UIAlertController(title: "Analytics", message: "This app contains anonymous analytics for research and project development. By continuing to use this app, you are consenting to this data collection", preferredStyle: .alert)
|
||||
|
||||
let ok2 = UIAlertAction(title: "OK", style: .default, handler: { (action) -> Void in})
|
||||
|
||||
dialogMessage2.addAction(ok2)
|
||||
self.present(dialogMessage2, animated: true, completion: nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func displayError(_ msg: String) {
|
||||
print(msg)
|
||||
let alert = UIAlertController(title: "Error launching SideStore", message: msg, preferredStyle: .alert)
|
||||
self.present(alert, animated: true)
|
||||
}
|
||||
// Create a new alert
|
||||
let dialogMessage = UIAlertController(title: "Error launching SideStore", message: msg, preferredStyle: .alert)
|
||||
|
||||
// Present alert to user
|
||||
self.present(dialogMessage, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
let url = urls[0]
|
||||
let isSecuredURL = url.startAccessingSecurityScopedResource() == true
|
||||
defer {
|
||||
if (isSecuredURL) {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
guard let pairingString = String(data: data, encoding: .utf8) else {
|
||||
// Read to a string
|
||||
let data1 = try Data(contentsOf: urls[0])
|
||||
let pairing_string = String(bytes: data1, encoding: .utf8)
|
||||
if pairing_string == nil {
|
||||
displayError("Unable to read pairing file")
|
||||
return
|
||||
}
|
||||
try pairingString.write(to: FileManager.default.documentsDirectory.appendingPathComponent(pairingFileName), atomically: true, encoding: .utf8)
|
||||
start_minimuxer_threads(pairingString)
|
||||
|
||||
// Save to a file for next launch
|
||||
let pairingFile = FileManager.default.documentsDirectory.appendingPathComponent("\(pairingFileName)")
|
||||
try pairing_string?.write(to: pairingFile, atomically: true, encoding: String.Encoding.utf8)
|
||||
|
||||
// Start minimuxer now that we have a file
|
||||
start_minimuxer_threads(pairing_string!)
|
||||
} catch {
|
||||
displayError("Unable to read pairing file")
|
||||
}
|
||||
|
||||
if (isSecuredURL) {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
controller.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||
displayError("Choosing a pairing file was cancelled. Please re-open the app and try again.")
|
||||
}
|
||||
|
||||
func importAccountAtFile(_ file: URL, remove: Bool = false) {
|
||||
_ = file.startAccessingSecurityScopedResource()
|
||||
defer { file.stopAccessingSecurityScopedResource() }
|
||||
guard let accountD = try? Data(contentsOf: file) else {
|
||||
return Logger.main.notice("Could not parse data from file \(file)")
|
||||
func start_minimuxer_threads(_ pairing_file: String) {
|
||||
target_minimuxer_address()
|
||||
let documentsDirectory = FileManager.default.documentsDirectory.absoluteString
|
||||
do {
|
||||
// enable minimuxer console logging only if enabled in settings
|
||||
let isMinimuxerConsoleLoggingEnabled = UserDefaults.standard.isMinimuxerConsoleLoggingEnabled
|
||||
try minimuxer.startWithLogger(pairing_file, documentsDirectory, isMinimuxerConsoleLoggingEnabled)
|
||||
} catch {
|
||||
try! FileManager.default.removeItem(at: FileManager.default.documentsDirectory.appendingPathComponent("\(pairingFileName)"))
|
||||
displayError("minimuxer failed to start, please restart SideStore. \((error as? LocalizedError)?.failureReason ?? "UNKNOWN ERROR!!!!!! REPORT TO GITHUB ISSUES!")")
|
||||
}
|
||||
guard let account = try? Foundation.JSONDecoder().decode(ImportedAccount.self, from: accountD) else {
|
||||
return Logger.main.notice("Could not parse data from file \(file)")
|
||||
if #available(iOS 17, *) {
|
||||
// TODO: iOS 17 and above have a new JIT implementation that is completely broken in SideStore :(
|
||||
}
|
||||
print("We want to import this account probably: \(account)")
|
||||
if remove {
|
||||
try? FileManager.default.removeItem(at: file)
|
||||
else {
|
||||
start_auto_mounter(documentsDirectory)
|
||||
}
|
||||
Keychain.shared.appleIDEmailAddress = account.email
|
||||
Keychain.shared.appleIDPassword = account.password
|
||||
Keychain.shared.adiPb = account.adiPB
|
||||
Keychain.shared.identifier = account.local_user
|
||||
if let altCert = ALTCertificate(p12Data: account.cert, password: account.certpass) {
|
||||
Keychain.shared.signingCertificate = altCert.encryptedP12Data(withPassword: "")!
|
||||
Keychain.shared.signingCertificatePassword = account.certpass
|
||||
let toastView = ToastView(text: NSLocalizedString("Successfully imported '\(account.email)'!", comment: ""), detailText: "SideStore should be fully operational!")
|
||||
return toastView.show(in: self)
|
||||
} else {
|
||||
let toastView = ToastView(text: NSLocalizedString("Failed to import account certificate!", comment: ""), detailText: "Failed to create ALTCertificate. Check if the password is correct. Still imported account/adi.pb details!")
|
||||
return toastView.show(in: self)
|
||||
}
|
||||
}
|
||||
|
||||
func detectAndImportAccountFile() {
|
||||
let accountFileURL = FileManager.default.documentsDirectory.appendingPathComponent("Account.sideconf")
|
||||
#if !DEBUG
|
||||
importAccountAtFile(accountFileURL, remove: true)
|
||||
#else
|
||||
importAccountAtFile(accountFileURL)
|
||||
#endif
|
||||
|
||||
// Create destinationViewController now so view controllers can register for receiving Notifications.
|
||||
self.destinationViewController = self.storyboard!.instantiateViewController(withIdentifier: "tabBarController") as? TabBarController
|
||||
}
|
||||
}
|
||||
|
||||
extension LaunchViewController {
|
||||
@MainActor
|
||||
func handleLaunchError(_ error: Error, retryCallback: (() async -> Void)? = nil) {
|
||||
do { throw error } catch let error as NSError {
|
||||
extension LaunchViewController
|
||||
{
|
||||
override func handleLaunchError(_ error: Error)
|
||||
{
|
||||
do
|
||||
{
|
||||
throw error
|
||||
}
|
||||
catch let error as NSError
|
||||
{
|
||||
let title = error.userInfo[NSLocalizedFailureErrorKey] as? String ?? NSLocalizedString("Unable to Launch SideStore", comment: "")
|
||||
let desc: String
|
||||
if #available(iOS 14.5, *) {
|
||||
desc = ([error.debugDescription] + error.underlyingErrors.map { ($0 as NSError).debugDescription }).joined(separator: "\n\n")
|
||||
} else {
|
||||
desc = error.debugDescription
|
||||
|
||||
let errorDescription: String
|
||||
|
||||
if #available(iOS 14.5, *)
|
||||
{
|
||||
let errorMessages = [error.debugDescription] + error.underlyingErrors.map { ($0 as NSError).debugDescription }
|
||||
errorDescription = errorMessages.joined(separator: "\n\n")
|
||||
}
|
||||
let alert = UIAlertController(title: title, message: desc, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("Retry", comment: ""), style: .default) { _ in
|
||||
Task { await retryCallback?() }
|
||||
})
|
||||
present(alert, animated: true)
|
||||
else
|
||||
{
|
||||
errorDescription = error.debugDescription
|
||||
}
|
||||
|
||||
let alertController = UIAlertController(title: title, message: errorDescription, preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Retry", comment: ""), style: .default, handler: { (action) in
|
||||
self.handleLaunchConditions()
|
||||
}))
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func finishLaunching() async {
|
||||
guard !didFinishLaunching else { return }
|
||||
didFinishLaunching = true
|
||||
|
||||
override func finishLaunching()
|
||||
{
|
||||
super.finishLaunching()
|
||||
|
||||
guard !self.didFinishLaunching else { return }
|
||||
|
||||
AppManager.shared.update()
|
||||
AppManager.shared.updatePatronsIfNeeded()
|
||||
PatreonAPI.shared.refreshPatreonAccount()
|
||||
|
||||
AppManager.shared.updateAllSources { result in
|
||||
guard case .failure(let error) = result else { return }
|
||||
Logger.main.error("Failed to update sources on launch. \(error.localizedDescription, privacy: .public)")
|
||||
|
||||
|
||||
let errorDesc = ErrorProcessing(.fullError).getDescription(error: error as NSError)
|
||||
print("Failed to update sources on launch. \(errorDesc)")
|
||||
|
||||
var mode: ToastView.InfoMode = .fullError
|
||||
if String(describing: error).contains("The Internet connection appears to be offline"){
|
||||
mode = .localizedDescription // dont make noise!
|
||||
}
|
||||
let toastView = ToastView(error: error, mode: mode)
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.addTarget(self.destinationViewController, action: #selector(TabBarController.presentSources), for: .touchUpInside)
|
||||
toastView.show(in: self.destinationViewController!.selectedViewController ?? self.destinationViewController!)
|
||||
toastView.show(in: self.destinationViewController.selectedViewController ?? self.destinationViewController)
|
||||
}
|
||||
updateKnownSources()
|
||||
|
||||
self.updateKnownSources()
|
||||
|
||||
// Ask widgets to be refreshed
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
didFinishLaunching = true
|
||||
|
||||
let destinationVC = destinationViewController!
|
||||
// Add view controller as child (rather than presenting modally)
|
||||
// so tint adjustment + card presentations works correctly.
|
||||
self.destinationViewController.view.frame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: self.view.bounds.height)
|
||||
self.destinationViewController.view.alpha = 0.0
|
||||
self.addChild(self.destinationViewController)
|
||||
self.view.addSubview(self.destinationViewController.view, pinningEdgesWith: .zero)
|
||||
self.destinationViewController.didMove(toParent: self)
|
||||
|
||||
let elapsed = abs(startTime.timeIntervalSinceNow)
|
||||
let remaining = elapsed >= 1 ? 0 : 1 - elapsed
|
||||
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
||||
|
||||
destinationVC.loadViewIfNeeded()
|
||||
addChild(destinationVC)
|
||||
destinationVC.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(destinationVC.view)
|
||||
destinationVC.didMove(toParent: self)
|
||||
|
||||
// Pin edges BEFORE animation
|
||||
NSLayoutConstraint.activate([
|
||||
destinationVC.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
destinationVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
destinationVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
destinationVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor)
|
||||
])
|
||||
|
||||
// Set initial alpha for fade-in
|
||||
destinationVC.view.alpha = 0
|
||||
|
||||
UIView.transition(with: view, duration: 0.3, options: .transitionCrossDissolve) { [self] in
|
||||
self.splashView.alpha = 0
|
||||
destinationVC.view.alpha = 1
|
||||
} completion: { _ in
|
||||
self.splashView.removeFromSuperview()
|
||||
self.destinationViewController = destinationVC
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.destinationViewController.view.alpha = 1.0
|
||||
}
|
||||
|
||||
self.didFinishLaunching = true
|
||||
}
|
||||
}
|
||||
|
||||
func updateKnownSources() {
|
||||
private extension LaunchViewController
|
||||
{
|
||||
func updateKnownSources()
|
||||
{
|
||||
AppManager.shared.updateKnownSources { result in
|
||||
switch result {
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): print("[ALTLog] Failed to update known sources:", error)
|
||||
case .success((_, let blockedSources)):
|
||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
|
||||
let blockedSourceIDs = Set(blockedSources.lazy.map { $0.identifier })
|
||||
let blockedSourceURLs = Set(blockedSources.lazy.compactMap { $0.sourceURL })
|
||||
let predicate = NSPredicate(format: "%K IN %@ OR %K IN %@", #keyPath(Source.identifier), blockedSourceIDs, #keyPath(Source.sourceURL), blockedSourceURLs)
|
||||
let sourceErrors = Source.all(satisfying: predicate, in: context).map { source in
|
||||
let blocked = blockedSources.first { $0.identifier == source.identifier }
|
||||
return SourceError.blocked(source, bundleIDs: blocked?.bundleIDs, existingSource: source)
|
||||
|
||||
let predicate = NSPredicate(format: "%K IN %@ OR %K IN %@",
|
||||
#keyPath(Source.identifier), blockedSourceIDs,
|
||||
#keyPath(Source.sourceURL), blockedSourceURLs)
|
||||
|
||||
let sourceErrors = Source.all(satisfying: predicate, in: context).map { (source) in
|
||||
let blockedSource = blockedSources.first { $0.identifier == source.identifier }
|
||||
return SourceError.blocked(source, bundleIDs: blockedSource?.bundleIDs, existingSource: source)
|
||||
}
|
||||
|
||||
guard !sourceErrors.isEmpty else { return }
|
||||
|
||||
Task {
|
||||
for error in sourceErrors {
|
||||
for error in sourceErrors
|
||||
{
|
||||
let title = String(format: NSLocalizedString("“%@” Blocked", comment: ""), error.$source.name)
|
||||
let message = [error.localizedDescription, error.recoverySuggestion].compactMap { $0 }.joined(separator: "\n\n")
|
||||
|
||||
await self.presentAlert(title: title, message: message)
|
||||
}
|
||||
}
|
||||
@@ -277,142 +380,3 @@ extension LaunchViewController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SplashView
|
||||
final class SplashView: UIView {
|
||||
let iconView = UIImageView()
|
||||
let titleLabel = UILabel()
|
||||
|
||||
init(frame: CGRect, appName: String) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .systemBackground
|
||||
setupIcon()
|
||||
setupTitle(appName: appName)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
private func setupIcon() {
|
||||
let container = UIView()
|
||||
container.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.layer.shadowColor = UIColor.black.cgColor
|
||||
container.layer.shadowOpacity = 0.25
|
||||
container.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||
container.layer.shadowRadius = 8
|
||||
addSubview(container)
|
||||
|
||||
iconView.image = UIImage(named: "AppIcon") ?? UIImage(named: "AppIcon60x60") ?? UIImage(systemName: "app.fill")
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
iconView.translatesAutoresizingMaskIntoConstraints = false
|
||||
iconView.layer.cornerRadius = 24
|
||||
iconView.clipsToBounds = true
|
||||
container.addSubview(iconView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
container.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
container.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -20),
|
||||
container.widthAnchor.constraint(equalToConstant: 120),
|
||||
container.heightAnchor.constraint(equalToConstant: 120),
|
||||
iconView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
iconView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
iconView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
iconView.trailingAnchor.constraint(equalTo: container.trailingAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
private func setupTitle(appName: String) {
|
||||
titleLabel.text = appName
|
||||
titleLabel.font = .systemFont(ofSize: 24, weight: .bold)
|
||||
titleLabel.textColor = .label
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(titleLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
titleLabel.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 12),
|
||||
titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PairingFileManager
|
||||
final class PairingFileManager {
|
||||
static let shared = PairingFileManager()
|
||||
func fetchPairingFile(presentingVC: UIViewController) -> String? {
|
||||
let fm = FileManager.default
|
||||
let filename = pairingFileName
|
||||
let documentsPath = fm.documentsDirectory.appendingPathComponent("/\(filename)")
|
||||
if fm.fileExists(atPath: documentsPath.path),
|
||||
let contents = try? String(contentsOf: documentsPath), !contents.isEmpty {
|
||||
return contents
|
||||
}
|
||||
if let url = Bundle.main.url(forResource: "ALTPairingFile", withExtension: "mobiledevicepairing"),
|
||||
fm.fileExists(atPath: url.path),
|
||||
let data = fm.contents(atPath: url.path),
|
||||
let contents = String(data: data, encoding: .utf8),
|
||||
!contents.isEmpty, !UserDefaults.standard.isPairingReset { return contents }
|
||||
if let plistString = Bundle.main.object(forInfoDictionaryKey: "ALTPairingFile") as? String,
|
||||
!plistString.isEmpty, !plistString.contains("insert pairing file here"), !UserDefaults.standard.isPairingReset { return plistString }
|
||||
|
||||
presentPairingFileAlert(on: presentingVC)
|
||||
return nil
|
||||
}
|
||||
|
||||
private func presentPairingFileAlert(on vc: UIViewController) {
|
||||
let alert = UIAlertController(title: "Pairing File", message: "Select the pairing file or select \"Help\" for help.", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Help", style: .default) { _ in
|
||||
if let url = URL(string: "https://docs.sidestore.io/docs/advanced/pairing-file") { UIApplication.shared.open(url) }
|
||||
sleep(2); exit(0)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
|
||||
var types = UTType.types(tag: "plist", tagClass: .filenameExtension, conformingTo: nil)
|
||||
types.append(contentsOf: UTType.types(tag: "mobiledevicepairing", tagClass: .filenameExtension, conformingTo: .data))
|
||||
types.append(.xml)
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types)
|
||||
picker.delegate = vc as? UIDocumentPickerDelegate
|
||||
picker.shouldShowFileExtensions = true
|
||||
vc.present(picker, animated: true)
|
||||
UserDefaults.standard.isPairingReset = false
|
||||
})
|
||||
vc.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SideJITManager
|
||||
final class SideJITManager {
|
||||
static let shared = SideJITManager()
|
||||
func checkAndPromptIfNeeded(presentingVC: UIViewController) {
|
||||
guard #available(iOS 17, *), !UserDefaults.standard.sidejitenable else { return }
|
||||
DispatchQueue.global().async {
|
||||
self.isSideJITServerDetected { result in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success():
|
||||
let alert = UIAlertController(title: "SideJITServer Detected", message: "Would you like to enable SideJITServer", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in UserDefaults.standard.sidejitenable = true })
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
presentingVC.present(alert, animated: true)
|
||||
case .failure(_): print("Cannot find sideJITServer")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func askForNetwork() {
|
||||
let address = UserDefaults.standard.textInputSideJITServerurl ?? ""
|
||||
let SJSURL = address.isEmpty ? "http://sidejitserver._http._tcp.local:8080" : address
|
||||
URLSession.shared.dataTask(with: URL(string: "\(SJSURL)/re/")!) { data, resp, err in
|
||||
print("data: \(String(describing: data)), response: \(String(describing: resp)), error: \(String(describing: err))")
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func isSideJITServerDetected(completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let address = UserDefaults.standard.textInputSideJITServerurl ?? ""
|
||||
let SJSURL = address.isEmpty ? "http://sidejitserver._http._tcp.local:8080" : address
|
||||
guard let url = URL(string: SJSURL) else { return }
|
||||
URLSession.shared.dataTask(with: url) { _, _, error in
|
||||
if let error = error { completion(.failure(error)); return }
|
||||
completion(.success(()))
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
import MobileCoreServices
|
||||
import Intents
|
||||
@@ -22,6 +23,7 @@ import Roxas
|
||||
extension AppManager
|
||||
{
|
||||
static let didFetchSourceNotification = Notification.Name("io.sidestore.AppManager.didFetchSource")
|
||||
static let didUpdatePatronsNotification = Notification.Name("io.sidestore.AppManager.didUpdatePatrons")
|
||||
static let didAddSourceNotification = Notification.Name("io.sidestore.AppManager.didAddSource")
|
||||
static let didRemoveSourceNotification = Notification.Name("io.sidestore.AppManager.didRemoveSource")
|
||||
static let willInstallAppFromNewSourceNotification = Notification.Name("io.sidestore.AppManager.willInstallAppFromNewSource")
|
||||
@@ -589,6 +591,34 @@ extension AppManager
|
||||
return updateKnownSourcesOperation
|
||||
}
|
||||
|
||||
func updatePatronsIfNeeded()
|
||||
{
|
||||
guard self.operationQueue.operations.allSatisfy({ !($0 is UpdatePatronsOperation) }) else {
|
||||
// There's already an UpdatePatronsOperation running.
|
||||
return
|
||||
}
|
||||
|
||||
self.updatePatronsResult = nil
|
||||
|
||||
let updatePatronsOperation = UpdatePatronsOperation()
|
||||
updatePatronsOperation.resultHandler = { (result) in
|
||||
do
|
||||
{
|
||||
try result.get()
|
||||
self.updatePatronsResult = .success(())
|
||||
}
|
||||
catch
|
||||
{
|
||||
print("Error updating Friend Zone Patrons:", error)
|
||||
self.updatePatronsResult = .failure(error)
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(name: AppManager.didUpdatePatronsNotification, object: self)
|
||||
}
|
||||
|
||||
self.run([updatePatronsOperation], context: nil)
|
||||
}
|
||||
|
||||
func updateAllSources(completion: @escaping (Result<Void, Error>) -> Void)
|
||||
{
|
||||
self.updateSourcesResult = nil
|
||||
@@ -604,9 +634,6 @@ extension AppManager
|
||||
do
|
||||
{
|
||||
let (_, context) = try result.get()
|
||||
// print("\n\n\n\(context.insertedObjects)\n\n\n")
|
||||
// print("\n\n\n\(context.updatedObjects)\n\n\n")
|
||||
// print("\n\n\n\(context.deletedObjects)\n\n\n")
|
||||
try context.save()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
@@ -675,37 +702,9 @@ extension AppManager
|
||||
}
|
||||
}
|
||||
|
||||
let operation = AppOperation.install(app)
|
||||
self.perform([operation], presentingViewController: presentingViewController, group: group)
|
||||
|
||||
Task{
|
||||
var app: AppProtocol = app
|
||||
// ---- Preflight bundle ID resolution ----
|
||||
if UserDefaults.standard.customizeAppId, // only show prompt when enabled by user
|
||||
let presentingViewController {
|
||||
let originalBundleID = app.bundleIdentifier
|
||||
|
||||
let resolution = await self.resolveBundleID(
|
||||
initial: originalBundleID,
|
||||
presentingViewController: presentingViewController
|
||||
)
|
||||
|
||||
switch resolution {
|
||||
case .cancelled:
|
||||
completionHandler(.failure(OperationError.cancelled))
|
||||
group.progress.cancel()
|
||||
|
||||
case .resolved(let newBundleID):
|
||||
app = AnyApp(
|
||||
name: app.name,
|
||||
bundleIdentifier: newBundleID,
|
||||
url: app.url,
|
||||
storeApp: app.storeApp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await self.perform([.install(app)], presentingViewController: presentingViewController, group: group)
|
||||
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
@@ -730,11 +729,10 @@ extension AppManager
|
||||
}
|
||||
}
|
||||
|
||||
assert(appVersion as AnyObject !== installedApp) // Make sure we never accidentally "update" to already installed app.
|
||||
let operation = AppOperation.update(appVersion)
|
||||
assert(operation.app as AnyObject !== installedApp) // Make sure we never accidentally "update" to already installed app.
|
||||
|
||||
Task{
|
||||
await self.perform([.update(appVersion)], presentingViewController: presentingViewController, group: group)
|
||||
}
|
||||
self.perform([operation], presentingViewController: presentingViewController, group: group)
|
||||
|
||||
return group.progress
|
||||
}
|
||||
@@ -744,20 +742,16 @@ extension AppManager
|
||||
{
|
||||
let group = group ?? RefreshGroup()
|
||||
|
||||
Task{
|
||||
await self.perform(installedApps.map { .refresh($0) }, presentingViewController: presentingViewController, group: group)
|
||||
}
|
||||
|
||||
return group
|
||||
let operations = installedApps.map { AppOperation.refresh($0) }
|
||||
return self.perform(operations, presentingViewController: presentingViewController, group: group)
|
||||
}
|
||||
|
||||
func activate(_ installedApp: InstalledApp, presentingViewController: UIViewController?, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void)
|
||||
{
|
||||
let group = RefreshGroup()
|
||||
|
||||
Task{
|
||||
await self.perform([.activate(installedApp)], presentingViewController: presentingViewController, group: group)
|
||||
}
|
||||
let operation = AppOperation.activate(installedApp)
|
||||
self.perform([operation], presentingViewController: presentingViewController, group: group)
|
||||
|
||||
group.completionHandler = { (results) in
|
||||
do
|
||||
@@ -815,9 +809,8 @@ extension AppManager
|
||||
}
|
||||
}
|
||||
|
||||
Task{
|
||||
await self.perform([.deactivate(installedApp)], presentingViewController: presentingViewController, group: group)
|
||||
}
|
||||
let operation = AppOperation.deactivate(installedApp)
|
||||
self.perform([operation], presentingViewController: presentingViewController, group: group)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -841,9 +834,8 @@ extension AppManager
|
||||
}
|
||||
}
|
||||
|
||||
Task{
|
||||
await self.perform([.backup(installedApp)], presentingViewController: presentingViewController, group: group)
|
||||
}
|
||||
let operation = AppOperation.backup(installedApp)
|
||||
self.perform([operation], presentingViewController: presentingViewController, group: group)
|
||||
}
|
||||
|
||||
func restore(_ installedApp: InstalledApp, presentingViewController: UIViewController?, completionHandler: @escaping (Result<InstalledApp, Error>) -> Void)
|
||||
@@ -868,9 +860,8 @@ extension AppManager
|
||||
}
|
||||
}
|
||||
|
||||
Task{
|
||||
await self.perform([.restore(installedApp)], presentingViewController: presentingViewController, group: group)
|
||||
}
|
||||
let operation = AppOperation.restore(installedApp)
|
||||
self.perform([operation], presentingViewController: presentingViewController, group: group)
|
||||
}
|
||||
|
||||
func remove(_ installedApp: InstalledApp, completionHandler: @escaping (Result<Void, Error>) -> Void)
|
||||
@@ -1097,7 +1088,7 @@ private extension AppManager
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func perform(_ operations: [AppOperation], presentingViewController: UIViewController?, group: RefreshGroup) async -> RefreshGroup
|
||||
private func perform(_ operations: [AppOperation], presentingViewController: UIViewController?, group: RefreshGroup) -> RefreshGroup
|
||||
{
|
||||
let operations = operations.filter { self.progress(for: $0) == nil || self.progress(for: $0)?.isCancelled == true }
|
||||
|
||||
@@ -1159,10 +1150,38 @@ private extension AppManager
|
||||
|
||||
case .activate(let app) where UserDefaults.standard.isLegacyDeactivationSupported: fallthrough
|
||||
case .refresh(let app):
|
||||
let refreshProgress = self._refresh(app, operation: operation, group: group) { (result) in
|
||||
self.finish(operation, result: result, group: group, progress: progress)
|
||||
}
|
||||
progress?.addChild(refreshProgress, withPendingUnitCount: 80)
|
||||
// Check if backup app is installed in place of real app.
|
||||
// let altBackupUti = UTTypeCopyDeclaration(app.installedBackupAppUTI as CFString)?.takeRetainedValue() as NSDictionary?
|
||||
|
||||
// if app.certificateSerialNumber != group.context.certificate?.serialNumber ||
|
||||
// altBackupUti != nil || // why would altbackup requires reinstall? it shouldn't cause we are just renewing profiles
|
||||
// app.needsResign || // why would an app require resign during refresh? it shouldn't!
|
||||
// We need to reinstall ourselves on refresh to ensure the new provisioning profile is used
|
||||
// => mahee96: jkcoxson confirmed misagent manages profiles independently without requiring lockdownd or installd intervention, so sidestore profile renewal shouldn't require reinstall
|
||||
// app.bundleIdentifier == StoreApp.altstoreAppID
|
||||
// {
|
||||
// Resign app instead of just refreshing profiles because either:
|
||||
// * Refreshing using different certificate // when can this happen?, lets assume, refreshing with different certificate, why not just ask user to re-install manually? (probably we need re-install button)
|
||||
// * Backup app is still installed // but why? I mean the AltBackup was put in place for a reason? ie during refresh just renew appIDs don't care about the app itself.
|
||||
// * App explicitly needs resigning // when can this happen?
|
||||
// * Device is jailbroken and using AltDaemon on iOS 14.0 or later (b/c refreshing with provisioning profiles is broken)
|
||||
|
||||
// let installProgress = self._install(app, operation: operation, group: group) { (result) in
|
||||
// self.finish(operation, result: result, group: group, progress: progress)
|
||||
// }
|
||||
// progress?.addChild(installProgress, withPendingUnitCount: 80)
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// Refreshing with same certificate as last time, and backup app isn't still installed,
|
||||
// so we can just refresh provisioning profiles.
|
||||
|
||||
let refreshProgress = self._refresh(app, operation: operation, group: group) { (result) in
|
||||
self.finish(operation, result: result, group: group, progress: progress)
|
||||
}
|
||||
progress?.addChild(refreshProgress, withPendingUnitCount: 80)
|
||||
// }
|
||||
|
||||
case .activate(let app):
|
||||
let activateProgress = self._activate(app, operation: operation, group: group) { (result) in
|
||||
self.finish(operation, result: result, group: group, progress: progress)
|
||||
@@ -1209,13 +1228,13 @@ private extension AppManager
|
||||
}
|
||||
else
|
||||
{
|
||||
// Disable the idleTimeout
|
||||
DispatchQueue.main.schedule {
|
||||
if !UIApplication.shared.isIdleTimerDisabled { // accept only once if concurrent
|
||||
UIApplication.shared.isIdleTimerDisabled = UserDefaults.standard.isIdleTimeoutDisableEnabled
|
||||
}
|
||||
UIApplication.shared.isIdleTimerDisabled = UserDefaults.standard.isIdleTimeoutDisableEnabled
|
||||
}
|
||||
performAppOperations()
|
||||
DispatchQueue.main.schedule {
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
}
|
||||
}
|
||||
|
||||
return group
|
||||
@@ -1232,10 +1251,21 @@ private extension AppManager
|
||||
{
|
||||
let progress = Progress.discreteProgress(totalUnitCount: 100)
|
||||
|
||||
let context = InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context)
|
||||
let context = context ?? InstallAppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context)
|
||||
assert(context.authenticatedContext === group.context)
|
||||
|
||||
context.beginInstallationHandler = { (installedApp) in
|
||||
switch appOperation
|
||||
{
|
||||
case .update where installedApp.bundleIdentifier == StoreApp.altstoreAppID:
|
||||
// AltStore will quit before installation finishes,
|
||||
// so assume if we get this far the update will finish successfully.
|
||||
let event = AnalyticsManager.Event.updatedApp(installedApp)
|
||||
AnalyticsManager.shared.trackEvent(event)
|
||||
|
||||
default: break
|
||||
}
|
||||
|
||||
group.beginInstallationHandler?(installedApp)
|
||||
}
|
||||
|
||||
@@ -1256,6 +1286,20 @@ private extension AppManager
|
||||
}
|
||||
}
|
||||
|
||||
var verifyPledgeOperation: VerifyAppPledgeOperation?
|
||||
if let storeApp = app.storeApp
|
||||
{
|
||||
verifyPledgeOperation = VerifyAppPledgeOperation(storeApp: storeApp, presentingViewController: context.presentingViewController)
|
||||
verifyPledgeOperation?.resultHandler = { result in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error):
|
||||
context.error = error
|
||||
case .success: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Download */
|
||||
let downloadedAppURL = context.temporaryDirectory.appendingPathComponent("Cached.app")
|
||||
let downloadOperation = DownloadAppOperation(app: downloadingApp, destinationURL: downloadedAppURL, context: context)
|
||||
@@ -1267,8 +1311,7 @@ private extension AppManager
|
||||
|
||||
if cacheApp
|
||||
{
|
||||
let updatedApp = AnyApp(from: app, bundleId: context.bundleIdentifier)
|
||||
try FileManager.default.copyItem(at: app.fileURL, to: InstalledApp.fileURL(for: updatedApp), shouldReplace: true)
|
||||
try FileManager.default.copyItem(at: app.fileURL, to: InstalledApp.fileURL(for: app), shouldReplace: true)
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -1278,9 +1321,15 @@ private extension AppManager
|
||||
}
|
||||
progress.addChild(downloadOperation.progress, withPendingUnitCount: 25)
|
||||
|
||||
if let verifyPledgeOperation
|
||||
{
|
||||
downloadOperation.addDependency(verifyPledgeOperation)
|
||||
}
|
||||
|
||||
|
||||
/* Verify App */
|
||||
let permissionsMode = UserDefaults.shared.permissionCheckingDisabled ? .none : permissionReviewMode
|
||||
let verifyOperation = VerifyAppOperation(permissionsMode: permissionsMode, context: context, customBundleId: app.bundleIdentifier)
|
||||
let verifyOperation = VerifyAppOperation(permissionsMode: permissionsMode, context: context)
|
||||
verifyOperation.resultHandler = { (result) in
|
||||
do
|
||||
{
|
||||
@@ -1433,7 +1482,7 @@ private extension AppManager
|
||||
let patchAppURL = URL(string: patchAppLink)
|
||||
else { throw OperationError.invalidApp }
|
||||
|
||||
let patchApp = AnyApp(name: app.name, bundleIdentifier: context.bundleIdentifier, url: patchAppURL, storeApp: nil)
|
||||
let patchApp = AnyApp(name: app.name, bundleIdentifier: app.bundleIdentifier, url: patchAppURL, storeApp: nil)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let storyboard = UIStoryboard(name: "PatchApp", bundle: nil)
|
||||
@@ -1455,7 +1504,7 @@ private extension AppManager
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
presentingViewController.present(navigationController, animated: true, completion: nil)
|
||||
presentingViewController.present(navigationController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -1467,24 +1516,6 @@ private extension AppManager
|
||||
patchAppOperation.addDependency(deactivateAppsOperation)
|
||||
|
||||
|
||||
let modifyAppExBundleIdOperation = RSTAsyncBlockOperation { operation in
|
||||
if !context.useMainProfile {
|
||||
operation.finish()
|
||||
return
|
||||
}
|
||||
|
||||
if let app = context.app, let profile = context.provisioningProfiles?[context.bundleIdentifier] {
|
||||
var appexBundleIds: [String: String] = [:]
|
||||
for appex in app.appExtensions {
|
||||
appexBundleIds[appex.bundleIdentifier] = appex.bundleIdentifier.replacingOccurrences(of: app.bundleIdentifier, with: profile.bundleIdentifier)
|
||||
}
|
||||
context.appexBundleIds = appexBundleIds
|
||||
}
|
||||
operation.finish()
|
||||
|
||||
}
|
||||
modifyAppExBundleIdOperation.addDependency(fetchProvisioningProfilesOperation)
|
||||
|
||||
/* Resign */
|
||||
let resignAppOperation = ResignAppOperation(context: context)
|
||||
resignAppOperation.resultHandler = { (result) in
|
||||
@@ -1499,7 +1530,6 @@ private extension AppManager
|
||||
}
|
||||
}
|
||||
resignAppOperation.addDependency(patchAppOperation)
|
||||
resignAppOperation.addDependency(modifyAppExBundleIdOperation)
|
||||
progress.addChild(resignAppOperation.progress, withPendingUnitCount: 20)
|
||||
|
||||
|
||||
@@ -1545,6 +1575,7 @@ private extension AppManager
|
||||
|
||||
// Operations picked for request
|
||||
var operations = [
|
||||
verifyPledgeOperation,
|
||||
downloadOperation,
|
||||
verifyOperation,
|
||||
removeAppExtensionsOperation,
|
||||
@@ -1552,7 +1583,6 @@ private extension AppManager
|
||||
patchAppOperation,
|
||||
refreshAnisetteDataOperation,
|
||||
fetchProvisioningProfilesOperation,
|
||||
modifyAppExBundleIdOperation,
|
||||
resignAppOperation,
|
||||
sendAppOperation,
|
||||
installOperation
|
||||
@@ -1632,8 +1662,8 @@ private extension AppManager
|
||||
|
||||
let context = AppOperationContext(bundleIdentifier: app.bundleIdentifier, authenticatedContext: group.context)
|
||||
context.app = ALTApplication(fileURL: app.fileURL)
|
||||
context.useMainProfile = app.useMainProfile
|
||||
// Since this doesn't involve modifying app bundle which will cause re-install, this is safe in refresh path
|
||||
|
||||
// Since this doesn't involve modifying app bundle which will cause re-install, this is safe in refresh path
|
||||
//App-Extensions: Ensure DB data and disk state must match
|
||||
let dbAppEx: Set<InstalledExtension> = Set(app.appExtensions)
|
||||
let diskAppEx: Set<ALTApplication> = Set(context.app!.appExtensions)
|
||||
@@ -2065,16 +2095,6 @@ private extension AppManager
|
||||
|
||||
func finish(_ operation: AppOperation, result: Result<InstalledApp, Error>, group: RefreshGroup, progress: Progress?)
|
||||
{
|
||||
// Remove disableIdleTimeout
|
||||
// TODO: This should disable for the last finish() request not the first though for batches
|
||||
// probably if we are in batch mode, we can count expected no of finishes() to arrive
|
||||
// and schedule disabling only on last request by matching it with count.
|
||||
DispatchQueue.main.schedule {
|
||||
if UIApplication.shared.isIdleTimerDisabled { // accept only once if concurrent
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// Must remove before saving installedApp.
|
||||
if let currentProgress = self.progress(for: operation), currentProgress == progress
|
||||
{
|
||||
@@ -2092,6 +2112,27 @@ private extension AppManager
|
||||
self.scheduleExpirationWarningLocalNotification(for: installedApp)
|
||||
}
|
||||
|
||||
let event: AnalyticsManager.Event?
|
||||
|
||||
switch operation
|
||||
{
|
||||
case .install: event = .installedApp(installedApp)
|
||||
case .refresh: event = .refreshedApp(installedApp)
|
||||
case .update where installedApp.bundleIdentifier == StoreApp.altstoreAppID:
|
||||
// AltStore quits before update finishes, so we've preemptively logged this update event.
|
||||
// In case AltStore doesn't quit, such as when update has a different bundle identifier,
|
||||
// make sure we don't log this update event a second time.
|
||||
event = nil
|
||||
|
||||
case .update: event = .updatedApp(installedApp)
|
||||
case .activate, .deactivate, .backup, .restore: event = nil
|
||||
}
|
||||
|
||||
if let event = event
|
||||
{
|
||||
AnalyticsManager.shared.trackEvent(event)
|
||||
}
|
||||
|
||||
// Ask widgets to be refreshed
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
|
||||
@@ -2176,7 +2217,7 @@ private extension AppManager
|
||||
switch operation
|
||||
{
|
||||
case _ where requiresSerialQueue: fallthrough
|
||||
case is InstallAppOperation, is RefreshAppOperation, is BackupAppOperation:
|
||||
case is InstallAppOperation, is RefreshAppOperation, is BackupAppOperation, is VerifyAppPledgeOperation:
|
||||
if let installAltStoreOperation = operation as? InstallAppOperation, installAltStoreOperation.context.bundleIdentifier == StoreApp.altstoreAppID
|
||||
{
|
||||
// Add dependencies on previous serial operations in `context` to ensure re-installing AltStore goes last.
|
||||
@@ -2228,126 +2269,3 @@ private extension AppManager
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum BundleIDAlertKeys {
|
||||
static var okAction: UInt8 = 0
|
||||
}
|
||||
|
||||
private func _isValidBundleID(_ value: String) -> Bool {
|
||||
let pattern = #"^[A-Za-z][A-Za-z0-9\-]*(\.[A-Za-z0-9\-]+)+$"#
|
||||
return value.range(of: pattern, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
private extension UIResponder {
|
||||
@objc func _validateBundleIDText(_ sender: UITextField) {
|
||||
let isValid = sender.text.map(_isValidBundleID) ?? false
|
||||
|
||||
sender.backgroundColor =
|
||||
isValid || sender.text?.isEmpty == true
|
||||
? .clear
|
||||
: UIColor.systemRed.withAlphaComponent(0.2)
|
||||
|
||||
if
|
||||
let alert = sender.superview?.superview as? UIAlertController,
|
||||
let okAction = objc_getAssociatedObject(alert, &BundleIDAlertKeys.okAction) as? UIAlertAction
|
||||
{
|
||||
okAction.isEnabled = isValid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private extension AppManager {
|
||||
|
||||
func _presentBundleIDOverrideDialog(
|
||||
bundleIdentifier: String,
|
||||
presentingViewController: UIViewController,
|
||||
completion: @escaping (BundleIDResolution) -> Void
|
||||
) {
|
||||
let alert = self._makeBundleIDOverrideAlert(
|
||||
initialBundleID: bundleIdentifier,
|
||||
completion: completion
|
||||
)
|
||||
|
||||
presentingViewController.present(alert, animated: true)
|
||||
}
|
||||
|
||||
func _makeBundleIDOverrideAlert(
|
||||
initialBundleID: String,
|
||||
completion: @escaping (BundleIDResolution) -> Void
|
||||
) -> UIAlertController {
|
||||
|
||||
let titleText = NSLocalizedString("AppID Customization", comment: "")
|
||||
let messageText = NSLocalizedString("Customize the AppID if required and press 'Confirm' to proceed.", comment: "")
|
||||
|
||||
let alert = UIAlertController(
|
||||
title: titleText,
|
||||
message: messageText,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
|
||||
var okAction: UIAlertAction!
|
||||
|
||||
alert.addTextField { textField in
|
||||
textField.text = initialBundleID
|
||||
textField.autocapitalizationType = .none
|
||||
textField.autocorrectionType = .no
|
||||
textField.addTarget(
|
||||
nil,
|
||||
action: #selector(UIResponder._validateBundleIDText(_:)),
|
||||
for: .editingChanged
|
||||
)
|
||||
}
|
||||
|
||||
okAction = UIAlertAction(title: NSLocalizedString("Confirm", comment: ""), style: .default) { _ in
|
||||
completion(.resolved(alert.textFields?.first?.text ?? initialBundleID))
|
||||
}
|
||||
|
||||
okAction.isEnabled = _isValidBundleID(initialBundleID)
|
||||
|
||||
let cancelAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel) { _ in
|
||||
completion(.cancelled)
|
||||
}
|
||||
|
||||
alert.addAction(cancelAction)
|
||||
alert.addAction(okAction)
|
||||
|
||||
objc_setAssociatedObject(
|
||||
alert,
|
||||
&BundleIDAlertKeys.okAction,
|
||||
okAction,
|
||||
.OBJC_ASSOCIATION_ASSIGN
|
||||
)
|
||||
|
||||
return alert
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---- Part 1: Add async resolver ----
|
||||
|
||||
private extension AppManager {
|
||||
|
||||
enum BundleIDResolution {
|
||||
case resolved(String)
|
||||
case cancelled
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func resolveBundleID(
|
||||
initial: String,
|
||||
presentingViewController: UIViewController
|
||||
) async -> BundleIDResolution {
|
||||
|
||||
await withCheckedContinuation { continuation in
|
||||
let alert = self._makeBundleIDOverrideAlert(
|
||||
initialBundleID: initial
|
||||
) { result in
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
|
||||
presentingViewController.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import AltStoreCore
|
||||
import AltSign
|
||||
import Roxas
|
||||
import minimuxer
|
||||
import SemanticVersion
|
||||
|
||||
import Nuke
|
||||
|
||||
@@ -166,11 +165,9 @@ class MyAppsViewController: UICollectionViewController, PeekPopPreviewing
|
||||
@IBAction func unwindToMyAppsViewController(_ segue: UIStoryboardSegue)
|
||||
{
|
||||
}
|
||||
|
||||
var minimuxerStatus: Bool {
|
||||
// added isMinimuxerStatusCheckEnabled to forcefully ignore minimuxer status if status check is disabled in settings
|
||||
guard !UserDefaults.standard.isMinimuxerStatusCheckEnabled || minimuxer.ready() else {
|
||||
ToastView(error: (OperationError.noWiFi as NSError).withLocalizedTitle("No Wi-Fi or VPN!")).show(in: self)
|
||||
guard minimuxer.ready() else {
|
||||
ToastView(error: (OperationError.noWiFi as NSError).withLocalizedTitle("No WiFi or VPN!")).show(in: self)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -237,30 +234,18 @@ private extension MyAppsViewController
|
||||
cell.layoutMargins.right = self.view.layoutMargins.right
|
||||
|
||||
cell.tintColor = app.tintColor ?? .altPrimary
|
||||
cell.versionDescriptionTextView.maximumNumberOfLines = 2
|
||||
cell.versionDescriptionTextView.text = latestSupportedVersion.localizedDescription ?? "nil"
|
||||
cell.versionDescriptionTextView.text = latestSupportedVersion.localizedDescription
|
||||
|
||||
cell.bannerView.iconImageView.image = nil
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
cell.bannerView.configure(for: app, action: .update)
|
||||
|
||||
var versionText = latestSupportedVersion.localizedVersion
|
||||
|
||||
// If the app is SideStore itself, remove the build number to save space
|
||||
if app.bundleIdentifier == Bundle.Info.appbundleIdentifier,
|
||||
let version = SemanticVersion(latestSupportedVersion.version)
|
||||
{
|
||||
// leave out the build so that it doesnt take up much space
|
||||
versionText = SemanticVersion(version.major, version.minor, version.patch, version.preRelease).description
|
||||
}
|
||||
|
||||
cell.bannerView.subtitleLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), versionText)
|
||||
cell.bannerView.subtitleLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), latestSupportedVersion.localizedVersion)
|
||||
|
||||
let appName: String
|
||||
|
||||
if ReleaseTracks.betaTracks.contains(latestSupportedVersion.channel)
|
||||
if app.isBeta
|
||||
{
|
||||
appName = String(format: NSLocalizedString("%@ beta", comment: ""), app.name)
|
||||
}
|
||||
@@ -284,9 +269,12 @@ private extension MyAppsViewController
|
||||
cell.mode = .collapsed
|
||||
}
|
||||
|
||||
cell.versionDescriptionTextView.toggleButton.addTarget(self, action: #selector(MyAppsViewController.toggleUpdateCellMode(_:)), for: .primaryActionTriggered)
|
||||
|
||||
cell.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(MyAppsViewController.toggleUpdateCellMode(_:)), for: .primaryActionTriggered)
|
||||
|
||||
cell.setNeedsLayout()
|
||||
|
||||
// Below lines are necessary to avoid "more" button layout issues.
|
||||
cell.versionDescriptionTextView.setNeedsLayout()
|
||||
cell.layoutIfNeeded()
|
||||
}
|
||||
dataSource.prefetchHandler = { (installedApp, indexPath, completionHandler) in
|
||||
@@ -367,12 +355,8 @@ private extension MyAppsViewController
|
||||
|
||||
formatter.maximumUnitCount = 1
|
||||
|
||||
var timeInterval: String? = "expired"
|
||||
let expirationDate = installedApp.expirationDate
|
||||
let isExpired = currentDate > expirationDate
|
||||
if(!isExpired) {
|
||||
timeInterval = formatter.string(from: currentDate, to: expirationDate)
|
||||
}
|
||||
|
||||
let timeInterval = formatter.string(from: currentDate, to: installedApp.expirationDate)
|
||||
cell.bannerView.button.setTitle(timeInterval?.uppercased(), for: .normal)
|
||||
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
@@ -380,7 +364,7 @@ private extension MyAppsViewController
|
||||
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||
|
||||
cell.bannerView.buttonLabel.isHidden = isExpired
|
||||
cell.bannerView.buttonLabel.isHidden = false
|
||||
cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "")
|
||||
|
||||
cell.bannerView.button.removeTarget(self, action: nil, for: .primaryActionTriggered)
|
||||
@@ -544,6 +528,11 @@ private extension MyAppsViewController
|
||||
{
|
||||
print("[ALTLog] Failed to fetch updates:", error)
|
||||
}
|
||||
|
||||
if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isAltStorePatron, PatreonAPI.shared.isAuthenticated
|
||||
{
|
||||
self.dataSource.predicate = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,29 +708,22 @@ private extension MyAppsViewController
|
||||
|
||||
let cell = self.collectionView.cellForItem(at: indexPath) as? UpdateCollectionViewCell
|
||||
|
||||
// Toggle the state
|
||||
if self.expandedAppUpdates.contains(installedApp.bundleIdentifier)
|
||||
{
|
||||
self.expandedAppUpdates.remove(installedApp.bundleIdentifier)
|
||||
// Set collapsed mode on the cell
|
||||
cell?.mode = .collapsed
|
||||
}
|
||||
else
|
||||
{
|
||||
self.expandedAppUpdates.insert(installedApp.bundleIdentifier)
|
||||
// Set expanded mode on the cell
|
||||
cell?.mode = .expanded
|
||||
}
|
||||
|
||||
// Clear cached size so it's recalculated
|
||||
self.cachedUpdateSizes[installedApp.bundleIdentifier] = nil
|
||||
|
||||
// Animate the change smoothly with a duration
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.collectionView.performBatchUpdates({
|
||||
self.collectionView.collectionViewLayout.invalidateLayout()
|
||||
}, completion: nil)
|
||||
}
|
||||
self.collectionView.performBatchUpdates({
|
||||
self.collectionView.collectionViewLayout.invalidateLayout()
|
||||
}, completion: nil)
|
||||
}
|
||||
|
||||
@IBAction func refreshApp(_ sender: UIButton)
|
||||
@@ -1062,6 +1044,57 @@ private extension MyAppsViewController
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = false
|
||||
}
|
||||
|
||||
func removeAppExtensions(from application: ALTApplication, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
{
|
||||
guard !application.appExtensions.isEmpty else { return completion(.success(())) }
|
||||
|
||||
func removeAppExtensions() throws
|
||||
{
|
||||
for appExtension in application.appExtensions
|
||||
{
|
||||
try FileManager.default.removeItem(at: appExtension.fileURL)
|
||||
}
|
||||
|
||||
let scInfoURL = application.fileURL.appendingPathComponent("SC_Info")
|
||||
let manifestPlistURL = scInfoURL.appendingPathComponent("Manifest.plist")
|
||||
|
||||
if let manifestPlist = NSMutableDictionary(contentsOf: manifestPlistURL),
|
||||
let sinfReplicationPaths = manifestPlist["SinfReplicationPaths"] as? [String]
|
||||
{
|
||||
let replacementPaths = sinfReplicationPaths.filter { !$0.starts(with: "PlugIns/") } // Filter out app extension paths.
|
||||
manifestPlist["SinfReplicationPaths"] = replacementPaths
|
||||
try manifestPlist.write(to: manifestPlistURL)
|
||||
}
|
||||
}
|
||||
|
||||
let firstSentence: String
|
||||
|
||||
if UserDefaults.standard.activeAppLimitIncludesExtensions
|
||||
{
|
||||
firstSentence = NSLocalizedString("Non-developer Apple IDs are limited to 3 active apps and app extensions.", comment: "")
|
||||
}
|
||||
else
|
||||
{
|
||||
firstSentence = NSLocalizedString("Non-developer Apple IDs are limited to creating 10 App IDs per week.", comment: "")
|
||||
}
|
||||
|
||||
let message = firstSentence + " " + NSLocalizedString("Would you like to remove this app's extensions so they don't count towards your limit?", comment: "")
|
||||
|
||||
let alertController = UIAlertController(title: NSLocalizedString("App Contains Extensions", comment: ""), message: message, preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style, handler: { (action) in
|
||||
completion(.failure(OperationError.cancelled))
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Keep App Extensions", comment: ""), style: .default) { (action) in
|
||||
completion(.success(()))
|
||||
})
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove App Extensions", comment: ""), style: .destructive) { (action) in
|
||||
let result = Result { try removeAppExtensions() }
|
||||
completion(result)
|
||||
})
|
||||
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc func showHiddenUpdatesAlert(_ sender: UIButton)
|
||||
{
|
||||
guard !self.unsupportedUpdates.isEmpty else { return }
|
||||
@@ -1482,6 +1515,15 @@ private extension MyAppsViewController
|
||||
guard minimuxerStatus else { return }
|
||||
}
|
||||
|
||||
if #available(iOS 17, *), !sidejitenabled {
|
||||
let error = OperationError.tooNewError as NSError
|
||||
let localizedError = error.withLocalizedTitle("No iOS 17 On Device JIT!")
|
||||
|
||||
ToastView(error: localizedError, opensLog: true).show(in: self)
|
||||
AppManager.shared.log(error, operation: .enableJIT, app: installedApp)
|
||||
return
|
||||
}
|
||||
|
||||
AppManager.shared.enableJIT(for: installedApp) { result in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
@@ -1560,7 +1602,6 @@ private extension MyAppsViewController
|
||||
}
|
||||
catch let error as AppManager.FetchSourcesError
|
||||
{
|
||||
print(error)
|
||||
try await error.managedObjectContext?.performAsync {
|
||||
try error.managedObjectContext?.save()
|
||||
}
|
||||
@@ -1592,7 +1633,6 @@ private extension MyAppsViewController
|
||||
}
|
||||
catch let error as NSError
|
||||
{
|
||||
print(error)
|
||||
let toastView = ToastView(error: error.withLocalizedTitle(NSLocalizedString("Unable to Check for Updates", comment: "")))
|
||||
toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside)
|
||||
toastView.show(in: self)
|
||||
|
||||
@@ -21,19 +21,12 @@ extension UpdateCollectionViewCell
|
||||
{
|
||||
var mode: Mode = .expanded {
|
||||
didSet {
|
||||
switch self.mode {
|
||||
case .collapsed:
|
||||
self.versionDescriptionTextView.isCollapsed = true
|
||||
case .expanded:
|
||||
self.versionDescriptionTextView.isCollapsed = false
|
||||
}
|
||||
self.setNeedsLayout()
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
@IBOutlet var bannerView: AppBannerView!
|
||||
// @IBOutlet var versionDescriptionTextView: CollapsingTextView!
|
||||
@IBOutlet var versionDescriptionTextView: CollapsingMarkdownView!
|
||||
@IBOutlet var versionDescriptionTextView: CollapsingTextView!
|
||||
|
||||
@IBOutlet private var blurView: UIVisualEffectView!
|
||||
|
||||
@@ -92,16 +85,16 @@ extension UpdateCollectionViewCell
|
||||
}
|
||||
}
|
||||
|
||||
// override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize
|
||||
// {
|
||||
// // Ensure cell is laid out so it will report correct size.
|
||||
// self.versionDescriptionTextView.setNeedsLayout()
|
||||
// self.versionDescriptionTextView.layoutIfNeeded()
|
||||
//
|
||||
// let size = super.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: horizontalFittingPriority, verticalFittingPriority: verticalFittingPriority)
|
||||
//
|
||||
// return size
|
||||
// }
|
||||
override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize
|
||||
{
|
||||
// Ensure cell is laid out so it will report correct size.
|
||||
self.versionDescriptionTextView.setNeedsLayout()
|
||||
self.versionDescriptionTextView.layoutIfNeeded()
|
||||
|
||||
let size = super.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: horizontalFittingPriority, verticalFittingPriority: verticalFittingPriority)
|
||||
|
||||
return size
|
||||
}
|
||||
}
|
||||
|
||||
private extension UpdateCollectionViewCell
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="22505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22504"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
@@ -11,7 +11,7 @@
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="UpdateCell" id="Kqf-Pv-ca3" customClass="UpdateCollectionViewCell" customModule="SideStore" customModuleProvider="target">
|
||||
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="UpdateCell" id="Kqf-Pv-ca3" customClass="UpdateCollectionViewCell" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="125"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
|
||||
@@ -30,7 +30,7 @@
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="uYl-PH-DuP">
|
||||
<rect key="frame" x="0.0" y="0.0" width="343" height="125"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Nop-pL-Icx" customClass="AppBannerView" customModule="SideStore" customModuleProvider="target">
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Nop-pL-Icx" customClass="AppBannerView" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="343" height="50"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="88" id="EPP-7O-1Ad"/>
|
||||
@@ -39,7 +39,7 @@
|
||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="firstBaseline" translatesAutoresizingMaskIntoConstraints="NO" id="RSR-5W-7tt" userLabel="Release Notes">
|
||||
<rect key="frame" x="0.0" y="50" width="343" height="75"/>
|
||||
<subviews>
|
||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" text="Version Notes" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="rNs-2O-k3V" customClass="CollapsingMarkdownView" customModule="SideStore" customModuleProvider="target">
|
||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" text="Version Notes" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="rNs-2O-k3V" customClass="CollapsingTextView" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="15" y="0.0" width="313" height="26"/>
|
||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="13"/>
|
||||
@@ -91,7 +91,7 @@
|
||||
<color red="1" green="1" blue="1" alpha="0.30000001192092896" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
<systemColor name="secondaryLabelColor">
|
||||
<color red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
|
||||
@@ -319,8 +319,7 @@ private extension NewsViewController
|
||||
let app = self.dataSource.item(at: indexPath)
|
||||
guard let storeApp = app.storeApp else { return }
|
||||
|
||||
// if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable
|
||||
if let installedApp = storeApp.installedApp, !installedApp.hasUpdate
|
||||
if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable
|
||||
{
|
||||
self.open(installedApp)
|
||||
}
|
||||
@@ -339,8 +338,7 @@ private extension NewsViewController
|
||||
}
|
||||
|
||||
Task<Void, Never>(priority: .userInitiated) { @MainActor in
|
||||
// if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable
|
||||
if let installedApp = storeApp.installedApp, installedApp.hasUpdate
|
||||
if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable
|
||||
{
|
||||
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
|
||||
}
|
||||
|
||||
@@ -84,87 +84,71 @@ final class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate, A
|
||||
self.finish(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
// try to use cached session
|
||||
if
|
||||
let certificate = Keychain.shared.certificate,
|
||||
let session = Keychain.shared.session,
|
||||
let team = Keychain.shared.team
|
||||
{
|
||||
if session.anisetteData.date.timeIntervalSinceNow < -40.0 {
|
||||
let anisetteData = try await withUnsafeThrowingContinuation { (c: UnsafeContinuation<ALTAnisetteData, any Error>) in
|
||||
let fetchAnisetteDataOperation = FetchAnisetteDataOperation(context: self.context)
|
||||
fetchAnisetteDataOperation.resultHandler = { (result) in
|
||||
c.resume(with: result)
|
||||
}
|
||||
self.operationQueue.addOperation(fetchAnisetteDataOperation)
|
||||
}
|
||||
session.anisetteData = anisetteData
|
||||
}
|
||||
self.context.team = team
|
||||
self.context.session = session
|
||||
self.context.certificate = certificate
|
||||
self.finish(.success((team, certificate, session)))
|
||||
return
|
||||
}
|
||||
|
||||
// Sign In
|
||||
self.signIn() { (result) in
|
||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
||||
|
||||
// new login
|
||||
do {
|
||||
let (account, session) = try await withUnsafeThrowingContinuation { c in
|
||||
self.signIn() { (result) in
|
||||
c.resume(with: result)
|
||||
}
|
||||
}
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): self.finish(.failure(error))
|
||||
case .success((let account, let session)):
|
||||
self.context.session = session
|
||||
self.progress.completedUnitCount += 1
|
||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
||||
|
||||
let team = try await withUnsafeThrowingContinuation { c in
|
||||
self.fetchTeam(for: account, session: session) { (result) in
|
||||
c.resume(with: result)
|
||||
// Fetch Team
|
||||
self.fetchTeam(for: account, session: session) { (result) in
|
||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
||||
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): self.finish(.failure(error))
|
||||
case .success(let team):
|
||||
self.context.team = team
|
||||
self.progress.completedUnitCount += 1
|
||||
|
||||
// Fetch Certificate
|
||||
self.fetchCertificate(for: team, session: session) { (result) in
|
||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
||||
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): self.finish(.failure(error))
|
||||
case .success(let certificate):
|
||||
self.context.certificate = certificate
|
||||
self.progress.completedUnitCount += 1
|
||||
|
||||
// Register Device
|
||||
self.registerCurrentDevice(for: team, session: session) { (result) in
|
||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
||||
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): self.finish(.failure(error))
|
||||
case .success:
|
||||
self.progress.completedUnitCount += 1
|
||||
|
||||
// Save account/team to disk.
|
||||
self.save(team) { (result) in
|
||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
||||
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): self.finish(.failure(error))
|
||||
case .success:
|
||||
// Must cache App IDs _after_ saving account/team to disk.
|
||||
self.cacheAppIDs(team: team, session: session) { (result) in
|
||||
let result = result.map { _ in (team, certificate, session) }
|
||||
self.finish(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.context.team = team
|
||||
self.progress.completedUnitCount += 1
|
||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
||||
|
||||
let certificate = try await withUnsafeThrowingContinuation { c in
|
||||
self.fetchCertificate(for: team, session: session) { (result) in
|
||||
c.resume(with: result)
|
||||
}
|
||||
}
|
||||
self.context.certificate = certificate
|
||||
self.progress.completedUnitCount += 1
|
||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
||||
|
||||
let _ = try await withUnsafeThrowingContinuation { c in
|
||||
self.registerCurrentDevice(for: team, session: session) { (result) in
|
||||
c.resume(with: result)
|
||||
}
|
||||
}
|
||||
self.progress.completedUnitCount += 1
|
||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
||||
|
||||
try await withUnsafeThrowingContinuation { c in
|
||||
self.save(team) { (result) in
|
||||
c.resume(with: result)
|
||||
}
|
||||
}
|
||||
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
|
||||
|
||||
try await withUnsafeThrowingContinuation { c in
|
||||
self.cacheAppIDs(team: team, session: session) { (result) in
|
||||
c.resume(with: result)
|
||||
}
|
||||
}
|
||||
Keychain.shared.team = team
|
||||
Keychain.shared.certificate = certificate
|
||||
Keychain.shared.session = session
|
||||
self.finish(.success((team, certificate, session)))
|
||||
|
||||
} catch {
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,29 +359,6 @@ private extension AuthenticationOperation
|
||||
}
|
||||
}
|
||||
|
||||
if let adsid = Keychain.shared.appleIDAdsid, let xcodeToken = Keychain.shared.appleIDXcodeToken {
|
||||
Logger.sideload.notice("Authenticating Apple ID with tokens...")
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var shouldContinue = true
|
||||
Task {
|
||||
defer {
|
||||
semaphore.signal()
|
||||
}
|
||||
do {
|
||||
let (account, session) = try await self.authenticateWithToken(adsid: adsid, xcodeToken: xcodeToken)
|
||||
completionHandler(.success((account, session)))
|
||||
shouldContinue = false
|
||||
} catch {
|
||||
Logger.sideload.notice("Authentication failed with token. Fall back to email and password login: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
semaphore.wait()
|
||||
if !shouldContinue {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let appleID = Keychain.shared.appleIDEmailAddress, let password = Keychain.shared.appleIDPassword
|
||||
{
|
||||
Logger.sideload.notice("Authenticating Apple ID...")
|
||||
@@ -423,25 +384,6 @@ private extension AuthenticationOperation
|
||||
}
|
||||
}
|
||||
|
||||
func authenticateWithToken(adsid: String, xcodeToken: String) async throws -> (ALTAccount, ALTAppleAPISession) {
|
||||
let anisetteData = try await withUnsafeThrowingContinuation { (c: UnsafeContinuation<ALTAnisetteData, any Error>) in
|
||||
let fetchAnisetteDataOperation = FetchAnisetteDataOperation(context: self.context)
|
||||
fetchAnisetteDataOperation.resultHandler = { (result) in
|
||||
c.resume(with: result)
|
||||
}
|
||||
self.operationQueue.addOperation(fetchAnisetteDataOperation)
|
||||
}
|
||||
|
||||
let session = ALTAppleAPISession(dsid: adsid, authToken: xcodeToken, anisetteData: anisetteData)
|
||||
let account = try await withUnsafeThrowingContinuation { (c: UnsafeContinuation<ALTAccount, any Error>) in
|
||||
ALTAppleAPI.shared.fetchAccount2(session: session) { result in
|
||||
c.resume(with: result)
|
||||
}
|
||||
}
|
||||
|
||||
return (account, session)
|
||||
}
|
||||
|
||||
func authenticate(appleID: String, password: String, completionHandler: @escaping (Result<(ALTAccount, ALTAppleAPISession), Swift.Error>) -> Void)
|
||||
{
|
||||
self.appleIDEmailAddress = appleID
|
||||
@@ -502,8 +444,6 @@ private extension AuthenticationOperation
|
||||
verificationHandler: verificationHandler) { (account, session, error) in
|
||||
if let account = account, let session = session
|
||||
{
|
||||
Keychain.shared.appleIDAdsid = session.dsid
|
||||
Keychain.shared.appleIDXcodeToken = session.authToken
|
||||
completionHandler(.success((account, session)))
|
||||
}
|
||||
else
|
||||
@@ -807,30 +747,3 @@ extension AuthenticationOperation
|
||||
self.submitCodeAction?.isEnabled = (textField.text ?? "").count == 6
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension ALTAppleAPI {
|
||||
func fetchAccount2(session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTAccount, Error>) -> Void)
|
||||
{
|
||||
let url = URL(string: "viewDeveloper.action", relativeTo: self.baseURL)!
|
||||
|
||||
self.sendRequest(with: url, additionalParameters: nil, session: session, team: nil) { (responseDictionary, requestError) in
|
||||
do
|
||||
{
|
||||
guard let responseDictionary = responseDictionary else { throw requestError ?? ALTAppleAPIError.unknown() }
|
||||
|
||||
guard let account = try self.processResponse(responseDictionary, parseHandler: { () -> Any? in
|
||||
guard let dictionary = responseDictionary["developer"] as? [String: Any] else { return nil }
|
||||
let account = ALTAccount(responseDictionary: dictionary)
|
||||
return account
|
||||
}, resultCodeHandler: nil) as? ALTAccount else {
|
||||
throw ALTAppleAPIError.unknown()
|
||||
}
|
||||
|
||||
completionHandler(.success(account))
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,10 +99,7 @@ final class BackgroundRefreshAppsOperation: ResultOperation<[String: Result<Inst
|
||||
self.finish(.failure(RefreshError(.noInstalledApps)))
|
||||
return
|
||||
}
|
||||
|
||||
if UserDefaults.standard.enableEMPforWireguard {
|
||||
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
||||
}
|
||||
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
||||
target_minimuxer_address()
|
||||
let documentsDirectory = FileManager.default.documentsDirectory.absoluteString
|
||||
do {
|
||||
|
||||
@@ -38,7 +38,7 @@ final class DownloadAppOperation: ResultOperation<ALTApplication>
|
||||
self.context = context
|
||||
|
||||
self.appName = app.name
|
||||
self.bundleIdentifier = context.bundleIdentifier
|
||||
self.bundleIdentifier = app.bundleIdentifier
|
||||
self.sourceURL = app.url
|
||||
self.destinationURL = destinationURL
|
||||
|
||||
@@ -77,7 +77,7 @@ final class DownloadAppOperation: ResultOperation<ALTApplication>
|
||||
guard let latestVersion = storeApp.latestAvailableVersion else {
|
||||
let failureReason = String(format: NSLocalizedString("The latest version of %@ could not be determined.", comment: ""), self.appName)
|
||||
throw OperationError.unknown(failureReason: failureReason)
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to download latest _available_ version, and fall back to older versions if necessary.
|
||||
appVersion = latestVersion
|
||||
@@ -99,8 +99,7 @@ final class DownloadAppOperation: ResultOperation<ALTApplication>
|
||||
|
||||
if let installedApp = storeApp.installedApp
|
||||
{
|
||||
// guard !installedApp.matches(latestSupportedVersion) else { return self.finish(.failure(error)) }
|
||||
guard installedApp.hasUpdate else { return self.finish(.failure(error)) }
|
||||
guard !installedApp.matches(latestSupportedVersion) else { return self.finish(.failure(error)) }
|
||||
}
|
||||
|
||||
let title = NSLocalizedString("Unsupported iOS Version", comment: "")
|
||||
@@ -224,6 +223,12 @@ private extension DownloadAppOperation
|
||||
fileURL = sourceURL
|
||||
self.progress.completedUnitCount += 3
|
||||
}
|
||||
else if let host = sourceURL.host, host.lowercased().hasSuffix("patreon.com") && sourceURL.path.lowercased() == "/file"
|
||||
{
|
||||
// Patreon app
|
||||
fileURL = try await downloadPatreonApp(from: sourceURL)
|
||||
self.printWithTid("downloadPatreonApp: completed at \(fileURL.path)")
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regular app
|
||||
@@ -317,6 +322,107 @@ private extension DownloadAppOperation
|
||||
self.printWithTid("download started: \(downloadURL)")
|
||||
}
|
||||
}
|
||||
|
||||
func downloadPatreonApp(from patreonURL: URL) async throws -> URL
|
||||
{
|
||||
guard !UserDefaults.shared.skipPatreonDownloads else {
|
||||
// Skip all hacks, take user straight to Patreon post.
|
||||
return try await downloadFromPatreonPost()
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
// User is pledged to this app, attempt to download.
|
||||
|
||||
let fileURL = try await downloadFile(from: patreonURL)
|
||||
return fileURL
|
||||
}
|
||||
catch URLError.noPermissionsToReadFile
|
||||
{
|
||||
guard let presentingViewController = self.context.presentingViewController else { throw OperationError.pledgeRequired(appName: self.appName) }
|
||||
|
||||
// Attempt to sign-in again in case our Patreon session has expired.
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
PatreonAPI.shared.authenticate(presentingViewController: presentingViewController) { result in
|
||||
do
|
||||
{
|
||||
let account = try result.get()
|
||||
try account.managedObjectContext?.save()
|
||||
|
||||
continuation.resume()
|
||||
}
|
||||
catch
|
||||
{
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
// Success, so try to download once more now that we're definitely authenticated.
|
||||
|
||||
let fileURL = try await downloadFile(from: patreonURL)
|
||||
return fileURL
|
||||
}
|
||||
catch URLError.noPermissionsToReadFile
|
||||
{
|
||||
// We know authentication succeeded, so failure must mean user isn't patron/on the correct tier,
|
||||
// or that our hacky workaround for downloading Patreon attachments has failed.
|
||||
// Either way, taking them directly to the post serves as a decent fallback.
|
||||
|
||||
return try await downloadFromPatreonPost()
|
||||
}
|
||||
}
|
||||
|
||||
func downloadFromPatreonPost() async throws -> URL
|
||||
{
|
||||
guard let presentingViewController = self.context.presentingViewController else { throw OperationError.pledgeRequired(appName: self.appName) }
|
||||
|
||||
let downloadURL: URL
|
||||
|
||||
if let components = URLComponents(url: patreonURL, resolvingAgainstBaseURL: false),
|
||||
let postItem = components.queryItems?.first(where: { $0.name == "h" }),
|
||||
let postID = postItem.value,
|
||||
let patreonPostURL = URL(string: "https://www.patreon.com/posts/" + postID)
|
||||
{
|
||||
downloadURL = patreonPostURL
|
||||
}
|
||||
else
|
||||
{
|
||||
downloadURL = patreonURL
|
||||
}
|
||||
|
||||
return try await downloadFromPatreon(downloadURL, presentingViewController: presentingViewController)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func downloadFromPatreon(_ patreonURL: URL, presentingViewController: UIViewController) async throws -> URL
|
||||
{
|
||||
let webViewController = WebViewController(url: patreonURL)
|
||||
webViewController.delegate = self
|
||||
webViewController.webView.navigationDelegate = self
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: webViewController)
|
||||
presentingViewController.present(navigationController, animated: true)
|
||||
|
||||
let downloadURL: URL
|
||||
|
||||
do
|
||||
{
|
||||
defer {
|
||||
navigationController.dismiss(animated: true)
|
||||
}
|
||||
|
||||
downloadURL = try await withCheckedThrowingContinuation { continuation in
|
||||
self.downloadPatreonAppContinuation = continuation
|
||||
}
|
||||
}
|
||||
|
||||
let fileURL = try await downloadFile(from: downloadURL)
|
||||
return fileURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@ struct OperationError: ALTLocalizedError {
|
||||
case .unknownResult: return NSLocalizedString("The operation returned an unknown result.", comment: "")
|
||||
case .timedOut: return NSLocalizedString("The operation timed out.", comment: "")
|
||||
case .notAuthenticated: return NSLocalizedString("You are not signed in.", comment: "")
|
||||
case .unknownUDID: return NSLocalizedString("SideStore could not determine this device's UDID. Please replace your pairing using iloader.", comment: "")
|
||||
case .unknownUDID: return NSLocalizedString("SideStore could not determine this device's UDID.", comment: "")
|
||||
case .invalidApp: return NSLocalizedString("The app is in an invalid format.", comment: "")
|
||||
case .maximumAppIDLimitReached: return NSLocalizedString("Cannot register more than 10 App IDs within a 7 day period.", comment: "")
|
||||
case .noSources: return NSLocalizedString("There are no SideStore sources.", comment: "")
|
||||
@@ -220,16 +220,16 @@ struct OperationError: ALTLocalizedError {
|
||||
case .openAppFailed:
|
||||
let appName = self.appName ?? NSLocalizedString("The app", comment: "")
|
||||
return String(format: NSLocalizedString("SideStore was denied permission to launch %@.", comment: ""), appName)
|
||||
case .noWiFi: return NSLocalizedString("You do not appear to be connected to Wi-Fi and/or LocalDevVPN!\nSideStore cannot install or refresh applications without Wi-Fi and LocalDevVPN. If both are connected, replace your pairing with iloader.", comment: "")
|
||||
case .tooNewError: return NSLocalizedString("iOS 17.0-17.3.1 changed how JIT is enabled so SideStore cannot enable JIT without SideJITServer on these versions, sorry for any inconvenience.", comment: "")
|
||||
case .unableToConnectSideJIT: return NSLocalizedString("Unable to connect to SideJITServer. Please check that you are on the same Wi-Fi of and your Firewall has been set correctly on your server.", comment: "")
|
||||
case .unableToRespondSideJITDevice: return NSLocalizedString("SideJITServer is unable to connect to your iDevice. Please make sure you have paired your iDevice by running 'SideJITServer -y', or try refreshing SideJITServer from Settings.", comment: "")
|
||||
case .wrongSideJITIP: return NSLocalizedString("Incorrect SideJITServer IP. Please make sure that you are on the same Wi-Fi as SideJITServer", comment: "")
|
||||
case .refreshsidejit: return NSLocalizedString("Unable to find app; Please try refreshing SideJITServer from Settings.", comment: "")
|
||||
case .anisetteV1Error: return NSLocalizedString("An error occurred while getting anisette data from a V1 server: %@. Try using another anisette server.", comment: "")
|
||||
case .provisioningError: return NSLocalizedString("An error occurred while provisioning: %@ %@. Please try again. If the issue persists, report it on GitHub Issues!", comment: "")
|
||||
case .anisetteV3Error: return NSLocalizedString("An error occurred while getting anisette data from a V3 server: %@. Please try again. If the issue persists, report it on GitHub Issues!", comment: "")
|
||||
case .cacheClearError: return NSLocalizedString("An error occurred while clearing the cache: %@", comment: "")
|
||||
case .noWiFi: return NSLocalizedString("You do not appear to be connected to WiFi and/or the WireGuard VPN!\nSideStore will never be able to install or refresh applications without WiFi and the WireGuard VPN.", comment: "")
|
||||
case .tooNewError: return NSLocalizedString("iOS 17 has changed how JIT is enabled therefore SideStore cannot enable it without SideJITServer at this time, sorry for any inconvenience.\nWe will let everyone know once we have a solution!", comment: "")
|
||||
case .unableToConnectSideJIT: return NSLocalizedString("Unable to connect to SideJITServer Please check that you are on the Same Wi-Fi and your Firewall has been set correctly", comment: "")
|
||||
case .unableToRespondSideJITDevice: return NSLocalizedString("SideJITServer is unable to connect to your iDevice Please make sure you have paired your Device by doing 'SideJITServer -y' or try Refreshing SideJITServer from Settings", comment: "")
|
||||
case .wrongSideJITIP: return NSLocalizedString("Incorrect SideJITServer IP Please make sure that you are on the Samw Wifi as SideJITServer", comment: "")
|
||||
case .refreshsidejit: return NSLocalizedString("Unable to find App Please try Refreshing SideJITServer from Settings", comment: "")
|
||||
case .anisetteV1Error: return NSLocalizedString("An error occurred when getting anisette data from a V1 server: %@. Try using another anisette server.", comment: "")
|
||||
case .provisioningError: return NSLocalizedString("An error occurred when provisioning: %@ %@. Please try again. If the issue persists, report it on GitHub Issues!", comment: "")
|
||||
case .anisetteV3Error: return NSLocalizedString("An error occurred when getting anisette data from a V3 server: %@. Please try again. If the issue persists, report it on GitHub Issues!", comment: "")
|
||||
case .cacheClearError: return NSLocalizedString("An error occurred while clearing cache: %@", comment: "")
|
||||
case .SideJITIssue: return NSLocalizedString("An error occurred while using SideJIT: %@", comment: "")
|
||||
|
||||
case .refreshAppFailed:
|
||||
@@ -238,10 +238,10 @@ struct OperationError: ALTLocalizedError {
|
||||
|
||||
case .invalidParameters:
|
||||
let message = self._failureReason.map { ": \n\($0)" } ?? "."
|
||||
return String(format: NSLocalizedString("Invalid parameters%@", comment: ""), message)
|
||||
return String(format: NSLocalizedString("Invalid parameters\n%@", comment: ""), message)
|
||||
case .invalidOperationContext:
|
||||
let message = self._failureReason.map { ": \n\($0)" } ?? "."
|
||||
return String(format: NSLocalizedString("Invalid Operation Context%@", comment: ""), message)
|
||||
return String(format: NSLocalizedString("Invalid Operation Context\n%@", comment: ""), message)
|
||||
case .serverNotFound: return NSLocalizedString("AltServer could not be found.", comment: "")
|
||||
case .connectionFailed: return NSLocalizedString("A connection to AltServer could not be established.", comment: "")
|
||||
case .connectionDropped: return NSLocalizedString("The connection to AltServer was dropped.", comment: "")
|
||||
@@ -260,7 +260,7 @@ struct OperationError: ALTLocalizedError {
|
||||
var recoverySuggestion: String? {
|
||||
switch self.code
|
||||
{
|
||||
case .noWiFi: return NSLocalizedString("Make sure LocalDevVPN is connected and that you are connected to any Wi-Fi network!", comment: "")
|
||||
case .noWiFi: return NSLocalizedString("Make sure the VPN is toggled on and you are connected to any WiFi network!", comment: "")
|
||||
case .serverNotFound: return NSLocalizedString("Make sure you're on the same Wi-Fi network as a computer running AltServer, or try connecting this device to your computer via USB.", comment: "")
|
||||
case .maximumAppIDLimitReached:
|
||||
let baseMessage = NSLocalizedString("Delete sideloaded apps to free up App ID slots.", comment: "")
|
||||
@@ -308,9 +308,9 @@ extension MinimuxerError: LocalizedError {
|
||||
case .NoDevice:
|
||||
return NSLocalizedString("Cannot fetch the device from the muxer", comment: "")
|
||||
case .NoConnection:
|
||||
return NSLocalizedString("Unable to connect to the device, make sure LocalDevVPN is enabled and you're connected to Wi-Fi. This could mean an invalid pairing.", comment: "")
|
||||
return NSLocalizedString("Unable to connect to the device, make sure Wireguard is enabled and you're connected to WiFi. This could mean an invalid pairing.", comment: "")
|
||||
case .PairingFile:
|
||||
return NSLocalizedString("Invalid pairing file. Your pairing file either didn't have a UDID, or it wasn't a valid plist. Please use iloader to replace it.", comment: "")
|
||||
return NSLocalizedString("Invalid pairing file. Your pairing file either didn't have a UDID, or it wasn't a valid plist. Please use jitterbugpair to generate it", comment: "")
|
||||
|
||||
case .CreateDebug:
|
||||
return self.createService(name: "debug")
|
||||
@@ -338,7 +338,7 @@ extension MinimuxerError: LocalizedError {
|
||||
case .CreateAfc:
|
||||
return self.createService(name: "AFC")
|
||||
case .RwAfc:
|
||||
return NSLocalizedString("AFC was unable to manage files on the device. Ensure Wi-Fi and LocalDevVPN are connected. If they both are, replace your pairing using iloader.", comment: "")
|
||||
return NSLocalizedString("AFC was unable to manage files on the device. This usually means an invalid pairing.", comment: "")
|
||||
case .InstallApp(let message):
|
||||
return NSLocalizedString("Unable to install the app: \(message.toString())", comment: "")
|
||||
case .UninstallApp:
|
||||
@@ -350,38 +350,6 @@ extension MinimuxerError: LocalizedError {
|
||||
return NSLocalizedString("Unable to manage profiles on the device", comment: "")
|
||||
case .ProfileRemove:
|
||||
return NSLocalizedString("Unable to manage profiles on the device", comment: "")
|
||||
case .CreateLockdown:
|
||||
return NSLocalizedString("Unable to connect to lockdown", comment: "")
|
||||
case .CreateCoreDevice:
|
||||
return NSLocalizedString("Unable to connect to core device proxy", comment: "")
|
||||
case .CreateSoftwareTunnel:
|
||||
return NSLocalizedString("Unable to create software tunnel", comment: "")
|
||||
case .CreateRemoteServer:
|
||||
return NSLocalizedString("Unable to connect to remote server", comment: "")
|
||||
case .CreateProcessControl:
|
||||
return NSLocalizedString("Unable to connect to process control", comment: "")
|
||||
case .GetLockdownValue:
|
||||
return NSLocalizedString("Unable to get value from lockdown", comment: "")
|
||||
case .Connect:
|
||||
return NSLocalizedString("Unable to connect to TCP port", comment: "")
|
||||
case .Close:
|
||||
return NSLocalizedString("Unable to close TCP port", comment: "")
|
||||
case .XpcHandshake:
|
||||
return NSLocalizedString("Unable to get services from XPC", comment: "")
|
||||
case .NoService:
|
||||
return NSLocalizedString("Device did not contain service", comment: "")
|
||||
case .InvalidProductVersion:
|
||||
return NSLocalizedString("Service version was in an unexpected format", comment: "")
|
||||
case .CreateFolder:
|
||||
return NSLocalizedString("Unable to create DDI folder", comment: "")
|
||||
case .DownloadImage:
|
||||
return NSLocalizedString("Unable to download DDI", comment: "")
|
||||
case .ImageLookup:
|
||||
return NSLocalizedString("Unable to lookup DDI images", comment: "")
|
||||
case .ImageRead:
|
||||
return NSLocalizedString("Unable to read images to memory", comment: "")
|
||||
case .Mount:
|
||||
return NSLocalizedString("Mount failed", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,14 +45,8 @@ extension VerificationError
|
||||
VerificationError(code: .mismatchedHash, app: app, hash: hash, expectedHash: expectedHash)
|
||||
}
|
||||
|
||||
static func mismatchedVersion(version: String,
|
||||
expectedVersion: String,
|
||||
app: AppProtocol) -> VerificationError
|
||||
{
|
||||
VerificationError(code: .mismatchedVersion, app: app,
|
||||
version: version,
|
||||
expectedVersion: expectedVersion
|
||||
)
|
||||
static func mismatchedVersion(_ version: String, expectedVersion: String, app: AppProtocol) -> VerificationError {
|
||||
VerificationError(code: .mismatchedVersion, app: app, version: version, expectedVersion: expectedVersion)
|
||||
}
|
||||
|
||||
static func mismatchedBuildVersion(_ version: String, expectedVersion: String, app: AppProtocol) -> VerificationError {
|
||||
@@ -161,11 +155,11 @@ struct VerificationError: ALTLocalizedError
|
||||
|
||||
case .mismatchedVersion:
|
||||
let appName = self.$app.name ?? NSLocalizedString("the app", comment: "")
|
||||
return String(format: NSLocalizedString("The downloaded version of %@ does not match the version specified by the source.\nExpected version: %@\nFound version: %@", comment: ""), appName, expectedVersion ?? "nil", version ?? "nil")
|
||||
return String(format: NSLocalizedString("The downloaded version of %@ does not match the version specified by the source.", comment: ""), appName)
|
||||
|
||||
case .mismatchedBuildVersion:
|
||||
let appName = self.$app.name ?? NSLocalizedString("the app", comment: "")
|
||||
return String(format: NSLocalizedString("The downloaded version of %@ does not match the build number specified by the source.\nExpected version: %@\nFound version: %@", comment: ""), appName, expectedVersion ?? "nil", version ?? "nil")
|
||||
return String(format: NSLocalizedString("The downloaded version of %@ does not match the build number specified by the source.", comment: ""), appName)
|
||||
|
||||
case .undeclaredPermissions:
|
||||
let appName = self.$app.name ?? NSLocalizedString("The app", comment: "")
|
||||
|
||||
@@ -198,7 +198,7 @@ final class FetchAnisetteDataOperation: ResultOperation<ALTAnisetteData>, WebSoc
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.timeZone = TimeZone.init(secondsFromGMT: 0)
|
||||
formatter.timeZone = TimeZone.current
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
|
||||
let dateString = formatter.string(from: Date())
|
||||
formattedJSON["date"] = dateString
|
||||
|
||||
@@ -54,8 +54,7 @@ class FetchProvisioningProfilesOperation: ResultOperation<[String: ALTProvisioni
|
||||
Logger.sideload.notice("Fetching provisioning profiles for app \(self.context.bundleIdentifier, privacy: .public)...")
|
||||
|
||||
self.progress.totalUnitCount = Int64(1 + app.appExtensions.count)
|
||||
let effectiveBundleId = self.context.bundleIdentifier
|
||||
|
||||
|
||||
self.prepareProvisioningProfile(for: app, parentApp: nil, team: team, session: session) { (result) in
|
||||
do
|
||||
{
|
||||
@@ -63,27 +62,25 @@ class FetchProvisioningProfilesOperation: ResultOperation<[String: ALTProvisioni
|
||||
|
||||
let profile = try result.get()
|
||||
|
||||
var profiles = [effectiveBundleId: profile]
|
||||
var profiles = [app.bundleIdentifier: profile]
|
||||
var error: Error?
|
||||
|
||||
let dispatchGroup = DispatchGroup()
|
||||
|
||||
if !self.context.useMainProfile {
|
||||
for appExtension in app.appExtensions
|
||||
{
|
||||
dispatchGroup.enter()
|
||||
|
||||
self.prepareProvisioningProfile(for: appExtension, parentApp: app, team: team, session: session) { (result) in
|
||||
switch result
|
||||
{
|
||||
case .failure(let e): error = e
|
||||
case .success(let profile): profiles[appExtension.bundleIdentifier] = profile
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
|
||||
self.progress.completedUnitCount += 1
|
||||
for appExtension in app.appExtensions
|
||||
{
|
||||
dispatchGroup.enter()
|
||||
|
||||
self.prepareProvisioningProfile(for: appExtension, parentApp: app, team: team, session: session) { (result) in
|
||||
switch result
|
||||
{
|
||||
case .failure(let e): error = e
|
||||
case .success(let profile): profiles[appExtension.bundleIdentifier] = profile
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
|
||||
self.progress.completedUnitCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,36 +119,6 @@ class FetchProvisioningProfilesOperation: ResultOperation<[String: ALTProvisioni
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
internal func fetchProvisioningProfile(for appID: ALTAppID, app: ALTApplication, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void)
|
||||
{
|
||||
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, deviceType: .iphone, team: team, session: session) { (profile, error) in
|
||||
switch Result(profile, error)
|
||||
{
|
||||
case .failure(let error): completionHandler(.failure(error))
|
||||
case .success(let profile):
|
||||
|
||||
// Delete existing profile
|
||||
ALTAppleAPI.shared.delete(profile, for: team, session: session) { (success, error) in
|
||||
switch Result(success, error)
|
||||
{
|
||||
case .failure:
|
||||
// As of March 20, 2023, the free provisioning profile is re-generated each fetch, and you can no longer delete it.
|
||||
// So instead, we just return the fetched profile from above.
|
||||
completionHandler(.success(profile))
|
||||
|
||||
case .success:
|
||||
Logger.sideload.notice("Generating new free provisioning profile for App ID \(appID.bundleIdentifier, privacy: .public).")
|
||||
|
||||
// Fetch new provisioning profile
|
||||
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, deviceType: .iphone, team: team, session: session) { (profile, error) in
|
||||
completionHandler(Result(profile, error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FetchProvisioningProfilesOperation
|
||||
@@ -221,30 +188,19 @@ extension FetchProvisioningProfilesOperation
|
||||
// Or, if the app _is_ installed but with a different team, we need to create a new
|
||||
// bundle identifier anyway to prevent collisions with the previous team.
|
||||
let parentBundleID = parentApp?.bundleIdentifier ?? app.bundleIdentifier
|
||||
let effectiveParentBundleID = self.context.bundleIdentifier
|
||||
|
||||
let updatedParentBundleID: String
|
||||
|
||||
|
||||
if app.isAltStoreApp
|
||||
{
|
||||
// Use legacy bundle ID format for AltStore (and its extensions).
|
||||
updatedParentBundleID = effectiveParentBundleID + "." + team.identifier // Append just team identifier to make it harder to track.
|
||||
updatedParentBundleID = parentBundleID + "." + team.identifier // Append just team identifier to make it harder to track.
|
||||
}
|
||||
else
|
||||
{
|
||||
updatedParentBundleID = effectiveParentBundleID + "." + team.identifier // Append just team identifier to make it harder to track.
|
||||
}
|
||||
|
||||
if let parentApp = parentApp,
|
||||
app.bundleIdentifier.hasPrefix(parentBundleID + ".")
|
||||
{
|
||||
let suffix = String(app.bundleIdentifier.dropFirst(parentBundleID.count))
|
||||
bundleID = updatedParentBundleID + suffix
|
||||
}
|
||||
else
|
||||
{
|
||||
bundleID = updatedParentBundleID
|
||||
updatedParentBundleID = parentBundleID + "." + team.identifier // Append just team identifier to make it harder to track.
|
||||
}
|
||||
|
||||
bundleID = app.bundleIdentifier.replacingOccurrences(of: parentBundleID, with: updatedParentBundleID)
|
||||
}
|
||||
|
||||
let preferredName: String
|
||||
@@ -267,7 +223,7 @@ extension FetchProvisioningProfilesOperation
|
||||
|
||||
//process
|
||||
self.fetchProvisioningProfile(
|
||||
for: appID, app: app, team: team, session: session, completionHandler: completionHandler
|
||||
for: appID, team: team, session: session, completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -372,6 +328,43 @@ extension FetchProvisioningProfilesOperation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal func fetchProvisioningProfile(for appID: ALTAppID, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void)
|
||||
{
|
||||
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, deviceType: .iphone, team: team, session: session) { (profile, error) in
|
||||
switch Result(profile, error)
|
||||
{
|
||||
case .failure(let error): completionHandler(.failure(error))
|
||||
case .success(let profile):
|
||||
|
||||
// Delete existing profile
|
||||
ALTAppleAPI.shared.delete(profile, for: team, session: session) { (success, error) in
|
||||
switch Result(success, error)
|
||||
{
|
||||
case .failure:
|
||||
// As of March 20, 2023, the free provisioning profile is re-generated each fetch, and you can no longer delete it.
|
||||
// So instead, we just return the fetched profile from above.
|
||||
completionHandler(.success(profile))
|
||||
|
||||
case .success:
|
||||
Logger.sideload.notice("Generating new free provisioning profile for App ID \(appID.bundleIdentifier, privacy: .public).")
|
||||
|
||||
// Fetch new provisioning profile
|
||||
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, deviceType: .iphone, team: team, session: session) { (profile, error) in
|
||||
completionHandler(Result(profile, error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FetchProvisioningProfilesRefreshOperation: FetchProvisioningProfilesOperation, @unchecked Sendable {
|
||||
override init(context: AppOperationContext)
|
||||
{
|
||||
super.init(context: context)
|
||||
}
|
||||
}
|
||||
|
||||
class FetchProvisioningProfilesInstallOperation: FetchProvisioningProfilesOperation, @unchecked Sendable{
|
||||
@@ -381,8 +374,8 @@ class FetchProvisioningProfilesInstallOperation: FetchProvisioningProfilesOperat
|
||||
}
|
||||
|
||||
// modify Operations are allowed for the app groups and other stuffs
|
||||
override func fetchProvisioningProfile(for appID: ALTAppID,
|
||||
app: ALTApplication,
|
||||
func fetchProvisioningProfile(appID: ALTAppID,
|
||||
for app: ALTApplication,
|
||||
team: ALTTeam,
|
||||
session: ALTAppleAPISession,
|
||||
completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void)
|
||||
@@ -403,7 +396,7 @@ class FetchProvisioningProfilesInstallOperation: FetchProvisioningProfilesOperat
|
||||
case .success(let appID):
|
||||
|
||||
// Fetch Provisioning Profile
|
||||
super.fetchProvisioningProfile(for: appID, app: app, team: team, session: session, completionHandler: completionHandler)
|
||||
super.fetchProvisioningProfile(for: appID, team: team, session: session, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -418,6 +411,11 @@ class FetchProvisioningProfilesInstallOperation: FetchProvisioningProfilesOperat
|
||||
entitlements[key] = value
|
||||
}
|
||||
|
||||
for (key, value) in [ALTEntitlement.increasedMemoryLimit : true]
|
||||
{
|
||||
entitlements[key] = value
|
||||
}
|
||||
|
||||
let requiredFeatures = entitlements.compactMap { (entitlement, value) -> (ALTFeature, Any)? in
|
||||
guard let feature = ALTFeature(entitlement: entitlement) else { return nil }
|
||||
return (feature, value)
|
||||
@@ -620,14 +618,3 @@ class FetchProvisioningProfilesInstallOperation: FetchProvisioningProfilesOperat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// <TEST> : users were reporting that refresh (though seemed like it refreshed the app becomes no longer available)
|
||||
// possibly, this is caused since refesh was not updating appFeatures and AppGroups in the new profile? not sure.
|
||||
// for now we are reverting by keeping same operation that happens during fetch in install path to see if it fixes issue #893
|
||||
// class FetchProvisioningProfilesRefreshOperation: FetchProvisioningProfilesOperation, @unchecked Sendable {
|
||||
class FetchProvisioningProfilesRefreshOperation: FetchProvisioningProfilesInstallOperation, @unchecked Sendable {
|
||||
override init(context: AppOperationContext)
|
||||
{
|
||||
super.init(context: context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import CoreData
|
||||
|
||||
import AltStoreCore
|
||||
import Roxas
|
||||
import SemanticVersion
|
||||
|
||||
@objc(FetchSourceOperation)
|
||||
final class FetchSourceOperation: ResultOperation<Source>
|
||||
@@ -177,6 +176,7 @@ final class FetchSourceOperation: ResultOperation<Source>
|
||||
}
|
||||
|
||||
try self.verify(source, response: response)
|
||||
try self.verifyPledges(for: source, in: childContext)
|
||||
|
||||
try childContext.save()
|
||||
|
||||
@@ -246,20 +246,66 @@ private extension FetchSourceOperation
|
||||
#endif
|
||||
}
|
||||
|
||||
let incomingSourceID = source.identifier
|
||||
if let previousSourceID = self.$source.identifier,
|
||||
incomingSourceID != previousSourceID
|
||||
if let previousSourceID = self.$source.identifier
|
||||
{
|
||||
// if let version = BuildInfo().marketing_version,
|
||||
// SemanticVersion(version)! <= SemanticVersion("0.6.1")!
|
||||
// {
|
||||
// // delete the source, so that incoming will be saved.
|
||||
// self.source?.managedObjectContext?.delete(self.source!)
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
throw SourceError.changedID(source.identifier, previousID: self.$source.identifier ?? "nil", source: source)
|
||||
// }
|
||||
guard source.identifier == previousSourceID else { throw SourceError.changedID(source.identifier, previousID: previousSourceID, source: source) }
|
||||
}
|
||||
}
|
||||
|
||||
func verifyPledges(for source: Source, in context: NSManagedObjectContext) throws
|
||||
{
|
||||
guard let patreonURL = source.patreonURL, let patreonAccount = DatabaseManager.shared.patreonAccount(in: context) else { return }
|
||||
|
||||
let normalizedPatreonURL = try patreonURL.normalized()
|
||||
|
||||
guard let pledge = patreonAccount.pledges.first(where: { pledge in
|
||||
do
|
||||
{
|
||||
let normalizedCampaignURL = try pledge.campaignURL.normalized()
|
||||
return normalizedCampaignURL == normalizedPatreonURL
|
||||
}
|
||||
catch
|
||||
{
|
||||
Logger.main.error("Failed to normalize Patreon URL \(pledge.campaignURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}) else { return }
|
||||
|
||||
// User is pledged to this source's Patreon, so check which apps they're pledged to.
|
||||
|
||||
// We only assign `isPledged = true` because false is already the default,
|
||||
// and only one check needs to be true for isPledged to be true.
|
||||
|
||||
for app in source.apps where app.isPledgeRequired
|
||||
{
|
||||
if let requiredAppPledge = app.pledgeAmount
|
||||
{
|
||||
if pledge.amount >= requiredAppPledge
|
||||
{
|
||||
app.isPledged = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if let tierIDs = app._tierIDs
|
||||
{
|
||||
let tier = pledge.tiers.first { tierIDs.contains($0.identifier) }
|
||||
if tier != nil
|
||||
{
|
||||
app.isPledged = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if let rewardID = app._rewardID
|
||||
{
|
||||
let reward = pledge.rewards.first { $0.identifier == rewardID }
|
||||
if reward != nil
|
||||
{
|
||||
app.isPledged = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,8 +72,6 @@ final class InstallAppOperation: ResultOperation<InstalledApp>
|
||||
}
|
||||
|
||||
installedApp.update(resignedApp: resignedApp, certificateSerialNumber: certificate.serialNumber, storeBuildVersion: storeBuildVersion)
|
||||
installedApp.useMainProfile = self.context.useMainProfile
|
||||
|
||||
installedApp.needsResign = false
|
||||
|
||||
if let team = DatabaseManager.shared.activeTeam(in: backgroundContext)
|
||||
@@ -98,22 +96,22 @@ final class InstallAppOperation: ResultOperation<InstalledApp>
|
||||
let resignedParentBundleID = resignedApp.bundleIdentifier
|
||||
|
||||
let resignedBundleID = appExtension.bundleIdentifier
|
||||
let appExBundleID = resignedBundleID.replacingOccurrences(of: resignedParentBundleID, with: parentBundleID)
|
||||
let originalBundleID = resignedBundleID.replacingOccurrences(of: resignedParentBundleID, with: parentBundleID)
|
||||
|
||||
print("`parentBundleID`: \(parentBundleID)")
|
||||
print("`resignedParentBundleID`: \(resignedParentBundleID)")
|
||||
print("`appExBundleID`: \(appExBundleID)")
|
||||
print("`resignedAppExBundleID`: \(resignedBundleID)")
|
||||
print("`resignedBundleID`: \(resignedBundleID)")
|
||||
print("`originalBundleID`: \(originalBundleID)")
|
||||
|
||||
let installedExtension: InstalledExtension
|
||||
|
||||
if let appExtension = installedApp.appExtensions.first(where: { $0.bundleIdentifier == appExBundleID })
|
||||
if let appExtension = installedApp.appExtensions.first(where: { $0.bundleIdentifier == originalBundleID })
|
||||
{
|
||||
installedExtension = appExtension
|
||||
}
|
||||
else
|
||||
{
|
||||
installedExtension = InstalledExtension(resignedAppExtension: appExtension, originalBundleIdentifier: appExBundleID, context: backgroundContext)
|
||||
installedExtension = InstalledExtension(resignedAppExtension: appExtension, originalBundleIdentifier: originalBundleID, context: backgroundContext)
|
||||
}
|
||||
|
||||
installedExtension.update(resignedAppExtension: appExtension)
|
||||
|
||||
@@ -66,8 +66,6 @@ class AppOperationContext
|
||||
|
||||
var app: ALTApplication?
|
||||
var provisioningProfiles: [String: ALTProvisioningProfile]?
|
||||
var appexBundleIds: [String: String]?
|
||||
var useMainProfile = false
|
||||
|
||||
var isFinished = false
|
||||
|
||||
|
||||
@@ -55,23 +55,8 @@ final class RemoveAppExtensionsOperation: ResultOperation<Void>
|
||||
try FileManager.default.removeItem(at: appExtension.fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateManifest() throws {
|
||||
guard let app = context.app else {
|
||||
return
|
||||
}
|
||||
|
||||
let scInfoURL = app.fileURL.appendingPathComponent("SC_Info")
|
||||
let manifestPlistURL = scInfoURL.appendingPathComponent("Manifest.plist")
|
||||
|
||||
if let manifestPlist = NSMutableDictionary(contentsOf: manifestPlistURL),
|
||||
let sinfReplicationPaths = manifestPlist["SinfReplicationPaths"] as? [String]
|
||||
{
|
||||
let replacementPaths = sinfReplicationPaths.filter { !$0.starts(with: "PlugIns/") } // Filter out app extension paths.
|
||||
manifestPlist["SinfReplicationPaths"] = replacementPaths
|
||||
try manifestPlist.write(to: manifestPlistURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private func removeAppExtensions(from targetAppBundle: ALTApplication,
|
||||
localAppExtensions: Set<ALTApplication>?,
|
||||
@@ -100,7 +85,7 @@ final class RemoveAppExtensionsOperation: ResultOperation<Void>
|
||||
presentingViewController.present(alertController, animated: true){
|
||||
|
||||
// if for any reason the view wasn't presented, then just signal that as error
|
||||
if presentingViewController.presentedViewController == nil && !alertController.isViewLoaded {
|
||||
if presentingViewController.presentedViewController == nil {
|
||||
let errMsg = "RemoveAppExtensionsOperation: unable to present dialog, view context not available." +
|
||||
"\nDid you move to different screen or background after starting the operation?"
|
||||
self.finish(.failure(
|
||||
@@ -136,17 +121,12 @@ final class RemoveAppExtensionsOperation: ResultOperation<Void>
|
||||
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style, handler: { (action) in
|
||||
self.finish(.failure(OperationError.cancelled))
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Keep App Extensions (Use Main Profile)", comment: ""), style: .default) { (action) in
|
||||
self.context.useMainProfile = true
|
||||
self.finish(.success(()))
|
||||
})
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Keep App Extensions (Register App ID for Each Extension)", comment: ""), style: .default) { (action) in
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Keep App Extensions", comment: ""), style: .default) { (action) in
|
||||
self.finish(.success(()))
|
||||
})
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove App Extensions", comment: ""), style: .destructive) { (action) in
|
||||
do {
|
||||
try Self.removeExtensions(from: targetAppBundle.appExtensions)
|
||||
try self.updateManifest()
|
||||
return self.finish(.success(()))
|
||||
} catch {
|
||||
return self.finish(.failure(error))
|
||||
|
||||
@@ -55,8 +55,7 @@ final class ResignAppOperation: ResultOperation<ALTApplication>
|
||||
let prepareAppProgress = Progress.discreteProgress(totalUnitCount: 2)
|
||||
self.progress.addChild(prepareAppProgress, withPendingUnitCount: 3)
|
||||
|
||||
let effectiveBundleId = self.context.bundleIdentifier
|
||||
let prepareAppBundleProgress = self.prepareAppBundle(for: app, profiles: profiles, appexBundleIds: context.appexBundleIds ?? [:]) { (result) in
|
||||
let prepareAppBundleProgress = self.prepareAppBundle(for: app, profiles: profiles) { (result) in
|
||||
guard let appBundleURL = self.process(result) else { return }
|
||||
|
||||
// Resign app bundle
|
||||
@@ -66,13 +65,7 @@ final class ResignAppOperation: ResultOperation<ALTApplication>
|
||||
// Finish
|
||||
do
|
||||
{
|
||||
let updatedApp = AnyApp(
|
||||
name: app.name,
|
||||
bundleIdentifier: effectiveBundleId,
|
||||
url: app.fileURL,
|
||||
storeApp: app.storeApp
|
||||
)
|
||||
let destinationURL = InstalledApp.refreshedIPAURL(for: updatedApp)
|
||||
let destinationURL = InstalledApp.refreshedIPAURL(for: app)
|
||||
try FileManager.default.copyItem(at: resignedURL, to: destinationURL, shouldReplace: true)
|
||||
print("Successfully resigned app to \(destinationURL.absoluteString)")
|
||||
|
||||
@@ -114,26 +107,22 @@ final class ResignAppOperation: ResultOperation<ALTApplication>
|
||||
|
||||
private extension ResignAppOperation
|
||||
{
|
||||
func prepareAppBundle(for app: ALTApplication, profiles: [String: ALTProvisioningProfile], appexBundleIds: [String: String], completionHandler: @escaping (Result<URL, Error>) -> Void) -> Progress
|
||||
func prepareAppBundle(for app: ALTApplication, profiles: [String: ALTProvisioningProfile], completionHandler: @escaping (Result<URL, Error>) -> Void) -> Progress
|
||||
{
|
||||
let progress = Progress.discreteProgress(totalUnitCount: 1)
|
||||
|
||||
let bundleIdentifier = context.bundleIdentifier
|
||||
|
||||
let bundleIdentifier = app.bundleIdentifier
|
||||
let openURL = InstalledApp.openAppURL(for: app)
|
||||
|
||||
let fileURL = app.fileURL
|
||||
|
||||
func prepare(_ bundle: Bundle, bundleID identifier: String?, additionalInfoDictionaryValues: [String: Any] = [:]) throws
|
||||
|
||||
func prepare(_ bundle: Bundle, additionalInfoDictionaryValues: [String: Any] = [:]) throws
|
||||
{
|
||||
guard let identifier else { throw ALTError(.missingAppBundle) }
|
||||
guard let profile = context.useMainProfile ? profiles.values.first : profiles[identifier] else { throw ALTError(.missingProvisioningProfile) }
|
||||
guard let identifier = bundle.bundleIdentifier else { throw ALTError(.missingAppBundle) }
|
||||
guard let profile = profiles[identifier] else { throw ALTError(.missingProvisioningProfile) }
|
||||
guard var infoDictionary = bundle.completeInfoDictionary else { throw ALTError(.missingInfoPlist) }
|
||||
|
||||
if let forcedBundleIdentifier = appexBundleIds[identifier] {
|
||||
infoDictionary[kCFBundleIdentifierKey as String] = forcedBundleIdentifier
|
||||
} else {
|
||||
infoDictionary[kCFBundleIdentifierKey as String] = profile.bundleIdentifier
|
||||
}
|
||||
|
||||
infoDictionary[kCFBundleIdentifierKey as String] = profile.bundleIdentifier
|
||||
infoDictionary[Bundle.Info.altBundleID] = identifier
|
||||
infoDictionary[Bundle.Info.devicePairingString] = "<insert pairing file here>"
|
||||
infoDictionary.removeValue(forKey: "DTXcode")
|
||||
@@ -204,7 +193,7 @@ private extension ResignAppOperation
|
||||
if app.isAltStoreApp
|
||||
{
|
||||
guard let udid = fetch_udid()?.toString() as? String else { throw OperationError.unknownUDID }
|
||||
guard Bundle.main.object(forInfoDictionaryKey: Bundle.Info.devicePairingString) is String else { throw OperationError.unknownUDID }
|
||||
guard let pairingFileString = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.devicePairingString) as? String else { throw OperationError.unknownUDID }
|
||||
additionalValues[Bundle.Info.devicePairingString] = "<insert pairing file here>"
|
||||
additionalValues[Bundle.Info.deviceID] = udid
|
||||
additionalValues[Bundle.Info.serverID] = UserDefaults.standard.preferredServerID
|
||||
@@ -248,7 +237,7 @@ private extension ResignAppOperation
|
||||
}
|
||||
|
||||
// Prepare app
|
||||
try prepare(appBundle, bundleID: bundleIdentifier, additionalInfoDictionaryValues: additionalValues)
|
||||
try prepare(appBundle, additionalInfoDictionaryValues: additionalValues)
|
||||
try self.removeMissingAppExtensionReferences(from: appBundle)
|
||||
|
||||
if let directory = appBundle.builtInPlugInsURL, let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil, options: [.skipsSubdirectoryDescendants])
|
||||
@@ -265,8 +254,7 @@ private extension ResignAppOperation
|
||||
#endif
|
||||
|
||||
guard let appExtension = Bundle(url: fileURL) else { throw ALTError(.missingAppBundle) }
|
||||
let updatedAppExBundleId = appExtension.bundleIdentifier?.replacingOccurrences(of: app.bundleIdentifier, with: bundleIdentifier)
|
||||
try prepare(appExtension, bundleID: updatedAppExBundleId)
|
||||
try prepare(appExtension)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,13 +38,11 @@ final class VerifyAppOperation: ResultOperation<Void>
|
||||
{
|
||||
let permissionsMode: PermissionReviewMode
|
||||
let context: InstallAppOperationContext
|
||||
var customBundleId: String?
|
||||
|
||||
init(permissionsMode: PermissionReviewMode, context: InstallAppOperationContext, customBundleId: String? = nil)
|
||||
init(permissionsMode: PermissionReviewMode, context: InstallAppOperationContext)
|
||||
{
|
||||
self.permissionsMode = permissionsMode
|
||||
self.context = context
|
||||
self.customBundleId = customBundleId
|
||||
|
||||
super.init()
|
||||
}
|
||||
@@ -67,8 +65,7 @@ final class VerifyAppOperation: ResultOperation<Void>
|
||||
}
|
||||
|
||||
if !["ny.litritt.ignited", "com.litritt.ignited"].contains(where: { $0 == app.bundleIdentifier }) {
|
||||
let bundleId = customBundleId ?? app.bundleIdentifier
|
||||
guard bundleId == self.context.bundleIdentifier else {
|
||||
guard app.bundleIdentifier == self.context.bundleIdentifier else {
|
||||
throw VerificationError.mismatchedBundleIdentifiers(sourceBundleID: self.context.bundleIdentifier, app: app)
|
||||
}
|
||||
}
|
||||
@@ -85,16 +82,17 @@ final class VerifyAppOperation: ResultOperation<Void>
|
||||
do
|
||||
{
|
||||
guard let ipaURL = self.context.ipaURL else { throw OperationError.appNotFound(name: app.name) }
|
||||
|
||||
|
||||
|
||||
// TODO: @mahee96: appVersion is instantiated source info as AppVersion incoming from source json
|
||||
// app is the instantiated ipa downloaded from the specified in the source json in temp dir
|
||||
//
|
||||
// For alpha and beta/nightly releases, the CFBundleShortVersionString which is the
|
||||
// $(MARKETING_VERSION) will be overriden with the commit id before invoking xcode build
|
||||
//
|
||||
try await self.verifyHash(of: app, at: ipaURL, matches: appVersion)
|
||||
try await self.verifyDownloadedVersion(of: app, matches: appVersion)
|
||||
|
||||
// process missing permissions check only if the source is V2 or later
|
||||
if let source = appVersion.app?.source,
|
||||
source.isSourceAtLeastV2
|
||||
{
|
||||
try await self.verifyPermissions(of: app, match: appVersion)
|
||||
}
|
||||
try await self.verifyPermissions(of: app, match: appVersion)
|
||||
|
||||
self.finish(.success(()))
|
||||
}
|
||||
@@ -131,17 +129,24 @@ private extension VerifyAppOperation
|
||||
{
|
||||
let (version, buildVersion) = await $appVersion.perform { ($0.version, $0.buildVersion) }
|
||||
|
||||
// marketplace buildVersion validation
|
||||
if let buildVersion
|
||||
{
|
||||
guard buildVersion == app.buildVersion else {
|
||||
throw VerificationError.mismatchedBuildVersion(app.buildVersion, expectedVersion: buildVersion, app: app)
|
||||
}
|
||||
}
|
||||
|
||||
let downloadedIpaRevision = Bundle(url: app.fileURL)!.object(forInfoDictionaryKey: "BuildRevision") as? String ?? ""
|
||||
let sourceJsonIpaRevision = appVersion.revision
|
||||
|
||||
// if not beta but version matches, then accept it, else compare revisions between source and downloaded
|
||||
if version != app.version {
|
||||
throw VerificationError.mismatchedVersion(version: app.version, expectedVersion: version, app: app)
|
||||
throw VerificationError.mismatchedVersion(app.version, expectedVersion: version, app: app)
|
||||
}
|
||||
if (appVersion.isBeta && downloadedIpaRevision != sourceJsonIpaRevision) {
|
||||
let sourceJsonIpaRevision = sourceJsonIpaRevision ?? "nil"
|
||||
throw VerificationError.mismatchedVersion(app.version + " - " + downloadedIpaRevision,
|
||||
expectedVersion: version + " - " + sourceJsonIpaRevision, app: app)
|
||||
}
|
||||
|
||||
// if let buildVersion
|
||||
// {
|
||||
// // TODO: @mahee96: requires altsign-marketplace branch release or equivalent
|
||||
// guard buildVersion == app.buildVersion else { throw VerificationError.mismatchedBuildVersion(app.buildVersion, expectedVersion: buildVersion, app: app) }
|
||||
// }
|
||||
}
|
||||
|
||||
func verifyPermissions(of app: ALTApplication, @AsyncManaged match appVersion: AppVersion) async throws
|
||||
|
||||
281
AltStore/Operations/VerifyAppPledgeOperation.swift
Normal file
@@ -0,0 +1,281 @@
|
||||
//
|
||||
// VerifyAppPledgeOperation.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 12/6/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
import AltStoreCore
|
||||
|
||||
class VerifyAppPledgeOperation: ResultOperation<Void>
|
||||
{
|
||||
@AsyncManaged
|
||||
private(set) var storeApp: StoreApp
|
||||
|
||||
private let presentingViewController: UIViewController?
|
||||
private var openPatreonPageContinuation: CheckedContinuation<Void, Never>?
|
||||
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
init(storeApp: StoreApp, presentingViewController: UIViewController?)
|
||||
{
|
||||
self.storeApp = storeApp
|
||||
self.presentingViewController = presentingViewController
|
||||
}
|
||||
|
||||
override func main()
|
||||
{
|
||||
super.main()
|
||||
|
||||
// _Don't_ rethrow earlier errors, or else user will only be taken to Patreon post if connected to same Wi-Fi as AltServer.
|
||||
// if let error = self.context.error
|
||||
// {
|
||||
// self.finish(.failure(error))
|
||||
// return
|
||||
// }
|
||||
|
||||
Task<Void, Never>.detached(priority: .medium) {
|
||||
do
|
||||
{
|
||||
guard await self.$storeApp.isPledgeRequired else { return self.finish(.success(())) }
|
||||
|
||||
if let presentingViewController = self.presentingViewController
|
||||
{
|
||||
// Ask user to connect Patreon account if they are signed-in to Patreon inside WebViewController, but haven't yet signed in through AltStore settings.
|
||||
// This is most likely because the user joined a Patreon campaign directly through WebViewController before connecting Patreon account in settings.
|
||||
try await self.connectPatreonAccountIfNeeded(presentingViewController: presentingViewController)
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
try await self.verifyPledge()
|
||||
}
|
||||
catch let error as OperationError where error.code == .pledgeRequired || error.code == .pledgeInactive
|
||||
{
|
||||
guard
|
||||
let presentingViewController = self.presentingViewController,
|
||||
let source = await self.$storeApp.source,
|
||||
let patreonURL = await self.$storeApp.perform({ _ in source.patreonURL })
|
||||
else { throw error }
|
||||
|
||||
let components = URLComponents(url: patreonURL, resolvingAgainstBaseURL: false)
|
||||
let lastPathComponent = components?.path.components(separatedBy: "/").last
|
||||
|
||||
let username = lastPathComponent ?? patreonURL.lastPathComponent
|
||||
|
||||
let checkoutURL: URL
|
||||
if await self.$storeApp.prefersCustomPledge, let customPledgeURL = URL(string: "https://www.patreon.com/checkout/" + username + "?rid=0&custom=1")
|
||||
{
|
||||
checkoutURL = customPledgeURL
|
||||
|
||||
let action = await UIAlertAction(title: NSLocalizedString("Continue", comment: ""), style: .default)
|
||||
try await presentingViewController.presentConfirmationAlert(title: NSLocalizedString("Custom Pledge", comment: ""),
|
||||
message: NSLocalizedString("This app supports custom pledges. Pledge any amount on Patreon to receive access.", comment: ""),
|
||||
primaryAction: action)
|
||||
}
|
||||
else if !username.isEmpty, let url = URL(string: "https://www.patreon.com/join/" + username)
|
||||
{
|
||||
// Prefer /join URL over campaign homepage.
|
||||
// URL format from https://support.patreon.com/hc/en-us/articles/360044376211-Managing-members-with-custom-pledges
|
||||
checkoutURL = url
|
||||
}
|
||||
else
|
||||
{
|
||||
checkoutURL = patreonURL
|
||||
}
|
||||
|
||||
// Direct user to Patreon page if they're not already pledged.
|
||||
await self.openPatreonPage(checkoutURL, presentingViewController: presentingViewController)
|
||||
|
||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
||||
if let patreonAccount = await context.performAsync({ DatabaseManager.shared.patreonAccount(in: context) })
|
||||
{
|
||||
// Patreon account is connected, so we'll update it via API to see if pledges changed.
|
||||
// If so, we'll re-fetch the source to update pledge statuses.
|
||||
try await self.updatePledges(for: source, account: patreonAccount)
|
||||
}
|
||||
else
|
||||
{
|
||||
// Patreon account is not connected, so prompt user to connect it.
|
||||
try await self.connectPatreonAccountIfNeeded(presentingViewController: presentingViewController)
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
try await self.verifyPledge()
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore error, but cancel remainder of operation.
|
||||
throw CancellationError()
|
||||
}
|
||||
}
|
||||
|
||||
self.finish(.success(()))
|
||||
}
|
||||
catch
|
||||
{
|
||||
self.finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension VerifyAppPledgeOperation
|
||||
{
|
||||
func verifyPledge() async throws
|
||||
{
|
||||
let (appName, isPledged) = await self.$storeApp.perform { ($0.name, $0.isPledged) }
|
||||
|
||||
if !PatreonAPI.shared.isAuthenticated || !isPledged
|
||||
{
|
||||
let isInstalled = await self.$storeApp.installedApp != nil
|
||||
if isInstalled
|
||||
{
|
||||
// Assume if there is an InstalledApp, the user had previously pledged to this app.
|
||||
throw OperationError.pledgeInactive(appName: appName)
|
||||
}
|
||||
else
|
||||
{
|
||||
throw OperationError.pledgeRequired(appName: appName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func connectPatreonAccountIfNeeded(presentingViewController: UIViewController) async throws
|
||||
{
|
||||
guard !PatreonAPI.shared.isAuthenticated, let authCookie = PatreonAPI.shared.authCookies.first(where: { $0.name.lowercased() == "session_id" }) else { return }
|
||||
|
||||
Logger.sideload.debug("Patreon Auth cookie: \(authCookie.name)=\(authCookie.value)")
|
||||
|
||||
let message = NSLocalizedString("You're signed into Patreon but haven't connected your account with SideStore.\n\nPlease connect your account to download Patreon-exclusive apps.", comment: "")
|
||||
let action = await UIAlertAction(title: NSLocalizedString("Connect Patreon Account", comment: ""), style: .default)
|
||||
|
||||
do
|
||||
{
|
||||
_ = try await presentingViewController.presentConfirmationAlert(title: NSLocalizedString("Patreon Account Detected", comment: ""),
|
||||
message: message, actions: [action])
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore and continue
|
||||
return
|
||||
}
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
PatreonAPI.shared.authenticate(presentingViewController: presentingViewController) { result in
|
||||
do
|
||||
{
|
||||
let account = try result.get()
|
||||
try account.managedObjectContext?.save()
|
||||
|
||||
continuation.resume()
|
||||
}
|
||||
catch
|
||||
{
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let source = await self.$storeApp.source
|
||||
{
|
||||
// Fetch source to update pledge status now that account is connected.
|
||||
try await self.update(source)
|
||||
}
|
||||
}
|
||||
|
||||
func updatePledges(@AsyncManaged for source: Source, @AsyncManaged account: PatreonAccount) async throws
|
||||
{
|
||||
guard PatreonAPI.shared.isAuthenticated else { return }
|
||||
|
||||
let previousPledgeIDs = Set(await $account.perform { $0.pledges.map(\.identifier) })
|
||||
|
||||
let updatedPledgeIDs = try await withCheckedThrowingContinuation { continuation in
|
||||
PatreonAPI.shared.fetchAccount { (result: Result<PatreonAccount, Swift.Error>) in
|
||||
do
|
||||
{
|
||||
let account = try result.get()
|
||||
let pledgeIDs = Set(account.pledges.map(\.identifier))
|
||||
|
||||
try account.managedObjectContext?.save()
|
||||
|
||||
continuation.resume(returning: pledgeIDs)
|
||||
}
|
||||
catch
|
||||
{
|
||||
Logger.sideload.error("Failed to update Patreon account. \(error.localizedDescription, privacy: .public)")
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if updatedPledgeIDs != previousPledgeIDs
|
||||
{
|
||||
// Active pledges changed, so fetch source to update pledge status.
|
||||
try await self.update(source)
|
||||
}
|
||||
}
|
||||
|
||||
func update(@AsyncManaged _ source: Source) async throws
|
||||
{
|
||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
||||
_ = try await AppManager.shared.fetchSource(sourceURL: $source.sourceURL, managedObjectContext: context)
|
||||
|
||||
try await context.performAsync {
|
||||
try context.save()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func openPatreonPage(_ patreonURL: URL, presentingViewController: UIViewController) async
|
||||
{
|
||||
let webViewController = WebViewController(url: patreonURL)
|
||||
webViewController.delegate = self
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: webViewController)
|
||||
presentingViewController.present(navigationController, animated: true)
|
||||
|
||||
// Automatically dismiss if user completes checkout flow.
|
||||
self.cancellable = webViewController.webView.publisher(for: \.url, options: [.new])
|
||||
.compactMap { $0 }
|
||||
.compactMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }
|
||||
.compactMap { components in
|
||||
let lastPathComponent = components.path.components(separatedBy: "/").last
|
||||
return lastPathComponent?.lowercased()
|
||||
}
|
||||
.filter { $0 == "membership" }
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] url in
|
||||
guard let continuation = self?.openPatreonPageContinuation else { return }
|
||||
self?.openPatreonPageContinuation = nil
|
||||
|
||||
continuation.resume()
|
||||
}
|
||||
|
||||
await withCheckedContinuation { continuation in
|
||||
self.openPatreonPageContinuation = continuation
|
||||
}
|
||||
|
||||
// Cache auth cookies just in case user signed in.
|
||||
await PatreonAPI.shared.saveAuthCookies()
|
||||
|
||||
navigationController.dismiss(animated: true)
|
||||
|
||||
self.cancellable = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension VerifyAppPledgeOperation: WebViewControllerDelegate
|
||||
{
|
||||
func webViewControllerDidFinish(_ webViewController: WebViewController)
|
||||
{
|
||||
guard let continuation = self.openPatreonPageContinuation else { return }
|
||||
self.openPatreonPageContinuation = nil
|
||||
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,6 @@
|
||||
<key>name</key>
|
||||
<string>Original</string>
|
||||
<key>imageName</key>
|
||||
<string>App</string>
|
||||
<key>iconName</key>
|
||||
<string>AppIcon</string>
|
||||
</dict>
|
||||
</array>
|
||||
@@ -20,88 +18,66 @@
|
||||
<string>Blue</string>
|
||||
<key>imageName</key>
|
||||
<string>Blue</string>
|
||||
<key>iconName</key>
|
||||
<string>BlueIcon</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Dark</string>
|
||||
<key>imageName</key>
|
||||
<string>Dark</string>
|
||||
<key>iconName</key>
|
||||
<string>DarkIcon</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Honeydew</string>
|
||||
<key>imageName</key>
|
||||
<string>Honeydew</string>
|
||||
<key>iconName</key>
|
||||
<string>HoneydewIcon</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Pride</string>
|
||||
<key>imageName</key>
|
||||
<string>Pride</string>
|
||||
<key>iconName</key>
|
||||
<string>PrideIcon</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Sandy</string>
|
||||
<key>imageName</key>
|
||||
<string>Sandy</string>
|
||||
<key>iconName</key>
|
||||
<string>SandyIcon</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Sky</string>
|
||||
<key>imageName</key>
|
||||
<string>Sky</string>
|
||||
<key>iconName</key>
|
||||
<string>SkyIcon</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Snow</string>
|
||||
<key>imageName</key>
|
||||
<string>Snow</string>
|
||||
<key>iconName</key>
|
||||
<string>SnowIcon</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Starburst</string>
|
||||
<key>imageName</key>
|
||||
<string>Starburst</string>
|
||||
<key>iconName</key>
|
||||
<string>StarburstIcon</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Storm</string>
|
||||
<key>imageName</key>
|
||||
<string>Storm</string>
|
||||
<key>iconName</key>
|
||||
<string>StormIcon</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Vista</string>
|
||||
<key>imageName</key>
|
||||
<string>Vista</string>
|
||||
<key>iconName</key>
|
||||
<string>VistaIcon</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Winter</string>
|
||||
<key>imageName</key>
|
||||
<string>Winter</string>
|
||||
<key>iconName</key>
|
||||
<string>WinterIcon</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
|
||||
|
Before Width: | Height: | Size: 50 KiB |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "App.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 38 KiB |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Blue.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Dark.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 373 KiB After Width: | Height: | Size: 373 KiB |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Honeydew.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 719 KiB After Width: | Height: | Size: 719 KiB |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Pride.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 352 KiB After Width: | Height: | Size: 352 KiB |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Sandy.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Sky.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 453 KiB After Width: | Height: | Size: 453 KiB |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Snow.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 181 KiB |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Starburst.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 464 KiB After Width: | Height: | Size: 464 KiB |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Storm.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Vista.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||