mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-09 06:43:25 +01:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d17a45ebb | ||
|
|
aca81321bf |
63
.github/maintenance/cache.py
vendored
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
|
||||
'''
|
||||
28
.github/workflows/alpha.yml
vendored
28
.github/workflows/alpha.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Alpha SideStore build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop-alpha
|
||||
|
||||
# cancel duplicate run if from same branch
|
||||
concurrency:
|
||||
group: ${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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 }}
|
||||
34
.github/workflows/increase-beta-build-num.sh
vendored
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
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
|
||||
|
||||
171
.github/workflows/nightly.yml
vendored
171
.github/workflows/nightly.yml
vendored
@@ -1,82 +1,115 @@
|
||||
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
|
||||
concurrency:
|
||||
group: ${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: 'macos-14'
|
||||
version: '15.4'
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- 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
|
||||
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
|
||||
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
|
||||
else
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
LAST_SUCCESS: ${{ env.last_success }}
|
||||
- name: Install xcbeautify
|
||||
run: brew install xcbeautify
|
||||
|
||||
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: Cache .nightly-build-num
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .nightly-build-num
|
||||
key: nightly-build-num
|
||||
|
||||
- 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: 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: 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
|
||||
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 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 Beta](https://github.com/${{ github.repository }}/releases?q=beta).
|
||||
|
||||
## 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: ./*.dSYM/
|
||||
|
||||
34
.github/workflows/pr.yml
vendored
34
.github/workflows/pr.yml
vendored
@@ -1,19 +1,16 @@
|
||||
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:
|
||||
include:
|
||||
- os: 'macos-14'
|
||||
version: '16.1'
|
||||
version: '15.4'
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
@@ -25,9 +22,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: brew install ldid
|
||||
|
||||
- name: Install xcbeautify
|
||||
run: brew install xcbeautify
|
||||
|
||||
- name: Add PR suffix to version
|
||||
run: sed -e "/MARKETING_VERSION = .*/s/\$/-pr.${{ github.event.pull_request.number }}+$(git rev-parse --short ${COMMIT:-HEAD})/" -i '' Build.xcconfig
|
||||
env:
|
||||
@@ -50,31 +44,9 @@ jobs:
|
||||
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: List Files and derived data
|
||||
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
|
||||
run: NSUnbufferedIO=YES make build 2>&1 | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
|
||||
run: NSUnbufferedIO=YES make build | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
|
||||
|
||||
- name: Fakesign app
|
||||
run: make fakesign
|
||||
@@ -95,4 +67,4 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: SideStore-${{ steps.version.outputs.version }}-dSYM
|
||||
path: ./SideStore.xcarchive/dSYMs/*
|
||||
path: ./*.dSYM/
|
||||
|
||||
104
.github/workflows/reusable-sidestore-build.yml
vendored
104
.github/workflows/reusable-sidestore-build.yml
vendored
@@ -1,104 +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
|
||||
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
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
|
||||
264
.github/workflows/sidestore-deploy.yml
vendored
264
.github/workflows/sidestore-deploy.yml
vendored
@@ -1,264 +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: 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!**
|
||||
|
||||
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 }}).
|
||||
|
||||
## 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: |
|
||||
# Format localized description
|
||||
LOCALIZED_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
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
# 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
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
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
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
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/
|
||||
|
||||
39
.gitignore
vendored
39
.gitignore
vendored
@@ -1,18 +1,14 @@
|
||||
# macOS
|
||||
#
|
||||
**/*.DS_Store
|
||||
*.DS_Store
|
||||
|
||||
# Xcode
|
||||
#
|
||||
|
||||
## CocoaPods
|
||||
Pods/
|
||||
|
||||
## Build generated
|
||||
build/
|
||||
DerivedData
|
||||
|
||||
SideStore.xcarchive
|
||||
archive.xcarchive
|
||||
## Various settings
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
@@ -23,7 +19,6 @@ SideStore.xcarchive
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
xcuserdata
|
||||
|
||||
## Other
|
||||
*.xccheckout
|
||||
*.moved-aside
|
||||
@@ -40,33 +35,11 @@ xcuserdata
|
||||
.idea/
|
||||
|
||||
Payload/
|
||||
**/SideStore.ipa
|
||||
**/AltBackup.ipa
|
||||
**/*.dSYM
|
||||
SideStore.ipa
|
||||
*.dSYM
|
||||
|
||||
Dependencies/.*-prebuilt-fetch-*
|
||||
SideStore/minimuxer/*
|
||||
SideStore/em_proxy/*
|
||||
Dependencies/minimuxer/*
|
||||
Dependencies/em_proxy/*
|
||||
!Dependencies/**/.gitkeep
|
||||
.nightly-build-num
|
||||
|
||||
## em_proxy and minimuxer biaries
|
||||
**/.last-prebuilt-fetch-em_proxy
|
||||
**/.last-prebuilt-fetch-minimuxer
|
||||
|
||||
# misc
|
||||
**/output.txt
|
||||
SideStore/.skip-prebuilt-fetch-minimuxer
|
||||
SideStore/.skip-prebuilt-fetch-em_proxy
|
||||
|
||||
.git.bkp/
|
||||
# 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
|
||||
77
.gitmodules
vendored
77
.gitmodules
vendored
@@ -1,68 +1,21 @@
|
||||
#-------------------------------
|
||||
# When changing url/branch in this .gitmodules file,
|
||||
# Always ensure you run:
|
||||
# 1. `git rm --cached <submodule_relative_path>` # this removes the submodule entry from general git tracking
|
||||
# 2. `rm -rf .git/modules/<submodule_relative_path>` # this removes the stale name entries in submodule tracker
|
||||
# 3. `rm -rf <submodule_relative_path>` # removes the submodule completely
|
||||
# 4. `git submodule --deinit <submodule_relative_path>` # make sure that the submodule is de-inited too (ignore errors at this point)
|
||||
# 5. `git submodule add [-b <branch_name>] <repo_url> <submodule_relative_path>` # This adds the submodule back into general git tracking and also adds to the submodule tracker
|
||||
# 6. Step 5 creates an entry in the .gitmodules when a submodule is added,
|
||||
# So if you already had one entry, try to remove duplicates at this point
|
||||
# 7. `git submodule sync --recursive` # this now sets/updates the submodule repo url tracker into git config
|
||||
# 8. `git submodule update --init --recursive` # this now clones the updated repo set by .gitmodules
|
||||
# But this will always fetch the latest commit sepecified by the custom(if set)/default branch
|
||||
# 9. If you do want to have a specific commit in that submodule branch and not latest, you need to perform normal detached head checkout and check-in as follows:
|
||||
# `pushd <submodule_relative_path>` # switch to the submodule repo
|
||||
# `git checkout <commit-id>` # this creates a detached head state
|
||||
# `popd` # get back to parent repo
|
||||
# `git add <submodule_relative_path>` # check-in the changes in parent for this submodule link (tracker)
|
||||
# `git commit -m <commit-message>` # commit it to parent repo
|
||||
# `git push` # push to parent repo to preserve this entire change in the submodule repo/link file
|
||||
#
|
||||
# NOTES:
|
||||
# 1. updating just this .gitmodules file is NOT ENOUGH when changing repo url and performing a simple `git submodule update --init --recursive`, need to do all the above listed steps for proper tracking
|
||||
# 2. updating the branch in this .gitmodules for same repo is okay as long as `git submodule update --init --recursive` is also performed followed by it
|
||||
# 3. Ensure there is no stale entries or duplicate entries in this .gitmodules file coz, `git submodule add ...` creates an entry here.
|
||||
#-------------------------------
|
||||
|
||||
[submodule "Dependencies/Roxas"]
|
||||
path = Dependencies/Roxas
|
||||
url = https://github.com/rileytestut/Roxas.git
|
||||
path = Dependencies/Roxas
|
||||
url = https://github.com/rileytestut/Roxas.git
|
||||
[submodule "Dependencies/libimobiledevice"]
|
||||
path = Dependencies/libimobiledevice
|
||||
url = https://github.com/SideStore/libimobiledevice
|
||||
path = Dependencies/libimobiledevice
|
||||
url = https://github.com/libimobiledevice/libimobiledevice
|
||||
[submodule "Dependencies/libusbmuxd"]
|
||||
path = Dependencies/libusbmuxd
|
||||
url = https://github.com/libimobiledevice/libusbmuxd.git
|
||||
path = Dependencies/libusbmuxd
|
||||
url = https://github.com/libimobiledevice/libusbmuxd.git
|
||||
[submodule "Dependencies/libplist"]
|
||||
path = Dependencies/libplist
|
||||
url = https://github.com/SideStore/libplist.git
|
||||
path = Dependencies/libplist
|
||||
url = https://github.com/SideStore/libplist.git
|
||||
[submodule "Dependencies/MarkdownAttributedString"]
|
||||
path = Dependencies/MarkdownAttributedString
|
||||
url = https://github.com/chockenberry/MarkdownAttributedString.git
|
||||
path = Dependencies/MarkdownAttributedString
|
||||
url = https://github.com/chockenberry/MarkdownAttributedString.git
|
||||
[submodule "Dependencies/libimobiledevice-glue"]
|
||||
path = Dependencies/libimobiledevice-glue
|
||||
url = https://github.com/libimobiledevice/libimobiledevice-glue
|
||||
|
||||
|
||||
#sidestore dependencies
|
||||
[submodule "SideStore/minimuxer"]
|
||||
path = SideStore/minimuxer
|
||||
url = https://github.com/SideStore/minimuxer
|
||||
branch = master
|
||||
[submodule "SideStore/em_proxy"]
|
||||
path = SideStore/em_proxy
|
||||
url = https://github.com/SideStore/em_proxy
|
||||
branch = master
|
||||
[submodule "SideStore/libfragmentzip"]
|
||||
path = SideStore/libfragmentzip
|
||||
url = https://github.com/SideStore/libfragmentzip
|
||||
branch = master
|
||||
[submodule "SideStore/apps-v2.json"]
|
||||
path = SideStore/apps-v2.json
|
||||
url = https://github.com/SideStore/apps-v2.json
|
||||
branch = main
|
||||
[submodule "SideStore/AltSign"]
|
||||
path = SideStore/AltSign
|
||||
url = https://github.com/SideStore/AltSign
|
||||
branch = master
|
||||
path = Dependencies/libimobiledevice-glue
|
||||
url = https://github.com/libimobiledevice/libimobiledevice-glue
|
||||
[submodule "Dependencies/libfragmentzip"]
|
||||
path = Dependencies/libfragmentzip
|
||||
url = https://github.com/SideStore/libfragmentzip.git
|
||||
|
||||
3
AltBackup.xcconfig
Normal file
3
AltBackup.xcconfig
Normal file
@@ -0,0 +1,3 @@
|
||||
#include "Build.xcconfig"
|
||||
|
||||
PRODUCT_BUNDLE_IDENTIFIER = $(PRODUCT_BUNDLE_IDENTIFIER).AltBackup
|
||||
@@ -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>
|
||||
|
||||
@@ -10,10 +10,10 @@ import UIKit
|
||||
|
||||
extension AppDelegate
|
||||
{
|
||||
static let startBackupNotification = Notification.Name("io.sidestore.StartBackup")
|
||||
static let startRestoreNotification = Notification.Name("io.sidestore.StartRestore")
|
||||
static let startBackupNotification = Notification.Name("io.altstore.StartBackup")
|
||||
static let startRestoreNotification = Notification.Name("io.altstore.StartRestore")
|
||||
|
||||
static let operationDidFinishNotification = Notification.Name("io.sidestore.BackupOperationFinished")
|
||||
static let operationDidFinishNotification = Notification.Name("io.altstore.BackupOperationFinished")
|
||||
|
||||
static let operationResultKey = "result"
|
||||
}
|
||||
@@ -88,25 +88,14 @@ private extension AppDelegate
|
||||
|
||||
@objc func operationDidFinish(_ notification: Notification)
|
||||
{
|
||||
defer {
|
||||
self.currentBackupReturnURL = nil
|
||||
}
|
||||
defer { self.currentBackupReturnURL = nil }
|
||||
|
||||
// TODO: @mahee96: This doesn't account cases where backup is too long and user switched to other apps
|
||||
// The check for self.currentBackupReturnURL when backup/restore was still in progress but app switched
|
||||
// between FG/BG is improper, since it will ignore(eat up) the response(success/failure) to parent
|
||||
//
|
||||
// This leaves the backup/restore to show dummy animation forever
|
||||
guard
|
||||
let returnURL = self.currentBackupReturnURL,
|
||||
let result = notification.userInfo?[AppDelegate.operationResultKey] as? Result<Void, Error>
|
||||
else {
|
||||
return // This is bad (Needs fixing - never eat up response like this unless there is no context to post response to!)
|
||||
}
|
||||
else { return }
|
||||
|
||||
guard var components = URLComponents(url: returnURL, resolvingAgainstBaseURL: false) else {
|
||||
return // This is ASSERTION Failure, ie RETURN URL needs to be valid. So ignoring (eating up) response is not the solution
|
||||
}
|
||||
guard var components = URLComponents(url: returnURL, resolvingAgainstBaseURL: false) else { return }
|
||||
|
||||
switch result
|
||||
{
|
||||
@@ -123,7 +112,6 @@ private extension AppDelegate
|
||||
guard let responseURL = components.url else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
// Response to the caller/parent app is posted here (url is provided by caller in incoming query params)
|
||||
UIApplication.shared.open(responseURL, options: [:]) { (success) in
|
||||
print("Sent response to app with success:", success)
|
||||
}
|
||||
|
||||
98
AltBackup/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
98
AltBackup/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.518",
|
||||
"green" : "0.502",
|
||||
"red" : "0.004"
|
||||
"blue" : "175",
|
||||
"green" : "4",
|
||||
"red" : "115"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
@@ -23,9 +23,9 @@
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.404",
|
||||
"green" : "0.322",
|
||||
"red" : "0.008"
|
||||
"blue" : "150",
|
||||
"green" : "3",
|
||||
"red" : "99"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
||||
57
AltBackup/BackupController.swift
Executable file → Normal file
57
AltBackup/BackupController.swift
Executable file → Normal file
@@ -26,60 +26,20 @@ extension Error
|
||||
|
||||
struct BackupError: ALTLocalizedError
|
||||
{
|
||||
enum Code: ALTErrorEnum, RawRepresentable
|
||||
enum Code
|
||||
{
|
||||
case invalidBundleID
|
||||
case appGroupNotFound(String?)
|
||||
case randomError // Used for debugging.
|
||||
|
||||
// Provide failure reason for each error code
|
||||
var errorFailureReason: String {
|
||||
switch self {
|
||||
case .invalidBundleID:
|
||||
return NSLocalizedString("The bundle identifier is invalid.", comment: "")
|
||||
case .appGroupNotFound(let appGroup):
|
||||
if let appGroup = appGroup {
|
||||
return String(format: NSLocalizedString("The app group “%@” could not be found.", comment: ""), appGroup)
|
||||
} else {
|
||||
return NSLocalizedString("The AltStore app group could not be found.", comment: "")
|
||||
}
|
||||
case .randomError:
|
||||
return NSLocalizedString("A random error occurred.", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
static var errorDomain: String {
|
||||
return "com.sidestore.BackupError"
|
||||
}
|
||||
|
||||
// Add a raw value for RawRepresentable conformance
|
||||
var rawValue: Int {
|
||||
switch self {
|
||||
case .invalidBundleID: return 0
|
||||
case .appGroupNotFound: return 1
|
||||
case .randomError: return 2
|
||||
}
|
||||
}
|
||||
|
||||
// Initializer for RawRepresentable
|
||||
init?(rawValue: Int) {
|
||||
switch rawValue {
|
||||
case 0: self = .invalidBundleID
|
||||
case 1: self = .appGroupNotFound(nil)
|
||||
case 2: self = .randomError
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let code: Code
|
||||
|
||||
let sourceFile: String
|
||||
let sourceFileLine: Int
|
||||
|
||||
var failure: String?
|
||||
|
||||
var errorTitle: String?
|
||||
var errorFailure: String?
|
||||
|
||||
var failureReason: String? {
|
||||
switch self.code
|
||||
{
|
||||
@@ -106,19 +66,12 @@ struct BackupError: ALTLocalizedError
|
||||
return userInfo.compactMapValues { $0 }
|
||||
}
|
||||
|
||||
// Implement description for CustomStringConvertible
|
||||
var description: String {
|
||||
return "\(errorTitle ?? "Unknown Error"): \(failureReason ?? "No reason available")"
|
||||
}
|
||||
|
||||
init(_ code: Code, description: String? = nil, file: String = #file, line: Int = #line)
|
||||
{
|
||||
self.code = code
|
||||
self.failure = description
|
||||
self.sourceFile = file
|
||||
self.sourceFileLine = line
|
||||
self.errorTitle = NSLocalizedString("Backup Error", comment: "")
|
||||
self.errorFailure = description
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,9 +96,7 @@ class BackupController: NSObject
|
||||
guard
|
||||
let altstoreAppGroup = Bundle.main.altstoreAppGroup,
|
||||
let sharedDirectoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: altstoreAppGroup)
|
||||
else {
|
||||
throw BackupError(.appGroupNotFound(nil), description: NSLocalizedString("Unable to create backup directory.", comment: ""))
|
||||
}
|
||||
else { throw BackupError(.appGroupNotFound(nil), description: NSLocalizedString("Unable to create backup directory.", comment: "")) }
|
||||
|
||||
let backupsDirectory = sharedDirectoryURL.appendingPathComponent("Backups")
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<key>ALTAppGroups</key>
|
||||
<array>
|
||||
<string>group.$(APP_GROUP_IDENTIFIER)</string>
|
||||
<string>group.com.SideStore.SideStore</string>
|
||||
</array>
|
||||
<key>ALTBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
@@ -28,15 +29,15 @@
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>SideBackup General</string>
|
||||
<string>AltBackup General</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>sidebackup</string>
|
||||
<string>altbackup</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
|
||||
@@ -1,18 +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>application-identifier</key>
|
||||
<string>XYZ0123456.com.SideStore.SideStore.AltBackup</string>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.team-identifier</key>
|
||||
<string>XYZ0123456</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.SideStore.SideStore</string>
|
||||
</array>
|
||||
<key>get-task-allow</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -82,25 +82,23 @@ class ViewController: UIViewController
|
||||
self.activityIndicatorView.color = .altstoreText
|
||||
self.activityIndicatorView.startAnimating()
|
||||
|
||||
// TODO: @mahee96: Disabled these backup/restore buttons in altbackup.app screen which were present for debugging purpose.
|
||||
// Can find something useful for these later, but these are not required by this backup/restore app
|
||||
// #if DEBUG
|
||||
// let button1 = UIButton(type: .system)
|
||||
// button1.setTitle("Backup", for: .normal)
|
||||
// button1.setTitleColor(.white, for: .normal)
|
||||
// button1.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
// button1.addTarget(self, action: #selector(ViewController.backup), for: .primaryActionTriggered)
|
||||
//
|
||||
// let button2 = UIButton(type: .system)
|
||||
// button2.setTitle("Restore", for: .normal)
|
||||
// button2.setTitleColor(.white, for: .normal)
|
||||
// button2.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
// button2.addTarget(self, action: #selector(ViewController.restore), for: .primaryActionTriggered)
|
||||
//
|
||||
// let arrangedSubviews = [self.textLabel!, self.detailTextLabel!, self.activityIndicatorView!, button1, button2]
|
||||
// #else
|
||||
#if DEBUG
|
||||
let button1 = UIButton(type: .system)
|
||||
button1.setTitle("Backup", for: .normal)
|
||||
button1.setTitleColor(.white, for: .normal)
|
||||
button1.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
button1.addTarget(self, action: #selector(ViewController.backup), for: .primaryActionTriggered)
|
||||
|
||||
let button2 = UIButton(type: .system)
|
||||
button2.setTitle("Restore", for: .normal)
|
||||
button2.setTitleColor(.white, for: .normal)
|
||||
button2.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
button2.addTarget(self, action: #selector(ViewController.restore), for: .primaryActionTriggered)
|
||||
|
||||
let arrangedSubviews = [self.textLabel!, self.detailTextLabel!, self.activityIndicatorView!, button1, button2]
|
||||
#else
|
||||
let arrangedSubviews = [self.textLabel!, self.detailTextLabel!, self.activityIndicatorView!]
|
||||
// #endif
|
||||
#endif
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: arrangedSubviews)
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -157,8 +155,7 @@ private extension ViewController
|
||||
self.textLabel.text = NSLocalizedString("Restoring app data…", comment: "")
|
||||
self.detailTextLabel.isHidden = true
|
||||
self.activityIndicatorView.startAnimating()
|
||||
|
||||
// TODO: @mahee96: This is pointless since, app going in bg/fg should still report its last operation properly
|
||||
|
||||
case .none:
|
||||
self.textLabel.text = String(format: NSLocalizedString("%@ is inactive.", comment: ""),
|
||||
Bundle.main.appName ?? NSLocalizedString("App", comment: ""))
|
||||
@@ -201,9 +198,6 @@ private extension ViewController
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: @mahee96: This doesn't account cases where backup is too long and user switched to other apps
|
||||
// Now the user has lost his progress since current operation was cancelled due to switch between FG and BG
|
||||
// if this just the reset for enum such that UI stops showing progress circle, then this is fine!
|
||||
@objc func didEnterBackground(_ notification: Notification)
|
||||
{
|
||||
// Reset UI once we've left app (but not before).
|
||||
|
||||
59
AltDaemon/AltDaemon-Bridging-Header.h
Normal file
59
AltDaemon/AltDaemon-Bridging-Header.h
Normal file
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
// Shared
|
||||
#import "ALTConstants.h"
|
||||
#import "ALTConnection.h"
|
||||
#import "NSError+ALTServerError.h"
|
||||
#import "CFNotificationName+AltStore.h"
|
||||
|
||||
// libproc
|
||||
int proc_pidpath(int pid, void * buffer, uint32_t buffersize);
|
||||
|
||||
// Security.framework
|
||||
CF_ENUM(uint32_t) {
|
||||
kSecCSInternalInformation = 1 << 0,
|
||||
kSecCSSigningInformation = 1 << 1,
|
||||
kSecCSRequirementInformation = 1 << 2,
|
||||
kSecCSDynamicInformation = 1 << 3,
|
||||
kSecCSContentInformation = 1 << 4,
|
||||
kSecCSSkipResourceDirectory = 1 << 5,
|
||||
kSecCSCalculateCMSDigest = 1 << 6,
|
||||
};
|
||||
|
||||
OSStatus SecStaticCodeCreateWithPath(CFURLRef path, uint32_t flags, void ** __nonnull CF_RETURNS_RETAINED staticCode);
|
||||
OSStatus SecCodeCopySigningInformation(void *code, uint32_t flags, CFDictionaryRef * __nonnull CF_RETURNS_RETAINED information);
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface AKDevice : NSObject
|
||||
|
||||
@property (class, readonly) AKDevice *currentDevice;
|
||||
|
||||
@property (strong, readonly) NSString *serialNumber;
|
||||
@property (strong, readonly) NSString *uniqueDeviceIdentifier;
|
||||
@property (strong, readonly) NSString *serverFriendlyDescription;
|
||||
|
||||
@end
|
||||
|
||||
@interface AKAppleIDSession : NSObject
|
||||
|
||||
- (instancetype)initWithIdentifier:(NSString *)identifier;
|
||||
|
||||
- (NSDictionary<NSString *, NSString *> *)appleIDHeadersForRequest:(NSURLRequest *)request;
|
||||
|
||||
@end
|
||||
|
||||
@interface LSApplicationWorkspace : NSObject
|
||||
|
||||
@property (class, readonly) LSApplicationWorkspace *defaultWorkspace;
|
||||
|
||||
- (BOOL)installApplication:(NSURL *)fileURL withOptions:(nullable NSDictionary<NSString *, id> *)options error:(NSError *_Nullable *)error;
|
||||
- (BOOL)uninstallApplication:(NSString *)bundleIdentifier withOptions:(nullable NSDictionary *)options;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
22
AltDaemon/AltDaemon.entitlements
Normal file
22
AltDaemon/AltDaemon.entitlements
Normal file
@@ -0,0 +1,22 @@
|
||||
<?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>application-identifier</key>
|
||||
<string>$(DEVELOPMENT_TEAM).$(ORG_IDENTIFIER).AltDaemon</string>
|
||||
<key>get-task-allow</key>
|
||||
<true/>
|
||||
<key>platform-application</key>
|
||||
<true/>
|
||||
<key>com.apple.authkit.client.private</key>
|
||||
<true/>
|
||||
<key>com.apple.private.mobileinstall.allowedSPI</key>
|
||||
<array>
|
||||
<string>Install</string>
|
||||
<string>Uninstall</string>
|
||||
<string>InstallForLaunchServices</string>
|
||||
<string>UninstallForLaunchServices</string>
|
||||
<string>InstallLocalProvisioned</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
65
AltDaemon/AnisetteDataManager.swift
Normal file
65
AltDaemon/AnisetteDataManager.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// AnisetteDataManager.swift
|
||||
// AltDaemon
|
||||
//
|
||||
// Created by Riley Testut on 6/1/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import AltSign
|
||||
|
||||
private extension UserDefaults
|
||||
{
|
||||
@objc var localUserID: String? {
|
||||
get { return self.string(forKey: #keyPath(UserDefaults.localUserID)) }
|
||||
set { self.set(newValue, forKey: #keyPath(UserDefaults.localUserID)) }
|
||||
}
|
||||
}
|
||||
|
||||
struct AnisetteDataManager
|
||||
{
|
||||
static let shared = AnisetteDataManager()
|
||||
|
||||
private let dateFormatter = ISO8601DateFormatter()
|
||||
|
||||
private init()
|
||||
{
|
||||
dlopen("/System/Library/PrivateFrameworks/AuthKit.framework/AuthKit", RTLD_NOW);
|
||||
}
|
||||
|
||||
func requestAnisetteData() throws -> ALTAnisetteData
|
||||
{
|
||||
var request = URLRequest(url: URL(string: "https://developerservices2.apple.com/services/QH65B2/listTeams.action?clientId=XABBG36SBA")!)
|
||||
request.httpMethod = "POST"
|
||||
|
||||
let akAppleIDSession = unsafeBitCast(NSClassFromString("AKAppleIDSession")!, to: AKAppleIDSession.Type.self)
|
||||
let akDevice = unsafeBitCast(NSClassFromString("AKDevice")!, to: AKDevice.Type.self)
|
||||
|
||||
let session = akAppleIDSession.init(identifier: "com.apple.gs.xcode.auth")
|
||||
let headers = session.appleIDHeaders(for: request)
|
||||
|
||||
let device = akDevice.current
|
||||
let date = self.dateFormatter.date(from: headers["X-Apple-I-Client-Time"] ?? "") ?? Date()
|
||||
|
||||
var localUserID = UserDefaults.standard.localUserID
|
||||
if localUserID == nil
|
||||
{
|
||||
localUserID = UUID().uuidString
|
||||
UserDefaults.standard.localUserID = localUserID
|
||||
}
|
||||
|
||||
let anisetteData = ALTAnisetteData(machineID: headers["X-Apple-I-MD-M"] ?? "",
|
||||
oneTimePassword: headers["X-Apple-I-MD"] ?? "",
|
||||
localUserID: headers["X-Apple-I-MD-LU"] ?? localUserID ?? "",
|
||||
routingInfo: UInt64(headers["X-Apple-I-MD-RINFO"] ?? "") ?? 0,
|
||||
deviceUniqueIdentifier: device.uniqueDeviceIdentifier,
|
||||
deviceSerialNumber: device.serialNumber,
|
||||
deviceDescription: "<MacBookPro15,1> <Mac OS X;10.15.2;19C57> <com.apple.AuthKit/1 (com.apple.dt.Xcode/3594.4.19)>",
|
||||
date: date,
|
||||
locale: .current,
|
||||
timeZone: .current)
|
||||
return anisetteData
|
||||
}
|
||||
}
|
||||
138
AltDaemon/AppManager.swift
Normal file
138
AltDaemon/AppManager.swift
Normal file
@@ -0,0 +1,138 @@
|
||||
//
|
||||
// AppManager.swift
|
||||
// AltDaemon
|
||||
//
|
||||
// Created by Riley Testut on 6/1/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import AltSign
|
||||
|
||||
private extension URL
|
||||
{
|
||||
static let profilesDirectoryURL = URL(fileURLWithPath: "/var/MobileDevice/ProvisioningProfiles", isDirectory: true)
|
||||
}
|
||||
|
||||
private extension CFNotificationName
|
||||
{
|
||||
static let updatedProvisioningProfiles = CFNotificationName("MISProvisioningProfileRemoved" as CFString)
|
||||
}
|
||||
|
||||
struct AppManager
|
||||
{
|
||||
static let shared = AppManager()
|
||||
|
||||
private let appQueue = DispatchQueue(label: "com.rileytestut.AltDaemon.appQueue", qos: .userInitiated)
|
||||
private let profilesQueue = OperationQueue()
|
||||
|
||||
private let fileCoordinator = NSFileCoordinator()
|
||||
|
||||
private init()
|
||||
{
|
||||
self.profilesQueue.name = "com.rileytestut.AltDaemon.profilesQueue"
|
||||
self.profilesQueue.qualityOfService = .userInitiated
|
||||
}
|
||||
|
||||
func installApp(at fileURL: URL, bundleIdentifier: String, activeProfiles: Set<String>?, completionHandler: @escaping (Result<Void, Error>) -> Void)
|
||||
{
|
||||
self.appQueue.async {
|
||||
let lsApplicationWorkspace = unsafeBitCast(NSClassFromString("LSApplicationWorkspace")!, to: LSApplicationWorkspace.Type.self)
|
||||
|
||||
let options = ["CFBundleIdentifier": bundleIdentifier, "AllowInstallLocalProvisioned": NSNumber(value: true)] as [String : Any]
|
||||
let result = Result { try lsApplicationWorkspace.default.installApplication(fileURL, withOptions: options) }
|
||||
|
||||
completionHandler(result)
|
||||
}
|
||||
}
|
||||
|
||||
func removeApp(forBundleIdentifier bundleIdentifier: String, completionHandler: @escaping (Result<Void, Error>) -> Void)
|
||||
{
|
||||
self.appQueue.async {
|
||||
let lsApplicationWorkspace = unsafeBitCast(NSClassFromString("LSApplicationWorkspace")!, to: LSApplicationWorkspace.Type.self)
|
||||
lsApplicationWorkspace.default.uninstallApplication(bundleIdentifier, withOptions: nil)
|
||||
|
||||
completionHandler(.success(()))
|
||||
}
|
||||
}
|
||||
|
||||
func install(_ profiles: Set<ALTProvisioningProfile>, activeProfiles: Set<String>?, completionHandler: @escaping (Result<Void, Error>) -> Void)
|
||||
{
|
||||
let intent = NSFileAccessIntent.writingIntent(with: .profilesDirectoryURL, options: [])
|
||||
self.fileCoordinator.coordinate(with: [intent], queue: self.profilesQueue) { (error) in
|
||||
do
|
||||
{
|
||||
if let error = error
|
||||
{
|
||||
throw error
|
||||
}
|
||||
|
||||
let installingBundleIDs = Set(profiles.map(\.bundleIdentifier))
|
||||
|
||||
let profileURLs = try FileManager.default.contentsOfDirectory(at: intent.url, includingPropertiesForKeys: nil, options: [])
|
||||
|
||||
// Remove all inactive profiles (if active profiles are provided), and the previous profiles.
|
||||
for fileURL in profileURLs
|
||||
{
|
||||
// Use memory mapping to reduce peak memory usage and stay within limit.
|
||||
guard let profile = try? ALTProvisioningProfile(url: fileURL, options: [.mappedIfSafe]) else { continue }
|
||||
|
||||
if installingBundleIDs.contains(profile.bundleIdentifier) || (activeProfiles?.contains(profile.bundleIdentifier) == false && profile.isFreeProvisioningProfile)
|
||||
{
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
else
|
||||
{
|
||||
print("Ignoring:", profile.bundleIdentifier, profile.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
for profile in profiles
|
||||
{
|
||||
let destinationURL = URL.profilesDirectoryURL.appendingPathComponent(profile.uuid.uuidString.lowercased())
|
||||
try profile.data.write(to: destinationURL, options: .atomic)
|
||||
}
|
||||
|
||||
completionHandler(.success(()))
|
||||
}
|
||||
catch
|
||||
{
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
|
||||
// Notify system to prevent accidentally untrusting developer certificate.
|
||||
CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), .updatedProvisioningProfiles, nil, nil, true)
|
||||
}
|
||||
}
|
||||
|
||||
func removeProvisioningProfiles(forBundleIdentifiers bundleIdentifiers: Set<String>, completionHandler: @escaping (Result<Void, Error>) -> Void)
|
||||
{
|
||||
let intent = NSFileAccessIntent.writingIntent(with: .profilesDirectoryURL, options: [])
|
||||
self.fileCoordinator.coordinate(with: [intent], queue: self.profilesQueue) { (error) in
|
||||
do
|
||||
{
|
||||
let profileURLs = try FileManager.default.contentsOfDirectory(at: intent.url, includingPropertiesForKeys: nil, options: [])
|
||||
|
||||
for fileURL in profileURLs
|
||||
{
|
||||
guard let profile = ALTProvisioningProfile(url: fileURL) else { continue }
|
||||
|
||||
if bundleIdentifiers.contains(profile.bundleIdentifier)
|
||||
{
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
completionHandler(.success(()))
|
||||
}
|
||||
catch
|
||||
{
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
|
||||
// Notify system to prevent accidentally untrusting developer certificate.
|
||||
CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), .updatedProvisioningProfiles, nil, nil, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
123
AltDaemon/DaemonRequestHandler.swift
Normal file
123
AltDaemon/DaemonRequestHandler.swift
Normal file
@@ -0,0 +1,123 @@
|
||||
//
|
||||
// DaemonRequestHandler.swift
|
||||
// AltDaemon
|
||||
//
|
||||
// Created by Riley Testut on 6/1/20.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
typealias DaemonConnectionManager = ConnectionManager<DaemonRequestHandler>
|
||||
|
||||
private let connectionManager = ConnectionManager(requestHandler: DaemonRequestHandler(),
|
||||
connectionHandlers: [XPCConnectionHandler()])
|
||||
|
||||
extension DaemonConnectionManager
|
||||
{
|
||||
static var shared: ConnectionManager {
|
||||
return connectionManager
|
||||
}
|
||||
}
|
||||
|
||||
struct DaemonRequestHandler: RequestHandler
|
||||
{
|
||||
func handleAnisetteDataRequest(_ request: AnisetteDataRequest, for connection: Connection, completionHandler: @escaping (Result<AnisetteDataResponse, Error>) -> Void)
|
||||
{
|
||||
do
|
||||
{
|
||||
let anisetteData = try AnisetteDataManager.shared.requestAnisetteData()
|
||||
|
||||
let response = AnisetteDataResponse(anisetteData: anisetteData)
|
||||
completionHandler(.success(response))
|
||||
}
|
||||
catch
|
||||
{
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
func handlePrepareAppRequest(_ request: PrepareAppRequest, for connection: Connection, completionHandler: @escaping (Result<InstallationProgressResponse, Error>) -> Void)
|
||||
{
|
||||
guard let fileURL = request.fileURL else { return completionHandler(.failure(ALTServerError(.invalidRequest))) }
|
||||
|
||||
print("Awaiting begin installation request...")
|
||||
|
||||
connection.receiveRequest() { (result) in
|
||||
print("Received begin installation request with result:", result)
|
||||
|
||||
do
|
||||
{
|
||||
guard case .beginInstallation(let request) = try result.get() else { throw ALTServerError(.unknownRequest) }
|
||||
guard let bundleIdentifier = request.bundleIdentifier else { throw ALTServerError(.invalidRequest) }
|
||||
|
||||
AppManager.shared.installApp(at: fileURL, bundleIdentifier: bundleIdentifier, activeProfiles: request.activeProfiles) { (result) in
|
||||
let result = result.map { InstallationProgressResponse(progress: 1.0) }
|
||||
print("Installed app with result:", result)
|
||||
|
||||
completionHandler(result)
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleInstallProvisioningProfilesRequest(_ request: InstallProvisioningProfilesRequest, for connection: Connection,
|
||||
completionHandler: @escaping (Result<InstallProvisioningProfilesResponse, Error>) -> Void)
|
||||
{
|
||||
AppManager.shared.install(request.provisioningProfiles, activeProfiles: request.activeProfiles) { (result) in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error):
|
||||
print("Failed to install profiles \(request.provisioningProfiles.map { $0.bundleIdentifier }):", error)
|
||||
completionHandler(.failure(error))
|
||||
|
||||
case .success:
|
||||
print("Installed profiles:", request.provisioningProfiles.map { $0.bundleIdentifier })
|
||||
|
||||
let response = InstallProvisioningProfilesResponse()
|
||||
completionHandler(.success(response))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleRemoveProvisioningProfilesRequest(_ request: RemoveProvisioningProfilesRequest, for connection: Connection,
|
||||
completionHandler: @escaping (Result<RemoveProvisioningProfilesResponse, Error>) -> Void)
|
||||
{
|
||||
AppManager.shared.removeProvisioningProfiles(forBundleIdentifiers: request.bundleIdentifiers) { (result) in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error):
|
||||
print("Failed to remove profiles \(request.bundleIdentifiers):", error)
|
||||
completionHandler(.failure(error))
|
||||
|
||||
case .success:
|
||||
print("Removed profiles:", request.bundleIdentifiers)
|
||||
|
||||
let response = RemoveProvisioningProfilesResponse()
|
||||
completionHandler(.success(response))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleRemoveAppRequest(_ request: RemoveAppRequest, for connection: Connection, completionHandler: @escaping (Result<RemoveAppResponse, Error>) -> Void)
|
||||
{
|
||||
AppManager.shared.removeApp(forBundleIdentifier: request.bundleIdentifier) { (result) in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error):
|
||||
print("Failed to remove app \(request.bundleIdentifier):", error)
|
||||
completionHandler(.failure(error))
|
||||
|
||||
case .success:
|
||||
print("Removed app:", request.bundleIdentifier)
|
||||
|
||||
let response = RemoveAppResponse()
|
||||
completionHandler(.success(response))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
93
AltDaemon/XPCConnectionHandler.swift
Normal file
93
AltDaemon/XPCConnectionHandler.swift
Normal file
@@ -0,0 +1,93 @@
|
||||
//
|
||||
// XPCConnectionHandler.swift
|
||||
// AltDaemon
|
||||
//
|
||||
// Created by Riley Testut on 9/14/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
class XPCConnectionHandler: NSObject, ConnectionHandler
|
||||
{
|
||||
var connectionHandler: ((Connection) -> Void)?
|
||||
var disconnectionHandler: ((Connection) -> Void)?
|
||||
|
||||
private let dispatchQueue = DispatchQueue(label: "io.altstore.XPCConnectionListener", qos: .utility)
|
||||
private let listeners = XPCConnection.machServiceNames.map { NSXPCListener.makeListener(machServiceName: $0) }
|
||||
|
||||
deinit
|
||||
{
|
||||
self.stopListening()
|
||||
}
|
||||
|
||||
func startListening()
|
||||
{
|
||||
for listener in self.listeners
|
||||
{
|
||||
listener.delegate = self
|
||||
listener.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func stopListening()
|
||||
{
|
||||
self.listeners.forEach { $0.suspend() }
|
||||
}
|
||||
}
|
||||
|
||||
private extension XPCConnectionHandler
|
||||
{
|
||||
func disconnect(_ connection: Connection)
|
||||
{
|
||||
connection.disconnect()
|
||||
|
||||
self.disconnectionHandler?(connection)
|
||||
}
|
||||
}
|
||||
|
||||
extension XPCConnectionHandler: NSXPCListenerDelegate
|
||||
{
|
||||
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool
|
||||
{
|
||||
let maximumPathLength = 4 * UInt32(MAXPATHLEN)
|
||||
|
||||
let pathBuffer = UnsafeMutablePointer<CChar>.allocate(capacity: Int(maximumPathLength))
|
||||
defer { pathBuffer.deallocate() }
|
||||
|
||||
proc_pidpath(newConnection.processIdentifier, pathBuffer, maximumPathLength)
|
||||
|
||||
let path = String(cString: pathBuffer)
|
||||
let fileURL = URL(fileURLWithPath: path)
|
||||
|
||||
var code: UnsafeMutableRawPointer?
|
||||
defer { code.map { Unmanaged<AnyObject>.fromOpaque($0).release() } }
|
||||
|
||||
var status = SecStaticCodeCreateWithPath(fileURL as CFURL, 0, &code)
|
||||
guard status == 0 else { return false }
|
||||
|
||||
var signingInfo: CFDictionary?
|
||||
defer { signingInfo.map { Unmanaged<AnyObject>.passUnretained($0).release() } }
|
||||
|
||||
status = SecCodeCopySigningInformation(code, kSecCSInternalInformation | kSecCSSigningInformation, &signingInfo)
|
||||
guard status == 0 else { return false }
|
||||
|
||||
// Only accept connections from AltStore.
|
||||
guard
|
||||
let codeSigningInfo = signingInfo as? [String: Any],
|
||||
let bundleIdentifier = codeSigningInfo["identifier"] as? String,
|
||||
bundleIdentifier.contains(Bundle.Info.appbundleIdentifier)
|
||||
else { return false }
|
||||
|
||||
let connection = XPCConnection(newConnection)
|
||||
newConnection.invalidationHandler = { [weak self, weak connection] in
|
||||
guard let self = self, let connection = connection else { return }
|
||||
self.disconnect(connection)
|
||||
}
|
||||
|
||||
self.connectionHandler?(connection)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
14
AltDaemon/main.swift
Normal file
14
AltDaemon/main.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// main.swift
|
||||
// AltDaemon
|
||||
//
|
||||
// Created by Riley Testut on 6/2/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
autoreleasepool {
|
||||
DaemonConnectionManager.shared.start()
|
||||
RunLoop.current.run()
|
||||
}
|
||||
10
AltDaemon/package/DEBIAN/control
Normal file
10
AltDaemon/package/DEBIAN/control
Normal file
@@ -0,0 +1,10 @@
|
||||
Package: com.rileytestut.altdaemon
|
||||
Name: AltDaemon
|
||||
Depends:
|
||||
Version: 1.0
|
||||
Architecture: iphoneos-arm
|
||||
Description: AltDaemon allows AltStore to install and refresh apps without a computer.
|
||||
Maintainer: Riley Testut
|
||||
Author: Riley Testut
|
||||
Homepage: https://altstore.io
|
||||
Section: System
|
||||
2
AltDaemon/package/DEBIAN/postinst
Executable file
2
AltDaemon/package/DEBIAN/postinst
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
launchctl load /Library/LaunchDaemons/com.rileytestut.altdaemon.plist
|
||||
2
AltDaemon/package/DEBIAN/preinst
Executable file
2
AltDaemon/package/DEBIAN/preinst
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
launchctl unload /Library/LaunchDaemons/com.rileytestut.altdaemon.plist >> /dev/null 2>&1
|
||||
2
AltDaemon/package/DEBIAN/prerm
Executable file
2
AltDaemon/package/DEBIAN/prerm
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
launchctl unload /Library/LaunchDaemons/com.rileytestut.altdaemon.plist
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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>Label</key>
|
||||
<string>com.rileytestut.altdaemon</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/bin/env</string>
|
||||
<string>_MSSafeMode=1</string>
|
||||
<string>_SafeMode=1</string>
|
||||
<string>/usr/bin/AltDaemon</string>
|
||||
</array>
|
||||
<key>UserName</key>
|
||||
<string>mobile</string>
|
||||
<key>KeepAlive</key>
|
||||
<false/>
|
||||
<key>RunAtLoad</key>
|
||||
<false/>
|
||||
<key>MachServices</key>
|
||||
<dict>
|
||||
<key>cy:io.altstore.altdaemon</key>
|
||||
<true/>
|
||||
<key>lh:io.altstore.altdaemon</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
AltDaemon/package/usr/bin/AltDaemon
Executable file
BIN
AltDaemon/package/usr/bin/AltDaemon
Executable file
Binary file not shown.
48
AltServer/ErrorDetailsViewController.swift
Normal file
48
AltServer/ErrorDetailsViewController.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// ErrorDetailsViewController.swift
|
||||
// AltServer
|
||||
//
|
||||
// Created by Riley Testut on 10/4/22.
|
||||
// Copyright © 2022 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
|
||||
class ErrorDetailsViewController: NSViewController
|
||||
{
|
||||
var error: NSError? {
|
||||
didSet {
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
@IBOutlet private var errorCodeLabel: NSTextField!
|
||||
@IBOutlet private var detailedDescriptionLabel: NSTextField!
|
||||
|
||||
override func viewDidLoad()
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
self.detailedDescriptionLabel.preferredMaxLayoutWidth = 800
|
||||
}
|
||||
}
|
||||
|
||||
private extension ErrorDetailsViewController
|
||||
{
|
||||
func update()
|
||||
{
|
||||
if !self.isViewLoaded
|
||||
{
|
||||
self.loadView()
|
||||
}
|
||||
|
||||
guard let error = self.error else { return }
|
||||
|
||||
self.errorCodeLabel.stringValue = error.localizedErrorCode
|
||||
|
||||
let font = self.detailedDescriptionLabel.font ?? NSFont.systemFont(ofSize: 12)
|
||||
let detailedDescription = error.formattedDetailedDescription(with: font)
|
||||
self.detailedDescriptionLabel.attributedStringValue = detailedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
#include "../Build.xcconfig"
|
||||
#include "Build.xcconfig"
|
||||
|
||||
PRODUCT_BUNDLE_IDENTIFIER = $(PRODUCT_BUNDLE_IDENTIFIER)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"originHash" : "ee46302f91cbb62c5234c36750d40856658e961e191f5536cf4fe74d10fc2c94",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "altsign",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SideStore/AltSign",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "4323ff794e600ce1759cb6ea57275e13b7ea72f2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "appcenter-sdk-apple",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/microsoft/appcenter-sdk-apple.git",
|
||||
"state" : {
|
||||
"revision" : "b2dc99cfedead0bad4e6573d86c5228c89cff332",
|
||||
"version" : "4.4.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "imobiledevice.swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SideStore/iMobileDevice.swift",
|
||||
"state" : {
|
||||
"revision" : "74e481106dd155c0cd21bca6795fd9fe5f751654",
|
||||
"version" : "1.0.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "keychainaccess",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kishikawakatsumi/KeychainAccess.git",
|
||||
"state" : {
|
||||
"revision" : "84e546727d66f1adc5439debad16270d0fdd04e7",
|
||||
"version" : "4.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "launchatlogin",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sindresorhus/LaunchAtLogin.git",
|
||||
"state" : {
|
||||
"revision" : "e8171b3e38a2816f579f58f3dac1522aa39efe41",
|
||||
"version" : "4.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "nuke",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kean/Nuke.git",
|
||||
"state" : {
|
||||
"revision" : "9318d02a8a6d20af56505c9673261c1fd3b3aebe",
|
||||
"version" : "7.6.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "openssl",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/krzyzanowskim/OpenSSL",
|
||||
"state" : {
|
||||
"revision" : "8cb1d641ab5ebce2cd7cf31c93baef07bed672d4",
|
||||
"version" : "1.1.2301"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "plcrashreporter",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/microsoft/PLCrashReporter.git",
|
||||
"state" : {
|
||||
"revision" : "81cdec2b3827feb03286cb297f4c501a8eb98df1",
|
||||
"version" : "1.10.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "semanticversion",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SwiftPackageIndex/SemanticVersion.git",
|
||||
"state" : {
|
||||
"revision" : "ea8eea9d89842a29af1b8e6c7677f1c86e72fa42",
|
||||
"version" : "0.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sparkle",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sparkle-project/Sparkle.git",
|
||||
"state" : {
|
||||
"revision" : "0ef1ee0220239b3776f433314515fd849025673f",
|
||||
"version" : "2.6.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "starscream",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/daltoniam/Starscream.git",
|
||||
"state" : {
|
||||
"revision" : "c6bfd1af48efcc9a9ad203665db12375ba6b145a",
|
||||
"version" : "4.0.8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "stprivilegedtask",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/JoeMatt/STPrivilegedTask.git",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "10a9150ef32d444af326beba76356ae9af95a3e7"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1610"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BF58047A246A28F7008AE704"
|
||||
BuildableName = "AltBackup.app"
|
||||
BlueprintName = "AltBackup"
|
||||
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</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">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BF58047A246A28F7008AE704"
|
||||
BuildableName = "AltBackup.app"
|
||||
BlueprintName = "AltBackup"
|
||||
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
<CommandLineArgument
|
||||
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
||||
isEnabled = "NO">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "-com.apple.CoreData.MigrationDebug 1"
|
||||
isEnabled = "YES">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "-com.apple.CoreData.SQLiteIntegrityCheck 1"
|
||||
isEnabled = "NO">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "-com.apple.CoreData.SQLDebug 1"
|
||||
isEnabled = "NO">
|
||||
</CommandLineArgument>
|
||||
</CommandLineArguments>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "OS_ACTIVITY_MODE"
|
||||
value = "$(DEBUG_ACTIVITY_MODE)"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "OBJC_DEBUG_DUPLICATE_CLASSES"
|
||||
value = "$(DEBUG_DUPLICATE_CLASSES)"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BF58047A246A28F7008AE704"
|
||||
BuildableName = "AltBackup.app"
|
||||
BlueprintName = "AltBackup"
|
||||
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -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>
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1020"
|
||||
version = "1.7">
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
@@ -26,8 +26,9 @@
|
||||
buildConfiguration = "Release"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Release"
|
||||
@@ -52,7 +53,7 @@
|
||||
<CommandLineArguments>
|
||||
<CommandLineArgument
|
||||
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
||||
isEnabled = "NO">
|
||||
isEnabled = "YES">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "-com.apple.CoreData.MigrationDebug 1"
|
||||
@@ -73,11 +74,6 @@
|
||||
value = "$(DEBUG_ACTIVITY_MODE)"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "OBJC_DEBUG_DUPLICATE_CLASSES"
|
||||
value = "$(DEBUG_DUPLICATE_CLASSES)"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1610"
|
||||
version = "1.7">
|
||||
LastUpgradeVersion = "1020"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
@@ -28,28 +27,7 @@
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<TestPlans>
|
||||
<TestPlanReference
|
||||
reference = "container:SideStore/Tests/SideStoreTests.xctestplan"
|
||||
default = "YES">
|
||||
</TestPlanReference>
|
||||
</TestPlans>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A8E2DB202D684CBD009E5D31"
|
||||
BuildableName = "UITests.xctest"
|
||||
BlueprintName = "UITests"
|
||||
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||
</BuildableReference>
|
||||
<SelectedTests>
|
||||
<Test
|
||||
Identifier = "UITests/testExample()">
|
||||
</Test>
|
||||
</SelectedTests>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
@@ -75,7 +53,7 @@
|
||||
<CommandLineArguments>
|
||||
<CommandLineArgument
|
||||
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
||||
isEnabled = "NO">
|
||||
isEnabled = "YES">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "-com.apple.CoreData.MigrationDebug 1"
|
||||
@@ -96,11 +74,6 @@
|
||||
value = "$(DEBUG_ACTIVITY_MODE)"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "OBJC_DEBUG_DUPLICATE_CLASSES"
|
||||
value = "$(DEBUG_DUPLICATE_CLASSES)"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
|
||||
13
AltStore.xcworkspace/contents.xcworkspacedata
generated
13
AltStore.xcworkspace/contents.xcworkspacedata
generated
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:AltStore.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:SideStore/AltSign">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Dependencies/Roxas/Roxas.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -2,14 +2,14 @@
|
||||
<!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.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>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>
|
||||
@@ -14,13 +14,7 @@ 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
|
||||
{
|
||||
@@ -30,14 +24,10 @@ extension AnalyticsManager
|
||||
case bundleIdentifier
|
||||
case developerName
|
||||
case version
|
||||
case buildVersion
|
||||
case size
|
||||
case tintColor
|
||||
case sourceIdentifier
|
||||
case sourceURL
|
||||
case patreonURL
|
||||
case pledgeAmount
|
||||
case pledgeCurrency
|
||||
}
|
||||
|
||||
enum Event
|
||||
@@ -69,14 +59,10 @@ extension AnalyticsManager
|
||||
.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
|
||||
.sourceURL: app.storeApp?.source?.sourceURL.absoluteString
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ final class AppContentViewController: UITableViewController
|
||||
{
|
||||
var app: StoreApp!
|
||||
|
||||
// private lazy var screenshotsDataSource = self.makeScreenshotsDataSource()
|
||||
private lazy var screenshotsDataSource = self.makeScreenshotsDataSource()
|
||||
private lazy var permissionsDataSource = self.makePermissionsDataSource()
|
||||
|
||||
private lazy var dateFormatter: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .medium
|
||||
@@ -43,113 +45,150 @@ 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!
|
||||
|
||||
@IBOutlet private(set) var appScreenshotsViewController: AppScreenshotsViewController!
|
||||
@IBOutlet private var appScreenshotsHeightConstraint: NSLayoutConstraint!
|
||||
@IBOutlet private var screenshotsCollectionView: UICollectionView!
|
||||
@IBOutlet private var permissionsCollectionView: UICollectionView!
|
||||
|
||||
@IBOutlet private(set) var appDetailCollectionViewController: AppDetailCollectionViewController!
|
||||
@IBOutlet private var appDetailCollectionViewHeightConstraint: NSLayoutConstraint!
|
||||
var preferredScreenshotSize: CGSize? {
|
||||
let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
||||
|
||||
let aspectRatio: CGFloat = 16.0 / 9.0 // Hardcoded for now.
|
||||
|
||||
let width = self.screenshotsCollectionView.bounds.width - (layout.minimumInteritemSpacing * 2)
|
||||
|
||||
let itemWidth = width / 1.5
|
||||
let itemHeight = itemWidth * aspectRatio
|
||||
|
||||
return CGSize(width: itemWidth, height: itemHeight)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
override func viewDidLoad()
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
self.tableView.contentInset.bottom = 20
|
||||
|
||||
self.screenshotsCollectionView.dataSource = self.screenshotsDataSource
|
||||
self.screenshotsCollectionView.prefetchDataSource = self.screenshotsDataSource
|
||||
|
||||
self.permissionsCollectionView.dataSource = self.permissionsDataSource
|
||||
|
||||
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"
|
||||
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"
|
||||
if let version = self.app.latestAvailableVersion
|
||||
{
|
||||
self.versionDescriptionTextView.text = version.localizedDescription
|
||||
self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), version.version)
|
||||
self.versionDateLabel.text = Date().relativeDateString(since: version.date, dateFormatter: self.dateFormatter)
|
||||
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()
|
||||
{
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
var needsTableViewUpdate = false
|
||||
guard var size = self.preferredScreenshotSize else { return }
|
||||
size.height = min(size.height, self.screenshotsCollectionView.bounds.height) // Silence temporary "item too tall" warning.
|
||||
|
||||
let screenshotsHeight = self.appScreenshotsViewController.collectionView.contentSize.height
|
||||
if self.appScreenshotsHeightConstraint.constant != screenshotsHeight && screenshotsHeight > 0
|
||||
{
|
||||
self.appScreenshotsHeightConstraint.constant = screenshotsHeight
|
||||
needsTableViewUpdate = true
|
||||
}
|
||||
let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
||||
layout.itemSize = size
|
||||
}
|
||||
|
||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?)
|
||||
{
|
||||
guard segue.identifier == "showPermission" else { return }
|
||||
|
||||
let permissionsHeight = self.appDetailCollectionViewController.collectionView.contentSize.height
|
||||
if self.appDetailCollectionViewHeightConstraint.constant != permissionsHeight && permissionsHeight > 0
|
||||
{
|
||||
self.appDetailCollectionViewHeightConstraint.constant = permissionsHeight
|
||||
needsTableViewUpdate = true
|
||||
}
|
||||
guard let cell = sender as? UICollectionViewCell, let indexPath = self.permissionsCollectionView.indexPath(for: cell) else { return }
|
||||
|
||||
if needsTableViewUpdate
|
||||
{
|
||||
UIView.performWithoutAnimation {
|
||||
// Update row height without animation.
|
||||
self.tableView.beginUpdates()
|
||||
self.tableView.endUpdates()
|
||||
}
|
||||
}
|
||||
let permission = self.permissionsDataSource.item(at: indexPath)
|
||||
|
||||
let maximumWidth = self.view.bounds.width - 20
|
||||
|
||||
let permissionPopoverViewController = segue.destination as! PermissionPopoverViewController
|
||||
permissionPopoverViewController.permission = permission
|
||||
permissionPopoverViewController.view.widthAnchor.constraint(lessThanOrEqualToConstant: maximumWidth).isActive = true
|
||||
|
||||
let size = permissionPopoverViewController.view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
permissionPopoverViewController.preferredContentSize = size
|
||||
|
||||
permissionPopoverViewController.popoverPresentationController?.delegate = self
|
||||
permissionPopoverViewController.popoverPresentationController?.sourceRect = cell.frame
|
||||
permissionPopoverViewController.popoverPresentationController?.sourceView = self.permissionsCollectionView
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppContentViewController
|
||||
{
|
||||
@IBSegueAction
|
||||
func makeAppScreenshotsViewController(_ coder: NSCoder, sender: Any?) -> UIViewController?
|
||||
func makeScreenshotsDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>
|
||||
{
|
||||
let appScreenshotsViewController = AppScreenshotsViewController(app: self.app, coder: coder)
|
||||
self.appScreenshotsViewController = appScreenshotsViewController
|
||||
return appScreenshotsViewController
|
||||
}
|
||||
|
||||
func makePermissionsDataSource() -> RSTArrayCollectionViewDataSource<AppPermission>
|
||||
{
|
||||
let dataSource = RSTArrayCollectionViewDataSource(items: Array(self.app.permissions))
|
||||
dataSource.cellConfigurationHandler = { (cell, permission, indexPath) in
|
||||
let cell = cell as! PermissionCollectionViewCell
|
||||
// cell.button.setImage(permission.type.icon, for: .normal)
|
||||
// cell.button.tintColor = .label
|
||||
// cell.textLabel.text = permission.type.localizedShortName ?? permission.type.localizedName
|
||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: self.app.screenshotURLs as [NSURL])
|
||||
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in
|
||||
let cell = cell as! ScreenshotCollectionViewCell
|
||||
cell.imageView.image = nil
|
||||
cell.imageView.isIndicatingActivity = true
|
||||
}
|
||||
dataSource.prefetchHandler = { (imageURL, indexPath, completionHandler) in
|
||||
return RSTAsyncBlockOperation() { (operation) in
|
||||
let request = ImageRequest(url: imageURL as URL, processor: .screenshot)
|
||||
ImagePipeline.shared.loadImage(with: request, progress: nil, completion: { (response, error) in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
if let image = response?.image
|
||||
{
|
||||
completionHandler(image, nil)
|
||||
}
|
||||
else
|
||||
{
|
||||
completionHandler(nil, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
||||
let cell = cell as! ScreenshotCollectionViewCell
|
||||
cell.imageView.isIndicatingActivity = false
|
||||
cell.imageView.image = image
|
||||
|
||||
let icon = UIImage(systemName: permission.symbolName ?? "lock")
|
||||
cell.button.setImage(icon, for: .normal)
|
||||
|
||||
cell.textLabel.text = permission.localizedDisplayName
|
||||
if let error = error
|
||||
{
|
||||
print("Error loading image:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
@IBSegueAction
|
||||
func makeAppDetailCollectionViewController(_ coder: NSCoder, sender: Any?) -> UIViewController?
|
||||
{
|
||||
let appDetailViewController = AppDetailCollectionViewController(app: self.app, coder: coder)
|
||||
self.appDetailCollectionViewController = appDetailViewController
|
||||
return appDetailViewController
|
||||
func makePermissionsDataSource() -> RSTArrayCollectionViewDataSource<AppPermission>
|
||||
{
|
||||
let dataSource = RSTArrayCollectionViewDataSource(items: self.app.permissions)
|
||||
dataSource.cellConfigurationHandler = { (cell, permission, indexPath) in
|
||||
let cell = cell as! PermissionCollectionViewCell
|
||||
cell.button.setImage(permission.type.icon, for: .normal)
|
||||
cell.button.tintColor = .label
|
||||
cell.textLabel.text = permission.type.localizedShortName ?? permission.type.localizedName
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,12 +200,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,15 +224,23 @@ extension AppContentViewController
|
||||
switch Row.allCases[indexPath.row]
|
||||
{
|
||||
case .screenshots:
|
||||
guard !self.app.allScreenshots.isEmpty else { return 0.0 }
|
||||
return UITableView.automaticDimension
|
||||
guard let size = self.preferredScreenshotSize else { return 0.0 }
|
||||
return size.height
|
||||
|
||||
case .permissions:
|
||||
guard !self.app.permissions.isEmpty else { return 0.0 }
|
||||
return UITableView.automaticDimension
|
||||
return super.tableView(tableView, heightForRowAt: indexPath)
|
||||
|
||||
default:
|
||||
return super.tableView(tableView, heightForRowAt: indexPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppContentViewController: UIPopoverPresentationControllerDelegate
|
||||
{
|
||||
func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle
|
||||
{
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
//
|
||||
// AppDetailCollectionViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/5/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
import AltStoreCore
|
||||
import Roxas
|
||||
|
||||
extension AppDetailCollectionViewController
|
||||
{
|
||||
private enum Section: Int
|
||||
{
|
||||
case privacy
|
||||
case knownEntitlements
|
||||
case unknownEntitlements
|
||||
}
|
||||
|
||||
private enum ElementKind: String
|
||||
{
|
||||
case title
|
||||
case button
|
||||
}
|
||||
|
||||
@objc(SafeAreaIgnoringCollectionView)
|
||||
private class SafeAreaIgnoringCollectionView: UICollectionView
|
||||
{
|
||||
override var safeAreaInsets: UIEdgeInsets {
|
||||
get {
|
||||
// Fixes incorrect layout if collection view height is taller than safe area height.
|
||||
return .zero
|
||||
}
|
||||
set {
|
||||
// There MUST be a setter for this to work, even if it does nothing ¯\_(ツ)_/¯
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AppDetailCollectionViewController: UICollectionViewController
|
||||
{
|
||||
let app: StoreApp
|
||||
private let privacyPermissions: [AppPermission]
|
||||
private let knownEntitlementPermissions: [AppPermission]
|
||||
private let unknownEntitlementPermissions: [AppPermission]
|
||||
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
private lazy var privacyDataSource = self.makePrivacyDataSource()
|
||||
private lazy var entitlementsDataSource = self.makeEntitlementsDataSource()
|
||||
|
||||
private var headerRegistration: UICollectionView.SupplementaryRegistration<UICollectionViewListCell>!
|
||||
|
||||
override var collectionViewLayout: UICollectionViewCompositionalLayout {
|
||||
return self.collectionView.collectionViewLayout as! UICollectionViewCompositionalLayout
|
||||
}
|
||||
|
||||
init?(app: StoreApp, coder: NSCoder)
|
||||
{
|
||||
self.app = app
|
||||
|
||||
let comparator: (AppPermission, AppPermission) -> Bool = { (permissionA, permissionB) -> Bool in
|
||||
switch (permissionA.localizedName, permissionB.localizedName)
|
||||
{
|
||||
case (let nameA?, let nameB?):
|
||||
// Sort by localizedName, if both have one.
|
||||
return nameA.localizedStandardCompare(nameB) == .orderedAscending
|
||||
|
||||
case (nil, nil):
|
||||
// Sort by raw permission value as fallback.
|
||||
return permissionA.permission.rawValue < permissionB.permission.rawValue
|
||||
|
||||
// Sort "known" permissions before "unknown" ones.
|
||||
case (_?, nil): return true
|
||||
case (nil, _?): return false
|
||||
}
|
||||
}
|
||||
|
||||
self.privacyPermissions = app.permissions.filter { $0.type == .privacy }.sorted(by: comparator)
|
||||
|
||||
let entitlementPermissions = app.permissions.lazy.filter { $0.type == .entitlement }
|
||||
self.knownEntitlementPermissions = entitlementPermissions.filter { $0.isKnown }.sorted(by: comparator)
|
||||
self.unknownEntitlementPermissions = entitlementPermissions.filter { !$0.isKnown }.sorted(by: comparator)
|
||||
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad()
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
// Allow parent background color to show through.
|
||||
self.collectionView.backgroundColor = nil
|
||||
|
||||
// Match the parent table view margins.
|
||||
self.collectionView.directionalLayoutMargins.leading = 20
|
||||
self.collectionView.directionalLayoutMargins.trailing = 20
|
||||
|
||||
let collectionViewLayout = self.makeLayout()
|
||||
self.collectionView.collectionViewLayout = collectionViewLayout
|
||||
|
||||
self.collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "PrivacyCell")
|
||||
self.collectionView.register(UICollectionViewListCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||
|
||||
self.headerRegistration = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { [weak self] (headerView, elementKind, indexPath) in
|
||||
var configuration = UIListContentConfiguration.plainHeader()
|
||||
|
||||
// Match parent table view section headers.
|
||||
configuration.textProperties.font = UIFont.systemFont(ofSize: 22, weight: .bold) // .boldSystemFont(ofSize:) returns *semi-bold* color smh.
|
||||
configuration.textProperties.color = .label
|
||||
|
||||
switch Section(rawValue: indexPath.section)!
|
||||
{
|
||||
case .privacy: break
|
||||
case .knownEntitlements:
|
||||
configuration.text = nil
|
||||
|
||||
configuration.secondaryTextProperties.font = UIFont.preferredFont(forTextStyle: .callout)
|
||||
configuration.textToSecondaryTextVerticalPadding = 8
|
||||
configuration.secondaryText = NSLocalizedString("Entitlements are additional permissions that grant access to certain system services, including potentially sensitive information.", comment: "")
|
||||
|
||||
case .unknownEntitlements:
|
||||
configuration.text = NSLocalizedString("Other Entitlements", comment: "")
|
||||
|
||||
let action = UIAction(image: UIImage(systemName: "questionmark.circle")) { _ in
|
||||
self?.showUnknownEntitlementsAlert()
|
||||
}
|
||||
|
||||
let helpButton = UIButton(primaryAction: action)
|
||||
let customAccessory = UICellAccessory.customView(configuration: .init(customView: helpButton, placement: .trailing(), tintColor: self?.app.tintColor ?? .altPrimary))
|
||||
headerView.accessories = [customAccessory]
|
||||
}
|
||||
|
||||
headerView.contentConfiguration = configuration
|
||||
headerView.backgroundConfiguration = UIBackgroundConfiguration.clear()
|
||||
}
|
||||
|
||||
self.dataSource.proxy = self
|
||||
self.collectionView.dataSource = self.dataSource
|
||||
self.collectionView.delegate = self
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppDetailCollectionViewController
|
||||
{
|
||||
func makeLayout() -> UICollectionViewCompositionalLayout
|
||||
{
|
||||
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
|
||||
layoutConfig.contentInsetsReference = .layoutMargins
|
||||
|
||||
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [privacyPermissions, knownEntitlementPermissions, unknownEntitlementPermissions] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
|
||||
guard let section = Section(rawValue: sectionIndex) else { return nil }
|
||||
switch section
|
||||
{
|
||||
case .privacy:
|
||||
guard !privacyPermissions.isEmpty, #available(iOS 16, *) else { return nil } // Hide section pre-iOS 16.
|
||||
|
||||
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(50)) // Underestimate height to prevent jumping size abruptly.
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
|
||||
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(50))
|
||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
|
||||
|
||||
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||
layoutSection.interGroupSpacing = 10
|
||||
return layoutSection
|
||||
|
||||
case .knownEntitlements where !knownEntitlementPermissions.isEmpty: fallthrough
|
||||
case .unknownEntitlements where !unknownEntitlementPermissions.isEmpty:
|
||||
var configuration = UICollectionLayoutListConfiguration(appearance: .plain)
|
||||
configuration.headerMode = .supplementary
|
||||
configuration.showsSeparators = false
|
||||
configuration.backgroundColor = .altBackground
|
||||
|
||||
let layoutSection = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment)
|
||||
layoutSection.contentInsets.top = 4
|
||||
return layoutSection
|
||||
|
||||
case .knownEntitlements, .unknownEntitlements: return nil
|
||||
}
|
||||
}, configuration: layoutConfig)
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
func makeDataSource() -> RSTCompositeCollectionViewDataSource<AppPermission>
|
||||
{
|
||||
let dataSource = RSTCompositeCollectionViewDataSource(dataSources: [self.privacyDataSource, self.entitlementsDataSource])
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func makePrivacyDataSource() -> RSTDynamicCollectionViewDataSource<AppPermission>
|
||||
{
|
||||
let dataSource = RSTDynamicCollectionViewDataSource<AppPermission>()
|
||||
dataSource.cellIdentifierHandler = { _ in "PrivacyCell" }
|
||||
dataSource.numberOfSectionsHandler = { 1 }
|
||||
dataSource.cellConfigurationHandler = { [weak self] (cell, _, indexPath) in
|
||||
guard let self, #available(iOS 16, *) else { return }
|
||||
|
||||
cell.contentConfiguration = UIHostingConfiguration {
|
||||
AppPermissionsCard(title: "Privacy",
|
||||
description: "\(self.app.name) may request access to the following:",
|
||||
tintColor: Color(uiColor: self.app.tintColor ?? .altPrimary),
|
||||
permissions: self.privacyPermissions)
|
||||
}
|
||||
.margins(.horizontal, 0)
|
||||
}
|
||||
|
||||
if #available(iOS 16, *)
|
||||
{
|
||||
dataSource.numberOfItemsHandler = { [privacyPermissions] _ in !privacyPermissions.isEmpty ? 1 : 0 }
|
||||
}
|
||||
else
|
||||
{
|
||||
dataSource.numberOfItemsHandler = { _ in 0 }
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func makeEntitlementsDataSource() -> RSTCompositeCollectionViewDataSource<AppPermission>
|
||||
{
|
||||
let knownEntitlementsDataSource = RSTArrayCollectionViewDataSource(items: self.knownEntitlementPermissions)
|
||||
let unknownEntitlementsDataSource = RSTArrayCollectionViewDataSource(items: self.unknownEntitlementPermissions)
|
||||
|
||||
let dataSource = RSTCompositeCollectionViewDataSource(dataSources: [knownEntitlementsDataSource, unknownEntitlementsDataSource])
|
||||
dataSource.cellConfigurationHandler = { [weak self] (cell, appPermission, _) in
|
||||
let cell = cell as! UICollectionViewListCell
|
||||
let tintColor = self?.app.tintColor ?? .altPrimary
|
||||
|
||||
var content = cell.defaultContentConfiguration()
|
||||
content.text = appPermission.localizedDisplayName
|
||||
content.secondaryText = appPermission.permission.rawValue
|
||||
content.secondaryTextProperties.color = .secondaryLabel
|
||||
|
||||
if appPermission.isKnown
|
||||
{
|
||||
content.image = UIImage(systemName: appPermission.effectiveSymbolName)
|
||||
content.imageProperties.tintColor = tintColor
|
||||
|
||||
if #available(iOS 15.4, *) /*, let self */ // Capturing self leads to strong-reference cycle.
|
||||
{
|
||||
let detailAccessory = UICellAccessory.detail(options: .init(tintColor: tintColor)) {
|
||||
self?.showPermissionAlert(for: appPermission)
|
||||
}
|
||||
cell.accessories = [detailAccessory]
|
||||
}
|
||||
}
|
||||
|
||||
cell.contentConfiguration = content
|
||||
cell.backgroundConfiguration = UIBackgroundConfiguration.clear()
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppDetailCollectionViewController
|
||||
{
|
||||
func showPermissionAlert(for permission: AppPermission)
|
||||
{
|
||||
let alertController = UIAlertController(title: permission.localizedDisplayName, message: permission.localizedDescription, preferredStyle: .alert)
|
||||
alertController.addAction(.ok)
|
||||
self.present(alertController, animated: true)
|
||||
}
|
||||
|
||||
func showUnknownEntitlementsAlert()
|
||||
{
|
||||
let alertController = UIAlertController(title: NSLocalizedString("Other Entitlements", comment: ""), message: NSLocalizedString("SideStore does not have detailed information for these entitlements.", comment: ""), preferredStyle: .alert)
|
||||
alertController.addAction(.ok)
|
||||
self.present(alertController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension AppDetailCollectionViewController
|
||||
{
|
||||
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
|
||||
{
|
||||
let headerView = self.collectionView.dequeueConfiguredReusableSupplementary(using: self.headerRegistration, for: indexPath)
|
||||
return headerView
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool
|
||||
{
|
||||
return false
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool
|
||||
{
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
//
|
||||
// AppPermissionsCard.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/4/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
import AltStoreCore
|
||||
|
||||
@available(iOS 16, *)
|
||||
extension AppPermissionsCard
|
||||
{
|
||||
private struct TransitionKey: Hashable
|
||||
{
|
||||
static func name(_ permission: Permission) -> TransitionKey {
|
||||
TransitionKey(key: "name", permission: permission)
|
||||
}
|
||||
|
||||
static func icon(_ permission: Permission) -> TransitionKey {
|
||||
TransitionKey(key: "icon", permission: permission)
|
||||
}
|
||||
|
||||
let key: String
|
||||
let permission: Permission
|
||||
|
||||
private init(key: String, permission: Permission)
|
||||
{
|
||||
self.key = key
|
||||
self.permission = permission
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16, *)
|
||||
struct AppPermissionsCard<Permission: AppPermissionProtocol>: View
|
||||
{
|
||||
let title: LocalizedStringKey
|
||||
let description: LocalizedStringKey
|
||||
let tintColor: Color
|
||||
|
||||
let permissions: [Permission]
|
||||
|
||||
@State
|
||||
private var selectedPermission: Permission?
|
||||
|
||||
@Namespace
|
||||
private var animation
|
||||
|
||||
private var isTitleVisible: Bool {
|
||||
if selectedPermission == nil
|
||||
{
|
||||
// Title should always be visible when showing all permissions.
|
||||
return true
|
||||
}
|
||||
|
||||
// If showing permission details, only show title if there
|
||||
// are more than 2 permissions total to save vertical space.
|
||||
let isTitleVisible = permissions.count > 2
|
||||
return isTitleVisible
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let title = Text(title)
|
||||
.font(.title3)
|
||||
.bold()
|
||||
.minimumScaleFactor(0.1) // Avoid clipping during matchedGeometryEffect animation.
|
||||
|
||||
VStack(spacing: 8) {
|
||||
if isTitleVisible
|
||||
{
|
||||
// If title is visible, place _outside_ `content`
|
||||
// to avoid being covered by permissionDetailView.
|
||||
title
|
||||
}
|
||||
|
||||
let content = VStack(spacing: 8) {
|
||||
if !isTitleVisible
|
||||
{
|
||||
// Place title inside `content` when not visible
|
||||
// so it's covered by permissionDetailView.
|
||||
title
|
||||
}
|
||||
|
||||
VStack(spacing: 20) {
|
||||
Text(description)
|
||||
.font(.subheadline)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Grid(verticalSpacing: 15) {
|
||||
ForEach(permissions, id: \.self) { permission in
|
||||
permissionRow(for: permission)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Tap a permission to learn more.")
|
||||
.font(.subheadline)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
if let selectedPermission
|
||||
{
|
||||
// Hide content with overlay to preserve existing size.
|
||||
content.hidden().overlay {
|
||||
permissionDetailView(for: selectedPermission)
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
content
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if selectedPermission != nil
|
||||
{
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.imageScale(.medium)
|
||||
}
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(20)
|
||||
.overlay {
|
||||
if selectedPermission != nil
|
||||
{
|
||||
// Make entire view tappable when overlay is visible.
|
||||
SwiftUI.Button(action: hidePermission) {
|
||||
VStack {}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary) // Vibrancy
|
||||
.background(.regularMaterial) // Blur background for auto-legibility correction.
|
||||
.background(tintColor, in: RoundedRectangle(cornerRadius: 30, style: .continuous))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func permissionRow(for permission: Permission) -> some View
|
||||
{
|
||||
GridRow {
|
||||
SwiftUI.Button(action: { show(permission) }) {
|
||||
HStack {
|
||||
let text = Text(permission.localizedDisplayName)
|
||||
.font(.body)
|
||||
.bold()
|
||||
.minimumScaleFactor(0.33)
|
||||
.lineLimit(.max) // Setting lineLimit to anything fixes text wrapping at large text sizes.
|
||||
|
||||
let image = Image(systemName: permission.effectiveSymbolName)
|
||||
.gridColumnAlignment(.center)
|
||||
|
||||
if selectedPermission != nil
|
||||
{
|
||||
Label(title: { text }, icon: { image })
|
||||
.hidden()
|
||||
}
|
||||
else
|
||||
{
|
||||
Label {
|
||||
text.matchedGeometryEffect(id: TransitionKey.name(permission), in: animation)
|
||||
} icon: {
|
||||
image.matchedGeometryEffect(id: TransitionKey.icon(permission), in: animation)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "info.circle")
|
||||
.imageScale(.large)
|
||||
}
|
||||
.contentShape(Rectangle()) // Make entire HStack tappable.
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 30) // Make row tall enough to tap.
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func permissionDetailView(for permission: Permission) -> some View
|
||||
{
|
||||
VStack(spacing: 15) {
|
||||
Image(systemName: permission.effectiveSymbolName)
|
||||
.font(.largeTitle)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.matchedGeometryEffect(id: TransitionKey.icon(permission), in: animation)
|
||||
|
||||
Text(permission.localizedDisplayName)
|
||||
.font(.title2)
|
||||
.bold()
|
||||
.minimumScaleFactor(0.1) // Avoid clipping during matchedGeometryEffect animation.
|
||||
.matchedGeometryEffect(id: TransitionKey.name(permission), in: animation)
|
||||
|
||||
if let usageDescription = permission.usageDescription
|
||||
{
|
||||
Text(usageDescription)
|
||||
.font(.subheadline)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
}
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
init(title: LocalizedStringKey, description: LocalizedStringKey, tintColor: Color, permissions: [Permission])
|
||||
{
|
||||
self.init(title: title, description: description, tintColor: tintColor, permissions: permissions, selectedPermission: nil)
|
||||
}
|
||||
|
||||
fileprivate init(title: LocalizedStringKey, description: LocalizedStringKey, tintColor: Color, permissions: [Permission], selectedPermission: Permission? = nil)
|
||||
{
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.tintColor = tintColor
|
||||
self.permissions = permissions
|
||||
|
||||
// Set _selectedPermission directly or else the preview won't detect it.
|
||||
self._selectedPermission = State(initialValue: selectedPermission)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16, *)
|
||||
private extension AppPermissionsCard
|
||||
{
|
||||
func show(_ permission: Permission)
|
||||
{
|
||||
withAnimation {
|
||||
self.selectedPermission = permission
|
||||
}
|
||||
}
|
||||
|
||||
func hidePermission()
|
||||
{
|
||||
withAnimation {
|
||||
self.selectedPermission = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16, *)
|
||||
struct AppPermissionsCard_Previews: PreviewProvider
|
||||
{
|
||||
static var previews: some View {
|
||||
let appPermissions = [
|
||||
PreviewAppPermission(permission: ALTAppPrivacyPermission.localNetwork),
|
||||
PreviewAppPermission(permission: ALTAppPrivacyPermission.microphone),
|
||||
PreviewAppPermission(permission: ALTAppPrivacyPermission.photos),
|
||||
PreviewAppPermission(permission: ALTAppPrivacyPermission.camera),
|
||||
PreviewAppPermission(permission: ALTAppPrivacyPermission.faceID),
|
||||
PreviewAppPermission(permission: ALTAppPrivacyPermission.appleMusic),
|
||||
PreviewAppPermission(permission: ALTAppPrivacyPermission.bluetooth),
|
||||
PreviewAppPermission(permission: ALTAppPrivacyPermission.calendars),
|
||||
]
|
||||
|
||||
let tintColor = Color(uiColor: .deltaPrimary!)
|
||||
|
||||
return ForEach(1...8, id: \.self) { index in
|
||||
AppPermissionsCard(title: "Privacy",
|
||||
description: "Delta may request access to the following:",
|
||||
tintColor: tintColor,
|
||||
permissions: Array(appPermissions.prefix(index)))
|
||||
.frame(width: 350)
|
||||
.previewLayout(.sizeThatFits)
|
||||
|
||||
AppPermissionsCard(title: "Privacy",
|
||||
description: "Delta may request access to the following:",
|
||||
tintColor: tintColor,
|
||||
permissions: Array(appPermissions.prefix(index)),
|
||||
selectedPermission: appPermissions.first)
|
||||
.frame(width: 350)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,22 +42,13 @@ final class AppViewController: UIViewController
|
||||
@IBOutlet private var navigationBarAppNameLabel: UILabel!
|
||||
|
||||
private var _shouldResetLayout = false
|
||||
private var _viewDidAppear = false
|
||||
private var _backgroundBlurEffect: UIBlurEffect?
|
||||
private var _backgroundBlurTintColor: UIColor?
|
||||
|
||||
private var _preferredStatusBarStyle: UIStatusBarStyle = .default
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
if #available(iOS 17, *)
|
||||
{
|
||||
// On iOS 17+, .default will update the status bar automatically.
|
||||
return .default
|
||||
}
|
||||
else
|
||||
{
|
||||
return _preferredStatusBarStyle
|
||||
}
|
||||
return _preferredStatusBarStyle
|
||||
}
|
||||
|
||||
override func viewDidLoad()
|
||||
@@ -67,11 +58,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
|
||||
@@ -87,7 +73,6 @@ final class AppViewController: UIViewController
|
||||
self.contentViewController.view.layer.masksToBounds = true
|
||||
|
||||
self.contentViewController.tableView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer)
|
||||
self.contentViewController.appDetailCollectionViewController.collectionView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer)
|
||||
self.contentViewController.tableView.showsVerticalScrollIndicator = false
|
||||
|
||||
// Bring to front so the scroll indicators are visible.
|
||||
@@ -101,12 +86,15 @@ final class AppViewController: UIViewController
|
||||
self.bannerView.iconImageView.tintColor = self.app.tintColor
|
||||
self.bannerView.button.tintColor = self.app.tintColor
|
||||
self.bannerView.tintColor = self.app.tintColor
|
||||
|
||||
self.bannerView.configure(for: self.app)
|
||||
self.bannerView.accessibilityTraits.remove(.button)
|
||||
|
||||
self.bannerView.button.addTarget(self, action: #selector(AppViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
||||
|
||||
self.backButtonContainerView.tintColor = self.app.tintColor
|
||||
|
||||
self.navigationController?.navigationBar.tintColor = self.app.tintColor
|
||||
self.navigationBarDownloadButton.tintColor = self.app.tintColor
|
||||
self.navigationBarAppNameLabel.text = self.app.name
|
||||
self.navigationBarAppIconImageView.tintColor = self.app.tintColor
|
||||
@@ -130,17 +118,13 @@ final class AppViewController: UIViewController
|
||||
{
|
||||
imageView.isIndicatingActivity = true
|
||||
|
||||
Nuke.loadImage(with: self.app.iconURL, options: .shared, into: imageView, progress: nil) { [weak imageView] (result) in
|
||||
switch result
|
||||
Nuke.loadImage(with: self.app.iconURL, options: .shared, into: imageView, progress: nil) { [weak imageView] (response, error) in
|
||||
if response?.image != nil
|
||||
{
|
||||
case .success: imageView?.isIndicatingActivity = false
|
||||
case .failure(let error): print("[ALTLog] Failed to load app icons.", error)
|
||||
imageView?.isIndicatingActivity = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start with navigation bar hidden.
|
||||
self.hideNavigationBar()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool)
|
||||
@@ -152,26 +136,42 @@ final class AppViewController: UIViewController
|
||||
// Update blur immediately.
|
||||
self.view.setNeedsLayout()
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
override func viewIsAppearing(_ animated: Bool)
|
||||
{
|
||||
super.viewIsAppearing(animated)
|
||||
|
||||
// Prevent banner temporarily flashing a color due to being added back to self.view.
|
||||
self.bannerView.backgroundEffectView.backgroundColor = .clear
|
||||
|
||||
self.transitionCoordinator?.animate(alongsideTransition: { (context) in
|
||||
self.hideNavigationBar()
|
||||
}, completion: nil)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool)
|
||||
{
|
||||
super.viewDidAppear(animated)
|
||||
self._viewDidAppear = true
|
||||
|
||||
self._shouldResetLayout = true
|
||||
self.view.setNeedsLayout()
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool)
|
||||
{
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
// Guard against "dismissing" when presenting via 3D Touch pop.
|
||||
guard self.navigationController != nil else { return }
|
||||
|
||||
// Store reference since self.navigationController will be nil after disappearing.
|
||||
let navigationController = self.navigationController
|
||||
navigationController?.navigationBar.barStyle = .default // Don't animate, or else status bar might appear messed-up.
|
||||
|
||||
self.transitionCoordinator?.animate(alongsideTransition: { (context) in
|
||||
self.showNavigationBar(for: navigationController)
|
||||
}, completion: { (context) in
|
||||
if !context.isCancelled
|
||||
{
|
||||
self.showNavigationBar(for: navigationController)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool)
|
||||
{
|
||||
super.viewDidDisappear(animated)
|
||||
@@ -193,6 +193,7 @@ final class AppViewController: UIViewController
|
||||
{
|
||||
// Fix navigation bar + tab bar appearance on iOS 15.
|
||||
self.setContentScrollView(self.scrollView)
|
||||
self.navigationItem.scrollEdgeAppearance = self.navigationController?.navigationBar.standardAppearance
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +205,11 @@ final class AppViewController: UIViewController
|
||||
{
|
||||
// Various events can cause UI to mess up, so reset affected components now.
|
||||
|
||||
if self.navigationController?.topViewController == self
|
||||
{
|
||||
self.hideNavigationBar()
|
||||
}
|
||||
|
||||
self.prepareBlur()
|
||||
|
||||
// Reset navigation bar animation, and create a new one later in this method if necessary.
|
||||
@@ -212,21 +218,7 @@ final class AppViewController: UIViewController
|
||||
self._shouldResetLayout = false
|
||||
}
|
||||
|
||||
let statusBarHeight: Double
|
||||
|
||||
if let navigationController, navigationController.presentingViewController != nil, navigationController.modalPresentationStyle != .fullScreen
|
||||
{
|
||||
statusBarHeight = 20
|
||||
}
|
||||
else if let statusBarManager = (self.view.window ?? self.presentedViewController?.view.window)?.windowScene?.statusBarManager
|
||||
{
|
||||
statusBarHeight = statusBarManager.statusBarFrame.height
|
||||
}
|
||||
else
|
||||
{
|
||||
statusBarHeight = 0
|
||||
}
|
||||
|
||||
let statusBarHeight = self.view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0
|
||||
let cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius
|
||||
|
||||
let inset = 12 as CGFloat
|
||||
@@ -285,25 +277,13 @@ final class AppViewController: UIViewController
|
||||
}
|
||||
|
||||
let difference = self.scrollView.contentOffset.y - showNavigationBarThreshold
|
||||
|
||||
let range: Double
|
||||
if self.presentingViewController == nil && self.parent?.presentingViewController == nil
|
||||
{
|
||||
// Not presented modally, so rely on safe area + navigation bar height.
|
||||
range = (headerFrame.height + padding) - (self.navigationController?.navigationBar.bounds.height ?? self.view.safeAreaInsets.top)
|
||||
}
|
||||
else
|
||||
{
|
||||
// Presented modally, so rely on maximumContentY.
|
||||
range = maximumContentY - (maximumContentY - padding - headerFrame.height) - inset
|
||||
}
|
||||
let range = (headerFrame.height + padding) - (self.navigationController?.navigationBar.bounds.height ?? self.view.safeAreaInsets.top)
|
||||
|
||||
let fractionComplete = min(difference, range) / range
|
||||
self.navigationBarAnimator?.fractionComplete = fractionComplete
|
||||
}
|
||||
else
|
||||
{
|
||||
self.navigationBarAnimator?.fractionComplete = 0.0
|
||||
self.resetNavigationBarAnimation()
|
||||
}
|
||||
|
||||
@@ -360,11 +340,7 @@ final class AppViewController: UIViewController
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?)
|
||||
{
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
if self._viewDidAppear
|
||||
{
|
||||
self._shouldResetLayout = true
|
||||
}
|
||||
self._shouldResetLayout = true
|
||||
}
|
||||
|
||||
deinit
|
||||
@@ -390,40 +366,46 @@ private extension AppViewController
|
||||
{
|
||||
func update()
|
||||
{
|
||||
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
|
||||
{
|
||||
// Explicitly set button action to .update if there is an update available, even if it's not supported.
|
||||
buttonAction = .update
|
||||
}
|
||||
|
||||
for button in [self.bannerView.button!, self.navigationBarDownloadButton!]
|
||||
{
|
||||
button.tintColor = self.app.tintColor
|
||||
button.isIndicatingActivity = false
|
||||
|
||||
if self.app.installedApp == nil
|
||||
{
|
||||
button.setTitle(NSLocalizedString("FREE", comment: ""), for: .normal)
|
||||
}
|
||||
else
|
||||
{
|
||||
button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
|
||||
}
|
||||
|
||||
let progress = AppManager.shared.installationProgress(for: self.app)
|
||||
button.progress = progress
|
||||
}
|
||||
|
||||
self.bannerView.configure(for: self.app, action: buttonAction)
|
||||
|
||||
let title = self.bannerView.button.title(for: .normal)
|
||||
self.navigationBarDownloadButton.setTitle(title, for: .normal)
|
||||
self.navigationBarDownloadButton.progress = self.bannerView.button.progress
|
||||
self.navigationBarDownloadButton.countdownDate = self.bannerView.button.countdownDate
|
||||
if let versionDate = self.app.latestAvailableVersion?.date, versionDate > Date()
|
||||
{
|
||||
self.bannerView.button.countdownDate = versionDate
|
||||
self.navigationBarDownloadButton.countdownDate = versionDate
|
||||
}
|
||||
else
|
||||
{
|
||||
self.bannerView.button.countdownDate = nil
|
||||
self.navigationBarDownloadButton.countdownDate = nil
|
||||
}
|
||||
|
||||
let barButtonItem = self.navigationItem.rightBarButtonItem
|
||||
self.navigationItem.rightBarButtonItem = nil
|
||||
self.navigationItem.rightBarButtonItem = barButtonItem
|
||||
}
|
||||
|
||||
func showNavigationBar()
|
||||
func showNavigationBar(for navigationController: UINavigationController? = nil)
|
||||
{
|
||||
self.navigationBarAppIconImageView.alpha = 1.0
|
||||
self.navigationBarAppNameLabel.alpha = 1.0
|
||||
self.navigationBarDownloadButton.alpha = 1.0
|
||||
|
||||
self.updateNavigationBarAppearance(isHidden: false)
|
||||
let navigationController = navigationController ?? self.navigationController
|
||||
navigationController?.navigationBar.alpha = 1.0
|
||||
navigationController?.navigationBar.tintColor = .altPrimary
|
||||
navigationController?.navigationBar.setNeedsLayout()
|
||||
|
||||
if self.traitCollection.userInterfaceStyle == .dark
|
||||
{
|
||||
@@ -434,51 +416,16 @@ private extension AppViewController
|
||||
self._preferredStatusBarStyle = .default
|
||||
}
|
||||
|
||||
if #unavailable(iOS 17)
|
||||
{
|
||||
self.navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||
}
|
||||
navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||
}
|
||||
|
||||
func hideNavigationBar()
|
||||
func hideNavigationBar(for navigationController: UINavigationController? = nil)
|
||||
{
|
||||
self.navigationBarAppIconImageView.alpha = 0.0
|
||||
self.navigationBarAppNameLabel.alpha = 0.0
|
||||
self.navigationBarDownloadButton.alpha = 0.0
|
||||
|
||||
self.updateNavigationBarAppearance(isHidden: true)
|
||||
let navigationController = navigationController ?? self.navigationController
|
||||
navigationController?.navigationBar.alpha = 0.0
|
||||
|
||||
self._preferredStatusBarStyle = .lightContent
|
||||
|
||||
if #unavailable(iOS 17)
|
||||
{
|
||||
self.navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
// Copied from HeaderContentViewController
|
||||
func updateNavigationBarAppearance(isHidden: Bool)
|
||||
{
|
||||
let barAppearance = self.navigationItem.standardAppearance as? NavigationBarAppearance ?? NavigationBarAppearance()
|
||||
|
||||
if isHidden
|
||||
{
|
||||
barAppearance.configureWithTransparentBackground()
|
||||
barAppearance.ignoresUserInteraction = true
|
||||
}
|
||||
else
|
||||
{
|
||||
barAppearance.configureWithDefaultBackground()
|
||||
barAppearance.ignoresUserInteraction = false
|
||||
}
|
||||
|
||||
barAppearance.titleTextAttributes = [.foregroundColor: UIColor.clear]
|
||||
|
||||
let tintColor = isHidden ? UIColor.clear : self.app.tintColor ?? .altPrimary
|
||||
barAppearance.configureWithTintColor(tintColor)
|
||||
|
||||
self.navigationItem.standardAppearance = barAppearance
|
||||
self.navigationItem.scrollEdgeAppearance = barAppearance
|
||||
navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||
}
|
||||
|
||||
func prepareBlur()
|
||||
@@ -506,10 +453,8 @@ private extension AppViewController
|
||||
|
||||
self.navigationBarAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in
|
||||
self?.showNavigationBar()
|
||||
|
||||
// Must call layoutIfNeeded() to animate appearance change.
|
||||
self?.navigationController?.navigationBar.layoutIfNeeded()
|
||||
|
||||
self?.navigationController?.navigationBar.tintColor = self?.app.tintColor
|
||||
self?.navigationController?.navigationBar.barTintColor = nil
|
||||
self?.contentViewController.view.layer.cornerRadius = 0
|
||||
}
|
||||
|
||||
@@ -521,8 +466,6 @@ private extension AppViewController
|
||||
|
||||
func resetNavigationBarAnimation()
|
||||
{
|
||||
guard self.navigationBarAnimator != nil else { return }
|
||||
|
||||
self.navigationBarAnimator?.stopAnimation(true)
|
||||
self.navigationBarAnimator = nil
|
||||
|
||||
@@ -543,15 +486,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
|
||||
{
|
||||
self.updateApp(installedApp, to: latestVersion)
|
||||
}
|
||||
else
|
||||
{
|
||||
self.open(installedApp)
|
||||
}
|
||||
self.open(installedApp)
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -563,72 +498,38 @@ extension AppViewController
|
||||
{
|
||||
guard self.app.installedApp == nil else { return }
|
||||
|
||||
Task<Void, Never>(priority: .userInitiated) {
|
||||
let group = await AppManager.shared.installAsync(self.app, presentingViewController: self) { (result) in
|
||||
do
|
||||
{
|
||||
_ = try result.get()
|
||||
}
|
||||
catch OperationError.cancelled
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
catch
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.opensErrorLog = true
|
||||
toastView.show(in: self)
|
||||
}
|
||||
}
|
||||
|
||||
let group = AppManager.shared.install(self.app, presentingViewController: self) { (result) in
|
||||
do
|
||||
{
|
||||
_ = try result.get()
|
||||
}
|
||||
catch OperationError.cancelled
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
catch
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
self.bannerView.button.progress = nil
|
||||
self.navigationBarDownloadButton.progress = nil
|
||||
self.update()
|
||||
let toastView = ToastView(error: error, opensLog: true)
|
||||
toastView.show(in: self)
|
||||
}
|
||||
}
|
||||
|
||||
if !group.progress.isCancelled
|
||||
{
|
||||
self.bannerView.button.progress = group.progress
|
||||
self.navigationBarDownloadButton.progress = group.progress
|
||||
DispatchQueue.main.async {
|
||||
self.bannerView.button.progress = nil
|
||||
self.navigationBarDownloadButton.progress = nil
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
self.bannerView.button.progress = group.progress
|
||||
self.navigationBarDownloadButton.progress = group.progress
|
||||
}
|
||||
|
||||
func open(_ installedApp: InstalledApp)
|
||||
{
|
||||
UIApplication.shared.open(installedApp.openAppURL)
|
||||
}
|
||||
|
||||
func updateApp(_ installedApp: InstalledApp, to version: AppVersion)
|
||||
{
|
||||
let previousProgress = AppManager.shared.installationProgress(for: installedApp)
|
||||
guard previousProgress == nil else {
|
||||
//TODO: Handle cancellation
|
||||
//previousProgress?.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
AppManager.shared.update(installedApp, to: version, presentingViewController: self) { (result) in
|
||||
DispatchQueue.main.async {
|
||||
switch result
|
||||
{
|
||||
case .success: print("Updated app from AppViewController:", installedApp.bundleIdentifier)
|
||||
case .failure(OperationError.cancelled): break
|
||||
case .failure(let error):
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.opensErrorLog = true
|
||||
toastView.show(in: self)
|
||||
}
|
||||
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppViewController
|
||||
|
||||
@@ -21,7 +21,7 @@ final class PermissionPopoverViewController: UIViewController
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
self.nameLabel.text = self.permission.localizedName ?? self.permission.permission.rawValue
|
||||
self.nameLabel.text = self.permission.type.localizedName
|
||||
self.descriptionLabel.text = self.permission.usageDescription
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
//
|
||||
// AppScreenshotCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 10/11/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import AltStoreCore
|
||||
|
||||
extension AppScreenshotCollectionViewCell
|
||||
{
|
||||
private class ImageView: UIImageView
|
||||
{
|
||||
override func layoutSubviews()
|
||||
{
|
||||
super.layoutSubviews()
|
||||
|
||||
// Explicitly layout cell to ensure rounded corners are accurate.
|
||||
self.superview?.superview?.setNeedsLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AppScreenshotCollectionViewCell: UICollectionViewCell
|
||||
{
|
||||
let imageView: UIImageView
|
||||
|
||||
var aspectRatio: CGSize = AppScreenshot.defaultAspectRatio {
|
||||
didSet {
|
||||
self.updateAspectRatio()
|
||||
}
|
||||
}
|
||||
|
||||
private var isRounded: Bool = false {
|
||||
didSet {
|
||||
self.setNeedsLayout()
|
||||
self.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private var aspectRatioConstraint: NSLayoutConstraint?
|
||||
|
||||
override init(frame: CGRect)
|
||||
{
|
||||
self.imageView = ImageView(frame: .zero)
|
||||
self.imageView.clipsToBounds = true
|
||||
self.imageView.layer.cornerCurve = .continuous
|
||||
self.imageView.layer.borderColor = UIColor.tertiaryLabel.cgColor
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.contentView.addSubview(self.imageView)
|
||||
|
||||
let widthConstraint = self.imageView.widthAnchor.constraint(equalTo: self.contentView.widthAnchor)
|
||||
widthConstraint.priority = .defaultHigh
|
||||
|
||||
let heightConstraint = self.imageView.heightAnchor.constraint(equalTo: self.contentView.heightAnchor)
|
||||
heightConstraint.priority = .defaultHigh
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
widthConstraint,
|
||||
heightConstraint,
|
||||
self.imageView.widthAnchor.constraint(lessThanOrEqualTo: self.contentView.widthAnchor),
|
||||
self.imageView.heightAnchor.constraint(lessThanOrEqualTo: self.contentView.heightAnchor),
|
||||
self.imageView.centerXAnchor.constraint(equalTo: self.contentView.centerXAnchor),
|
||||
self.imageView.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor)
|
||||
])
|
||||
|
||||
self.updateAspectRatio()
|
||||
self.updateTraits()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?)
|
||||
{
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
self.updateTraits()
|
||||
}
|
||||
|
||||
override func layoutSubviews()
|
||||
{
|
||||
super.layoutSubviews()
|
||||
|
||||
if self.isRounded
|
||||
{
|
||||
let cornerRadius = self.imageView.bounds.width / 9.0 // Based on iPhone 15
|
||||
self.imageView.layer.cornerRadius = cornerRadius
|
||||
}
|
||||
else
|
||||
{
|
||||
self.imageView.layer.cornerRadius = 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppScreenshotCollectionViewCell
|
||||
{
|
||||
func setImage(_ image: UIImage?)
|
||||
{
|
||||
guard var image, let cgImage = image.cgImage else {
|
||||
self.imageView.image = image
|
||||
return
|
||||
}
|
||||
|
||||
if image.size.width > image.size.height && self.aspectRatio.width < self.aspectRatio.height
|
||||
{
|
||||
// Image is landscape, but cell has portrait aspect ratio, so rotate image to match.
|
||||
image = UIImage(cgImage: cgImage, scale: image.scale, orientation: .right)
|
||||
}
|
||||
|
||||
self.imageView.image = image
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppScreenshotCollectionViewCell
|
||||
{
|
||||
func updateAspectRatio()
|
||||
{
|
||||
self.aspectRatioConstraint?.isActive = false
|
||||
|
||||
self.aspectRatioConstraint = self.imageView.widthAnchor.constraint(equalTo: self.imageView.heightAnchor, multiplier: self.aspectRatio.width / self.aspectRatio.height)
|
||||
self.aspectRatioConstraint?.isActive = true
|
||||
|
||||
let aspectRatio: Double
|
||||
if self.aspectRatio.width > self.aspectRatio.height
|
||||
{
|
||||
aspectRatio = self.aspectRatio.height / self.aspectRatio.width
|
||||
}
|
||||
else
|
||||
{
|
||||
aspectRatio = self.aspectRatio.width / self.aspectRatio.height
|
||||
}
|
||||
|
||||
let tolerance = 0.001 as Double
|
||||
let modernAspectRatio = AppScreenshot.defaultAspectRatio.width / AppScreenshot.defaultAspectRatio.height
|
||||
|
||||
let isRounded = (aspectRatio >= modernAspectRatio - tolerance) && (aspectRatio <= modernAspectRatio + tolerance)
|
||||
self.isRounded = isRounded
|
||||
}
|
||||
|
||||
func updateTraits()
|
||||
{
|
||||
let displayScale = (self.traitCollection.displayScale == 0.0) ? 1.0 : self.traitCollection.displayScale
|
||||
self.imageView.layer.borderWidth = 1.0 / displayScale
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
//
|
||||
// AppScreenshotsViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/18/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import AltStoreCore
|
||||
import Roxas
|
||||
|
||||
import Nuke
|
||||
|
||||
class AppScreenshotsViewController: UICollectionViewController
|
||||
{
|
||||
let app: StoreApp
|
||||
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
|
||||
init?(app: StoreApp, coder: NSCoder)
|
||||
{
|
||||
self.app = app
|
||||
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad()
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
self.collectionView.showsHorizontalScrollIndicator = false
|
||||
|
||||
// Allow parent background color to show through.
|
||||
self.collectionView.backgroundColor = nil
|
||||
|
||||
// Match the parent table view margins.
|
||||
self.collectionView.directionalLayoutMargins.top = 0
|
||||
self.collectionView.directionalLayoutMargins.bottom = 0
|
||||
self.collectionView.directionalLayoutMargins.leading = 20
|
||||
self.collectionView.directionalLayoutMargins.trailing = 20
|
||||
|
||||
let collectionViewLayout = self.makeLayout()
|
||||
self.collectionView.collectionViewLayout = collectionViewLayout
|
||||
|
||||
self.collectionView.register(AppScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||
|
||||
self.collectionView.dataSource = self.dataSource
|
||||
self.collectionView.prefetchDataSource = self.dataSource
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppScreenshotsViewController
|
||||
{
|
||||
func makeLayout() -> UICollectionViewCompositionalLayout
|
||||
{
|
||||
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
|
||||
layoutConfig.contentInsetsReference = .layoutMargins
|
||||
|
||||
let preferredHeight = 400.0
|
||||
let estimatedWidth = preferredHeight * (AppScreenshot.defaultAspectRatio.width / AppScreenshot.defaultAspectRatio.height)
|
||||
|
||||
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [dataSource] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
|
||||
let screenshotWidths = dataSource.items.map { screenshot in
|
||||
var aspectRatio = screenshot.size ?? AppScreenshot.defaultAspectRatio
|
||||
if aspectRatio.width > aspectRatio.height
|
||||
{
|
||||
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
||||
}
|
||||
|
||||
let screenshotWidth = (preferredHeight * (aspectRatio.width / aspectRatio.height)).rounded()
|
||||
return screenshotWidth
|
||||
}
|
||||
|
||||
let smallestWidth = screenshotWidths.sorted().first
|
||||
let itemWidth = smallestWidth ?? estimatedWidth // Use smallestWidth to ensure we never overshoot an item when paging.
|
||||
|
||||
let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(itemWidth), heightDimension: .fractionalHeight(1.0))
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
|
||||
let groupSize = NSCollectionLayoutSize(widthDimension: .estimated(itemWidth), heightDimension: .absolute(preferredHeight))
|
||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
|
||||
|
||||
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||
layoutSection.interGroupSpacing = 10
|
||||
layoutSection.orthogonalScrollingBehavior = .groupPaging
|
||||
|
||||
return layoutSection
|
||||
}, configuration: layoutConfig)
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>
|
||||
{
|
||||
let screenshots = self.app.preferredScreenshots()
|
||||
|
||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>(items: screenshots)
|
||||
dataSource.cellConfigurationHandler = { [weak self] (cell, screenshot, indexPath) in
|
||||
let cell = cell as! AppScreenshotCollectionViewCell
|
||||
cell.imageView.isIndicatingActivity = true
|
||||
cell.setImage(nil)
|
||||
|
||||
var aspectRatio = screenshot.size ?? AppScreenshot.defaultAspectRatio
|
||||
if aspectRatio.width > aspectRatio.height
|
||||
{
|
||||
switch screenshot.deviceType
|
||||
{
|
||||
case .iphone:
|
||||
// Always rotate landscape iPhone screenshots regardless of horizontal size class.
|
||||
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
||||
|
||||
case .ipad where self?.traitCollection.horizontalSizeClass == .compact:
|
||||
// Only rotate landscape iPad screenshots if we're in horizontally compact environment.
|
||||
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
cell.aspectRatio = aspectRatio
|
||||
}
|
||||
dataSource.prefetchHandler = { (screenshot, indexPath, completionHandler) in
|
||||
let imageURL = screenshot.imageURL
|
||||
return RSTAsyncBlockOperation() { (operation) in
|
||||
let request = ImageRequest(url: imageURL)
|
||||
ImagePipeline.shared.loadImage(with: request, progress: nil) { result in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
switch result
|
||||
{
|
||||
case .success(let response): completionHandler(response.image, nil)
|
||||
case .failure(let error): completionHandler(nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
||||
let cell = cell as! AppScreenshotCollectionViewCell
|
||||
cell.imageView.isIndicatingActivity = false
|
||||
cell.setImage(image)
|
||||
|
||||
if let error = error
|
||||
{
|
||||
print("Error loading image:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
}
|
||||
|
||||
extension AppScreenshotsViewController
|
||||
{
|
||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
||||
{
|
||||
let screenshot = self.dataSource.item(at: indexPath)
|
||||
|
||||
let previewViewController = PreviewAppScreenshotsViewController(app: self.app)
|
||||
previewViewController.currentScreenshot = screenshot
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: previewViewController)
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
self.present(navigationController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17, *)
|
||||
#Preview(traits: .portrait) {
|
||||
DatabaseManager.shared.startForPreview()
|
||||
|
||||
let fetchRequest = StoreApp.fetchRequest()
|
||||
let storeApp = try! DatabaseManager.shared.viewContext.fetch(fetchRequest).first!
|
||||
|
||||
let storyboard = UIStoryboard(name: "Main", bundle: .main)
|
||||
let appViewConttroller = storyboard.instantiateViewController(withIdentifier: "appViewController") as! AppViewController
|
||||
appViewConttroller.app = storeApp
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: appViewConttroller)
|
||||
return navigationController
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
//
|
||||
// PreviewAppScreenshotsViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/19/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import AltStoreCore
|
||||
import Roxas
|
||||
|
||||
import Nuke
|
||||
|
||||
class PreviewAppScreenshotsViewController: UICollectionViewController
|
||||
{
|
||||
let app: StoreApp
|
||||
|
||||
var currentScreenshot: AppScreenshot?
|
||||
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
|
||||
init(app: StoreApp)
|
||||
{
|
||||
self.app = app
|
||||
|
||||
super.init(collectionViewLayout: UICollectionViewFlowLayout())
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad()
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
let tintColor = self.app.tintColor ?? .altPrimary
|
||||
self.navigationController?.view.tintColor = tintColor
|
||||
|
||||
self.view.backgroundColor = .systemBackground
|
||||
self.collectionView.backgroundColor = nil
|
||||
|
||||
let collectionViewLayout = self.makeLayout()
|
||||
self.collectionView.collectionViewLayout = collectionViewLayout
|
||||
|
||||
self.collectionView.directionalLayoutMargins.leading = 20
|
||||
self.collectionView.directionalLayoutMargins.trailing = 20
|
||||
|
||||
self.collectionView.preservesSuperviewLayoutMargins = true
|
||||
self.collectionView.insetsLayoutMarginsFromSafeArea = true
|
||||
|
||||
self.collectionView.alwaysBounceVertical = false
|
||||
self.collectionView.register(AppScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||
|
||||
self.collectionView.dataSource = self.dataSource
|
||||
self.collectionView.prefetchDataSource = self.dataSource
|
||||
|
||||
let doneButton = UIBarButtonItem(systemItem: .done, primaryAction: UIAction { [weak self] _ in
|
||||
self?.dismissPreview()
|
||||
})
|
||||
self.navigationItem.rightBarButtonItem = doneButton
|
||||
|
||||
let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(PreviewAppScreenshotsViewController.dismissPreview))
|
||||
swipeGestureRecognizer.direction = .down
|
||||
self.view.addGestureRecognizer(swipeGestureRecognizer)
|
||||
}
|
||||
|
||||
override func viewIsAppearing(_ animated: Bool)
|
||||
{
|
||||
super.viewIsAppearing(animated)
|
||||
|
||||
if let screenshot = self.currentScreenshot, let index = self.dataSource.items.firstIndex(of: screenshot)
|
||||
{
|
||||
let indexPath = IndexPath(item: index, section: 0)
|
||||
self.collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension PreviewAppScreenshotsViewController
|
||||
{
|
||||
func makeLayout() -> UICollectionViewCompositionalLayout
|
||||
{
|
||||
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
|
||||
layoutConfig.contentInsetsReference = .none
|
||||
|
||||
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
|
||||
guard let self else { return nil }
|
||||
|
||||
let contentInsets = self.collectionView.directionalLayoutMargins
|
||||
let groupWidth = layoutEnvironment.container.contentSize.width - (contentInsets.leading + contentInsets.trailing)
|
||||
let groupHeight = layoutEnvironment.container.contentSize.height - (contentInsets.top + contentInsets.bottom)
|
||||
|
||||
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
|
||||
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(groupWidth), heightDimension: .absolute(groupHeight))
|
||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
|
||||
|
||||
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
|
||||
layoutSection.interGroupSpacing = 10
|
||||
return layoutSection
|
||||
}, configuration: layoutConfig)
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>
|
||||
{
|
||||
let screenshots = self.app.preferredScreenshots()
|
||||
|
||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>(items: screenshots)
|
||||
dataSource.cellConfigurationHandler = { [weak self] (cell, screenshot, indexPath) in
|
||||
let cell = cell as! AppScreenshotCollectionViewCell
|
||||
cell.imageView.isIndicatingActivity = true
|
||||
cell.setImage(nil)
|
||||
|
||||
var aspectRatio = screenshot.size ?? AppScreenshot.defaultAspectRatio
|
||||
if aspectRatio.width > aspectRatio.height
|
||||
{
|
||||
switch screenshot.deviceType
|
||||
{
|
||||
case .iphone:
|
||||
// Always rotate landscape iPhone screenshots regardless of horizontal size class.
|
||||
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
||||
|
||||
case .ipad where self?.traitCollection.horizontalSizeClass == .compact:
|
||||
// Only rotate landscape iPad screenshots if we're in horizontally compact environment.
|
||||
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
cell.aspectRatio = aspectRatio
|
||||
}
|
||||
dataSource.prefetchHandler = { (screenshot, indexPath, completionHandler) in
|
||||
let imageURL = screenshot.imageURL
|
||||
return RSTAsyncBlockOperation() { (operation) in
|
||||
let request = ImageRequest(url: imageURL)
|
||||
ImagePipeline.shared.loadImage(with: request, progress: nil) { result in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
switch result
|
||||
{
|
||||
case .success(let response): completionHandler(response.image, nil)
|
||||
case .failure(let error): completionHandler(nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
||||
let cell = cell as! AppScreenshotCollectionViewCell
|
||||
cell.imageView.isIndicatingActivity = false
|
||||
cell.setImage(image)
|
||||
|
||||
if let error = error
|
||||
{
|
||||
print("Error loading image:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
}
|
||||
|
||||
private extension PreviewAppScreenshotsViewController
|
||||
{
|
||||
@objc func dismissPreview()
|
||||
{
|
||||
self.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17, *)
|
||||
#Preview(traits: .portrait) {
|
||||
DatabaseManager.shared.startForPreview()
|
||||
|
||||
let fetchRequest = StoreApp.fetchRequest()
|
||||
let storeApp = try! DatabaseManager.shared.viewContext.fetch(fetchRequest).first!
|
||||
|
||||
let previewViewController = PreviewAppScreenshotsViewController(app: storeApp)
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: previewViewController)
|
||||
return navigationController
|
||||
}
|
||||
@@ -72,11 +72,10 @@ private extension AppIDsViewController
|
||||
dataSource.cellConfigurationHandler = { (cell, appID, indexPath) in
|
||||
let tintColor = UIColor.altPrimary
|
||||
|
||||
let cell = cell as! AppBannerCollectionViewCell
|
||||
let cell = cell as! BannerCollectionViewCell
|
||||
cell.layoutMargins.left = self.view.layoutMargins.left
|
||||
cell.layoutMargins.right = self.view.layoutMargins.right
|
||||
cell.tintColor = tintColor
|
||||
|
||||
cell.contentView.preservesSuperviewLayoutMargins = false
|
||||
cell.contentView.layoutMargins = UIEdgeInsets(top: 0, left: self.view.layoutMargins.left, bottom: 0, right: self.view.layoutMargins.right)
|
||||
|
||||
cell.bannerView.iconImageView.isHidden = true
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
@@ -93,20 +92,19 @@ private extension AppIDsViewController
|
||||
cell.bannerView.buttonLabel.isHidden = false
|
||||
|
||||
let currentDate = Date()
|
||||
|
||||
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.unitsStyle = .full
|
||||
formatter.includesApproximationPhrase = false
|
||||
formatter.includesTimeRemainingPhrase = false
|
||||
formatter.allowedUnits = [.minute, .hour, .day]
|
||||
formatter.maximumUnitCount = 1
|
||||
|
||||
let timeInterval = formatter.string(from: currentDate, to: expirationDate)
|
||||
let timeIntervalText = timeInterval ?? NSLocalizedString("Unknown", comment: "")
|
||||
cell.bannerView.button.setTitle(timeIntervalText.uppercased(), for: .normal)
|
||||
|
||||
cell.bannerView.button.setTitle((formatter.string(from: currentDate, to: expirationDate) ?? NSLocalizedString("Unknown", comment: "")).uppercased(), for: .normal)
|
||||
|
||||
// formatter.includesTimeRemainingPhrase = true
|
||||
attributedAccessibilityLabel.mutableString.append(timeIntervalText)
|
||||
|
||||
// attributedAccessibilityLabel.mutableString.append((formatter.string(from: currentDate, to: expirationDate) ?? NSLocalizedString("Unknown", comment: "")) + " ")
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -119,11 +117,10 @@ private extension AppIDsViewController
|
||||
cell.bannerView.titleLabel.text = appID.name
|
||||
cell.bannerView.subtitleLabel.text = appID.bundleIdentifier
|
||||
cell.bannerView.subtitleLabel.numberOfLines = 2
|
||||
cell.bannerView.subtitleLabel.minimumScaleFactor = 1.0 // Disable font shrinking
|
||||
|
||||
let attributedBundleIdentifier = NSMutableAttributedString(string: appID.bundleIdentifier.lowercased(), attributes: [.accessibilitySpeechPunctuation: true])
|
||||
|
||||
if let team = appID.team, let range = attributedBundleIdentifier.string.range(of: team.identifier.lowercased())
|
||||
if let team = appID.team, let range = attributedBundleIdentifier.string.range(of: team.identifier.lowercased()), #available(iOS 13, *)
|
||||
{
|
||||
// Prefer to speak the team ID one character at a time.
|
||||
let nsRange = NSRange(range, in: attributedBundleIdentifier.string)
|
||||
@@ -184,18 +181,14 @@ extension AppIDsViewController: UICollectionViewDelegateFlowLayout
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize
|
||||
{
|
||||
// let indexPath = IndexPath(row: 0, section: section)
|
||||
// let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionHeader, at: indexPath)
|
||||
let indexPath = IndexPath(row: 0, section: section)
|
||||
let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionHeader, at: indexPath)
|
||||
|
||||
// // Use this view to calculate the optimal size based on the collection view's width
|
||||
// let size = headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingCompressedSize.height),
|
||||
// withHorizontalFittingPriority: .required, // Width is fixed
|
||||
// verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed
|
||||
// return size
|
||||
|
||||
// NOTE: double dequeue of cell has been discontinued
|
||||
// TODO: Using harcoded value until this is fixed
|
||||
return CGSize(width: collectionView.bounds.width, height: 200)
|
||||
// Use this view to calculate the optimal size based on the collection view's width
|
||||
let size = headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height),
|
||||
withHorizontalFittingPriority: .required, // Width is fixed
|
||||
verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed
|
||||
return size
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize
|
||||
|
||||
@@ -16,10 +16,6 @@ import AltSign
|
||||
import Roxas
|
||||
import EmotionalDamage
|
||||
|
||||
import Nuke
|
||||
|
||||
extension UIApplication: LegacyBackgroundFetching {}
|
||||
|
||||
extension AppDelegate
|
||||
{
|
||||
static let openPatreonSettingsDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".OpenPatreonSettingsDeepLinkNotification")
|
||||
@@ -27,12 +23,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
|
||||
@@ -40,47 +34,34 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
private let intentHandler = IntentHandler()
|
||||
private let viewAppIntentHandler = ViewAppIntentHandler()
|
||||
@available(iOS 14, *)
|
||||
private var intentHandler: IntentHandler {
|
||||
get { _intentHandler as! IntentHandler }
|
||||
set { _intentHandler = newValue }
|
||||
}
|
||||
|
||||
public let consoleLog = ConsoleLog()
|
||||
@available(iOS 14, *)
|
||||
private var viewAppIntentHandler: ViewAppIntentHandler {
|
||||
get { _viewAppIntentHandler as! ViewAppIntentHandler }
|
||||
set { _viewAppIntentHandler = newValue }
|
||||
}
|
||||
|
||||
private lazy var _intentHandler: Any = {
|
||||
guard #available(iOS 14, *) else { fatalError() }
|
||||
return IntentHandler()
|
||||
}()
|
||||
|
||||
private lazy var _viewAppIntentHandler: Any = {
|
||||
guard #available(iOS 14, *) else { fatalError() }
|
||||
return ViewAppIntentHandler()
|
||||
}()
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool
|
||||
{
|
||||
// navigation bar buttons spacing is too much (so hack it to use minimal spacing)
|
||||
// this is swift-5 specific behavior and might change
|
||||
// https://stackoverflow.com/a/64988363/11971304
|
||||
//
|
||||
// Warning: this affects all screens through out the app, and basically overrides storyboard
|
||||
let stackViewAppearance = UIStackView.appearance(whenContainedInInstancesOf: [UINavigationBar.self])
|
||||
stackViewAppearance.spacing = -8 // adjust as needed
|
||||
|
||||
consoleLog.startCapturing()
|
||||
print("===================================================")
|
||||
print("| App is Starting up |")
|
||||
print("===================================================")
|
||||
print("| Console Logger started capturing output streams |")
|
||||
print("===================================================")
|
||||
print("\n ")
|
||||
|
||||
// Override point for customization after application launch.
|
||||
// UserDefaults.standard.setValue(true, forKey: "com.apple.CoreData.MigrationDebug")
|
||||
// UserDefaults.standard.setValue(true, forKey: "com.apple.CoreData.SQLDebug")
|
||||
|
||||
// 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
|
||||
@@ -96,13 +77,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
SecureValueTransformer.register()
|
||||
|
||||
if UserDefaults.standard.firstLaunch == nil
|
||||
@@ -113,7 +88,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
UserDefaults.standard.preferredServerID = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.serverID) as? String
|
||||
|
||||
#if DEBUG && targetEnvironment(simulator)
|
||||
#if DEBUG || BETA
|
||||
UserDefaults.standard.isDebugModeEnabled = true
|
||||
#endif
|
||||
|
||||
@@ -125,10 +100,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func applicationDidEnterBackground(_ application: UIApplication)
|
||||
{
|
||||
// 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()
|
||||
}
|
||||
|
||||
guard let oneMonthAgo = Calendar.current.date(byAdding: .month, value: -1, to: Date()) else { return }
|
||||
|
||||
let midnightOneMonthAgo = Calendar.current.startOfDay(for: oneMonthAgo)
|
||||
@@ -145,11 +117,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func applicationWillEnterForeground(_ application: UIApplication)
|
||||
{
|
||||
AppManager.shared.update()
|
||||
if UserDefaults.standard.enableEMPforWireguard {
|
||||
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
||||
}
|
||||
|
||||
PatreonAPI.shared.refreshPatreonAccount()
|
||||
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) -> Bool
|
||||
@@ -159,6 +127,8 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
func application(_ application: UIApplication, handlerFor intent: INIntent) -> Any?
|
||||
{
|
||||
guard #available(iOS 14, *) else { return nil }
|
||||
|
||||
switch intent
|
||||
{
|
||||
case is RefreshAllIntent: return self.intentHandler
|
||||
@@ -166,19 +136,9 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Stop console logging and clean up resources
|
||||
print("\n ")
|
||||
print("===================================================")
|
||||
print("| Console Logger stopped capturing output streams |")
|
||||
print("===================================================")
|
||||
print("| App is being terminated |")
|
||||
print("===================================================")
|
||||
consoleLog.stopCapturing()
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13, *)
|
||||
extension AppDelegate
|
||||
{
|
||||
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration
|
||||
@@ -203,33 +163,6 @@ private extension AppDelegate
|
||||
self.window?.tintColor = .altPrimary
|
||||
}
|
||||
|
||||
func prepareImageCache()
|
||||
{
|
||||
// Avoid caching responses twice.
|
||||
DataLoader.sharedUrlCache.diskCapacity = 0
|
||||
|
||||
let pipeline = ImagePipeline { configuration in
|
||||
do
|
||||
{
|
||||
let dataCache = try DataCache(name: "io.sidestore.Nuke")
|
||||
dataCache.sizeLimit = 512 * 1024 * 1024 // 512MB
|
||||
|
||||
configuration.dataCache = dataCache
|
||||
}
|
||||
catch
|
||||
{
|
||||
Logger.main.error("Failed to create image disk cache. Falling back to URL cache. \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
ImagePipeline.shared = pipeline
|
||||
|
||||
if let dataCache = ImagePipeline.shared.configuration.dataCache as? DataCache, #available(iOS 15, *)
|
||||
{
|
||||
Logger.main.info("Current image cache size: \(dataCache.totalSize.formatted(.byteCount(style: .file)), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func open(_ url: URL) -> Bool
|
||||
{
|
||||
if url.isFileURL
|
||||
@@ -300,25 +233,12 @@ private extension AppDelegate
|
||||
|
||||
return true
|
||||
|
||||
case "pairing":
|
||||
case "jit":
|
||||
let queryItems = components.queryItems?.reduce(into: [String: String]()) { $0[$1.name.lowercased()] = $1.value } ?? [:]
|
||||
guard let callbackTemplate = queryItems["urlName"]?.removingPercentEncoding else { return false }
|
||||
guard let pidstr = queryItems["pid"], let pid = Int32(pidstr) 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
|
||||
return ptrace(14, pid, nil, 0) == 0
|
||||
|
||||
|
||||
default: return false
|
||||
}
|
||||
@@ -331,12 +251,12 @@ extension AppDelegate
|
||||
private func prepareForBackgroundFetch()
|
||||
{
|
||||
// "Fetch" every hour, but then refresh only those that need to be refreshed (so we don't drain the battery).
|
||||
(UIApplication.shared as LegacyBackgroundFetching).setMinimumBackgroundFetchInterval(1 * 60 * 60)
|
||||
UIApplication.shared.setMinimumBackgroundFetchInterval(1 * 60 * 60)
|
||||
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { (success, error) in
|
||||
}
|
||||
|
||||
#if DEBUG && targetEnvironment(simulator)
|
||||
#if DEBUG
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
#endif
|
||||
}
|
||||
@@ -445,12 +365,10 @@ private extension AppDelegate
|
||||
{
|
||||
let (sources, context) = try result.get()
|
||||
|
||||
let previousUpdatesFetchRequest = InstalledApp.supportedUpdatesFetchRequest() as! NSFetchRequest<NSFetchRequestResult>
|
||||
let previousUpdatesFetchRequest = InstalledApp.updatesFetchRequest() as! NSFetchRequest<NSFetchRequestResult>
|
||||
previousUpdatesFetchRequest.includesPendingChanges = false
|
||||
previousUpdatesFetchRequest.resultType = .dictionaryResultType
|
||||
previousUpdatesFetchRequest.propertiesToFetch = [#keyPath(InstalledApp.bundleIdentifier),
|
||||
#keyPath(InstalledApp.storeApp.latestSupportedVersion.version),
|
||||
#keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion)]
|
||||
previousUpdatesFetchRequest.propertiesToFetch = [#keyPath(InstalledApp.bundleIdentifier)]
|
||||
|
||||
let previousNewsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NSFetchRequestResult>
|
||||
previousNewsItemsFetchRequest.includesPendingChanges = false
|
||||
@@ -462,9 +380,7 @@ private extension AppDelegate
|
||||
|
||||
try context.save()
|
||||
|
||||
|
||||
|
||||
let updatesFetchRequest = InstalledApp.supportedUpdatesFetchRequest()
|
||||
let updatesFetchRequest = InstalledApp.updatesFetchRequest()
|
||||
let newsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NewsItem>
|
||||
|
||||
let updates = try context.fetch(updatesFetchRequest)
|
||||
@@ -472,23 +388,12 @@ private extension AppDelegate
|
||||
|
||||
for update in updates
|
||||
{
|
||||
guard let storeApp = update.storeApp, let latestSupportedVersion = storeApp.latestSupportedVersion, latestSupportedVersion.isSupported else { continue }
|
||||
|
||||
if let previousUpdate = previousUpdates.first(where: { $0[#keyPath(InstalledApp.bundleIdentifier)] == update.bundleIdentifier })
|
||||
{
|
||||
// An update for this app was already available, so check whether the version or build version is different.
|
||||
guard let previousVersion = previousUpdate[#keyPath(InstalledApp.storeApp.latestSupportedVersion.version)] else { continue }
|
||||
|
||||
// previousUpdate might not contain buildVersion, but if it does then map empty string to nil to match AppVersion.
|
||||
let previousBuildVersion = previousUpdate[#keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion)].map { $0.isEmpty ? nil : "" }
|
||||
|
||||
// Only show notification if previous latestSupportedVersion does not _exactly_ match current latestSupportedVersion.
|
||||
guard previousVersion != latestSupportedVersion.version || previousBuildVersion != latestSupportedVersion.buildVersion else { continue }
|
||||
}
|
||||
guard !previousUpdates.contains(where: { $0[#keyPath(InstalledApp.bundleIdentifier)] == update.bundleIdentifier }) else { continue }
|
||||
guard let storeApp = update.storeApp, let version = storeApp.latestSupportedVersion else { continue }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = NSLocalizedString("New Update Available", comment: "")
|
||||
content.body = String(format: NSLocalizedString("%@ %@ is now available for download.", comment: ""), update.name, latestSupportedVersion.localizedVersion)
|
||||
content.body = String(format: NSLocalizedString("%@ %@ is now available for download.", comment: ""), update.name, version)
|
||||
content.sound = .default
|
||||
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||
|
||||
@@ -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"/>
|
||||
@@ -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="17.5" width="264" height="60.5"/>
|
||||
<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,8 +318,8 @@
|
||||
<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">
|
||||
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
|
||||
<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="35"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
||||
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
@@ -393,7 +393,7 @@
|
||||
</subviews>
|
||||
<edgeInsets key="layoutMargins" top="35" left="0.0" bottom="35" right="0.0"/>
|
||||
</stackView>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qZ9-AR-2zK">
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qZ9-AR-2zK">
|
||||
<rect key="frame" x="16" y="608" width="343" height="51"/>
|
||||
<color key="backgroundColor" name="SettingsHighlighted"/>
|
||||
<constraints>
|
||||
@@ -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">
|
||||
@@ -445,7 +445,7 @@
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="tDQ-ao-1Jg">
|
||||
<rect key="frame" x="16" y="570" width="343" height="89"/>
|
||||
<subviews>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="xcg-hT-tDe" customClass="PillButton" customModule="SideStore" customModuleProvider="target">
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="xcg-hT-tDe" customClass="PillButton" customModule="SideStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="343" height="51"/>
|
||||
<color key="backgroundColor" name="SettingsHighlighted"/>
|
||||
<constraints>
|
||||
@@ -460,7 +460,7 @@
|
||||
<action selector="refreshAltStore:" destination="aoK-yE-UVT" eventType="primaryActionTriggered" id="WQu-9b-Zgg"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qua-VA-asJ">
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qua-VA-asJ">
|
||||
<rect key="frame" x="0.0" y="59" width="343" height="30"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="15"/>
|
||||
<state key="normal" title="Refresh Later">
|
||||
@@ -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"/>
|
||||
|
||||
@@ -31,21 +31,7 @@ final class AuthenticationViewController: UIViewController
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
// fetch anisette servers asap when loading Auth Screen (if list is empty
|
||||
if(UserDefaults.standard.menuAnisetteServersList.isEmpty){
|
||||
Task{
|
||||
let sourceURL = UserDefaults.standard.menuAnisetteList
|
||||
do{
|
||||
_ = try await AnisetteViewModel.getListOfServers(serverSource: sourceURL)
|
||||
print("AuthenticationViewController: Server list refresh request completed for sourceURL: \(sourceURL)")
|
||||
}catch{
|
||||
print("AuthenticationViewController: Server list refresh request Failed for sourceURL: \(sourceURL) Error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.signInButton.activityIndicatorView.style = .medium
|
||||
self.signInButton.activityIndicatorView.color = .white
|
||||
|
||||
for view in [self.appleIDBackgroundView!, self.passwordBackgroundView!, self.signInButton!]
|
||||
{
|
||||
@@ -123,9 +109,9 @@ private extension AuthenticationViewController
|
||||
case .failure(let error as NSError):
|
||||
DispatchQueue.main.async {
|
||||
let error = error.withLocalizedTitle(NSLocalizedString("Failed to Log In", comment: ""))
|
||||
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.show(in: self)
|
||||
toastView.backgroundColor = .white
|
||||
toastView.textLabel.textColor = .altPrimary
|
||||
toastView.detailTextLabel.textColor = .altPrimary
|
||||
self.toastView = toastView
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<?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" initialViewController="wKh-xq-NuP">
|
||||
<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" initialViewController="wKh-xq-NuP">
|
||||
<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="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="collection view cell content view" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
@@ -37,11 +36,11 @@
|
||||
</tabBar>
|
||||
<connections>
|
||||
<segue destination="kjR-gi-fgT" kind="relationship" relationship="viewControllers" id="eWy-uk-nwG"/>
|
||||
<segue destination="faz-B4-Sub" kind="relationship" relationship="viewControllers" id="dXz-Tu-hW8"/>
|
||||
<segue destination="3Ew-ox-i4n" kind="relationship" relationship="viewControllers" id="zii-dF-qEt"/>
|
||||
<segue destination="p3d-dP-Swg" kind="relationship" relationship="viewControllers" id="4Nf-rL-P4c"/>
|
||||
<segue destination="Qo4-72-Hmr" kind="presentation" identifier="presentSources" id="Qd6-ba-dIo"/>
|
||||
<segue destination="bTL-bY-9Yq" kind="presentation" identifier="finishJailbreak" id="cIc-Ta-uNk"/>
|
||||
<segue destination="HCK-G6-KdY" kind="relationship" relationship="viewControllers" id="X0t-T6-JeA"/>
|
||||
<segue destination="faz-B4-Sub" kind="relationship" relationship="viewControllers" id="OLu-kM-z1J"/>
|
||||
<segue destination="3Ew-ox-i4n" kind="relationship" relationship="viewControllers" id="phQ-Pc-pqw"/>
|
||||
<segue destination="p3d-dP-Swg" kind="relationship" relationship="viewControllers" id="cQE-Az-fdo"/>
|
||||
</connections>
|
||||
</tabBarController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="HuB-VB-40B" sceneMemberID="firstResponder"/>
|
||||
@@ -51,7 +50,7 @@
|
||||
<!--Browse-->
|
||||
<scene sceneID="rXq-UR-qQp">
|
||||
<objects>
|
||||
<collectionViewController storyboardIdentifier="browseViewController" id="e3L-BF-iXp" customClass="BrowseViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<collectionViewController id="e3L-BF-iXp" customClass="BrowseViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" dataMode="prototypes" id="CaT-1q-qnx">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
@@ -68,11 +67,20 @@
|
||||
<outlet property="delegate" destination="e3L-BF-iXp" id="Mdp-x4-hZe"/>
|
||||
</connections>
|
||||
</collectionView>
|
||||
<navigationItem key="navigationItem" title="Browse" id="pUx-6k-rtr"/>
|
||||
<navigationItem key="navigationItem" title="Browse" id="pUx-6k-rtr">
|
||||
<barButtonItem key="rightBarButtonItem" title="Sources" id="6Ul-JW-TMT">
|
||||
<connections>
|
||||
<segue destination="Qo4-72-Hmr" kind="presentation" id="de9-NH-aec"/>
|
||||
</connections>
|
||||
</barButtonItem>
|
||||
</navigationItem>
|
||||
<connections>
|
||||
<outlet property="sourcesBarButtonItem" destination="6Ul-JW-TMT" id="99s-O4-OpX"/>
|
||||
</connections>
|
||||
</collectionViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="cMN-i4-Bxk" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="2730" y="-373"/>
|
||||
<point key="canvasLocation" x="1730" y="-17"/>
|
||||
</scene>
|
||||
<!--App View Controller-->
|
||||
<scene sceneID="TgT-LO-3Er">
|
||||
@@ -216,7 +224,7 @@
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="C9o-C3-sMK" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="2730" y="439"/>
|
||||
<point key="canvasLocation" x="2525.5999999999999" y="-17.541229385307346"/>
|
||||
</scene>
|
||||
<!--App-->
|
||||
<scene sceneID="CgX-7h-sRI">
|
||||
@@ -254,40 +262,51 @@
|
||||
<inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="nI6-wC-H2d">
|
||||
<rect key="frame" x="0.0" y="107" width="375" height="300"/>
|
||||
<rect key="frame" x="0.0" y="107" width="375" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="nI6-wC-H2d" id="Z4y-vb-Z4Q">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<containerView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5yj-Nb-f5H">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" priority="999" constant="300" id="dpf-ba-NNr"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<segue destination="nX2-hQ-qjX" kind="embed" destinationCreationSelector="makeAppScreenshotsViewController:sender:" id="VxG-Pu-Kf1"/>
|
||||
</connections>
|
||||
</containerView>
|
||||
<collectionView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" dataMode="prototypes" translatesAutoresizingMaskIntoConstraints="NO" id="ppk-lL-at8">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
|
||||
<color key="backgroundColor" name="Background"/>
|
||||
<collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" minimumLineSpacing="10" minimumInteritemSpacing="15" id="ace-Ns-Jd2">
|
||||
<size key="itemSize" width="189" height="406"/>
|
||||
<size key="headerReferenceSize" width="0.0" height="0.0"/>
|
||||
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
||||
<inset key="sectionInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
|
||||
</collectionViewFlowLayout>
|
||||
<cells>
|
||||
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="Cell" id="2U6-d3-e4r" customClass="ScreenshotCollectionViewCell">
|
||||
<rect key="frame" x="15" y="-181" width="189" height="406"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
|
||||
<rect key="frame" x="0.0" y="0.0" width="189" height="406"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</view>
|
||||
</collectionViewCell>
|
||||
</cells>
|
||||
</collectionView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="5yj-Nb-f5H" secondAttribute="trailing" id="2DI-44-pC1"/>
|
||||
<constraint firstItem="5yj-Nb-f5H" firstAttribute="top" secondItem="Z4y-vb-Z4Q" secondAttribute="top" id="URh-5T-73x"/>
|
||||
<constraint firstAttribute="bottom" secondItem="5yj-Nb-f5H" secondAttribute="bottom" id="Yb6-aZ-qNF"/>
|
||||
<constraint firstItem="5yj-Nb-f5H" firstAttribute="leading" secondItem="Z4y-vb-Z4Q" secondAttribute="leading" id="rpG-Ip-qZU"/>
|
||||
<constraint firstAttribute="trailing" secondItem="ppk-lL-at8" secondAttribute="trailing" id="3QR-Y2-v26"/>
|
||||
<constraint firstAttribute="bottom" secondItem="ppk-lL-at8" secondAttribute="bottom" id="EgJ-Uw-5ta"/>
|
||||
<constraint firstItem="ppk-lL-at8" firstAttribute="leading" secondItem="Z4y-vb-Z4Q" secondAttribute="leading" id="wHf-S9-gMV"/>
|
||||
<constraint firstItem="ppk-lL-at8" firstAttribute="top" secondItem="Z4y-vb-Z4Q" secondAttribute="top" id="xY5-w8-roA"/>
|
||||
</constraints>
|
||||
</tableViewCellContentView>
|
||||
<color key="backgroundColor" name="Background"/>
|
||||
<inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="EL5-UC-RIw" customClass="AppContentTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="407" width="375" height="98"/>
|
||||
<rect key="frame" x="0.0" y="151" width="375" height="98"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="EL5-UC-RIw" id="D1G-nK-G0Z">
|
||||
<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"/>
|
||||
@@ -305,7 +324,7 @@
|
||||
<inset key="separatorInset" minX="1000" minY="0.0" maxX="0.0" maxY="0.0"/>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="47M-El-a4G" customClass="AppContentTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="505" width="375" height="137.5"/>
|
||||
<rect key="frame" x="0.0" y="249" width="375" height="137.5"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="47M-El-a4G" id="f9D-OR-oGE">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="137.5"/>
|
||||
@@ -337,8 +356,8 @@
|
||||
<stackView opaque="NO" contentMode="scaleToFill" distribution="equalSpacing" translatesAutoresizingMaskIntoConstraints="NO" id="ewH-gi-pyW">
|
||||
<rect key="frame" x="0.0" y="30.5" width="335" height="17"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="Version 4.4.2" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="7E0-TV-G4l">
|
||||
<rect key="frame" x="0.0" y="0.0" width="84.5" height="17"/>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="Version 0.5.6" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="7E0-TV-G4l">
|
||||
<rect key="frame" x="0.0" y="0.0" width="84" height="17"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
||||
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
@@ -353,7 +372,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"/>
|
||||
@@ -373,35 +392,83 @@
|
||||
<color key="backgroundColor" name="Background"/>
|
||||
<inset key="separatorInset" minX="1000" minY="0.0" maxX="0.0" maxY="0.0"/>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" rowHeight="300" id="nM7-vJ-W8b">
|
||||
<rect key="frame" x="0.0" y="642.5" width="375" height="300"/>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" rowHeight="149" id="nM7-vJ-W8b">
|
||||
<rect key="frame" x="0.0" y="386.5" width="375" height="149"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="nM7-vJ-W8b" id="cQ2-Jd-pRK">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="149"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" distribution="equalSpacing" alignment="center" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="Jvb-r8-XrY">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="149"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="Permissions" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="dj7-G8-GFv">
|
||||
<rect key="frame" x="20" y="0.0" width="335" height="26.5"/>
|
||||
<rect key="frame" x="20" y="0.0" width="335" height="26"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="22"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<containerView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wus-dU-ZqZ">
|
||||
<rect key="frame" x="0.0" y="80" width="375" height="200"/>
|
||||
<collectionView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" dataMode="prototypes" translatesAutoresizingMaskIntoConstraints="NO" id="r8T-dj-wQX">
|
||||
<rect key="frame" x="0.0" y="41" width="375" height="88"/>
|
||||
<color key="backgroundColor" name="Background"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="200" id="HFx-PP-dAt"/>
|
||||
<constraint firstAttribute="height" priority="999" constant="88" id="6Lk-OO-MsA"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<segue destination="OYP-I1-A3i" kind="embed" destinationCreationSelector="makeAppDetailCollectionViewController:sender:" id="Uxh-GM-nzb"/>
|
||||
</connections>
|
||||
</containerView>
|
||||
<collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" minimumLineSpacing="10" minimumInteritemSpacing="10" id="2HF-4d-3Im">
|
||||
<size key="itemSize" width="60" height="88"/>
|
||||
<size key="headerReferenceSize" width="0.0" height="0.0"/>
|
||||
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
||||
<inset key="sectionInset" minX="20" minY="0.0" maxX="20" maxY="0.0"/>
|
||||
</collectionViewFlowLayout>
|
||||
<cells>
|
||||
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="Cell" id="WYy-bZ-h3T" customClass="PermissionCollectionViewCell" customModule="SideStore" customModuleProvider="target">
|
||||
<rect key="frame" x="20" y="0.0" width="60" height="88"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
|
||||
<rect key="frame" x="0.0" y="0.0" width="60" height="88"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<stackView verifyAmbiguity="off" opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="center" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="fSx-We-L4W">
|
||||
<rect key="frame" x="0.0" y="0.0" width="60" height="87.5"/>
|
||||
<subviews>
|
||||
<button opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="79g-9q-mE2">
|
||||
<rect key="frame" x="5" y="0.0" width="50" height="50"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="50" id="0LZ-4n-COH"/>
|
||||
<constraint firstAttribute="height" constant="50" id="keD-mf-Rga"/>
|
||||
</constraints>
|
||||
</button>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" textAlignment="center" lineBreakMode="wordWrap" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="pQi-FD-18P">
|
||||
<rect key="frame" x="12.5" y="56" width="35.5" height="31.5"/>
|
||||
<string key="text">Hello
|
||||
World</string>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="13"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
</view>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="fSx-We-L4W" secondAttribute="trailing" id="IyD-vD-tA4"/>
|
||||
<constraint firstItem="fSx-We-L4W" firstAttribute="leading" secondItem="WYy-bZ-h3T" secondAttribute="leading" id="bTq-op-ivD"/>
|
||||
<constraint firstItem="fSx-We-L4W" firstAttribute="top" secondItem="WYy-bZ-h3T" secondAttribute="top" id="sMw-NS-jtY"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<outlet property="button" destination="79g-9q-mE2" id="G5V-SS-vaA"/>
|
||||
<outlet property="textLabel" destination="pQi-FD-18P" id="D5d-20-cm3"/>
|
||||
<segue destination="Ojq-DN-xcF" kind="popoverPresentation" identifier="showPermission" popoverAnchorView="r8T-dj-wQX" id="ftM-H7-Q7G">
|
||||
<popoverArrowDirection key="popoverArrowDirection" down="YES"/>
|
||||
</segue>
|
||||
</connections>
|
||||
</collectionViewCell>
|
||||
</cells>
|
||||
</collectionView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="dj7-G8-GFv" firstAttribute="leading" secondItem="Jvb-r8-XrY" secondAttribute="leading" constant="20" id="9pB-Md-91A"/>
|
||||
<constraint firstItem="wus-dU-ZqZ" firstAttribute="width" secondItem="Jvb-r8-XrY" secondAttribute="width" id="coR-wZ-TkD"/>
|
||||
<constraint firstItem="r8T-dj-wQX" firstAttribute="width" secondItem="Jvb-r8-XrY" secondAttribute="width" id="QJH-2y-DSh"/>
|
||||
</constraints>
|
||||
<edgeInsets key="layoutMargins" top="0.0" left="0.0" bottom="20" right="0.0"/>
|
||||
</stackView>
|
||||
@@ -427,9 +494,9 @@
|
||||
<navigationItem key="navigationItem" title="App" largeTitleDisplayMode="never" id="sWo-Y8-aF6"/>
|
||||
<size key="freeformSize" width="375" height="667"/>
|
||||
<connections>
|
||||
<outlet property="appDetailCollectionViewHeightConstraint" destination="HFx-PP-dAt" id="ti3-q6-ku1"/>
|
||||
<outlet property="appScreenshotsHeightConstraint" destination="dpf-ba-NNr" id="shO-Kq-Y90"/>
|
||||
<outlet property="descriptionTextView" destination="Pyt-8D-BZA" id="cgV-Hg-LrH"/>
|
||||
<outlet property="permissionsCollectionView" destination="r8T-dj-wQX" id="Xud-5X-w2E"/>
|
||||
<outlet property="screenshotsCollectionView" destination="ppk-lL-at8" id="YoQ-Z6-WTP"/>
|
||||
<outlet property="sizeLabel" destination="DgM-bD-bBY" id="Oky-ax-u20"/>
|
||||
<outlet property="subtitleLabel" destination="BsL-O2-UjD" id="cfe-cf-4a9"/>
|
||||
<outlet property="versionDateLabel" destination="wGD-mS-8fO" id="icB-lC-g9x"/>
|
||||
@@ -439,52 +506,52 @@
|
||||
</tableViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dhh-ZN-LoG" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="3506" y="437"/>
|
||||
<point key="canvasLocation" x="3302" y="-18"/>
|
||||
</scene>
|
||||
<!--App Screenshots View Controller-->
|
||||
<scene sceneID="E6k-TI-c4N">
|
||||
<!--Permission Popover View Controller-->
|
||||
<scene sceneID="24j-EJ-G4e">
|
||||
<objects>
|
||||
<collectionViewController storyboardIdentifier="appScreenshotsViewController" id="nX2-hQ-qjX" customClass="AppScreenshotsViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" contentInsetAdjustmentBehavior="never" dataMode="prototypes" id="zXl-if-KtH">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
|
||||
<viewController id="Ojq-DN-xcF" customClass="PermissionPopoverViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="IgU-aM-YrX">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="217"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" automaticEstimatedItemSize="YES" minimumLineSpacing="10" minimumInteritemSpacing="10" id="MGS-YY-5g9">
|
||||
<size key="itemSize" width="150" height="300"/>
|
||||
<size key="headerReferenceSize" width="0.0" height="0.0"/>
|
||||
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
||||
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
|
||||
</collectionViewFlowLayout>
|
||||
<cells/>
|
||||
<connections>
|
||||
<outlet property="dataSource" destination="nX2-hQ-qjX" id="QRj-01-ddR"/>
|
||||
<outlet property="delegate" destination="nX2-hQ-qjX" id="Ha5-Xa-Q6e"/>
|
||||
</connections>
|
||||
</collectionView>
|
||||
</collectionViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="np0-Hj-vy7" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="xnC-tS-ZdV">
|
||||
<rect key="frame" x="20" y="10" width="335" height="197"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="500" insetsLayoutMarginsFromSafeArea="NO" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="4fh-lO-rAn">
|
||||
<rect key="frame" x="0.0" y="0.0" width="335" height="17"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="TopLeft" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="500" insetsLayoutMarginsFromSafeArea="NO" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" preferredMaxLayoutWidth="300" translatesAutoresizingMaskIntoConstraints="NO" id="ErG-8A-uqY">
|
||||
<rect key="frame" x="0.0" y="21" width="335" height="176"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="13"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<edgeInsets key="layoutMargins" top="0.0" left="0.0" bottom="0.0" right="0.0"/>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="c7x-ee-3HH"/>
|
||||
<color key="backgroundColor" systemColor="tertiarySystemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstItem="xnC-tS-ZdV" firstAttribute="leading" secondItem="c7x-ee-3HH" secondAttribute="leading" constant="20" id="LO8-Au-SYF"/>
|
||||
<constraint firstItem="c7x-ee-3HH" firstAttribute="bottom" secondItem="xnC-tS-ZdV" secondAttribute="bottom" constant="10" id="NZ9-iG-E10"/>
|
||||
<constraint firstItem="c7x-ee-3HH" firstAttribute="trailing" secondItem="xnC-tS-ZdV" secondAttribute="trailing" constant="20" id="ZkD-tb-mBf"/>
|
||||
<constraint firstItem="xnC-tS-ZdV" firstAttribute="top" secondItem="c7x-ee-3HH" secondAttribute="top" constant="10" id="oKq-9e-DtW"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="descriptionLabel" destination="ErG-8A-uqY" id="iuN-kE-IEm"/>
|
||||
<outlet property="nameLabel" destination="4fh-lO-rAn" id="GWh-7k-yWw"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="7Tu-x9-xBb" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="4302" y="20"/>
|
||||
</scene>
|
||||
<!--App Detail Collection View Controller-->
|
||||
<scene sceneID="Pcn-h5-5fk">
|
||||
<objects>
|
||||
<collectionViewController id="OYP-I1-A3i" customClass="AppDetailCollectionViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" dataMode="prototypes" id="y1V-56-IqS" customClass="SafeAreaIgnoringCollectionView">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="200"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<collectionViewLayout key="collectionViewLayout" id="KQE-PB-FbG"/>
|
||||
<cells/>
|
||||
<connections>
|
||||
<outlet property="dataSource" destination="OYP-I1-A3i" id="YDU-V6-g0R"/>
|
||||
<outlet property="delegate" destination="OYP-I1-A3i" id="faX-I5-qJ2"/>
|
||||
</connections>
|
||||
</collectionView>
|
||||
</collectionViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="fxm-bB-W29" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="4298" y="434"/>
|
||||
<point key="canvasLocation" x="4257" y="-412"/>
|
||||
</scene>
|
||||
<!--Settings-->
|
||||
<scene sceneID="KlD-j0-ROn">
|
||||
@@ -494,7 +561,7 @@
|
||||
</viewControllerPlaceholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="HgE-PD-dC2" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="233" y="550"/>
|
||||
<point key="canvasLocation" x="962" y="1197"/>
|
||||
</scene>
|
||||
<!--News-->
|
||||
<scene sceneID="bqw-wB-hyB">
|
||||
@@ -532,11 +599,10 @@
|
||||
<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>
|
||||
<segue destination="KKu-kI-2kg" kind="relationship" relationship="rootViewController" id="2Dm-Oy-wu0"/>
|
||||
<segue destination="e3L-BF-iXp" kind="relationship" relationship="rootViewController" id="EVp-fA-PvU"/>
|
||||
</connections>
|
||||
</navigationController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="OkH-49-O0J" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
@@ -549,7 +615,7 @@
|
||||
<viewControllerPlaceholder storyboardName="PatchApp" id="bTL-bY-9Yq" sceneMemberID="viewController"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="NyZ-z6-R2q" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="-228" y="551"/>
|
||||
<point key="canvasLocation" x="-1" y="545"/>
|
||||
</scene>
|
||||
<!--My Apps-->
|
||||
<scene sceneID="nhh-BJ-XiT">
|
||||
@@ -562,7 +628,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>
|
||||
@@ -639,19 +704,12 @@
|
||||
<color key="textColor" name="Primary"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Who-nd-jyt">
|
||||
<rect key="frame" x="313" y="13" width="38" height="34.5"/>
|
||||
<state key="normal" title="Button"/>
|
||||
<buttonConfiguration key="configuration" style="plain" title="..."/>
|
||||
</button>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="Who-nd-jyt" firstAttribute="trailing" secondItem="F8U-ab-fOM" secondAttribute="trailingMargin" id="0Fe-FJ-P3p"/>
|
||||
<constraint firstItem="z04-yg-x1t" firstAttribute="centerY" secondItem="F8U-ab-fOM" secondAttribute="centerY" id="9w9-Z0-jZl"/>
|
||||
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="z04-yg-x1t" secondAttribute="bottom" constant="10" id="IWL-Ei-QC2"/>
|
||||
<constraint firstItem="z04-yg-x1t" firstAttribute="top" relation="greaterThanOrEqual" secondItem="F8U-ab-fOM" secondAttribute="top" constant="10" id="fLp-au-PLf"/>
|
||||
<constraint firstItem="z04-yg-x1t" firstAttribute="centerX" secondItem="F8U-ab-fOM" secondAttribute="centerX" id="fiy-Zt-GmB"/>
|
||||
<constraint firstItem="Who-nd-jyt" firstAttribute="centerY" secondItem="F8U-ab-fOM" secondAttribute="centerY" id="tV3-4W-6Ha"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<vibrancyEffect style="secondaryLabel">
|
||||
@@ -679,8 +737,6 @@
|
||||
</constraints>
|
||||
<connections>
|
||||
<outlet property="blurView" destination="7iO-O4-Mr9" id="kQ4-9N-nnv"/>
|
||||
<outlet property="button" destination="Who-nd-jyt" id="EA8-Jn-NJs"/>
|
||||
<outlet property="textLabel" destination="z04-yg-x1t" id="njE-fn-vxd"/>
|
||||
</connections>
|
||||
</collectionViewCell>
|
||||
</cells>
|
||||
@@ -709,7 +765,7 @@
|
||||
</stackView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="bottom" secondItem="GFQ-Wy-Qhy" secondAttribute="bottom" priority="999" constant="8" id="HGl-P6-G2v"/>
|
||||
<constraint firstAttribute="bottom" secondItem="GFQ-Wy-Qhy" secondAttribute="bottom" constant="8" id="HGl-P6-G2v"/>
|
||||
<constraint firstItem="GFQ-Wy-Qhy" firstAttribute="top" secondItem="HYs-co-nJZ" secondAttribute="top" id="gg9-XU-2ej"/>
|
||||
<constraint firstItem="GFQ-Wy-Qhy" firstAttribute="centerX" secondItem="HYs-co-nJZ" secondAttribute="centerX" id="vyo-h4-yD9"/>
|
||||
</constraints>
|
||||
@@ -736,56 +792,7 @@
|
||||
</collectionViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="kiO-UO-esV" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="1729" y="716"/>
|
||||
</scene>
|
||||
<!--Featured View Controller-->
|
||||
<scene sceneID="1eF-L7-aZz">
|
||||
<objects>
|
||||
<collectionViewController storyboardIdentifier="featuredViewController" id="KKu-kI-2kg" customClass="FeaturedViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" dataMode="prototypes" id="2HL-eH-weG">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<collectionViewFlowLayout key="collectionViewLayout" automaticEstimatedItemSize="YES" minimumLineSpacing="10" minimumInteritemSpacing="10" id="PI1-YC-d4l">
|
||||
<size key="itemSize" width="128" height="128"/>
|
||||
<size key="headerReferenceSize" width="0.0" height="0.0"/>
|
||||
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
||||
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
|
||||
</collectionViewFlowLayout>
|
||||
<cells>
|
||||
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="Cell" id="Eo1-84-9m0">
|
||||
<rect key="frame" x="0.0" y="0.0" width="128" height="128"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<collectionViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="4ra-vw-qNw">
|
||||
<rect key="frame" x="0.0" y="0.0" width="128" height="128"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</collectionViewCellContentView>
|
||||
</collectionViewCell>
|
||||
</cells>
|
||||
<connections>
|
||||
<outlet property="dataSource" destination="KKu-kI-2kg" id="tXR-fi-SxU"/>
|
||||
<outlet property="delegate" destination="KKu-kI-2kg" id="XC4-MP-Zdr"/>
|
||||
</connections>
|
||||
</collectionView>
|
||||
<navigationItem key="navigationItem" id="zft-Mo-I7C"/>
|
||||
<connections>
|
||||
<segue destination="e3L-BF-iXp" kind="show" identifier="showBrowseViewController" destinationCreationSelector="makeBrowseViewController:sender:" id="qDq-A7-sdW"/>
|
||||
<segue destination="177-gr-dJU" kind="show" identifier="showSourceDetails" destinationCreationSelector="makeSourceDetailViewController:sender:" id="dmC-aP-9Hg"/>
|
||||
</connections>
|
||||
</collectionViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="Hwb-Di-x8C" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="1729" y="-19"/>
|
||||
</scene>
|
||||
<!--sourceDetailViewController-->
|
||||
<scene sceneID="nDc-kS-RDF">
|
||||
<objects>
|
||||
<viewControllerPlaceholder storyboardName="Sources" referencedIdentifier="sourceDetailViewController" id="177-gr-dJU" sceneMemberID="viewController">
|
||||
<navigationItem key="navigationItem" id="7hT-A6-bBi"/>
|
||||
</viewControllerPlaceholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="bhw-oh-Eeq" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="2730" y="-21"/>
|
||||
<point key="canvasLocation" x="1728.8" y="716.49175412293857"/>
|
||||
</scene>
|
||||
<!--App IDs-->
|
||||
<scene sceneID="kvf-US-rRe">
|
||||
@@ -802,22 +809,30 @@
|
||||
<inset key="sectionInset" minX="0.0" minY="10" maxX="0.0" maxY="20"/>
|
||||
</collectionViewFlowLayout>
|
||||
<cells>
|
||||
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" reuseIdentifier="Cell" id="XWu-DU-xbh" customClass="AppBannerCollectionViewCell" customModule="SideStore" customModuleProvider="target">
|
||||
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" reuseIdentifier="Cell" id="XWu-DU-xbh" customClass="BannerCollectionViewCell" customModule="SideStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="70" width="375" height="80"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="80"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="1w8-fI-98T" customClass="AppBannerView" customModule="SideStore" customModuleProvider="target">
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="1w8-fI-98T" customClass="AppBannerView" customModule="SideStore" customModuleProvider="target">
|
||||
<rect key="frame" x="8" y="0.0" width="359" height="80"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<accessibility key="accessibilityConfiguration">
|
||||
<bool key="isElement" value="YES"/>
|
||||
</accessibility>
|
||||
</view>
|
||||
</subviews>
|
||||
</view>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailingMargin" secondItem="1w8-fI-98T" secondAttribute="trailing" id="0bS-49-dqo"/>
|
||||
<constraint firstAttribute="bottom" secondItem="1w8-fI-98T" secondAttribute="bottom" id="Bif-xB-0gt"/>
|
||||
<constraint firstItem="1w8-fI-98T" firstAttribute="top" secondItem="XWu-DU-xbh" secondAttribute="top" id="aEf-KK-MHU"/>
|
||||
<constraint firstItem="1w8-fI-98T" firstAttribute="leading" secondItem="XWu-DU-xbh" secondAttribute="leadingMargin" id="mFW-ti-cVB"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<outlet property="bannerView" destination="1w8-fI-98T" id="OH8-L9-TZn"/>
|
||||
</connections>
|
||||
</collectionViewCell>
|
||||
</cells>
|
||||
<collectionReusableView key="sectionHeaderView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="Header" id="th0-G6-bRt" customClass="TextCollectionReusableView" customModule="SideStore" customModuleProvider="target">
|
||||
@@ -866,7 +881,7 @@
|
||||
</connections>
|
||||
</collectionView>
|
||||
<navigationItem key="navigationItem" title="App IDs" id="3Co-uv-Fhb">
|
||||
<barButtonItem key="leftBarButtonItem" id="Aqs-QK-Ups">
|
||||
<barButtonItem key="leftBarButtonItem" style="plain" id="Aqs-QK-Ups">
|
||||
<view key="customView" contentMode="scaleToFill" id="p0q-Fg-3Ba">
|
||||
<rect key="frame" x="16" y="7" width="83" height="42"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
@@ -885,7 +900,7 @@
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="Lvd-jC-AZO" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
<exit id="eS1-sQ-VUA" userLabel="Exit" sceneMemberID="exit"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="3506" y="1121"/>
|
||||
<point key="canvasLocation" x="3301.5999999999999" y="715.59220389805103"/>
|
||||
</scene>
|
||||
<!--News-->
|
||||
<scene sceneID="BV8-6J-nIv">
|
||||
@@ -915,7 +930,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>
|
||||
@@ -924,31 +938,164 @@
|
||||
</navigationController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="3LN-mt-qWn" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="2730" y="1120"/>
|
||||
<point key="canvasLocation" x="2526" y="731"/>
|
||||
</scene>
|
||||
<!--Sources-->
|
||||
<scene sceneID="Vzf-tb-LIH">
|
||||
<scene sceneID="0S1-zn-9KZ">
|
||||
<objects>
|
||||
<viewControllerPlaceholder storyboardName="Sources" id="HCK-G6-KdY" sceneMemberID="viewController">
|
||||
<tabBarItem key="tabBarItem" title="Item" id="Q7y-bi-ncT"/>
|
||||
</viewControllerPlaceholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="VTd-he-VYb" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
<collectionViewController title="Sources" id="cHC-TX-KzQ" customClass="SourcesViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" alwaysBounceVertical="YES" dataMode="prototypes" id="S36-hD-vu2">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="647"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="15" minimumInteritemSpacing="10" id="X20-b5-XEP">
|
||||
<size key="itemSize" width="375" height="80"/>
|
||||
<size key="headerReferenceSize" width="50" height="200"/>
|
||||
<size key="footerReferenceSize" width="50" height="50"/>
|
||||
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
|
||||
</collectionViewFlowLayout>
|
||||
<cells>
|
||||
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" reuseIdentifier="Cell" id="XcN-o4-9qm" customClass="BannerCollectionViewCell" customModule="SideStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="200" width="375" height="80"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="80"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LW1-CC-bWu" customClass="AppBannerView" customModule="SideStore" customModuleProvider="target">
|
||||
<rect key="frame" x="8" y="0.0" width="359" height="80"/>
|
||||
<accessibility key="accessibilityConfiguration">
|
||||
<bool key="isElement" value="YES"/>
|
||||
</accessibility>
|
||||
</view>
|
||||
</subviews>
|
||||
</view>
|
||||
<constraints>
|
||||
<constraint firstAttribute="bottom" secondItem="LW1-CC-bWu" secondAttribute="bottom" id="Pkr-zO-0wx"/>
|
||||
<constraint firstItem="LW1-CC-bWu" firstAttribute="leading" secondItem="XcN-o4-9qm" secondAttribute="leadingMargin" id="egJ-X3-yEz"/>
|
||||
<constraint firstItem="LW1-CC-bWu" firstAttribute="top" secondItem="XcN-o4-9qm" secondAttribute="top" id="glF-aM-4xQ"/>
|
||||
<constraint firstAttribute="trailingMargin" secondItem="LW1-CC-bWu" secondAttribute="trailing" id="tQx-yV-LTq"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<outlet property="bannerView" destination="LW1-CC-bWu" id="mwO-Ne-L1L"/>
|
||||
</connections>
|
||||
</collectionViewCell>
|
||||
</cells>
|
||||
<collectionReusableView key="sectionHeaderView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="Header" id="8N7-JY-mcA" customClass="TextCollectionReusableView" customModule="SideStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="200"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Manage sources to control which apps are available to download through SideStore." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="TZv-TM-uJj">
|
||||
<rect key="frame" x="8" y="14" width="359" height="171"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleCallout"/>
|
||||
<color key="textColor" white="0.5" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="TZv-TM-uJj" firstAttribute="top" secondItem="8N7-JY-mcA" secondAttribute="top" constant="14" id="2zE-UV-24S"/>
|
||||
<constraint firstAttribute="bottom" secondItem="TZv-TM-uJj" secondAttribute="bottom" constant="15" id="Aml-PC-dko"/>
|
||||
<constraint firstAttribute="trailingMargin" secondItem="TZv-TM-uJj" secondAttribute="trailing" id="V0U-al-5eb"/>
|
||||
<constraint firstAttribute="leadingMargin" secondItem="TZv-TM-uJj" secondAttribute="leading" id="aS5-6Y-rMd"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<outlet property="bottomLayoutConstraint" destination="Aml-PC-dko" id="I1s-ae-C8A"/>
|
||||
<outlet property="leadingLayoutConstraint" destination="aS5-6Y-rMd" id="An8-KN-xfb"/>
|
||||
<outlet property="textLabel" destination="TZv-TM-uJj" id="kWV-Wv-5gz"/>
|
||||
<outlet property="topLayoutConstraint" destination="2zE-UV-24S" id="mjq-yH-v8J"/>
|
||||
<outlet property="trailingLayoutConstraint" destination="V0U-al-5eb" id="z8b-2G-SgY"/>
|
||||
</connections>
|
||||
</collectionReusableView>
|
||||
<collectionReusableView key="sectionFooterView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="Footer" id="X5B-Kp-w1p" customClass="SourcesFooterView">
|
||||
<rect key="frame" x="0.0" y="280" width="375" height="50"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="j0O-xE-gyd">
|
||||
<rect key="frame" x="8" y="0.0" width="359" height="50"/>
|
||||
<subviews>
|
||||
<activityIndicatorView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" hidesWhenStopped="YES" animating="YES" style="medium" translatesAutoresizingMaskIntoConstraints="NO" id="PNx-uR-y2F">
|
||||
<rect key="frame" x="0.0" y="0.0" width="359" height="0.0"/>
|
||||
</activityIndicatorView>
|
||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="800" scrollEnabled="NO" contentInsetAdjustmentBehavior="never" editable="NO" text="Test" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="66c-H8-KJx">
|
||||
<rect key="frame" x="0.0" y="15" width="359" height="35"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<color key="textColor" white="0.5" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
|
||||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||
</textView>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="bottom" secondItem="j0O-xE-gyd" secondAttribute="bottom" id="BQ5-11-BzK"/>
|
||||
<constraint firstItem="j0O-xE-gyd" firstAttribute="top" secondItem="X5B-Kp-w1p" secondAttribute="top" id="KZg-fd-8Cp" propertyAccessControl="none"/>
|
||||
<constraint firstItem="j0O-xE-gyd" firstAttribute="leading" secondItem="X5B-Kp-w1p" secondAttribute="leadingMargin" id="R2x-Io-bXD"/>
|
||||
<constraint firstAttribute="trailingMargin" secondItem="j0O-xE-gyd" secondAttribute="trailing" id="aBK-Bq-P9O"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<outlet property="activityIndicatorView" destination="PNx-uR-y2F" id="7Le-VW-GYK"/>
|
||||
<outlet property="bottomLayoutConstraint" destination="BQ5-11-BzK" id="iJR-4o-u9l"/>
|
||||
<outlet property="leadingLayoutConstraint" destination="R2x-Io-bXD" id="plZ-Yj-zTc"/>
|
||||
<outlet property="textView" destination="66c-H8-KJx" id="kwc-OH-U6i"/>
|
||||
<outlet property="topLayoutConstraint" destination="KZg-fd-8Cp" id="zNM-UU-feF"/>
|
||||
<outlet property="trailingLayoutConstraint" destination="aBK-Bq-P9O" id="L2r-VL-ruT"/>
|
||||
</connections>
|
||||
</collectionReusableView>
|
||||
<connections>
|
||||
<outlet property="dataSource" destination="cHC-TX-KzQ" id="VHQ-ls-gde"/>
|
||||
<outlet property="delegate" destination="cHC-TX-KzQ" id="MWr-Xg-N2k"/>
|
||||
</connections>
|
||||
</collectionView>
|
||||
<navigationItem key="navigationItem" title="Sources" id="QTB-W7-6BG">
|
||||
<barButtonItem key="leftBarButtonItem" systemItem="add" id="kBB-5c-8gw">
|
||||
<connections>
|
||||
<action selector="addSource" destination="cHC-TX-KzQ" id="WiB-Jg-NzT"/>
|
||||
</connections>
|
||||
</barButtonItem>
|
||||
<barButtonItem key="rightBarButtonItem" style="done" systemItem="done" id="NQF-u2-PZv">
|
||||
<connections>
|
||||
<segue destination="zjS-Nr-VTw" kind="unwind" unwindAction="unwindFromSourcesViewController:" id="la1-dJ-UhL"/>
|
||||
</connections>
|
||||
</barButtonItem>
|
||||
</navigationItem>
|
||||
</collectionViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="TrV-p3-ZAt" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
<exit id="zjS-Nr-VTw" userLabel="Exit" sceneMemberID="exit"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="-2" y="550"/>
|
||||
<point key="canvasLocation" x="3302" y="1430"/>
|
||||
</scene>
|
||||
<!--Navigation Controller-->
|
||||
<scene sceneID="6NV-LQ-gKB">
|
||||
<objects>
|
||||
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="Qo4-72-Hmr" sceneMemberID="viewController">
|
||||
<toolbarItems/>
|
||||
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="mcx-oR-qPe">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="108"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</navigationBar>
|
||||
<nil name="viewControllers"/>
|
||||
<connections>
|
||||
<segue destination="cHC-TX-KzQ" kind="relationship" relationship="rootViewController" id="BC5-Fs-dCj"/>
|
||||
</connections>
|
||||
</navigationController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="4mO-93-4qk" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="2526" y="1445"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<inferredMetricsTieBreakers>
|
||||
<segue reference="Qd6-ba-dIo"/>
|
||||
<segue reference="cnd-KK-o60"/>
|
||||
</inferredMetricsTieBreakers>
|
||||
<color key="tintColor" name="Primary"/>
|
||||
<resources>
|
||||
<image name="Back" width="18" height="18"/>
|
||||
<image name="Browse" width="128" height="128"/>
|
||||
<image name="Browse" width="20" height="20"/>
|
||||
<image name="MyApps" width="20" height="20"/>
|
||||
<image name="News" width="19" height="20"/>
|
||||
<image name="Settings" width="20" height="20"/>
|
||||
<namedColor name="Background">
|
||||
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<color red="0.45098039215686275" green="0.015686274509803921" blue="0.68627450980392157" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
<namedColor name="BlurTint">
|
||||
<color red="1" green="1" blue="1" alpha="0.30000001192092896" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
@@ -959,5 +1106,8 @@
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
<systemColor name="tertiarySystemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
|
||||
99
AltStore/Browse/BrowseCollectionViewCell.swift
Normal file
99
AltStore/Browse/BrowseCollectionViewCell.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// BrowseCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/15/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import Roxas
|
||||
|
||||
import Nuke
|
||||
|
||||
@objc final class BrowseCollectionViewCell: UICollectionViewCell
|
||||
{
|
||||
var imageURLs: [URL] = [] {
|
||||
didSet {
|
||||
self.dataSource.items = self.imageURLs as [NSURL]
|
||||
}
|
||||
}
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
|
||||
@IBOutlet var bannerView: AppBannerView!
|
||||
@IBOutlet var subtitleLabel: UILabel!
|
||||
|
||||
@IBOutlet private(set) var screenshotsCollectionView: UICollectionView!
|
||||
|
||||
override func awakeFromNib()
|
||||
{
|
||||
super.awakeFromNib()
|
||||
|
||||
self.contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
// Must be registered programmatically, not in BrowseCollectionViewCell.xib, or else it'll throw an exception 🤷♂️.
|
||||
self.screenshotsCollectionView.register(ScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||
|
||||
self.screenshotsCollectionView.delegate = self
|
||||
self.screenshotsCollectionView.dataSource = self.dataSource
|
||||
self.screenshotsCollectionView.prefetchDataSource = self.dataSource
|
||||
}
|
||||
}
|
||||
|
||||
private extension BrowseCollectionViewCell
|
||||
{
|
||||
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>
|
||||
{
|
||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: [])
|
||||
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in
|
||||
let cell = cell as! ScreenshotCollectionViewCell
|
||||
cell.imageView.image = nil
|
||||
cell.imageView.isIndicatingActivity = true
|
||||
}
|
||||
dataSource.prefetchHandler = { (imageURL, indexPath, completionHandler) in
|
||||
return RSTAsyncBlockOperation() { (operation) in
|
||||
let request = ImageRequest(url: imageURL as URL, processor: .screenshot)
|
||||
ImagePipeline.shared.loadImage(with: request, progress: nil, completion: { (response, error) in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
if let image = response?.image
|
||||
{
|
||||
completionHandler(image, nil)
|
||||
}
|
||||
else
|
||||
{
|
||||
completionHandler(nil, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
||||
let cell = cell as! ScreenshotCollectionViewCell
|
||||
cell.imageView.isIndicatingActivity = false
|
||||
cell.imageView.image = image
|
||||
|
||||
if let error = error
|
||||
{
|
||||
print("Error loading image:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
}
|
||||
|
||||
extension BrowseCollectionViewCell: UICollectionViewDelegateFlowLayout
|
||||
{
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
|
||||
{
|
||||
// Assuming 9.0 / 16.0 ratio for now.
|
||||
let aspectRatio: CGFloat = 9.0 / 16.0
|
||||
|
||||
let itemHeight = collectionView.bounds.height
|
||||
let itemWidth = itemHeight * aspectRatio
|
||||
|
||||
let size = CGSize(width: itemWidth.rounded(.down), height: itemHeight.rounded(.down))
|
||||
return size
|
||||
}
|
||||
}
|
||||
64
AltStore/Browse/BrowseCollectionViewCell.xib
Normal file
64
AltStore/Browse/BrowseCollectionViewCell.xib
Normal file
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15400" 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="15404"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<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="Cell" id="ln4-pC-7KY" customClass="BrowseCollectionViewCell" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="369"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="369"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<stackView verifyAmbiguity="off" opaque="NO" contentMode="scaleToFill" ambiguous="YES" axis="vertical" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="5gU-g3-Fsy">
|
||||
<rect key="frame" x="16" y="0.0" width="343" height="369"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ziA-mP-AY2" customClass="AppBannerView" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="343" height="88"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="88" id="z3D-cI-jhp"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Classic Nintendo games in your pocket." textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="Imx-Le-bcy">
|
||||
<rect key="frame" x="0.0" y="103" width="343" height="17"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<collectionView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" scrollEnabled="NO" contentInsetAdjustmentBehavior="never" dataMode="none" translatesAutoresizingMaskIntoConstraints="NO" id="RFs-qp-Ca4">
|
||||
<rect key="frame" x="0.0" y="135" width="343" height="234"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="0.0" minimumInteritemSpacing="15" id="jH9-Jo-IHA">
|
||||
<size key="itemSize" width="120" height="213"/>
|
||||
<size key="headerReferenceSize" width="0.0" height="0.0"/>
|
||||
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
||||
<inset key="sectionInset" minX="8" minY="0.0" maxX="8" maxY="0.0"/>
|
||||
</collectionViewFlowLayout>
|
||||
<cells/>
|
||||
</collectionView>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
</view>
|
||||
<constraints>
|
||||
<constraint firstItem="5gU-g3-Fsy" firstAttribute="top" secondItem="ln4-pC-7KY" secondAttribute="top" id="DnT-vq-BOc"/>
|
||||
<constraint firstItem="5gU-g3-Fsy" firstAttribute="leading" secondItem="ln4-pC-7KY" secondAttribute="leadingMargin" id="YPy-xL-iUn"/>
|
||||
<constraint firstAttribute="bottom" secondItem="5gU-g3-Fsy" secondAttribute="bottom" id="gRu-Hz-CNL"/>
|
||||
<constraint firstAttribute="trailingMargin" secondItem="5gU-g3-Fsy" secondAttribute="trailing" id="vf4-ql-4Vq"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<outlet property="bannerView" destination="ziA-mP-AY2" id="yxo-ar-Cha"/>
|
||||
<outlet property="screenshotsCollectionView" destination="RFs-qp-Ca4" id="xfi-AN-l17"/>
|
||||
<outlet property="subtitleLabel" destination="Imx-Le-bcy" id="JVW-ZZ-51O"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="136.95652173913044" y="152.67857142857142"/>
|
||||
</collectionViewCell>
|
||||
</objects>
|
||||
</document>
|
||||
@@ -7,7 +7,6 @@
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
import minimuxer
|
||||
import AltStoreCore
|
||||
@@ -15,61 +14,17 @@ import Roxas
|
||||
|
||||
import Nuke
|
||||
|
||||
class BrowseViewController: UICollectionViewController, PeekPopPreviewing
|
||||
class BrowseViewController: UICollectionViewController
|
||||
{
|
||||
// Nil == Show apps from all sources.
|
||||
let source: Source?
|
||||
|
||||
private(set) var category: StoreCategory? {
|
||||
didSet {
|
||||
self.updateDataSource()
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
var searchPredicate: NSPredicate? {
|
||||
didSet {
|
||||
self.updateDataSource()
|
||||
}
|
||||
}
|
||||
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
|
||||
|
||||
private let prototypeCell = AppCardCollectionViewCell(frame: .zero)
|
||||
private var sortButton: UIBarButtonItem?
|
||||
private let prototypeCell = BrowseCollectionViewCell.instantiate(with: BrowseCollectionViewCell.nib!)!
|
||||
|
||||
private var preferredAppSorting: AppSorting = UserDefaults.shared.preferredAppSorting
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private var titleStackView: UIStackView!
|
||||
private var titleSourceIconView: AppIconImageView!
|
||||
private var titleCategoryIconView: UIImageView!
|
||||
private var titleLabel: UILabel!
|
||||
|
||||
init?(source: Source?, coder: NSCoder)
|
||||
{
|
||||
self.source = source
|
||||
self.category = nil
|
||||
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
init?(category: StoreCategory?, coder: NSCoder)
|
||||
{
|
||||
self.source = nil
|
||||
self.category = category
|
||||
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder)
|
||||
{
|
||||
self.source = nil
|
||||
self.category = nil
|
||||
|
||||
super.init(coder: coder)
|
||||
private var loadingState: LoadingState = .loading {
|
||||
didSet {
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
private var cachedItemSizes = [String: CGSize]()
|
||||
@@ -80,80 +35,20 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
self.collectionView.backgroundColor = .altBackground
|
||||
self.collectionView.alwaysBounceVertical = true
|
||||
|
||||
self.dataSource.searchController.searchableKeyPaths = [#keyPath(StoreApp.name),
|
||||
#keyPath(StoreApp.subtitle),
|
||||
#keyPath(StoreApp.developerName),
|
||||
#keyPath(StoreApp.bundleIdentifier)]
|
||||
#if BETA
|
||||
self.dataSource.searchController.searchableKeyPaths = [#keyPath(InstalledApp.name)]
|
||||
self.navigationItem.searchController = self.dataSource.searchController
|
||||
#endif
|
||||
|
||||
self.prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
self.collectionView.register(AppCardCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||
self.collectionView.register(BrowseCollectionViewCell.nib, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||
|
||||
self.collectionView.dataSource = self.dataSource
|
||||
self.collectionView.prefetchDataSource = self.dataSource
|
||||
|
||||
let collectionViewLayout = self.collectionViewLayout as! UICollectionViewFlowLayout
|
||||
collectionViewLayout.minimumLineSpacing = 30
|
||||
self.registerForPreviewing(with: self, sourceView: self.collectionView)
|
||||
|
||||
(self as PeekPopPreviewing).registerForPreviewing(with: self, sourceView: self.collectionView)
|
||||
|
||||
let refreshControl = UIRefreshControl(frame: .zero, primaryAction: UIAction { [weak self] _ in
|
||||
self?.updateSources()
|
||||
})
|
||||
self.collectionView.refreshControl = refreshControl
|
||||
|
||||
if self.category != nil, #available(iOS 16, *)
|
||||
{
|
||||
let categoriesMenu = UIMenu(children: [
|
||||
UIDeferredMenuElement.uncached { [weak self] completion in
|
||||
let actions = self?.makeCategoryActions() ?? []
|
||||
completion(actions)
|
||||
}
|
||||
])
|
||||
|
||||
self.navigationItem.titleMenuProvider = { _ in categoriesMenu }
|
||||
}
|
||||
|
||||
self.titleSourceIconView = AppIconImageView(style: .circular)
|
||||
|
||||
self.titleCategoryIconView = UIImageView(frame: .zero)
|
||||
self.titleCategoryIconView.contentMode = .scaleAspectFit
|
||||
|
||||
self.titleLabel = UILabel()
|
||||
self.titleLabel.font = UIFont.preferredFont(forTextStyle: .headline)
|
||||
|
||||
self.titleStackView = UIStackView(arrangedSubviews: [self.titleSourceIconView, self.titleCategoryIconView, self.titleLabel])
|
||||
self.titleStackView.spacing = 4
|
||||
self.titleStackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
self.navigationItem.largeTitleDisplayMode = .never
|
||||
|
||||
if #available(iOS 16, *)
|
||||
{
|
||||
self.navigationItem.preferredSearchBarPlacement = .automatic
|
||||
}
|
||||
|
||||
if #available(iOS 15, *)
|
||||
{
|
||||
self.prepareAppSorting()
|
||||
}
|
||||
|
||||
self.preparePipeline()
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
// Source icon = equal width and height
|
||||
self.titleSourceIconView.heightAnchor.constraint(equalToConstant: 26),
|
||||
self.titleSourceIconView.widthAnchor.constraint(equalTo: self.titleSourceIconView.heightAnchor),
|
||||
|
||||
// Category icon = constant height, variable widths
|
||||
self.titleCategoryIconView.heightAnchor.constraint(equalToConstant: 26)
|
||||
])
|
||||
|
||||
self.updateDataSource()
|
||||
self.update()
|
||||
}
|
||||
|
||||
@@ -161,371 +56,186 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing
|
||||
{
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
self.fetchSource()
|
||||
self.updateDataSource()
|
||||
|
||||
self.update()
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool)
|
||||
@IBAction private func unwindFromSourcesViewController(_ segue: UIStoryboardSegue)
|
||||
{
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
self.navigationController?.navigationBar.tintColor = nil
|
||||
self.fetchSource()
|
||||
}
|
||||
}
|
||||
|
||||
private extension BrowseViewController
|
||||
{
|
||||
func preparePipeline()
|
||||
{
|
||||
AppManager.shared.$updateSourcesResult
|
||||
.receive(on: RunLoop.main) // Delay to next run loop so we receive _current_ value (not previous value).
|
||||
.sink { [weak self] result in
|
||||
self?.update()
|
||||
}
|
||||
.store(in: &self.cancellables)
|
||||
}
|
||||
|
||||
func makeFetchRequest() -> NSFetchRequest<StoreApp>
|
||||
{
|
||||
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
|
||||
let predicate = StoreApp.visibleAppsPredicate
|
||||
|
||||
if let source = self.source
|
||||
{
|
||||
let filterPredicate = NSPredicate(format: "%K == %@", #keyPath(StoreApp._source), source)
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [filterPredicate, predicate])
|
||||
}
|
||||
else if let category = self.category
|
||||
{
|
||||
let categoryPredicate = switch category {
|
||||
case .other: StoreApp.otherCategoryPredicate
|
||||
default: NSPredicate(format: "%K == %@", #keyPath(StoreApp._category), category.rawValue)
|
||||
}
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [categoryPredicate, predicate])
|
||||
}
|
||||
else
|
||||
{
|
||||
fetchRequest.predicate = predicate
|
||||
}
|
||||
|
||||
var sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp.name, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true)]
|
||||
|
||||
switch self.preferredAppSorting
|
||||
{
|
||||
case .default:
|
||||
let descriptor = NSSortDescriptor(keyPath: \StoreApp.sortIndex, ascending: self.preferredAppSorting.isAscending)
|
||||
sortDescriptors.insert(descriptor, at: 0)
|
||||
|
||||
case .name:
|
||||
// Already sorting by name, no need to prepend additional sort descriptor.
|
||||
break
|
||||
|
||||
case .developer:
|
||||
let descriptor = NSSortDescriptor(keyPath: \StoreApp.developerName, ascending: self.preferredAppSorting.isAscending)
|
||||
sortDescriptors.insert(descriptor, at: 0)
|
||||
|
||||
case .lastUpdated:
|
||||
let descriptor = NSSortDescriptor(keyPath: \StoreApp.latestSupportedVersion?.date, ascending: self.preferredAppSorting.isAscending)
|
||||
sortDescriptors.insert(descriptor, at: 0)
|
||||
}
|
||||
|
||||
fetchRequest.sortDescriptors = sortDescriptors
|
||||
|
||||
return fetchRequest
|
||||
}
|
||||
|
||||
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
||||
{
|
||||
let fetchRequest = self.makeFetchRequest()
|
||||
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.sortIndex, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.name, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true)]
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
fetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID)
|
||||
|
||||
let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext
|
||||
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: context)
|
||||
dataSource.placeholderView = self.placeholderView
|
||||
dataSource.cellConfigurationHandler = { [weak self] (cell, app, indexPath) in
|
||||
guard let self else { return }
|
||||
|
||||
let cell = cell as! AppCardCollectionViewCell
|
||||
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
||||
dataSource.cellConfigurationHandler = { (cell, app, indexPath) in
|
||||
let cell = cell as! BrowseCollectionViewCell
|
||||
cell.layoutMargins.left = self.view.layoutMargins.left
|
||||
cell.layoutMargins.right = self.view.layoutMargins.right
|
||||
|
||||
let showSourceIcon = (self.source == nil) // Hide source icon if redundant
|
||||
cell.configure(for: app, showSourceIcon: showSourceIcon)
|
||||
cell.subtitleLabel.text = app.subtitle
|
||||
cell.imageURLs = Array(app.screenshotURLs.prefix(2))
|
||||
|
||||
cell.bannerView.configure(for: app)
|
||||
|
||||
cell.bannerView.iconImageView.image = nil
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||
|
||||
cell.bannerView.button.addTarget(self, action: #selector(BrowseViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
||||
cell.bannerView.button.activityIndicatorView.style = .medium
|
||||
cell.bannerView.button.activityIndicatorView.color = .white
|
||||
|
||||
// Explicitly set to false to ensure we're starting from a non-activity indicating state.
|
||||
// Otherwise, cell reuse can mess up some cached values.
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
|
||||
let tintColor = app.tintColor ?? .altPrimary
|
||||
cell.tintColor = tintColor
|
||||
|
||||
if app.installedApp == nil
|
||||
{
|
||||
let buttonTitle = NSLocalizedString("Free", comment: "")
|
||||
cell.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name)
|
||||
cell.bannerView.button.accessibilityValue = buttonTitle
|
||||
|
||||
let progress = AppManager.shared.installationProgress(for: app)
|
||||
cell.bannerView.button.progress = progress
|
||||
|
||||
if let versionDate = app.latestSupportedVersion?.date, versionDate > Date()
|
||||
{
|
||||
cell.bannerView.button.countdownDate = versionDate
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.bannerView.button.countdownDate = nil
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
|
||||
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), app.name)
|
||||
cell.bannerView.button.accessibilityValue = nil
|
||||
cell.bannerView.button.progress = nil
|
||||
cell.bannerView.button.countdownDate = nil
|
||||
}
|
||||
}
|
||||
dataSource.prefetchHandler = { (storeApp, indexPath, completionHandler) -> Foundation.Operation? in
|
||||
let iconURL = storeApp.iconURL
|
||||
|
||||
return RSTAsyncBlockOperation() { (operation) in
|
||||
ImagePipeline.shared.loadImage(with: iconURL, progress: nil) { result in
|
||||
ImagePipeline.shared.loadImage(with: iconURL, progress: nil, completion: { (response, error) in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
switch result
|
||||
if let image = response?.image
|
||||
{
|
||||
case .success(let response): completionHandler(response.image, nil)
|
||||
case .failure(let error): completionHandler(nil, error)
|
||||
completionHandler(image, nil)
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
completionHandler(nil, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { [weak dataSource] (cell, image, indexPath, error) in
|
||||
let cell = cell as! AppCardCollectionViewCell
|
||||
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
||||
let cell = cell as! BrowseCollectionViewCell
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = false
|
||||
cell.bannerView.iconImageView.image = image
|
||||
|
||||
if let error = error, let dataSource
|
||||
if let error = error
|
||||
{
|
||||
let app = dataSource.item(at: indexPath)
|
||||
Logger.main.debug("Failed to load app icon from \(app.iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
||||
print("Error loading image:", error)
|
||||
}
|
||||
}
|
||||
|
||||
dataSource.placeholderView = self.placeholderView
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func updateDataSource()
|
||||
{
|
||||
let fetchRequest = self.makeFetchRequest()
|
||||
|
||||
let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext
|
||||
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil)
|
||||
self.dataSource.fetchedResultsController = fetchedResultsController
|
||||
|
||||
self.dataSource.predicate = self.searchPredicate
|
||||
self.dataSource.predicate = nil
|
||||
}
|
||||
|
||||
func updateSources()
|
||||
func fetchSource()
|
||||
{
|
||||
AppManager.shared.updateAllSources { result in
|
||||
self.collectionView.refreshControl?.endRefreshing()
|
||||
|
||||
guard case .failure(let error) = result else { return }
|
||||
|
||||
if self.dataSource.itemCount > 0
|
||||
self.loadingState = .loading
|
||||
|
||||
AppManager.shared.fetchSources() { (result) in
|
||||
do
|
||||
{
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside)
|
||||
toastView.show(in: self)
|
||||
do
|
||||
{
|
||||
let (_, context) = try result.get()
|
||||
try context.save()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.loadingState = .finished(.success(()))
|
||||
}
|
||||
}
|
||||
catch let error as AppManager.FetchSourcesError
|
||||
{
|
||||
try error.managedObjectContext?.save()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
if self.dataSource.itemCount > 0
|
||||
{
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside)
|
||||
toastView.show(in: self)
|
||||
}
|
||||
|
||||
self.loadingState = .finished(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update()
|
||||
{
|
||||
if self.searchPredicate != nil
|
||||
switch self.loadingState
|
||||
{
|
||||
self.placeholderView.textLabel.text = NSLocalizedString("No Apps", comment: "")
|
||||
self.placeholderView.textLabel.isHidden = false
|
||||
|
||||
self.placeholderView.detailTextLabel.text = NSLocalizedString("Please make sure your spelling is correct, or try searching for another app.", comment: "")
|
||||
case .loading:
|
||||
self.placeholderView.textLabel.isHidden = true
|
||||
self.placeholderView.detailTextLabel.isHidden = false
|
||||
|
||||
self.placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "")
|
||||
|
||||
self.placeholderView.activityIndicatorView.startAnimating()
|
||||
|
||||
case .finished(.failure(let error)):
|
||||
self.placeholderView.textLabel.isHidden = false
|
||||
self.placeholderView.detailTextLabel.isHidden = false
|
||||
|
||||
self.placeholderView.textLabel.text = NSLocalizedString("Unable to Fetch Apps", comment: "")
|
||||
self.placeholderView.detailTextLabel.text = error.localizedDescription
|
||||
|
||||
self.placeholderView.activityIndicatorView.stopAnimating()
|
||||
|
||||
case .finished(.success):
|
||||
self.placeholderView.textLabel.isHidden = true
|
||||
self.placeholderView.detailTextLabel.isHidden = true
|
||||
|
||||
self.placeholderView.activityIndicatorView.stopAnimating()
|
||||
}
|
||||
else
|
||||
{
|
||||
switch AppManager.shared.updateSourcesResult
|
||||
{
|
||||
case nil:
|
||||
self.placeholderView.textLabel.isHidden = true
|
||||
self.placeholderView.detailTextLabel.isHidden = false
|
||||
|
||||
self.placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "")
|
||||
|
||||
self.placeholderView.activityIndicatorView.startAnimating()
|
||||
|
||||
case .failure(let error):
|
||||
self.placeholderView.textLabel.isHidden = false
|
||||
self.placeholderView.detailTextLabel.isHidden = false
|
||||
|
||||
self.placeholderView.textLabel.text = NSLocalizedString("Unable to Fetch Apps", comment: "")
|
||||
self.placeholderView.detailTextLabel.text = error.localizedDescription
|
||||
|
||||
self.placeholderView.activityIndicatorView.stopAnimating()
|
||||
|
||||
case .success:
|
||||
self.placeholderView.textLabel.text = NSLocalizedString("No Apps", comment: "")
|
||||
self.placeholderView.textLabel.isHidden = false
|
||||
self.placeholderView.detailTextLabel.isHidden = true
|
||||
|
||||
self.placeholderView.activityIndicatorView.stopAnimating()
|
||||
}
|
||||
}
|
||||
|
||||
let tintColor: UIColor
|
||||
|
||||
if let source = self.source
|
||||
{
|
||||
tintColor = source.effectiveTintColor?.adjustedForDisplay ?? .altPrimary
|
||||
|
||||
self.title = source.name
|
||||
|
||||
self.titleSourceIconView.backgroundColor = tintColor
|
||||
self.titleSourceIconView.isHidden = false
|
||||
|
||||
self.titleCategoryIconView.isHidden = true
|
||||
|
||||
if let iconURL = source.effectiveIconURL
|
||||
{
|
||||
Nuke.loadImage(with: iconURL, into: self.titleSourceIconView) { result in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): Logger.main.error("Failed to fetch source icon at \(iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
||||
case .success: self.titleSourceIconView.backgroundColor = .white
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if let category = self.category
|
||||
{
|
||||
tintColor = category.tintColor
|
||||
|
||||
self.title = category.localizedName
|
||||
|
||||
let image = UIImage(systemName: category.filledSymbolName)?.withTintColor(tintColor, renderingMode: .alwaysOriginal)
|
||||
self.titleCategoryIconView.image = image
|
||||
self.titleCategoryIconView.isHidden = false
|
||||
|
||||
self.titleSourceIconView.isHidden = true
|
||||
}
|
||||
else
|
||||
{
|
||||
tintColor = .altPrimary
|
||||
|
||||
self.title = NSLocalizedString("Browse", comment: "")
|
||||
|
||||
self.titleSourceIconView.isHidden = true
|
||||
self.titleCategoryIconView.isHidden = true
|
||||
}
|
||||
|
||||
self.titleLabel.text = self.title
|
||||
self.titleStackView.sizeToFit()
|
||||
self.navigationItem.titleView = self.titleStackView
|
||||
|
||||
self.view.tintColor = tintColor
|
||||
|
||||
let appearance = NavigationBarAppearance()
|
||||
appearance.configureWithTintColor(tintColor)
|
||||
appearance.configureWithDefaultBackground()
|
||||
|
||||
let edgeAppearance = appearance.copy()
|
||||
edgeAppearance.configureWithTransparentBackground()
|
||||
|
||||
self.navigationItem.standardAppearance = appearance
|
||||
self.navigationItem.scrollEdgeAppearance = edgeAppearance
|
||||
|
||||
// Necessary to tint UISearchController's inline bar button.
|
||||
self.navigationController?.navigationBar.tintColor = tintColor
|
||||
|
||||
if let sortButton
|
||||
{
|
||||
sortButton.image = sortButton.image?.withTintColor(tintColor, renderingMode: .alwaysOriginal)
|
||||
}
|
||||
}
|
||||
|
||||
func makeCategoryActions() -> [UIAction]
|
||||
{
|
||||
let handler = { [weak self] (category: StoreCategory) in
|
||||
self?.category = category
|
||||
}
|
||||
|
||||
let fetchRequest = NSFetchRequest(entityName: StoreApp.entity().name!) as NSFetchRequest<NSDictionary>
|
||||
fetchRequest.resultType = .dictionaryResultType
|
||||
fetchRequest.returnsDistinctResults = true
|
||||
fetchRequest.propertiesToFetch = [#keyPath(StoreApp._category)]
|
||||
fetchRequest.predicate = StoreApp.visibleAppsPredicate
|
||||
|
||||
do
|
||||
{
|
||||
let dictionaries = try DatabaseManager.shared.viewContext.fetch(fetchRequest)
|
||||
|
||||
// Keep nil values
|
||||
let categories = dictionaries.map { $0[#keyPath(StoreApp._category)] as? String? ?? nil }.map { rawCategory -> StoreCategory in
|
||||
guard let rawCategory else { return .other }
|
||||
return StoreCategory(rawValue: rawCategory) ?? .other
|
||||
}
|
||||
|
||||
var sortedCategories = Set(categories).sorted(by: { $0.localizedName.localizedStandardCompare($1.localizedName) == .orderedAscending })
|
||||
if let otherIndex = sortedCategories.firstIndex(of: .other)
|
||||
{
|
||||
// Ensure "Other" is always last
|
||||
sortedCategories.move(fromOffsets: [otherIndex], toOffset: sortedCategories.count)
|
||||
}
|
||||
|
||||
let actions = sortedCategories.map { category in
|
||||
let state: UIAction.State = (category == self.category) ? .on : .off
|
||||
let image = UIImage(systemName: category.filledSymbolName)?.withTintColor(category.tintColor, renderingMode: .alwaysOriginal)
|
||||
return UIAction(title: category.localizedName, image: image, state: state) { _ in
|
||||
handler(category)
|
||||
}
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
catch
|
||||
{
|
||||
Logger.main.error("Failed to fetch categories. \(error.localizedDescription, privacy: .public)")
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15, *)
|
||||
func prepareAppSorting()
|
||||
{
|
||||
if self.preferredAppSorting == .default && self.source == nil
|
||||
{
|
||||
// Only allow `default` sorting if source is non-nil.
|
||||
// Otherwise, fall back to `lastUpdated` sorting.
|
||||
self.preferredAppSorting = .lastUpdated
|
||||
|
||||
// Don't update UserDefaults unless explicitly changed by user.
|
||||
// UserDefaults.shared.preferredAppSorting = .lastUpdated
|
||||
}
|
||||
|
||||
let children = UIDeferredMenuElement.uncached { [weak self] completion in
|
||||
guard let self else { return completion([]) }
|
||||
|
||||
var sortingOptions = AppSorting.allCases
|
||||
if self.source == nil
|
||||
{
|
||||
// Only allow `default` sorting when source is non-nil.
|
||||
sortingOptions = sortingOptions.filter { $0 != .default }
|
||||
}
|
||||
|
||||
let actions = sortingOptions.map { sorting in
|
||||
let state: UIMenuElement.State = (sorting == self.preferredAppSorting) ? .on : .off
|
||||
let action = UIAction(title: sorting.localizedName, image: nil, state: state) { action in
|
||||
self.preferredAppSorting = sorting
|
||||
UserDefaults.shared.preferredAppSorting = sorting // Update separately to save change.
|
||||
|
||||
self.updateDataSource()
|
||||
}
|
||||
|
||||
return action
|
||||
}
|
||||
|
||||
completion(actions)
|
||||
}
|
||||
|
||||
let sortMenu = UIMenu(title: NSLocalizedString("Sort by…", comment: ""), options: [.singleSelection], children: [children])
|
||||
let sortIcon = UIImage(systemName: "arrow.up.arrow.down")
|
||||
|
||||
let sortButton = UIBarButtonItem(title: NSLocalizedString("Sort by…", comment: ""), image: sortIcon, primaryAction: nil, menu: sortMenu)
|
||||
self.sortButton = sortButton
|
||||
|
||||
self.navigationItem.rightBarButtonItems = [sortButton]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,8 +248,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
|
||||
{
|
||||
self.open(installedApp)
|
||||
}
|
||||
@@ -562,26 +271,8 @@ private extension BrowseViewController
|
||||
toastView.show(in: self)
|
||||
return
|
||||
}
|
||||
|
||||
Task<Void, Never>(priority: .userInitiated) { @MainActor in
|
||||
// if let installedApp = app.installedApp, installedApp.isUpdateAvailable
|
||||
if let installedApp = app.installedApp, installedApp.hasUpdate
|
||||
{
|
||||
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
|
||||
}
|
||||
else
|
||||
{
|
||||
await AppManager.shared.installAsync(app, presentingViewController: self, completionHandler: finish(_:))
|
||||
}
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
self.collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func finish(_ result: Result<InstalledApp, Error>)
|
||||
{
|
||||
|
||||
_ = AppManager.shared.install(app, presentingViewController: self) { (result) in
|
||||
DispatchQueue.main.async {
|
||||
switch result
|
||||
{
|
||||
@@ -589,22 +280,15 @@ private extension BrowseViewController
|
||||
case .failure(let error):
|
||||
let toastView = ToastView(error: error, opensLog: true)
|
||||
toastView.show(in: self)
|
||||
|
||||
|
||||
case .success: print("Installed app:", app.bundleIdentifier)
|
||||
}
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
if let indexPath = self.dataSource.fetchedResultsController.indexPath(forObject: app)
|
||||
{
|
||||
self.collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
else
|
||||
{
|
||||
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
|
||||
}
|
||||
}
|
||||
self.collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
}
|
||||
|
||||
self.collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
|
||||
func open(_ installedApp: InstalledApp)
|
||||
@@ -618,18 +302,21 @@ extension BrowseViewController: UICollectionViewDelegateFlowLayout
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
|
||||
{
|
||||
let item = self.dataSource.item(at: indexPath)
|
||||
let itemID = item.globallyUniqueID ?? item.bundleIdentifier
|
||||
|
||||
if let previousSize = self.cachedItemSizes[itemID]
|
||||
if let previousSize = self.cachedItemSizes[item.bundleIdentifier]
|
||||
{
|
||||
return previousSize
|
||||
}
|
||||
|
||||
self.dataSource.cellConfigurationHandler(self.prototypeCell, item, indexPath)
|
||||
|
||||
let insets = (self.view.layoutMargins.left + self.view.layoutMargins.right)
|
||||
|
||||
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width - insets)
|
||||
let maxVisibleScreenshots = 2 as CGFloat
|
||||
let aspectRatio: CGFloat = 16.0 / 9.0
|
||||
|
||||
let layout = self.prototypeCell.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
||||
let padding = (layout.minimumInteritemSpacing * (maxVisibleScreenshots - 1)) + layout.sectionInset.left + layout.sectionInset.right
|
||||
|
||||
self.dataSource.cellConfigurationHandler(self.prototypeCell, item, indexPath)
|
||||
|
||||
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
|
||||
widthConstraint.isActive = true
|
||||
defer { widthConstraint.isActive = false }
|
||||
|
||||
@@ -637,25 +324,31 @@ extension BrowseViewController: UICollectionViewDelegateFlowLayout
|
||||
self.prototypeCell.frame.size.width = widthConstraint.constant
|
||||
self.prototypeCell.layoutIfNeeded()
|
||||
|
||||
let collectionViewWidth = self.prototypeCell.screenshotsCollectionView.bounds.width
|
||||
let screenshotWidth = ((collectionViewWidth - padding) / maxVisibleScreenshots).rounded(.down)
|
||||
let screenshotHeight = screenshotWidth * aspectRatio
|
||||
|
||||
let heightConstraint = self.prototypeCell.screenshotsCollectionView.heightAnchor.constraint(equalToConstant: screenshotHeight)
|
||||
heightConstraint.priority = .defaultHigh // Prevent temporary unsatisfiable constraints error.
|
||||
heightConstraint.isActive = true
|
||||
defer { heightConstraint.isActive = false }
|
||||
|
||||
let itemSize = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
self.cachedItemSizes[itemID] = itemSize
|
||||
self.cachedItemSizes[item.bundleIdentifier] = itemSize
|
||||
return itemSize
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
||||
{
|
||||
let app = self.dataSource.item(at: indexPath)
|
||||
let appViewController = AppViewController.makeAppViewController(app: app)
|
||||
|
||||
// Fall back to presentingViewController.navigationController in case we're being used for search results.
|
||||
let navigationController = self.navigationController ?? self.presentingViewController?.navigationController
|
||||
navigationController?.pushViewController(appViewController, animated: true)
|
||||
let appViewController = AppViewController.makeAppViewController(app: app)
|
||||
self.navigationController?.pushViewController(appViewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension BrowseViewController: UIViewControllerPreviewingDelegate
|
||||
{
|
||||
@available(iOS, deprecated: 13.0)
|
||||
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController?
|
||||
{
|
||||
guard
|
||||
@@ -671,22 +364,8 @@ extension BrowseViewController: UIViewControllerPreviewingDelegate
|
||||
return appViewController
|
||||
}
|
||||
|
||||
@available(iOS, deprecated: 13.0)
|
||||
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController)
|
||||
{
|
||||
self.navigationController?.pushViewController(viewControllerToCommit, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17, *)
|
||||
#Preview(traits: .portrait) {
|
||||
DatabaseManager.shared.startForPreview()
|
||||
|
||||
let storyboard = UIStoryboard(name: "Main", bundle: .main)
|
||||
let browseViewController = storyboard.instantiateViewController(identifier: "browseViewController") { coder in
|
||||
BrowseViewController(source: nil, coder: coder)
|
||||
}
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: browseViewController)
|
||||
return navigationController
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
//
|
||||
// FeaturedComponents.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 12/4/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class LargeIconCollectionViewCell: UICollectionViewCell
|
||||
{
|
||||
let textLabel = UILabel(frame: .zero)
|
||||
let imageView = UIImageView(frame: .zero)
|
||||
|
||||
override init(frame: CGRect)
|
||||
{
|
||||
self.textLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.textLabel.textColor = .white
|
||||
self.textLabel.font = .preferredFont(forTextStyle: .headline)
|
||||
|
||||
self.imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.imageView.contentMode = .center
|
||||
self.imageView.tintColor = .white
|
||||
self.imageView.alpha = 0.4
|
||||
self.imageView.preferredSymbolConfiguration = .init(pointSize: 80)
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.contentView.clipsToBounds = true
|
||||
self.contentView.layer.cornerRadius = 16
|
||||
self.contentView.layer.cornerCurve = .continuous
|
||||
|
||||
self.contentView.addSubview(self.textLabel)
|
||||
self.contentView.addSubview(self.imageView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
self.textLabel.leadingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.leadingAnchor, constant: 4),
|
||||
self.textLabel.bottomAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.bottomAnchor, constant: -4),
|
||||
|
||||
self.imageView.centerXAnchor.constraint(equalTo: self.contentView.trailingAnchor, constant: -30),
|
||||
self.imageView.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor, constant: 0),
|
||||
self.imageView.heightAnchor.constraint(equalTo: self.contentView.heightAnchor, constant: 0),
|
||||
self.imageView.widthAnchor.constraint(equalTo: self.imageView.heightAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
class IconButtonCollectionReusableView: UICollectionReusableView
|
||||
{
|
||||
let iconButton: UIButton
|
||||
let titleButton: UIButton
|
||||
|
||||
private let stackView: UIStackView
|
||||
|
||||
override init(frame: CGRect)
|
||||
{
|
||||
let iconHeight = 26.0
|
||||
|
||||
self.iconButton = UIButton(type: .custom)
|
||||
self.iconButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.iconButton.clipsToBounds = true
|
||||
self.iconButton.layer.cornerRadius = iconHeight / 2
|
||||
|
||||
let content = UIListContentConfiguration.plainHeader()
|
||||
self.titleButton = UIButton(type: .system)
|
||||
self.titleButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.titleButton.titleLabel?.font = content.textProperties.font
|
||||
self.titleButton.setTitleColor(content.textProperties.color, for: .normal)
|
||||
|
||||
self.stackView = UIStackView(arrangedSubviews: [self.iconButton, self.titleButton])
|
||||
self.stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.stackView.axis = .horizontal
|
||||
self.stackView.alignment = .center
|
||||
self.stackView.spacing = UIStackView.spacingUseSystem
|
||||
self.stackView.isLayoutMarginsRelativeArrangement = false
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.addSubview(self.stackView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
self.iconButton.heightAnchor.constraint(equalToConstant: iconHeight),
|
||||
self.iconButton.widthAnchor.constraint(equalTo: self.iconButton.heightAnchor),
|
||||
|
||||
self.stackView.topAnchor.constraint(equalTo: self.topAnchor),
|
||||
self.stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
||||
self.stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
||||
self.stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
@@ -1,747 +0,0 @@
|
||||
//
|
||||
// FeaturedViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 11/8/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import AltStoreCore
|
||||
import Roxas
|
||||
|
||||
import Nuke
|
||||
|
||||
extension UIAction.Identifier
|
||||
{
|
||||
fileprivate static let showAllApps = Self("io.sidestore.ShowAllApps")
|
||||
fileprivate static let showSourceDetails = Self("io.sidestore.ShowSourceDetails")
|
||||
}
|
||||
|
||||
extension FeaturedViewController
|
||||
{
|
||||
// Open-ended because each Source is its own section
|
||||
private struct Section: RawRepresentable, Equatable
|
||||
{
|
||||
static let recentlyUpdated = Section(rawValue: 0)
|
||||
static let categories = Section(rawValue: 1)
|
||||
static let featuredHeader = Section(rawValue: 2)
|
||||
|
||||
let rawValue: Int
|
||||
|
||||
var isFeaturedAppsSection: Bool {
|
||||
return self.rawValue > Section.featuredHeader.rawValue
|
||||
}
|
||||
|
||||
init(rawValue: Int)
|
||||
{
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
}
|
||||
|
||||
private enum ReuseID: String
|
||||
{
|
||||
case recent = "RecentCell"
|
||||
case category = "CategoryCell"
|
||||
case featuredApp = "FeaturedAppCell"
|
||||
}
|
||||
|
||||
private enum ElementKind: String
|
||||
{
|
||||
case sectionHeader
|
||||
case sourceHeader
|
||||
case button
|
||||
}
|
||||
}
|
||||
|
||||
class FeaturedViewController: UICollectionViewController
|
||||
{
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
private lazy var recentlyUpdatedDataSource = self.makeRecentlyUpdatedDataSource()
|
||||
private lazy var categoriesDataSource = self.makeCategoriesDataSource()
|
||||
private lazy var featuredAppsDataSource = self.makeFeaturedAppsDataSource()
|
||||
|
||||
private var searchController: RSTSearchController!
|
||||
private var searchBrowseViewController: BrowseViewController!
|
||||
|
||||
override func viewDidLoad()
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
self.title = NSLocalizedString("Browse", comment: "")
|
||||
|
||||
let layout = Self.makeLayout()
|
||||
self.collectionView.collectionViewLayout = layout
|
||||
|
||||
self.dataSource.proxy = self
|
||||
self.collectionView.dataSource = self.dataSource
|
||||
self.collectionView.prefetchDataSource = self.dataSource
|
||||
|
||||
self.collectionView.register(AppBannerCollectionViewCell.self, forCellWithReuseIdentifier: ReuseID.recent.rawValue)
|
||||
self.collectionView.register(LargeIconCollectionViewCell.self, forCellWithReuseIdentifier: ReuseID.category.rawValue)
|
||||
self.collectionView.register(AppCardCollectionViewCell.self, forCellWithReuseIdentifier: ReuseID.featuredApp.rawValue)
|
||||
|
||||
self.collectionView.register(UICollectionViewListCell.self, forSupplementaryViewOfKind: ElementKind.sectionHeader.rawValue, withReuseIdentifier: ElementKind.sectionHeader.rawValue)
|
||||
self.collectionView.register(IconButtonCollectionReusableView.self, forSupplementaryViewOfKind: ElementKind.sourceHeader.rawValue, withReuseIdentifier: ElementKind.sourceHeader.rawValue)
|
||||
self.collectionView.register(ButtonCollectionReusableView.self, forSupplementaryViewOfKind: ElementKind.button.rawValue, withReuseIdentifier: ElementKind.button.rawValue)
|
||||
|
||||
self.collectionView.backgroundColor = .altBackground
|
||||
self.collectionView.directionalLayoutMargins.leading = 20
|
||||
self.collectionView.directionalLayoutMargins.trailing = 20
|
||||
|
||||
let storyboard = UIStoryboard(name: "Main", bundle: nil)
|
||||
self.searchBrowseViewController = storyboard.instantiateViewController(identifier: "browseViewController") { coder in
|
||||
let browseViewController = BrowseViewController(coder: coder)
|
||||
return browseViewController
|
||||
}
|
||||
|
||||
self.searchController = RSTSearchController(searchResultsController: self.searchBrowseViewController)
|
||||
self.searchController.searchableKeyPaths = [#keyPath(StoreApp.name),
|
||||
#keyPath(StoreApp.developerName),
|
||||
#keyPath(StoreApp.subtitle),
|
||||
#keyPath(StoreApp.bundleIdentifier)]
|
||||
self.searchController.searchHandler = { [weak searchBrowseViewController] (searchValue, _) in
|
||||
searchBrowseViewController?.searchPredicate = searchValue.predicate
|
||||
return nil
|
||||
}
|
||||
|
||||
self.navigationItem.searchController = self.searchController
|
||||
self.navigationItem.hidesSearchBarWhenScrolling = true
|
||||
|
||||
self.navigationItem.largeTitleDisplayMode = .always
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool)
|
||||
{
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
self.navigationController?.navigationBar.tintColor = .altPrimary
|
||||
}
|
||||
}
|
||||
|
||||
private extension FeaturedViewController
|
||||
{
|
||||
class func makeLayout() -> UICollectionViewCompositionalLayout
|
||||
{
|
||||
let config = UICollectionViewCompositionalLayoutConfiguration()
|
||||
config.interSectionSpacing = 0 // Must be 0 for Section.featuredHeader
|
||||
config.contentInsetsReference = .layoutMargins
|
||||
|
||||
let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
|
||||
let section = Section(rawValue: sectionIndex)
|
||||
|
||||
let spacing = 10.0
|
||||
let interSectionSpacing = 30.0
|
||||
let titleSize = NSCollectionLayoutSize(widthDimension: .estimated(100), heightDimension: .estimated(30))
|
||||
|
||||
switch section
|
||||
{
|
||||
case .recentlyUpdated:
|
||||
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(AppBannerView.standardHeight))
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
|
||||
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(AppBannerView.standardHeight * 2 + spacing))
|
||||
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item, item]) // 2 items per group
|
||||
group.interItemSpacing = .fixed(spacing)
|
||||
|
||||
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||
layoutSection.interGroupSpacing = spacing
|
||||
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
|
||||
layoutSection.contentInsets.bottom = interSectionSpacing
|
||||
layoutSection.boundarySupplementaryItems = [
|
||||
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sectionHeader.rawValue, alignment: .topLeading)
|
||||
]
|
||||
return layoutSection
|
||||
|
||||
case .categories:
|
||||
let itemWidth = (layoutEnvironment.container.effectiveContentSize.width - spacing) / 2
|
||||
let itemHeight = 90.0
|
||||
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth), heightDimension: .absolute(itemHeight))
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
|
||||
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(itemHeight))
|
||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, item]) // 2 items per group
|
||||
group.interItemSpacing = .fixed(spacing)
|
||||
|
||||
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||
layoutSection.interGroupSpacing = spacing
|
||||
layoutSection.orthogonalScrollingBehavior = .none
|
||||
layoutSection.contentInsets.bottom = interSectionSpacing
|
||||
layoutSection.boundarySupplementaryItems = [
|
||||
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sectionHeader.rawValue, alignment: .topLeading)
|
||||
]
|
||||
return layoutSection
|
||||
|
||||
case .featuredHeader:
|
||||
// We don't want to show any items, so set height to 1.0
|
||||
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(1.0))
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
|
||||
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
|
||||
|
||||
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||
layoutSection.contentInsets.top = 0
|
||||
layoutSection.contentInsets.bottom = 0
|
||||
layoutSection.boundarySupplementaryItems = [
|
||||
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sectionHeader.rawValue, alignment: .topLeading)
|
||||
]
|
||||
return layoutSection
|
||||
|
||||
case _ where section.isFeaturedAppsSection:
|
||||
let itemHeight: NSCollectionLayoutDimension = if #available(iOS 17, *) { .uniformAcrossSiblings(estimate: 350) } else { .estimated(350) }
|
||||
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: itemHeight)
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
|
||||
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: itemHeight)
|
||||
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
|
||||
group.interItemSpacing = .fixed(spacing)
|
||||
|
||||
let titleHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sourceHeader.rawValue, alignment: .topLeading)
|
||||
|
||||
let buttonSize = NSCollectionLayoutSize(widthDimension: .estimated(44), heightDimension: .estimated(20))
|
||||
let buttonHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: buttonSize, elementKind: ElementKind.button.rawValue, alignment: .topTrailing)
|
||||
|
||||
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||
layoutSection.interGroupSpacing = spacing
|
||||
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
|
||||
layoutSection.contentInsets.top = 8
|
||||
layoutSection.contentInsets.bottom = interSectionSpacing
|
||||
layoutSection.boundarySupplementaryItems = [titleHeader, buttonHeader]
|
||||
return layoutSection
|
||||
|
||||
default: return nil
|
||||
}
|
||||
}, configuration: config)
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
func makeDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
||||
{
|
||||
let featuredHeaderDataSource = RSTDynamicCollectionViewDataSource<StoreApp>()
|
||||
featuredHeaderDataSource.numberOfSectionsHandler = { 1 }
|
||||
featuredHeaderDataSource.numberOfItemsHandler = { _ in 0 }
|
||||
|
||||
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>(dataSources: [self.recentlyUpdatedDataSource, self.categoriesDataSource, featuredHeaderDataSource, self.featuredAppsDataSource])
|
||||
dataSource.predicate = StoreApp.visibleAppsPredicate // Ensure we never accidentally show hidden apps
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func makeRecentlyUpdatedDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
||||
{
|
||||
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
fetchRequest.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \StoreApp.latestSupportedVersion?.date, ascending: false),
|
||||
NSSortDescriptor(keyPath: \StoreApp.name, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true),
|
||||
]
|
||||
|
||||
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
||||
dataSource.cellIdentifierHandler = { _ in ReuseID.recent.rawValue }
|
||||
dataSource.liveFetchLimit = 10 // Show 10 most recently updated apps
|
||||
dataSource.cellConfigurationHandler = { cell, storeApp, indexPath in
|
||||
let cell = cell as! AppBannerCollectionViewCell
|
||||
cell.tintColor = storeApp.tintColor
|
||||
cell.contentView.preservesSuperviewLayoutMargins = false
|
||||
cell.contentView.layoutMargins = .zero
|
||||
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
cell.bannerView.configure(for: storeApp)
|
||||
|
||||
if let versionDate = storeApp.latestSupportedVersion?.date
|
||||
{
|
||||
cell.bannerView.subtitleLabel.text = Date().relativeDateString(since: versionDate, dateFormatter: Date.mediumDateFormatter)
|
||||
}
|
||||
|
||||
cell.bannerView.button.addTarget(self, action: #selector(FeaturedViewController.performAppAction), for: .primaryActionTriggered)
|
||||
|
||||
cell.bannerView.iconImageView.image = nil
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||
}
|
||||
dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in
|
||||
return RSTAsyncBlockOperation { (operation) in
|
||||
storeApp.managedObjectContext?.perform {
|
||||
ImagePipeline.shared.loadImage(with: storeApp.iconURL, progress: nil) { result in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
switch result
|
||||
{
|
||||
case .success(let response): completion(response.image, nil)
|
||||
case .failure(let error): completion(nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { [weak dataSource] (cell, image, indexPath, error) in
|
||||
let cell = cell as! AppBannerCollectionViewCell
|
||||
cell.bannerView.iconImageView.image = image
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = false
|
||||
|
||||
if let error, let dataSource
|
||||
{
|
||||
let app = dataSource.item(at: indexPath)
|
||||
Logger.main.debug("Failed to app icon from \(app.iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func makeCategoriesDataSource() -> RSTCompositeCollectionViewDataSource<StoreApp>
|
||||
{
|
||||
let knownCategories = StoreCategory.allCases.filter { $0 != .other }.map { $0.rawValue }
|
||||
|
||||
let knownFetchRequest = StoreApp.fetchRequest()
|
||||
knownFetchRequest.predicate = NSPredicate(format: "%K IN %@", #keyPath(StoreApp._category), knownCategories)
|
||||
knownFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp._category, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true)]
|
||||
|
||||
let unknownFetchRequest = StoreApp.fetchRequest()
|
||||
unknownFetchRequest.predicate = StoreApp.otherCategoryPredicate
|
||||
unknownFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp._category, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true)]
|
||||
|
||||
let knownController = NSFetchedResultsController(fetchRequest: knownFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(StoreApp._category), cacheName: nil)
|
||||
let knownDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: knownController)
|
||||
knownDataSource.liveFetchLimit = 1 // One app per category
|
||||
|
||||
let unknownController = NSFetchedResultsController(fetchRequest: unknownFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: nil, cacheName: nil)
|
||||
let unknownDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: unknownController)
|
||||
unknownDataSource.liveFetchLimit = 1
|
||||
|
||||
// Use composite data source to ensure "Other" category is always last.
|
||||
let dataSource = RSTCompositeCollectionViewDataSource<StoreApp>(dataSources: [knownDataSource, unknownDataSource])
|
||||
dataSource.shouldFlattenSections = true // Combine into single section, with one StoreApp per category.
|
||||
dataSource.cellIdentifierHandler = { _ in ReuseID.category.rawValue }
|
||||
dataSource.cellConfigurationHandler = { cell, storeApp, indexPath in
|
||||
let category = storeApp.category ?? .other
|
||||
|
||||
let cell = cell as! LargeIconCollectionViewCell
|
||||
cell.textLabel.text = category.localizedName
|
||||
cell.imageView.image = UIImage(systemName: category.symbolName)
|
||||
|
||||
var background = UIBackgroundConfiguration.clear()
|
||||
background.backgroundColor = category.tintColor
|
||||
background.cornerRadius = 16
|
||||
cell.backgroundConfiguration = background
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func makeFeaturedAppsDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
||||
{
|
||||
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
fetchRequest.sortDescriptors = [
|
||||
// Sort by Source first to group into sections.
|
||||
NSSortDescriptor(keyPath: \StoreApp._source?.featuredSortID, ascending: true),
|
||||
|
||||
// Show uninstalled apps first.
|
||||
// Sorting by StoreApp.installedApp crashes because InstalledApp does not respond to compare:
|
||||
// Instead, sort by StoreApp.installedApp.storeApp.source.sourceIdentifier, which will be either nil OR source ID.
|
||||
NSSortDescriptor(keyPath: \StoreApp.installedApp?.storeApp?.sourceIdentifier, ascending: true),
|
||||
|
||||
// Show featured apps first.
|
||||
// Sorting by StoreApp.featuringSource crashes because Source does not respond to compare:
|
||||
// Instead, sort by StoreApp.featuringSource.identifier, which will be either nil OR source ID.
|
||||
NSSortDescriptor(keyPath: \StoreApp.featuringSource?.identifier, ascending: false),
|
||||
|
||||
// Randomize order within sections.
|
||||
NSSortDescriptor(keyPath: \StoreApp.featuredSortID, ascending: true),
|
||||
|
||||
// Sanity check to ensure stable ordering
|
||||
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true)
|
||||
]
|
||||
|
||||
let sourceHasRemainingAppsPredicate = NSPredicate(format:
|
||||
"""
|
||||
SUBQUERY(%K, $app,
|
||||
($app.%K != %@) AND ($app.%K == nil) AND (($app.%K == NO) OR ($app.%K == NO) OR ($app.%K == YES))
|
||||
).@count > 0
|
||||
""",
|
||||
#keyPath(StoreApp._source._apps),
|
||||
#keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID,
|
||||
#keyPath(StoreApp.installedApp),
|
||||
#keyPath(StoreApp.isPledgeRequired), #keyPath(StoreApp.isHiddenWithoutPledge), #keyPath(StoreApp.isPledged)
|
||||
)
|
||||
|
||||
let primaryFetchRequest = fetchRequest.copy() as! NSFetchRequest<StoreApp>
|
||||
primaryFetchRequest.predicate = sourceHasRemainingAppsPredicate
|
||||
|
||||
let primaryController = NSFetchedResultsController(fetchRequest: primaryFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(StoreApp._source.featuredSortID), cacheName: nil)
|
||||
let primaryDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: primaryController)
|
||||
primaryDataSource.liveFetchLimit = 5
|
||||
|
||||
let secondaryFetchRequest = fetchRequest.copy() as! NSFetchRequest<StoreApp>
|
||||
secondaryFetchRequest.predicate = NSCompoundPredicate(notPredicateWithSubpredicate: sourceHasRemainingAppsPredicate)
|
||||
|
||||
let secondaryController = NSFetchedResultsController(fetchRequest: secondaryFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(StoreApp._source.featuredSortID), cacheName: nil)
|
||||
let secondaryDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: secondaryController)
|
||||
secondaryDataSource.liveFetchLimit = 5
|
||||
|
||||
// Ensure sources with no remaining apps always come last.
|
||||
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>(dataSources: [primaryDataSource, secondaryDataSource])
|
||||
dataSource.cellIdentifierHandler = { _ in ReuseID.featuredApp.rawValue }
|
||||
dataSource.cellConfigurationHandler = { cell, storeApp, indexPath in
|
||||
let cell = cell as! AppCardCollectionViewCell
|
||||
cell.configure(for: storeApp)
|
||||
cell.prefersPagingScreenshots = false
|
||||
|
||||
cell.bannerView.button.addTarget(self, action: #selector(FeaturedViewController.performAppAction), for: .primaryActionTriggered)
|
||||
cell.bannerView.sourceIconImageView.isHidden = true
|
||||
|
||||
cell.bannerView.iconImageView.image = nil
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||
}
|
||||
dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in
|
||||
return RSTAsyncBlockOperation { (operation) in
|
||||
storeApp.managedObjectContext?.perform {
|
||||
ImagePipeline.shared.loadImage(with: storeApp.iconURL, progress: nil) { result in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
switch result
|
||||
{
|
||||
case .success(let response): completion(response.image, nil)
|
||||
case .failure(let error): completion(nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { [weak dataSource] (cell, image, indexPath, error) in
|
||||
let cell = cell as! AppCardCollectionViewCell
|
||||
cell.bannerView.iconImageView.image = image
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = false
|
||||
|
||||
if let error = error, let dataSource
|
||||
{
|
||||
let app = dataSource.item(at: indexPath)
|
||||
Logger.main.debug("Failed to app icon from \(app.iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
}
|
||||
|
||||
private extension FeaturedViewController
|
||||
{
|
||||
@IBSegueAction
|
||||
func makeBrowseViewController(_ coder: NSCoder, sender: Any) -> UIViewController?
|
||||
{
|
||||
if let category = sender as? StoreCategory
|
||||
{
|
||||
let browseViewController = BrowseViewController(category: category, coder: coder)
|
||||
return browseViewController
|
||||
}
|
||||
else if let source = sender as? Source
|
||||
{
|
||||
let browseViewController = BrowseViewController(source: source, coder: coder)
|
||||
return browseViewController
|
||||
}
|
||||
else
|
||||
{
|
||||
let browseViewController = BrowseViewController(coder: coder)
|
||||
return browseViewController
|
||||
}
|
||||
}
|
||||
|
||||
@IBSegueAction
|
||||
func makeSourceDetailViewController(_ coder: NSCoder, sender: Any?) -> UIViewController?
|
||||
{
|
||||
guard let source = sender as? Source else { return nil }
|
||||
|
||||
let sourceDetailViewController = SourceDetailViewController(source: source, coder: coder)
|
||||
return sourceDetailViewController
|
||||
}
|
||||
|
||||
func showAllApps(for source: Source)
|
||||
{
|
||||
self.performSegue(withIdentifier: "showBrowseViewController", sender: source)
|
||||
}
|
||||
|
||||
func showSourceDetails(for source: Source)
|
||||
{
|
||||
self.performSegue(withIdentifier: "showSourceDetails", sender: source)
|
||||
}
|
||||
}
|
||||
|
||||
private extension FeaturedViewController
|
||||
{
|
||||
@objc func performAppAction(_ sender: PillButton)
|
||||
{
|
||||
let point = self.collectionView.convert(sender.center, from: sender.superview)
|
||||
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
|
||||
|
||||
let storeApp = self.dataSource.item(at: indexPath)
|
||||
|
||||
// if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable
|
||||
if let installedApp = storeApp.installedApp, !installedApp.hasUpdate
|
||||
{
|
||||
self.open(installedApp)
|
||||
}
|
||||
else
|
||||
{
|
||||
self.install(storeApp, at: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func install(_ storeApp: StoreApp, at indexPath: IndexPath)
|
||||
{
|
||||
let previousProgress = AppManager.shared.installationProgress(for: storeApp)
|
||||
guard previousProgress == nil else {
|
||||
previousProgress?.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
// if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable
|
||||
if let installedApp = storeApp.installedApp, installedApp.hasUpdate
|
||||
{
|
||||
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
|
||||
}
|
||||
else
|
||||
{
|
||||
AppManager.shared.install(storeApp, presentingViewController: self, completionHandler: finish(_:))
|
||||
}
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
self.collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
|
||||
func finish(_ result: Result<InstalledApp, Error>)
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
switch result
|
||||
{
|
||||
case .failure(OperationError.cancelled): break // Ignore
|
||||
case .failure(let error):
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.opensErrorLog = true
|
||||
toastView.show(in: self)
|
||||
|
||||
case .success:
|
||||
Logger.main.info("Installed app \(storeApp.bundleIdentifier, privacy: .public) from FeaturedViewController.")
|
||||
}
|
||||
|
||||
for indexPath in self.collectionView.indexPathsForVisibleItems
|
||||
{
|
||||
// Only need to reload if it's still visible.
|
||||
|
||||
let item = self.dataSource.item(at: indexPath)
|
||||
guard item == storeApp else { continue }
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
self.collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func open(_ installedApp: InstalledApp)
|
||||
{
|
||||
UIApplication.shared.open(installedApp.openAppURL)
|
||||
}
|
||||
}
|
||||
|
||||
extension FeaturedViewController
|
||||
{
|
||||
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
|
||||
{
|
||||
let section = Section(rawValue: indexPath.section)
|
||||
|
||||
switch kind
|
||||
{
|
||||
case ElementKind.sourceHeader.rawValue:
|
||||
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! IconButtonCollectionReusableView
|
||||
|
||||
let indexPath = IndexPath(item: 0, section: indexPath.section)
|
||||
let storeApp = self.dataSource.item(at: indexPath)
|
||||
|
||||
var content = UIListContentConfiguration.plainHeader()
|
||||
content.text = storeApp.source?.name ?? NSLocalizedString("Unknown Source", comment: "")
|
||||
content.textProperties.numberOfLines = 1
|
||||
|
||||
content.directionalLayoutMargins.leading = 0
|
||||
content.imageToTextPadding = 8
|
||||
content.imageProperties.reservedLayoutSize = CGSize(width: 26, height: 26)
|
||||
content.imageProperties.maximumSize = CGSize(width: 26, height: 26)
|
||||
content.imageProperties.cornerRadius = 13
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
headerView.titleButton.setTitle(content.text, for: .normal)
|
||||
headerView.titleButton.layoutIfNeeded()
|
||||
}
|
||||
|
||||
headerView.iconButton.backgroundColor = storeApp.source?.effectiveTintColor?.adjustedForDisplay
|
||||
headerView.iconButton.setImage(nil, for: .normal)
|
||||
|
||||
if let iconURL = storeApp.source?.effectiveIconURL
|
||||
{
|
||||
ImagePipeline.shared.loadImage(with: iconURL) { result in
|
||||
guard case .success(let image) = result else { return }
|
||||
|
||||
headerView.iconButton.backgroundColor = .white
|
||||
headerView.iconButton.setImage(image.image, for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
let buttons = [headerView.iconButton, headerView.titleButton]
|
||||
for button in buttons
|
||||
{
|
||||
button.removeAction(identifiedBy: .showSourceDetails, for: .primaryActionTriggered)
|
||||
|
||||
if let source = storeApp.source
|
||||
{
|
||||
let action = UIAction(identifier: .showSourceDetails) { [weak self] _ in
|
||||
self?.showSourceDetails(for: source)
|
||||
}
|
||||
button.addAction(action, for: .primaryActionTriggered)
|
||||
}
|
||||
}
|
||||
|
||||
return headerView
|
||||
|
||||
case ElementKind.sectionHeader.rawValue:
|
||||
// Regular section header
|
||||
|
||||
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! UICollectionViewListCell
|
||||
|
||||
var content: UIListContentConfiguration = if #available(iOS 15, *) {
|
||||
.prominentInsetGroupedHeader()
|
||||
}
|
||||
else {
|
||||
.groupedHeader()
|
||||
}
|
||||
|
||||
switch section
|
||||
{
|
||||
case .recentlyUpdated: content.text = NSLocalizedString("New & Updated", comment: "")
|
||||
case .categories: content.text = NSLocalizedString("Categories", comment: "")
|
||||
case .featuredHeader: content.text = NSLocalizedString("Featured", comment: "")
|
||||
default: break
|
||||
}
|
||||
|
||||
content.directionalLayoutMargins.leading = .zero
|
||||
content.directionalLayoutMargins.trailing = .zero
|
||||
|
||||
headerView.contentConfiguration = content
|
||||
return headerView
|
||||
|
||||
case ElementKind.button.rawValue where section.isFeaturedAppsSection:
|
||||
let buttonView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! ButtonCollectionReusableView
|
||||
|
||||
let indexPath = IndexPath(item: 0, section: indexPath.section)
|
||||
let storeApp = self.dataSource.item(at: indexPath)
|
||||
|
||||
buttonView.tintColor = storeApp.source?.effectiveTintColor?.adjustedForDisplay ?? .altPrimary
|
||||
|
||||
buttonView.button.setTitle(NSLocalizedString("See All", comment: ""), for: .normal)
|
||||
buttonView.button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
buttonView.button.contentEdgeInsets.bottom = 8
|
||||
|
||||
buttonView.button.removeAction(identifiedBy: .showAllApps, for: .primaryActionTriggered)
|
||||
|
||||
if let source = storeApp.source
|
||||
{
|
||||
let action = UIAction(identifier: .showAllApps) { [weak self] _ in
|
||||
self?.showAllApps(for: source)
|
||||
}
|
||||
buttonView.button.addAction(action, for: .primaryActionTriggered)
|
||||
}
|
||||
|
||||
return buttonView
|
||||
|
||||
default: return UICollectionReusableView(frame: .zero)
|
||||
}
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
||||
{
|
||||
let storeApp = self.dataSource.item(at: indexPath)
|
||||
|
||||
let section = Section(rawValue: indexPath.section)
|
||||
switch section
|
||||
{
|
||||
case _ where section.isFeaturedAppsSection: fallthrough
|
||||
case .recentlyUpdated:
|
||||
let appViewController = AppViewController.makeAppViewController(app: storeApp)
|
||||
self.navigationController?.pushViewController(appViewController, animated: true)
|
||||
|
||||
case .categories:
|
||||
let category = storeApp.category ?? .other
|
||||
self.performSegue(withIdentifier: "showBrowseViewController", sender: category)
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17, *)
|
||||
#Preview(traits: .portrait) {
|
||||
DatabaseManager.shared.startForPreview()
|
||||
|
||||
let storyboard = UIStoryboard(name: "Main", bundle: nil)
|
||||
let featuredViewController = storyboard.instantiateViewController(identifier: "featuredViewController")
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: featuredViewController)
|
||||
navigationController.navigationBar.prefersLargeTitles = true
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
|
||||
let viewController = UIViewController()
|
||||
|
||||
AppManager.shared.fetchSources() { (result) in
|
||||
do
|
||||
{
|
||||
let (_, context) = try result.get()
|
||||
try context.save()
|
||||
}
|
||||
catch let error as NSError
|
||||
{
|
||||
Logger.main.error("Failed to fetch sources for preview. \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
AppManager.shared.updateKnownSources { result in
|
||||
Task {
|
||||
do
|
||||
{
|
||||
let knownSources = try result.get()
|
||||
|
||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
||||
|
||||
await withThrowingTaskGroup(of: Void.self) { taskGroup in
|
||||
for source in knownSources.0
|
||||
{
|
||||
guard let sourceURL = source.sourceURL else { continue }
|
||||
|
||||
taskGroup.addTask {
|
||||
_ = try await AppManager.shared.fetchSource(sourceURL: sourceURL, managedObjectContext: context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await context.performAsync {
|
||||
try! context.save()
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
viewController.present(navigationController, animated: true)
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Logger.main.error("Failed to fetch known sources for preview. \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return viewController
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
//
|
||||
// AppBannerCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 3/23/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class AppBannerCollectionViewCell: UICollectionViewListCell
|
||||
{
|
||||
let bannerView = AppBannerView(frame: .zero)
|
||||
|
||||
override init(frame: CGRect)
|
||||
{
|
||||
super.init(frame: frame)
|
||||
|
||||
self.initialize()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder)
|
||||
{
|
||||
super.init(coder: coder)
|
||||
|
||||
self.initialize()
|
||||
}
|
||||
|
||||
private func initialize()
|
||||
{
|
||||
// Prevent content "squishing" when scrolling offscreen.
|
||||
self.insetsLayoutMarginsFromSafeArea = false
|
||||
self.contentView.insetsLayoutMarginsFromSafeArea = false
|
||||
self.bannerView.insetsLayoutMarginsFromSafeArea = false
|
||||
|
||||
self.backgroundView = UIView() // Clear background
|
||||
self.selectedBackgroundView = UIView() // Disable selection highlighting.
|
||||
|
||||
self.contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
self.contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
self.bannerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.contentView.addSubview(self.bannerView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
self.bannerView.topAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.topAnchor),
|
||||
self.bannerView.bottomAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.bottomAnchor),
|
||||
self.bannerView.leadingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.leadingAnchor),
|
||||
self.bannerView.trailingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.trailingAnchor),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -11,27 +11,6 @@ import UIKit
|
||||
import AltStoreCore
|
||||
import Roxas
|
||||
|
||||
import Nuke
|
||||
|
||||
extension AppBannerView
|
||||
{
|
||||
static let standardHeight = 88.0
|
||||
|
||||
enum Style
|
||||
{
|
||||
case app
|
||||
case source
|
||||
}
|
||||
|
||||
enum AppAction
|
||||
{
|
||||
case install
|
||||
case open
|
||||
case update
|
||||
case custom(String)
|
||||
}
|
||||
}
|
||||
|
||||
class AppBannerView: RSTNibView
|
||||
{
|
||||
override var accessibilityLabel: String? {
|
||||
@@ -59,8 +38,6 @@ class AppBannerView: RSTNibView
|
||||
set { self.accessibilityView?.accessibilityTraits = newValue }
|
||||
}
|
||||
|
||||
var style: Style = .app
|
||||
|
||||
private var originalTintColor: UIColor?
|
||||
|
||||
@IBOutlet var titleLabel: UILabel!
|
||||
@@ -69,16 +46,12 @@ class AppBannerView: RSTNibView
|
||||
@IBOutlet var button: PillButton!
|
||||
@IBOutlet var buttonLabel: UILabel!
|
||||
@IBOutlet var betaBadgeView: UIView!
|
||||
@IBOutlet var sourceIconImageView: AppIconImageView!
|
||||
|
||||
@IBOutlet var backgroundEffectView: UIVisualEffectView!
|
||||
|
||||
@IBOutlet private var vibrancyView: UIVisualEffectView!
|
||||
@IBOutlet private var stackView: UIStackView!
|
||||
@IBOutlet private var accessibilityView: UIView!
|
||||
|
||||
@IBOutlet private var iconImageViewHeightConstraint: NSLayoutConstraint!
|
||||
|
||||
override init(frame: CGRect)
|
||||
{
|
||||
super.init(frame: frame)
|
||||
@@ -101,15 +74,6 @@ class AppBannerView: RSTNibView
|
||||
self.accessibilityElements = [self.accessibilityView, self.button].compactMap { $0 }
|
||||
|
||||
self.betaBadgeView.isHidden = true
|
||||
|
||||
self.sourceIconImageView.style = .circular
|
||||
self.sourceIconImageView.isHidden = true
|
||||
|
||||
self.layoutMargins = self.stackView.layoutMargins
|
||||
self.insetsLayoutMarginsFromSafeArea = false
|
||||
|
||||
self.stackView.isLayoutMarginsRelativeArrangement = true
|
||||
self.stackView.preservesSuperviewLayoutMargins = true
|
||||
}
|
||||
|
||||
override func tintColorDidChange()
|
||||
@@ -127,7 +91,7 @@ class AppBannerView: RSTNibView
|
||||
|
||||
extension AppBannerView
|
||||
{
|
||||
func configure(for app: AppProtocol, action: AppAction? = nil, showSourceIcon: Bool = true)
|
||||
func configure(for app: AppProtocol)
|
||||
{
|
||||
struct AppValues
|
||||
{
|
||||
@@ -138,20 +102,17 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.style = .app
|
||||
|
||||
let values = AppValues(app: app)
|
||||
self.titleLabel.text = app.name // Don't use values.name since that already includes "beta".
|
||||
@@ -167,210 +128,6 @@ extension AppBannerView
|
||||
self.subtitleLabel.text = NSLocalizedString("Sideloaded", comment: "")
|
||||
self.accessibilityLabel = values.name
|
||||
}
|
||||
|
||||
if let storeApp = app.storeApp, storeApp.isPledgeRequired
|
||||
{
|
||||
// Always show button label for Patreon apps.
|
||||
self.buttonLabel.isHidden = false
|
||||
|
||||
if storeApp.isPledged
|
||||
{
|
||||
self.buttonLabel.text = NSLocalizedString("Pledged", comment: "")
|
||||
}
|
||||
else if storeApp.installedApp != nil
|
||||
{
|
||||
self.buttonLabel.text = NSLocalizedString("Pledge Expired", comment: "")
|
||||
}
|
||||
else
|
||||
{
|
||||
self.buttonLabel.text = NSLocalizedString("Join Patreon", comment: "")
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
self.buttonLabel.isHidden = true
|
||||
}
|
||||
|
||||
if let source = app.storeApp?.source, showSourceIcon
|
||||
{
|
||||
self.sourceIconImageView.isHidden = false
|
||||
self.sourceIconImageView.backgroundColor = source.effectiveTintColor?.adjustedForDisplay ?? .altPrimary
|
||||
|
||||
if let iconURL = source.effectiveIconURL
|
||||
{
|
||||
if let image = ImageCache.shared[iconURL]
|
||||
{
|
||||
self.sourceIconImageView.backgroundColor = .white
|
||||
self.sourceIconImageView.image = image.image
|
||||
}
|
||||
else
|
||||
{
|
||||
self.sourceIconImageView.image = nil
|
||||
|
||||
Nuke.loadImage(with: iconURL, into: self.sourceIconImageView) { result in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): Logger.main.error("Failed to fetch source icon from \(iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
||||
case .success: self.sourceIconImageView.backgroundColor = .white // In case icon has transparent background.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
self.sourceIconImageView.isHidden = true
|
||||
}
|
||||
|
||||
let buttonAction: AppAction
|
||||
|
||||
if let action
|
||||
{
|
||||
buttonAction = action
|
||||
}
|
||||
else if let storeApp = app.storeApp
|
||||
{
|
||||
if let installedApp = storeApp.installedApp
|
||||
{
|
||||
// App is installed
|
||||
|
||||
// if installedApp.isUpdateAvailable
|
||||
if installedApp.hasUpdate
|
||||
{
|
||||
buttonAction = .update
|
||||
}
|
||||
else
|
||||
{
|
||||
buttonAction = .open
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// App is not installed
|
||||
buttonAction = .install
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// App is not from a source, fall back to .open
|
||||
buttonAction = .open
|
||||
}
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
switch buttonAction
|
||||
{
|
||||
case .open:
|
||||
let buttonTitle = NSLocalizedString("Open", comment: "")
|
||||
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
self.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), values.name)
|
||||
self.button.accessibilityValue = buttonTitle
|
||||
|
||||
self.button.countdownDate = nil
|
||||
|
||||
case .update:
|
||||
let buttonTitle = NSLocalizedString("Update", comment: "")
|
||||
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
self.button.accessibilityLabel = String(format: NSLocalizedString("Update %@", comment: ""), values.name)
|
||||
self.button.accessibilityValue = buttonTitle
|
||||
|
||||
self.button.countdownDate = nil
|
||||
|
||||
case .custom(let buttonTitle):
|
||||
self.button.setTitle(buttonTitle, for: .normal)
|
||||
self.button.accessibilityLabel = buttonTitle
|
||||
self.button.accessibilityValue = buttonTitle
|
||||
|
||||
self.button.countdownDate = nil
|
||||
|
||||
case .install:
|
||||
if let storeApp = app.storeApp, storeApp.isPledgeRequired
|
||||
{
|
||||
// Pledge required
|
||||
|
||||
if storeApp.isPledged
|
||||
{
|
||||
let buttonTitle = NSLocalizedString("Install", comment: "")
|
||||
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
self.button.accessibilityLabel = String(format: NSLocalizedString("Install %@", comment: ""), app.name)
|
||||
self.button.accessibilityValue = buttonTitle
|
||||
}
|
||||
else if let amount = storeApp.pledgeAmount, let currencyCode = storeApp.pledgeCurrency, !storeApp.prefersCustomPledge, #available(iOS 15, *)
|
||||
{
|
||||
let price = amount.formatted(.currency(code: currencyCode).presentation(.narrow).precision(.fractionLength(0...2)))
|
||||
|
||||
let buttonTitle = String(format: NSLocalizedString("%@/mo", comment: ""), price)
|
||||
self.button.setTitle(buttonTitle, for: .normal)
|
||||
self.button.accessibilityLabel = String(format: NSLocalizedString("Pledge %@ a month", comment: ""), price)
|
||||
self.button.accessibilityValue = String(format: NSLocalizedString("%@ a month", comment: ""), price)
|
||||
}
|
||||
else
|
||||
{
|
||||
let buttonTitle = NSLocalizedString("Pledge", comment: "")
|
||||
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
self.button.accessibilityLabel = buttonTitle
|
||||
self.button.accessibilityValue = buttonTitle
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Free app
|
||||
|
||||
let buttonTitle = NSLocalizedString("Free", comment: "")
|
||||
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
self.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name)
|
||||
self.button.accessibilityValue = buttonTitle
|
||||
}
|
||||
|
||||
if let versionDate = app.storeApp?.latestSupportedVersion?.date, versionDate > Date()
|
||||
{
|
||||
self.button.countdownDate = versionDate
|
||||
}
|
||||
else
|
||||
{
|
||||
self.button.countdownDate = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure PillButton is correct size before assigning progress.
|
||||
self.layoutIfNeeded()
|
||||
}
|
||||
|
||||
if let progress = AppManager.shared.installationProgress(for: app), progress.fractionCompleted < 1.0
|
||||
{
|
||||
self.button.progress = progress
|
||||
}
|
||||
else
|
||||
{
|
||||
self.button.progress = nil
|
||||
}
|
||||
}
|
||||
|
||||
func configure(for source: Source)
|
||||
{
|
||||
self.style = .source
|
||||
|
||||
let subtitle: String
|
||||
if let text = source.subtitle
|
||||
{
|
||||
subtitle = text
|
||||
}
|
||||
else if let scheme = source.sourceURL.scheme
|
||||
{
|
||||
subtitle = source.sourceURL.absoluteString.replacingOccurrences(of: scheme + "://", with: "")
|
||||
}
|
||||
else
|
||||
{
|
||||
subtitle = source.sourceURL.absoluteString
|
||||
}
|
||||
|
||||
self.titleLabel.text = source.name
|
||||
self.subtitleLabel.text = subtitle
|
||||
|
||||
let tintColor = source.effectiveTintColor ?? .altPrimary
|
||||
self.tintColor = tintColor
|
||||
|
||||
let accessibilityLabel = source.name + "\n" + subtitle
|
||||
self.accessibilityLabel = accessibilityLabel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,48 +138,7 @@ private extension AppBannerView
|
||||
self.clipsToBounds = true
|
||||
self.layer.cornerRadius = 22
|
||||
|
||||
let tintColor = self.originalTintColor ?? self.tintColor
|
||||
self.subtitleLabel.textColor = tintColor
|
||||
|
||||
switch self.style
|
||||
{
|
||||
case .app:
|
||||
self.directionalLayoutMargins.trailing = self.stackView.directionalLayoutMargins.trailing
|
||||
|
||||
self.iconImageViewHeightConstraint.constant = 60
|
||||
self.iconImageView.style = .icon
|
||||
|
||||
self.titleLabel.textColor = .label
|
||||
|
||||
self.button.style = .pill
|
||||
|
||||
self.backgroundEffectView.contentView.backgroundColor = UIColor(resource: .blurTint)
|
||||
self.backgroundEffectView.backgroundColor = tintColor
|
||||
|
||||
case .source:
|
||||
self.directionalLayoutMargins.trailing = 20
|
||||
|
||||
self.iconImageViewHeightConstraint.constant = 44
|
||||
self.iconImageView.style = .circular
|
||||
|
||||
self.titleLabel.textColor = .white
|
||||
|
||||
self.button.style = .custom
|
||||
|
||||
self.backgroundEffectView.contentView.backgroundColor = tintColor?.adjustedForDisplay
|
||||
self.backgroundEffectView.backgroundColor = nil
|
||||
|
||||
if let tintColor, tintColor.isTooBright
|
||||
{
|
||||
let textVibrancyEffect = UIVibrancyEffect(blurEffect: .init(style: .systemChromeMaterialLight), style: .fill)
|
||||
self.vibrancyView.effect = textVibrancyEffect
|
||||
}
|
||||
else
|
||||
{
|
||||
// Thinner == more dull
|
||||
let textVibrancyEffect = UIVibrancyEffect(blurEffect: .init(style: .systemThinMaterialDark), style: .secondaryLabel)
|
||||
self.vibrancyView.effect = textVibrancyEffect
|
||||
}
|
||||
}
|
||||
self.subtitleLabel.textColor = self.originalTintColor ?? self.tintColor
|
||||
self.backgroundEffectView.backgroundColor = self.originalTintColor ?? self.tintColor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="22155" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17154" 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="22131"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17124"/>
|
||||
<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"/>
|
||||
@@ -17,9 +17,6 @@
|
||||
<outlet property="button" destination="tVx-3G-dcu" id="joa-AH-syX"/>
|
||||
<outlet property="buttonLabel" destination="Yd9-jw-faD" id="o7g-Gb-CIt"/>
|
||||
<outlet property="iconImageView" destination="avS-dx-4iy" id="TQs-Ej-gin"/>
|
||||
<outlet property="iconImageViewHeightConstraint" destination="6lU-H8-nEw" id="PSt-Xa-lQT"/>
|
||||
<outlet property="sourceIconImageView" destination="dku-SJ-aay" id="rA0-y1-dIb"/>
|
||||
<outlet property="stackView" destination="d1T-UD-gWG" id="E7N-Zb-lm1"/>
|
||||
<outlet property="subtitleLabel" destination="oN5-vu-Dnw" id="gA4-iJ-Tix"/>
|
||||
<outlet property="titleLabel" destination="mFe-zJ-eva" id="2OH-f8-cid"/>
|
||||
<outlet property="vibrancyView" destination="fC4-1V-iMn" id="PXE-2B-A7w"/>
|
||||
@@ -46,38 +43,31 @@
|
||||
</view>
|
||||
<blurEffect style="systemChromeMaterial"/>
|
||||
</visualEffectView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" alignment="center" spacing="11" translatesAutoresizingMaskIntoConstraints="NO" id="d1T-UD-gWG" userLabel="App Info">
|
||||
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" alignment="center" spacing="11" translatesAutoresizingMaskIntoConstraints="NO" id="d1T-UD-gWG" userLabel="App Info">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="avS-dx-4iy" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="16" y="14" width="60" height="60"/>
|
||||
<rect key="frame" x="14" y="14" width="60" height="60"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="60" id="6lU-H8-nEw"/>
|
||||
<constraint firstAttribute="width" secondItem="avS-dx-4iy" secondAttribute="height" multiplier="1:1" id="AYT-3g-wcV"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="100" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" alignment="top" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="caL-vN-Svn">
|
||||
<rect key="frame" x="87" y="25.5" width="184" height="37.5"/>
|
||||
<rect key="frame" x="85" y="25.5" width="190" height="37.5"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" horizontalCompressionResistancePriority="500" alignment="center" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="1Es-pv-zwd">
|
||||
<rect key="frame" x="0.0" y="0.0" width="147" height="19.5"/>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" horizontalCompressionResistancePriority="500" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="1Es-pv-zwd">
|
||||
<rect key="frame" x="0.0" y="0.0" width="126" height="19.5"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="400" verticalHuggingPriority="251" horizontalCompressionResistancePriority="500" text="App Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="mFe-zJ-eva">
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" horizontalCompressionResistancePriority="500" text="App Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="mFe-zJ-eva">
|
||||
<rect key="frame" x="0.0" y="0.0" width="79" height="19.5"/>
|
||||
<accessibility key="accessibilityConfiguration" identifier="NameLabel"/>
|
||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="16"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="dku-SJ-aay" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="84" y="1" width="17" height="17"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" secondItem="dku-SJ-aay" secondAttribute="height" id="VKw-lc-8NQ"/>
|
||||
<constraint firstAttribute="width" constant="17" id="hAe-gc-Ehh"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="BetaBadge" translatesAutoresizingMaskIntoConstraints="NO" id="qQl-Ez-zC5">
|
||||
<rect key="frame" x="106" y="1" width="41" height="17"/>
|
||||
<rect key="frame" x="85" y="0.0" width="41" height="19.5"/>
|
||||
<accessibility key="accessibilityConfiguration" identifier="Beta Badge">
|
||||
<accessibilityTraits key="traits" image="YES" notEnabled="YES"/>
|
||||
<bool key="isElement" value="YES"/>
|
||||
@@ -86,13 +76,13 @@
|
||||
</subviews>
|
||||
</stackView>
|
||||
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fC4-1V-iMn">
|
||||
<rect key="frame" x="0.0" y="21.5" width="62" height="16"/>
|
||||
<rect key="frame" x="0.0" y="21.5" width="190" height="16"/>
|
||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="LQh-pN-ePC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="62" height="16"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="190" height="16"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="750" verticalHuggingPriority="750" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="oN5-vu-Dnw">
|
||||
<rect key="frame" x="0.0" y="0.0" width="62" height="16"/>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="750" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="oN5-vu-Dnw">
|
||||
<rect key="frame" x="0.0" y="0.0" width="190" height="16"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="13"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
@@ -111,36 +101,39 @@
|
||||
</visualEffectView>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="900" horizontalCompressionResistancePriority="900" placeholderIntrinsicWidth="77" placeholderIntrinsicHeight="31" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="tVx-3G-dcu" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="282" y="28.5" width="77" height="31"/>
|
||||
<color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" relation="greaterThanOrEqual" secondItem="tVx-3G-dcu" secondAttribute="height" priority="999" id="Vbk-VH-5eU"/>
|
||||
<constraint firstAttribute="height" constant="31" id="Zwh-yQ-GTu"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
|
||||
<state key="normal" title="FREE"/>
|
||||
</button>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="3ox-Q2-Rnd" userLabel="Button Stack View">
|
||||
<rect key="frame" x="286" y="28.5" width="77" height="31"/>
|
||||
<subviews>
|
||||
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yd9-jw-faD">
|
||||
<rect key="frame" x="0.0" y="0.0" width="77" height="0.0"/>
|
||||
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="10"/>
|
||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="900" horizontalCompressionResistancePriority="900" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="tVx-3G-dcu" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="77" height="31"/>
|
||||
<color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="31" id="Zwh-yQ-GTu"/>
|
||||
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="77" id="eGc-Dk-QbL"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
|
||||
<state key="normal" title="FREE"/>
|
||||
</button>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<edgeInsets key="layoutMargins" top="14" left="14" bottom="14" right="12"/>
|
||||
</stackView>
|
||||
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yd9-jw-faD">
|
||||
<rect key="frame" x="307" y="12.5" width="27" height="12"/>
|
||||
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="10"/>
|
||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="d1T-UD-gWG" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="5tv-QN-ZWU"/>
|
||||
<constraint firstAttribute="bottom" secondItem="bJL-Yw-i4u" secondAttribute="bottom" id="FRq-ZD-2rE"/>
|
||||
<constraint firstItem="rZk-be-tiI" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="N6R-B2-Rie"/>
|
||||
<constraint firstItem="Yd9-jw-faD" firstAttribute="centerX" secondItem="tVx-3G-dcu" secondAttribute="centerX" id="acx-pf-8hH"/>
|
||||
<constraint firstItem="bJL-Yw-i4u" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="h6T-q1-YV9"/>
|
||||
<constraint firstItem="tVx-3G-dcu" firstAttribute="top" secondItem="Yd9-jw-faD" secondAttribute="bottom" constant="4" id="hTD-wh-KV8"/>
|
||||
<constraint firstAttribute="trailing" secondItem="d1T-UD-gWG" secondAttribute="trailing" id="mlG-w3-Ly6"/>
|
||||
<constraint firstAttribute="trailing" secondItem="rZk-be-tiI" secondAttribute="trailing" id="nEy-pm-Fcs"/>
|
||||
<constraint firstAttribute="bottom" secondItem="d1T-UD-gWG" secondAttribute="bottom" priority="999" id="nJo-To-LmX"/>
|
||||
<constraint firstAttribute="bottom" secondItem="d1T-UD-gWG" secondAttribute="bottom" id="nJo-To-LmX"/>
|
||||
<constraint firstItem="bJL-Yw-i4u" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="oLt-2z-QoJ"/>
|
||||
<constraint firstItem="rZk-be-tiI" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="pGD-Tl-U4c"/>
|
||||
<constraint firstItem="d1T-UD-gWG" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="q2p-0S-Nv5"/>
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
//
|
||||
// AppCardCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 10/13/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import AltStoreCore
|
||||
import Roxas
|
||||
|
||||
import Nuke
|
||||
|
||||
private let minimumItemSpacing = 8.0
|
||||
|
||||
class AppCardCollectionViewCell: UICollectionViewCell
|
||||
{
|
||||
let bannerView: AppBannerView
|
||||
let captionLabel: UILabel
|
||||
|
||||
var prefersPagingScreenshots = true
|
||||
|
||||
private let screenshotsCollectionView: UICollectionView
|
||||
private let stackView: UIStackView
|
||||
|
||||
private let topAreaPanGestureRecognizer: UIPanGestureRecognizer
|
||||
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
|
||||
private var screenshots: [AppScreenshot] = [] {
|
||||
didSet {
|
||||
self.dataSource.items = self.screenshots
|
||||
|
||||
if self.screenshots.isEmpty
|
||||
{
|
||||
// No screenshots, so hide collection view.
|
||||
self.collectionViewAspectRatioConstraint.isActive = false
|
||||
self.stackView.layoutMargins.bottom = 0
|
||||
}
|
||||
else
|
||||
{
|
||||
// At least one screenshot, so show collection view.
|
||||
self.collectionViewAspectRatioConstraint.isActive = true
|
||||
self.stackView.layoutMargins.bottom = self.screenshotsCollectionView.directionalLayoutMargins.leading
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let collectionViewAspectRatioConstraint: NSLayoutConstraint
|
||||
|
||||
override init(frame: CGRect)
|
||||
{
|
||||
self.bannerView = AppBannerView(frame: .zero)
|
||||
self.bannerView.layoutMargins.bottom = 0
|
||||
|
||||
let vibrancyEffect = UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemChromeMaterial), style: .secondaryLabel)
|
||||
let captionVibrancyView = UIVisualEffectView(effect: vibrancyEffect)
|
||||
|
||||
self.captionLabel = UILabel(frame: .zero)
|
||||
self.captionLabel.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .footnote).bolded(), size: 0)
|
||||
self.captionLabel.textAlignment = .center
|
||||
self.captionLabel.numberOfLines = 2
|
||||
self.captionLabel.minimumScaleFactor = 0.8
|
||||
captionVibrancyView.contentView.addSubview(self.captionLabel, pinningEdgesWith: .zero)
|
||||
|
||||
self.screenshotsCollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
|
||||
self.screenshotsCollectionView.backgroundColor = nil
|
||||
self.screenshotsCollectionView.alwaysBounceVertical = false
|
||||
self.screenshotsCollectionView.alwaysBounceHorizontal = true
|
||||
self.screenshotsCollectionView.showsHorizontalScrollIndicator = false
|
||||
self.screenshotsCollectionView.showsVerticalScrollIndicator = false
|
||||
|
||||
self.stackView = UIStackView(arrangedSubviews: [self.bannerView, captionVibrancyView, self.screenshotsCollectionView])
|
||||
self.stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
self.stackView.spacing = 12
|
||||
self.stackView.axis = .vertical
|
||||
self.stackView.alignment = .fill
|
||||
self.stackView.distribution = .equalSpacing
|
||||
|
||||
// Aspect ratio constraint to fit exactly 3 modern portrait iPhone screenshots side-by-side (with spacing).
|
||||
let inset = self.bannerView.layoutMargins.left
|
||||
let multiplier = (AppScreenshot.defaultAspectRatio.width * 3) / AppScreenshot.defaultAspectRatio.height
|
||||
let spacing = (inset * 2) + (minimumItemSpacing * 2)
|
||||
self.collectionViewAspectRatioConstraint = self.screenshotsCollectionView.widthAnchor.constraint(equalTo: self.screenshotsCollectionView.heightAnchor, multiplier: multiplier, constant: spacing)
|
||||
|
||||
// Allows us to ignore swipes in top portion of screenshotsCollectionView.
|
||||
self.topAreaPanGestureRecognizer = UIPanGestureRecognizer(target: nil, action: nil)
|
||||
self.topAreaPanGestureRecognizer.cancelsTouchesInView = false
|
||||
self.topAreaPanGestureRecognizer.delaysTouchesBegan = false
|
||||
self.topAreaPanGestureRecognizer.delaysTouchesEnded = false
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.contentView.clipsToBounds = true
|
||||
self.contentView.layer.cornerCurve = .continuous
|
||||
|
||||
self.contentView.addSubview(self.bannerView.backgroundEffectView, pinningEdgesWith: .zero)
|
||||
self.contentView.addSubview(self.stackView, pinningEdgesWith: .zero)
|
||||
|
||||
self.screenshotsCollectionView.collectionViewLayout = self.makeLayout()
|
||||
self.screenshotsCollectionView.dataSource = self.dataSource
|
||||
self.screenshotsCollectionView.prefetchDataSource = self.dataSource
|
||||
|
||||
// Adding screenshotsCollectionView's gesture recognizers to self.contentView breaks paging,
|
||||
// so instead we intercept taps and pass them onto delegate.
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(AppCardCollectionViewCell.handleTapGesture(_:)))
|
||||
tapGestureRecognizer.cancelsTouchesInView = false
|
||||
tapGestureRecognizer.delaysTouchesBegan = false
|
||||
tapGestureRecognizer.delaysTouchesEnded = false
|
||||
self.screenshotsCollectionView.addGestureRecognizer(tapGestureRecognizer)
|
||||
|
||||
self.topAreaPanGestureRecognizer.delegate = self
|
||||
self.screenshotsCollectionView.panGestureRecognizer.require(toFail: self.topAreaPanGestureRecognizer)
|
||||
self.screenshotsCollectionView.addGestureRecognizer(self.topAreaPanGestureRecognizer)
|
||||
|
||||
self.screenshotsCollectionView.register(AppScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||
|
||||
self.stackView.isLayoutMarginsRelativeArrangement = true
|
||||
self.stackView.layoutMargins.bottom = inset
|
||||
|
||||
self.contentView.preservesSuperviewLayoutMargins = true
|
||||
self.screenshotsCollectionView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: inset, bottom: 0, trailing: inset)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
self.bannerView.heightAnchor.constraint(equalToConstant: AppBannerView.standardHeight - inset)
|
||||
])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews()
|
||||
{
|
||||
super.layoutSubviews()
|
||||
|
||||
self.contentView.layer.cornerRadius = self.bannerView.layer.cornerRadius
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppCardCollectionViewCell
|
||||
{
|
||||
func makeLayout() -> UICollectionViewCompositionalLayout
|
||||
{
|
||||
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
|
||||
layoutConfig.contentInsetsReference = .layoutMargins
|
||||
|
||||
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
|
||||
guard let self else { return nil }
|
||||
|
||||
var contentWidth = 0.0
|
||||
var numberOfVisibleScreenshots = 0
|
||||
|
||||
for screenshot in self.screenshots
|
||||
{
|
||||
var aspectRatio = screenshot.aspectRatio
|
||||
if aspectRatio.width > aspectRatio.height
|
||||
{
|
||||
switch screenshot.deviceType
|
||||
{
|
||||
case .iphone:
|
||||
// Always rotate landscape iPhone screenshots
|
||||
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
||||
|
||||
case .ipad:
|
||||
// Never rotate iPad screenshots
|
||||
break
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
let screenshotWidth = (layoutEnvironment.container.effectiveContentSize.height * (aspectRatio.width / aspectRatio.height)).rounded(.up) // Round to ensure we over-estimate contentWidth.
|
||||
|
||||
let totalContentWidth = contentWidth + (screenshotWidth + minimumItemSpacing)
|
||||
if totalContentWidth > layoutEnvironment.container.effectiveContentSize.width
|
||||
{
|
||||
// totalContentWidth is larger than visible width.
|
||||
break
|
||||
}
|
||||
|
||||
contentWidth = totalContentWidth
|
||||
numberOfVisibleScreenshots += 1
|
||||
}
|
||||
|
||||
// Use .estimated(1) to ensure we don't over-estimate widths, which can cause incorrect layouts for the last group.
|
||||
let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(1), heightDimension: .fractionalHeight(1.0))
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
|
||||
if numberOfVisibleScreenshots == 1
|
||||
{
|
||||
// If there's only one screenshot visible initially, we'll (reluctantly) opt-in to flexible spacing on both sides.
|
||||
// This ensures the items are always centered, but may result in larger spacings between items than we'd prefer.
|
||||
item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(0), top: nil, trailing: .flexible(0), bottom: nil)
|
||||
}
|
||||
else
|
||||
{
|
||||
// Otherwise, only have flexible spacing on the leading edge, which will be balanced by trailingGroup's flexible trailing spacing.
|
||||
item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(0), top: nil, trailing: nil, bottom: nil)
|
||||
}
|
||||
|
||||
let groupItem = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
let trailingGroup = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [groupItem])
|
||||
trailingGroup.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: nil, top: nil, trailing: .flexible(0), bottom: nil)
|
||||
|
||||
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
|
||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, trailingGroup])
|
||||
group.interItemSpacing = .fixed(minimumItemSpacing)
|
||||
|
||||
if numberOfVisibleScreenshots < self.screenshots.count
|
||||
{
|
||||
// There are more screenshots than what is displayed, so no need to manually center them.
|
||||
}
|
||||
else
|
||||
{
|
||||
// We're showing all screenshots initially, so make sure they're centered.
|
||||
|
||||
let insetWidth = (layoutEnvironment.container.effectiveContentSize.width - contentWidth) / 2.0
|
||||
group.contentInsets.leading = (insetWidth - 1).rounded(.down) // Subtract 1 to avoid overflowing/clipping
|
||||
}
|
||||
|
||||
let layoutSection = NSCollectionLayoutSection(group: group)
|
||||
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
|
||||
layoutSection.interGroupSpacing = self.screenshotsCollectionView.directionalLayoutMargins.leading + self.screenshotsCollectionView.directionalLayoutMargins.trailing
|
||||
return layoutSection
|
||||
}, configuration: layoutConfig)
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>
|
||||
{
|
||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>(items: [])
|
||||
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in
|
||||
let cell = cell as! AppScreenshotCollectionViewCell
|
||||
cell.imageView.image = nil
|
||||
cell.imageView.isIndicatingActivity = true
|
||||
|
||||
var aspectRatio = screenshot.aspectRatio
|
||||
if aspectRatio.width > aspectRatio.height
|
||||
{
|
||||
switch screenshot.deviceType
|
||||
{
|
||||
case .iphone:
|
||||
// Always rotate landscape iPhone screenshots
|
||||
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
||||
|
||||
case .ipad:
|
||||
// Never rotate iPad screenshots
|
||||
break
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
cell.aspectRatio = aspectRatio
|
||||
}
|
||||
dataSource.prefetchHandler = { (screenshot, indexPath, completionHandler) in
|
||||
let imageURL = screenshot.imageURL
|
||||
return RSTAsyncBlockOperation() { (operation) in
|
||||
let request = ImageRequest(url: imageURL)
|
||||
ImagePipeline.shared.loadImage(with: request, progress: nil) { result in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
switch result
|
||||
{
|
||||
case .success(let response): completionHandler(response.image, nil)
|
||||
case .failure(let error): completionHandler(nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
||||
let cell = cell as! AppScreenshotCollectionViewCell
|
||||
cell.imageView.isIndicatingActivity = false
|
||||
cell.setImage(image)
|
||||
|
||||
if let error = error
|
||||
{
|
||||
print("Error loading image:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
@objc func handleTapGesture(_ tapGesture: UITapGestureRecognizer)
|
||||
{
|
||||
var superview: UIView? = self.superview
|
||||
var collectionView: UICollectionView? = nil
|
||||
|
||||
while case let view? = superview
|
||||
{
|
||||
if let cv = view as? UICollectionView
|
||||
{
|
||||
collectionView = cv
|
||||
break
|
||||
}
|
||||
|
||||
superview = view.superview
|
||||
}
|
||||
|
||||
if let collectionView, let indexPath = collectionView.indexPath(for: self)
|
||||
{
|
||||
collectionView.delegate?.collectionView?(collectionView, didSelectItemAt: indexPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppCardCollectionViewCell
|
||||
{
|
||||
func configure(for storeApp: StoreApp, showSourceIcon: Bool = true)
|
||||
{
|
||||
self.screenshots = storeApp.preferredScreenshots()
|
||||
|
||||
// Explicitly set to false to ensure we're starting from a non-activity indicating state.
|
||||
// Otherwise, cell reuse can mess up some cached values.
|
||||
self.bannerView.button.isIndicatingActivity = false
|
||||
|
||||
self.bannerView.tintColor = storeApp.tintColor
|
||||
self.bannerView.configure(for: storeApp, showSourceIcon: showSourceIcon)
|
||||
|
||||
self.bannerView.subtitleLabel.numberOfLines = 1
|
||||
self.bannerView.subtitleLabel.lineBreakMode = .byTruncatingTail
|
||||
self.bannerView.subtitleLabel.minimumScaleFactor = 0.8
|
||||
self.bannerView.subtitleLabel.text = storeApp.developerName
|
||||
|
||||
if let subtitle = storeApp.subtitle, !subtitle.isEmpty
|
||||
{
|
||||
self.captionLabel.text = subtitle
|
||||
self.captionLabel.isHidden = false
|
||||
}
|
||||
else
|
||||
{
|
||||
self.captionLabel.isHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppCardCollectionViewCell: UIGestureRecognizerDelegate
|
||||
{
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool
|
||||
{
|
||||
// Never recognize topAreaPanGestureRecognizer unless prefersPagingScreenshots is false.
|
||||
guard !self.prefersPagingScreenshots else { return false }
|
||||
|
||||
let point = gestureRecognizer.location(in: self.screenshotsCollectionView)
|
||||
|
||||
// Top area = Top 3/4
|
||||
let isTopArea = point.y < (self.screenshotsCollectionView.bounds.height / 4) * 3
|
||||
return isTopArea
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool
|
||||
{
|
||||
guard let panGestureRecognizer = otherGestureRecognizer as? UIPanGestureRecognizer, let view = panGestureRecognizer.view else { return false }
|
||||
|
||||
if view.isDescendant(of: self.screenshotsCollectionView)
|
||||
{
|
||||
// Only allow nested gesture recognizers if topAreaPanGestureRecognizer fails.
|
||||
return true
|
||||
}
|
||||
else
|
||||
{
|
||||
// Always allow parent gesture recognizers.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool
|
||||
{
|
||||
guard let panGestureRecognizer = otherGestureRecognizer as? UIPanGestureRecognizer, let view = panGestureRecognizer.view else { return true }
|
||||
|
||||
if view.isDescendant(of: self.screenshotsCollectionView)
|
||||
{
|
||||
// Don't recognize topAreaPanGestureRecognizer alongside nested gesture recognizers.
|
||||
return false
|
||||
}
|
||||
else
|
||||
{
|
||||
// Allow recognizing simultaneously with parent gesture recognizers.
|
||||
// This fixes accidentally breaking scrolling in parent.
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,62 +8,36 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
extension AppIconImageView
|
||||
final class AppIconImageView: UIImageView
|
||||
{
|
||||
enum Style
|
||||
override func awakeFromNib()
|
||||
{
|
||||
case icon
|
||||
case circular
|
||||
}
|
||||
}
|
||||
|
||||
class AppIconImageView: UIImageView
|
||||
{
|
||||
var style: Style = .icon {
|
||||
didSet {
|
||||
self.setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
init(style: Style)
|
||||
{
|
||||
self.style = style
|
||||
super.awakeFromNib()
|
||||
|
||||
super.init(image: nil)
|
||||
|
||||
self.initialize()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder)
|
||||
{
|
||||
super.init(coder: coder)
|
||||
|
||||
self.initialize()
|
||||
}
|
||||
|
||||
private func initialize()
|
||||
{
|
||||
self.contentMode = .scaleAspectFill
|
||||
self.clipsToBounds = true
|
||||
self.backgroundColor = .white
|
||||
|
||||
self.layer.cornerCurve = .continuous
|
||||
self.backgroundColor = .white
|
||||
|
||||
if #available(iOS 13, *)
|
||||
{
|
||||
self.layer.cornerCurve = .continuous
|
||||
}
|
||||
else
|
||||
{
|
||||
if self.layer.responds(to: Selector(("continuousCorners")))
|
||||
{
|
||||
self.layer.setValue(true, forKey: "continuousCorners")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews()
|
||||
{
|
||||
super.layoutSubviews()
|
||||
|
||||
switch self.style
|
||||
{
|
||||
case .icon:
|
||||
// Based off of 60pt icon having 12pt radius.
|
||||
let radius = self.bounds.height / 5
|
||||
self.layer.cornerRadius = radius
|
||||
|
||||
case .circular:
|
||||
let radius = self.bounds.height / 2
|
||||
self.layer.cornerRadius = radius
|
||||
}
|
||||
// Based off of 60pt icon having 12pt radius.
|
||||
let radius = self.bounds.height / 5
|
||||
self.layer.cornerRadius = radius
|
||||
}
|
||||
}
|
||||
|
||||
54
AltStore/Components/BannerCollectionViewCell.swift
Normal file
54
AltStore/Components/BannerCollectionViewCell.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// BannerCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 3/23/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class BannerCollectionViewCell: UICollectionViewCell
|
||||
{
|
||||
private(set) var errorBadge: UIView?
|
||||
@IBOutlet private(set) var bannerView: AppBannerView!
|
||||
|
||||
override func awakeFromNib()
|
||||
{
|
||||
super.awakeFromNib()
|
||||
|
||||
self.contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
self.contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
if #available(iOS 13.0, *)
|
||||
{
|
||||
let errorBadge = UIView()
|
||||
errorBadge.translatesAutoresizingMaskIntoConstraints = false
|
||||
errorBadge.isHidden = true
|
||||
self.addSubview(errorBadge)
|
||||
|
||||
// Solid background to make the X opaque white.
|
||||
let backgroundView = UIView()
|
||||
backgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
backgroundView.backgroundColor = .white
|
||||
errorBadge.addSubview(backgroundView)
|
||||
|
||||
let badgeView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill"))
|
||||
badgeView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(scale: .large)
|
||||
badgeView.tintColor = .systemRed
|
||||
errorBadge.addSubview(badgeView, pinningEdgesWith: .zero)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
errorBadge.centerXAnchor.constraint(equalTo: self.bannerView.trailingAnchor, constant: -5),
|
||||
errorBadge.centerYAnchor.constraint(equalTo: self.bannerView.topAnchor, constant: 5),
|
||||
|
||||
backgroundView.centerXAnchor.constraint(equalTo: badgeView.centerXAnchor),
|
||||
backgroundView.centerYAnchor.constraint(equalTo: badgeView.centerYAnchor),
|
||||
backgroundView.widthAnchor.constraint(equalTo: badgeView.widthAnchor, multiplier: 0.5),
|
||||
backgroundView.heightAnchor.constraint(equalTo: badgeView.heightAnchor, multiplier: 0.5)
|
||||
])
|
||||
|
||||
self.errorBadge = errorBadge
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ final class CollapsingTextView: UITextView
|
||||
{
|
||||
var isCollapsed = true {
|
||||
didSet {
|
||||
guard self.isCollapsed != oldValue else { return }
|
||||
self.setNeedsLayout()
|
||||
}
|
||||
}
|
||||
@@ -25,40 +24,12 @@ final class CollapsingTextView: UITextView
|
||||
|
||||
var lineSpacing: Double = 2 {
|
||||
didSet {
|
||||
|
||||
if #available(iOS 16, *)
|
||||
{
|
||||
self.updateText()
|
||||
}
|
||||
else
|
||||
{
|
||||
self.setNeedsLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override var text: String! {
|
||||
didSet {
|
||||
|
||||
guard #available(iOS 16, *) else { return }
|
||||
self.updateText()
|
||||
self.setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
let moreButton = UIButton(type: .system)
|
||||
|
||||
override init(frame: CGRect, textContainer: NSTextContainer?)
|
||||
{
|
||||
super.init(frame: frame, textContainer: textContainer)
|
||||
|
||||
self.initialize()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder)
|
||||
{
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
override func awakeFromNib()
|
||||
{
|
||||
super.awakeFromNib()
|
||||
@@ -110,13 +81,13 @@ final class CollapsingTextView: UITextView
|
||||
|
||||
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)
|
||||
let maximumCollapsedHeight = font.lineHeight * Double(self.maximumNumberOfLines)
|
||||
|
||||
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.
|
||||
@@ -126,7 +97,6 @@ final class CollapsingTextView: UITextView
|
||||
}
|
||||
else
|
||||
{
|
||||
self.textContainer.maximumNumberOfLines = 0 // Fixes last line having slightly smaller line spacing.
|
||||
self.textContainer.exclusionPaths = []
|
||||
|
||||
self.moreButton.isHidden = true
|
||||
|
||||
@@ -1,649 +0,0 @@
|
||||
//
|
||||
// HeaderContentViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 3/10/23.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import AltStoreCore
|
||||
import Roxas
|
||||
|
||||
import Nuke
|
||||
|
||||
protocol ScrollableContentViewController: UIViewController
|
||||
{
|
||||
var scrollView: UIScrollView { get }
|
||||
}
|
||||
|
||||
class HeaderContentViewController<Header: UIView, Content: ScrollableContentViewController> : UIViewController,
|
||||
UIAdaptivePresentationControllerDelegate,
|
||||
UIScrollViewDelegate,
|
||||
UIGestureRecognizerDelegate
|
||||
{
|
||||
var tintColor: UIColor? {
|
||||
didSet {
|
||||
guard self.isViewLoaded else { return }
|
||||
|
||||
self.view.tintColor = self.tintColor?.adjustedForDisplay
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var headerView: Header!
|
||||
private(set) var contentViewController: Content!
|
||||
|
||||
private(set) var backButton: VibrantButton!
|
||||
private(set) var backgroundImageView: UIImageView!
|
||||
|
||||
private(set) var navigationBarNameLabel: UILabel!
|
||||
private(set) var navigationBarIconView: UIImageView!
|
||||
private(set) var navigationBarTitleView: UIStackView!
|
||||
private(set) var navigationBarButton: PillButton!
|
||||
|
||||
private var scrollView: UIScrollView!
|
||||
private var headerScrollView: UIScrollView!
|
||||
private var headerContainerView: UIView!
|
||||
private var backgroundBlurView: UIVisualEffectView!
|
||||
private var contentViewControllerShadowView: UIView!
|
||||
|
||||
private var ignoreBackGestureRecognizer: UIPanGestureRecognizer!
|
||||
|
||||
private var blurAnimator: UIViewPropertyAnimator?
|
||||
private var navigationBarAnimator: UIViewPropertyAnimator?
|
||||
private var contentSizeObservation: NSKeyValueObservation?
|
||||
|
||||
private var _shouldResetLayout = false
|
||||
private var _backgroundBlurEffect: UIBlurEffect?
|
||||
private var _backgroundBlurTintColor: UIColor?
|
||||
|
||||
private var isViewingHeader: Bool {
|
||||
let isViewingHeader = (self.headerScrollView.contentOffset.x != self.headerScrollView.contentInset.left)
|
||||
return isViewingHeader
|
||||
}
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
if #available(iOS 17, *)
|
||||
{
|
||||
// On iOS 17+, .default will update the status bar automatically.
|
||||
return .default
|
||||
}
|
||||
else
|
||||
{
|
||||
return _preferredStatusBarStyle
|
||||
}
|
||||
}
|
||||
private var _preferredStatusBarStyle: UIStatusBarStyle = .default
|
||||
|
||||
init()
|
||||
{
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
deinit
|
||||
{
|
||||
self.blurAnimator?.stopAnimation(true)
|
||||
self.navigationBarAnimator?.stopAnimation(true)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder)
|
||||
{
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
func makeContentViewController() -> Content
|
||||
{
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func makeHeaderView() -> Header
|
||||
{
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override func viewDidLoad()
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
self.view.backgroundColor = .white
|
||||
self.view.clipsToBounds = true
|
||||
|
||||
self.navigationItem.largeTitleDisplayMode = .never
|
||||
self.navigationController?.presentationController?.delegate = self
|
||||
|
||||
|
||||
// Background
|
||||
self.backgroundImageView = UIImageView(frame: .zero)
|
||||
self.backgroundImageView.contentMode = .scaleAspectFill
|
||||
self.view.addSubview(self.backgroundImageView)
|
||||
|
||||
let blurEffect = UIBlurEffect(style: .regular)
|
||||
self.backgroundBlurView = UIVisualEffectView(effect: blurEffect)
|
||||
self.view.addSubview(self.backgroundBlurView, pinningEdgesWith: .zero)
|
||||
|
||||
|
||||
// Header View
|
||||
self.headerContainerView = UIView(frame: .zero)
|
||||
self.view.addSubview(self.headerContainerView, pinningEdgesWith: .zero)
|
||||
|
||||
self.ignoreBackGestureRecognizer = UIPanGestureRecognizer(target: self, action: nil)
|
||||
self.ignoreBackGestureRecognizer.delegate = self
|
||||
self.headerContainerView.addGestureRecognizer(self.ignoreBackGestureRecognizer)
|
||||
self.navigationController?.interactivePopGestureRecognizer?.require(toFail: self.ignoreBackGestureRecognizer) // So we can disable back gesture when viewing header.
|
||||
|
||||
self.headerScrollView = UIScrollView(frame: .zero)
|
||||
self.headerScrollView.delegate = self
|
||||
self.headerScrollView.isPagingEnabled = true
|
||||
self.headerScrollView.clipsToBounds = false
|
||||
self.headerScrollView.indicatorStyle = .white
|
||||
self.headerScrollView.showsVerticalScrollIndicator = false
|
||||
self.headerContainerView.addSubview(self.headerScrollView)
|
||||
self.headerContainerView.addGestureRecognizer(self.headerScrollView.panGestureRecognizer) // Allow panning outside headerScrollView bounds.
|
||||
|
||||
self.headerView = self.makeHeaderView()
|
||||
self.headerScrollView.addSubview(self.headerView)
|
||||
|
||||
let imageConfiguration = UIImage.SymbolConfiguration(weight: .semibold)
|
||||
let image = UIImage(systemName: "chevron.backward", withConfiguration: imageConfiguration)
|
||||
|
||||
self.backButton = VibrantButton(type: .system)
|
||||
self.backButton.image = image
|
||||
self.backButton.tintColor = self.tintColor
|
||||
self.backButton.sizeToFit()
|
||||
self.backButton.addTarget(self.navigationController, action: #selector(UINavigationController.popViewController(animated:)), for: .primaryActionTriggered)
|
||||
self.view.addSubview(self.backButton)
|
||||
|
||||
|
||||
// Content View Controller
|
||||
self.contentViewController = self.makeContentViewController()
|
||||
self.contentViewController.view.frame = self.view.bounds
|
||||
self.contentViewController.view.layer.cornerRadius = 38
|
||||
self.contentViewController.view.layer.masksToBounds = true
|
||||
|
||||
self.addChild(self.contentViewController)
|
||||
self.view.addSubview(self.contentViewController.view)
|
||||
self.contentViewController.didMove(toParent: self)
|
||||
|
||||
self.contentViewControllerShadowView = UIView()
|
||||
self.contentViewControllerShadowView.backgroundColor = .white
|
||||
self.contentViewControllerShadowView.layer.cornerRadius = 38
|
||||
self.contentViewControllerShadowView.layer.shadowColor = UIColor.black.cgColor
|
||||
self.contentViewControllerShadowView.layer.shadowOffset = CGSize(width: 0, height: -1)
|
||||
self.contentViewControllerShadowView.layer.shadowRadius = 10
|
||||
self.contentViewControllerShadowView.layer.shadowOpacity = 0.3
|
||||
self.view.insertSubview(self.contentViewControllerShadowView, belowSubview: self.contentViewController.view)
|
||||
|
||||
// Add scrollView to front so the scroll indicators are visible, but disable user interaction.
|
||||
self.scrollView = UIScrollView(frame: CGRect(origin: .zero, size: self.view.bounds.size))
|
||||
self.scrollView.delegate = self
|
||||
self.scrollView.isUserInteractionEnabled = false
|
||||
self.scrollView.contentInsetAdjustmentBehavior = .never
|
||||
self.view.addSubview(self.scrollView, pinningEdgesWith: .zero)
|
||||
self.view.addGestureRecognizer(self.scrollView.panGestureRecognizer)
|
||||
|
||||
self.contentViewController.scrollView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer)
|
||||
self.contentViewController.scrollView.showsVerticalScrollIndicator = false
|
||||
self.contentViewController.scrollView.contentInsetAdjustmentBehavior = .never
|
||||
|
||||
|
||||
// Navigation Bar Title View
|
||||
self.navigationBarNameLabel = UILabel(frame: .zero)
|
||||
self.navigationBarNameLabel.font = UIFont.boldSystemFont(ofSize: 17) // We want semibold, which this (apparently) returns.
|
||||
self.navigationBarNameLabel.text = self.title
|
||||
self.navigationBarNameLabel.sizeToFit()
|
||||
|
||||
self.navigationBarIconView = UIImageView(frame: .zero)
|
||||
self.navigationBarIconView.clipsToBounds = true
|
||||
|
||||
self.navigationBarTitleView = UIStackView(arrangedSubviews: [self.navigationBarIconView, self.navigationBarNameLabel])
|
||||
self.navigationBarTitleView.axis = .horizontal
|
||||
self.navigationBarTitleView.spacing = 8
|
||||
|
||||
self.navigationBarButton = PillButton(type: .system)
|
||||
self.navigationBarButton.setContentCompressionResistancePriority(UILayoutPriority(rawValue: 9000), for: .horizontal) // Prioritize over title length.
|
||||
|
||||
// Embed navigationBarButton in container view with Auto Layout to ensure it can automatically update its size.
|
||||
let buttonContainerView = UIView()
|
||||
buttonContainerView.addSubview(self.navigationBarButton, pinningEdgesWith: .zero)
|
||||
self.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: buttonContainerView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
self.navigationBarIconView.widthAnchor.constraint(equalToConstant: 35),
|
||||
self.navigationBarIconView.heightAnchor.constraint(equalTo: self.navigationBarIconView.widthAnchor)
|
||||
])
|
||||
|
||||
let size = self.navigationBarTitleView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
self.navigationBarTitleView.bounds.size = size
|
||||
self.navigationItem.titleView = self.navigationBarTitleView
|
||||
|
||||
self._backgroundBlurEffect = self.backgroundBlurView.effect as? UIBlurEffect
|
||||
self._backgroundBlurTintColor = self.backgroundBlurView.contentView.backgroundColor
|
||||
|
||||
self.contentSizeObservation = self.contentViewController.scrollView.observe(\.contentSize, options: [.new, .old]) { [weak self] (scrollView, change) in
|
||||
guard let size = change.newValue, let previousSize = change.oldValue, size != previousSize else { return }
|
||||
self?.view.setNeedsLayout()
|
||||
self?.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
// Don't call update() before subclasses have finished viewDidLoad().
|
||||
// self.update()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(HeaderContentViewController.willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(HeaderContentViewController.didBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
|
||||
if #available(iOS 15, *)
|
||||
{
|
||||
// Fix navigation bar + tab bar appearance on iOS 15.
|
||||
self.setContentScrollView(self.scrollView)
|
||||
}
|
||||
|
||||
// Start with navigation bar hidden.
|
||||
self.hideNavigationBar()
|
||||
|
||||
self.view.tintColor = self.tintColor?.adjustedForDisplay
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool)
|
||||
{
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
self.prepareBlur()
|
||||
|
||||
// Update blur immediately.
|
||||
self.view.setNeedsLayout()
|
||||
self.view.layoutIfNeeded()
|
||||
|
||||
self.headerScrollView.flashScrollIndicators()
|
||||
|
||||
self.update()
|
||||
}
|
||||
|
||||
override func viewIsAppearing(_ animated: Bool)
|
||||
{
|
||||
super.viewIsAppearing(animated)
|
||||
|
||||
// Ensure header view has correct layout dimensions.
|
||||
self.headerView.setNeedsLayout()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool)
|
||||
{
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
self._shouldResetLayout = true
|
||||
self.view.setNeedsLayout()
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews()
|
||||
{
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
if self._shouldResetLayout
|
||||
{
|
||||
// Various events can cause UI to mess up, so reset affected components now.
|
||||
|
||||
self.prepareBlur()
|
||||
|
||||
// Reset navigation bar animation, and create a new one later in this method if necessary.
|
||||
self.resetNavigationBarAnimation()
|
||||
|
||||
self._shouldResetLayout = false
|
||||
}
|
||||
|
||||
let statusBarHeight: Double
|
||||
|
||||
if let navigationController, navigationController.presentingViewController != nil, navigationController.modalPresentationStyle != .fullScreen
|
||||
{
|
||||
statusBarHeight = 20
|
||||
}
|
||||
else if let statusBarManager = (self.view.window ?? self.presentedViewController?.view.window)?.windowScene?.statusBarManager
|
||||
{
|
||||
statusBarHeight = statusBarManager.statusBarFrame.height
|
||||
}
|
||||
else
|
||||
{
|
||||
statusBarHeight = 0
|
||||
}
|
||||
|
||||
let cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius
|
||||
|
||||
let inset = 15 as CGFloat
|
||||
let padding = 20 as CGFloat
|
||||
|
||||
let backButtonSize = self.backButton.sizeThatFits(CGSize(width: Double.infinity, height: .infinity))
|
||||
let largestBackButtonDimension = max(backButtonSize.width, backButtonSize.height) // Enforce 1:1 aspect ratio.
|
||||
var backButtonFrame = CGRect(x: inset, y: statusBarHeight, width: largestBackButtonDimension, height: largestBackButtonDimension)
|
||||
|
||||
var headerFrame = CGRect(x: inset, y: 0, width: self.view.bounds.width - inset * 2, height: self.headerView.bounds.height)
|
||||
var contentFrame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: self.view.bounds.height)
|
||||
var backgroundIconFrame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: self.view.bounds.width)
|
||||
|
||||
let backButtonPadding = 8.0
|
||||
let minimumHeaderY = backButtonFrame.maxY + backButtonPadding
|
||||
|
||||
let minimumContentHeight = minimumHeaderY + headerFrame.height + padding // Minimum height for header + back button + spacing.
|
||||
let maximumContentY = max(self.view.bounds.width * 0.667, minimumContentHeight) // Initial Y-value of content view.
|
||||
|
||||
contentFrame.origin.y = maximumContentY - self.scrollView.contentOffset.y
|
||||
headerFrame.origin.y = contentFrame.origin.y - padding - headerFrame.height
|
||||
|
||||
// Stretch the app icon image to fill additional vertical space if necessary.
|
||||
let height = max(contentFrame.origin.y + cornerRadius * 2, backgroundIconFrame.height)
|
||||
backgroundIconFrame.size.height = height
|
||||
|
||||
// Update blur.
|
||||
self.updateBlur()
|
||||
|
||||
// Animate navigation bar.
|
||||
let showNavigationBarThreshold = (maximumContentY - minimumContentHeight) + backButtonFrame.origin.y
|
||||
if self.scrollView.contentOffset.y > showNavigationBarThreshold
|
||||
{
|
||||
if self.navigationBarAnimator == nil
|
||||
{
|
||||
self.prepareNavigationBarAnimation()
|
||||
}
|
||||
|
||||
let difference = self.scrollView.contentOffset.y - showNavigationBarThreshold
|
||||
|
||||
let range: Double
|
||||
if self.presentingViewController == nil && self.parent?.presentingViewController == nil
|
||||
{
|
||||
// Not presented modally, so rely on safe area + navigation bar height.
|
||||
range = (headerFrame.height + padding) - (self.navigationController?.navigationBar.bounds.height ?? self.view.safeAreaInsets.top)
|
||||
}
|
||||
else
|
||||
{
|
||||
// Presented modally, so rely on maximumContentY.
|
||||
range = maximumContentY - (maximumContentY - padding - headerFrame.height) - inset
|
||||
}
|
||||
|
||||
let fractionComplete = min(difference, range) / range
|
||||
self.navigationBarAnimator?.fractionComplete = fractionComplete
|
||||
}
|
||||
else
|
||||
{
|
||||
self.navigationBarAnimator?.fractionComplete = 0.0
|
||||
self.resetNavigationBarAnimation()
|
||||
}
|
||||
|
||||
let beginMovingBackButtonThreshold = (maximumContentY - minimumContentHeight)
|
||||
if self.scrollView.contentOffset.y > beginMovingBackButtonThreshold
|
||||
{
|
||||
let difference = self.scrollView.contentOffset.y - beginMovingBackButtonThreshold
|
||||
backButtonFrame.origin.y -= difference
|
||||
}
|
||||
|
||||
let pinContentToTopThreshold = maximumContentY
|
||||
if self.scrollView.contentOffset.y > pinContentToTopThreshold
|
||||
{
|
||||
contentFrame.origin.y = 0
|
||||
backgroundIconFrame.origin.y = 0
|
||||
|
||||
let difference = self.scrollView.contentOffset.y - pinContentToTopThreshold
|
||||
self.contentViewController.scrollView.contentOffset.y = -self.contentViewController.scrollView.contentInset.top + difference
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keep content table view's content offset at the top.
|
||||
self.contentViewController.scrollView.contentOffset.y = -self.contentViewController.scrollView.contentInset.top
|
||||
}
|
||||
|
||||
// Keep background app icon centered in gap between top of content and top of screen.
|
||||
backgroundIconFrame.origin.y = (contentFrame.origin.y / 2) - backgroundIconFrame.height / 2
|
||||
|
||||
// Set frames.
|
||||
self.contentViewController.view.frame = contentFrame
|
||||
self.contentViewControllerShadowView.frame = contentFrame
|
||||
self.backgroundImageView.frame = backgroundIconFrame
|
||||
|
||||
self.backButton.frame = backButtonFrame
|
||||
self.backButton.layer.cornerRadius = backButtonFrame.height / 2
|
||||
|
||||
// Adjust header scroll view content size for paging
|
||||
self.headerView.frame = CGRect(origin: .zero, size: headerFrame.size)
|
||||
self.headerScrollView.frame = headerFrame
|
||||
self.headerScrollView.contentSize = CGSize(width: headerFrame.width * 2, height: headerFrame.height)
|
||||
|
||||
self.scrollView.verticalScrollIndicatorInsets.top = statusBarHeight
|
||||
self.headerScrollView.horizontalScrollIndicatorInsets.bottom = -12
|
||||
|
||||
// Adjust content offset + size.
|
||||
let contentOffset = self.scrollView.contentOffset
|
||||
|
||||
var contentSize = self.contentViewController.scrollView.contentSize
|
||||
contentSize.height += self.contentViewController.scrollView.contentInset.top + self.contentViewController.scrollView.contentInset.bottom
|
||||
contentSize.height += maximumContentY
|
||||
contentSize.height = max(contentSize.height, self.view.bounds.height + maximumContentY - (self.navigationController?.navigationBar.bounds.height ?? 0))
|
||||
self.scrollView.contentSize = contentSize
|
||||
|
||||
self.scrollView.contentOffset = contentOffset
|
||||
}
|
||||
|
||||
func update()
|
||||
{
|
||||
// Overridden by subclasses.
|
||||
}
|
||||
|
||||
/// Cannot add @objc functions in extensions of generic types, so include them in main definition instead.
|
||||
|
||||
//MARK: Notifications
|
||||
|
||||
@objc private func willEnterForeground(_ notification: Notification)
|
||||
{
|
||||
guard let navigationController = self.navigationController, navigationController.topViewController == self else { return }
|
||||
|
||||
self._shouldResetLayout = true
|
||||
self.view.setNeedsLayout()
|
||||
}
|
||||
|
||||
@objc private func didBecomeActive(_ notification: Notification)
|
||||
{
|
||||
guard let navigationController = self.navigationController, navigationController.topViewController == self else { return }
|
||||
|
||||
// Fixes incorrect blur after app becomes inactive -> active again.
|
||||
self._shouldResetLayout = true
|
||||
self.view.setNeedsLayout()
|
||||
}
|
||||
|
||||
//MARK: UIAdaptivePresentationControllerDelegate
|
||||
|
||||
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool
|
||||
{
|
||||
return false
|
||||
}
|
||||
|
||||
//MARK: UIScrollViewDelegate
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView)
|
||||
{
|
||||
switch scrollView
|
||||
{
|
||||
case self.scrollView: self.view.setNeedsLayout()
|
||||
case self.headerScrollView:
|
||||
// Do NOT call setNeedsLayout(), or else it will mess with scrolling.
|
||||
self.headerScrollView.showsHorizontalScrollIndicator = false
|
||||
self.updateBlur()
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: UIGestureRecognizerDelegate
|
||||
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool
|
||||
{
|
||||
// Ignore interactive back gesture when viewing header, which means returning `true` to enable ignoreBackGestureRecognizer.
|
||||
let disableBackGesture = self.isViewingHeader
|
||||
return disableBackGesture
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool
|
||||
{
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private extension HeaderContentViewController
|
||||
{
|
||||
func showNavigationBar()
|
||||
{
|
||||
self.navigationBarIconView.alpha = 1.0
|
||||
self.navigationBarNameLabel.alpha = 1.0
|
||||
self.navigationBarButton.alpha = 1.0
|
||||
|
||||
self.updateNavigationBarAppearance(isHidden: false)
|
||||
|
||||
if self.traitCollection.userInterfaceStyle == .dark
|
||||
{
|
||||
self._preferredStatusBarStyle = .lightContent
|
||||
}
|
||||
else
|
||||
{
|
||||
self._preferredStatusBarStyle = .default
|
||||
}
|
||||
|
||||
if #unavailable(iOS 17)
|
||||
{
|
||||
self.navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
func hideNavigationBar()
|
||||
{
|
||||
self.navigationBarIconView.alpha = 0.0
|
||||
self.navigationBarNameLabel.alpha = 0.0
|
||||
self.navigationBarButton.alpha = 0.0
|
||||
|
||||
self.updateNavigationBarAppearance(isHidden: true)
|
||||
|
||||
self._preferredStatusBarStyle = .lightContent
|
||||
|
||||
if #unavailable(iOS 17)
|
||||
{
|
||||
self.navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
func updateNavigationBarAppearance(isHidden: Bool)
|
||||
{
|
||||
let barAppearance = self.navigationItem.standardAppearance as? NavigationBarAppearance ?? NavigationBarAppearance()
|
||||
|
||||
if isHidden
|
||||
{
|
||||
barAppearance.configureWithTransparentBackground()
|
||||
barAppearance.ignoresUserInteraction = true
|
||||
}
|
||||
else
|
||||
{
|
||||
barAppearance.configureWithDefaultBackground()
|
||||
barAppearance.ignoresUserInteraction = false
|
||||
}
|
||||
|
||||
barAppearance.titleTextAttributes = [.foregroundColor: UIColor.clear]
|
||||
|
||||
let dynamicColor = UIColor { traitCollection in
|
||||
var tintColor = self.tintColor ?? .altPrimary
|
||||
|
||||
if traitCollection.userInterfaceStyle == .dark && tintColor.isTooDark
|
||||
{
|
||||
tintColor = .white
|
||||
}
|
||||
else
|
||||
{
|
||||
tintColor = tintColor.adjustedForDisplay
|
||||
}
|
||||
|
||||
return tintColor
|
||||
}
|
||||
|
||||
let tintColor = isHidden ? UIColor.clear : dynamicColor
|
||||
barAppearance.configureWithTintColor(tintColor)
|
||||
|
||||
self.navigationItem.standardAppearance = barAppearance
|
||||
self.navigationItem.scrollEdgeAppearance = barAppearance
|
||||
}
|
||||
|
||||
func prepareBlur()
|
||||
{
|
||||
if let animator = self.blurAnimator
|
||||
{
|
||||
animator.stopAnimation(true)
|
||||
}
|
||||
|
||||
self.backgroundBlurView.effect = self._backgroundBlurEffect
|
||||
self.backgroundBlurView.contentView.backgroundColor = self._backgroundBlurTintColor
|
||||
|
||||
self.blurAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in
|
||||
self?.backgroundBlurView.effect = nil
|
||||
self?.backgroundBlurView.contentView.backgroundColor = .clear
|
||||
}
|
||||
|
||||
self.blurAnimator?.startAnimation()
|
||||
self.blurAnimator?.pauseAnimation()
|
||||
}
|
||||
|
||||
func updateBlur()
|
||||
{
|
||||
// A full blur is too much for header, so we reduce the visible blur by 0.3, resulting in 70% blur.
|
||||
let minimumBlurFraction = 0.3 as CGFloat
|
||||
|
||||
if self.isViewingHeader
|
||||
{
|
||||
let maximumX = self.headerScrollView.bounds.width
|
||||
let fraction = self.headerScrollView.contentOffset.x / maximumX
|
||||
|
||||
let fractionComplete = (fraction * (1.0 - minimumBlurFraction)) + minimumBlurFraction
|
||||
self.blurAnimator?.fractionComplete = fractionComplete
|
||||
}
|
||||
else if self.scrollView.contentOffset.y < 0
|
||||
{
|
||||
// Determine how much to lessen blur by.
|
||||
|
||||
let range = 75 as CGFloat
|
||||
let difference = -self.scrollView.contentOffset.y
|
||||
|
||||
let fraction = min(difference, range) / range
|
||||
|
||||
let fractionComplete = (fraction * (1.0 - minimumBlurFraction)) + minimumBlurFraction
|
||||
self.blurAnimator?.fractionComplete = fractionComplete
|
||||
}
|
||||
else
|
||||
{
|
||||
// Set blur to default.
|
||||
self.blurAnimator?.fractionComplete = minimumBlurFraction
|
||||
}
|
||||
}
|
||||
|
||||
func prepareNavigationBarAnimation()
|
||||
{
|
||||
self.resetNavigationBarAnimation()
|
||||
|
||||
self.navigationBarAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in
|
||||
self?.showNavigationBar()
|
||||
|
||||
// Must call layoutIfNeeded() to animate appearance change.
|
||||
self?.navigationController?.navigationBar.layoutIfNeeded()
|
||||
|
||||
self?.contentViewController.view.layer.cornerRadius = 0
|
||||
}
|
||||
self.navigationBarAnimator?.startAnimation()
|
||||
self.navigationBarAnimator?.pauseAnimation()
|
||||
|
||||
self.update()
|
||||
}
|
||||
|
||||
func resetNavigationBarAnimation()
|
||||
{
|
||||
guard self.navigationBarAnimator != nil else { return }
|
||||
|
||||
self.navigationBarAnimator?.stopAnimation(true)
|
||||
self.navigationBarAnimator = nil
|
||||
|
||||
self.hideNavigationBar()
|
||||
|
||||
self.contentViewController.view.layer.cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius
|
||||
}
|
||||
}
|
||||
@@ -10,24 +10,12 @@ import UIKit
|
||||
|
||||
import Roxas
|
||||
|
||||
class NavigationBarAppearance: UINavigationBarAppearance
|
||||
final class NavigationBar: UINavigationBar
|
||||
{
|
||||
// We sometimes need to ignore user interaction so
|
||||
// we can tap items underneath the navigation bar.
|
||||
var ignoresUserInteraction: Bool = false
|
||||
|
||||
override func copy(with zone: NSZone? = nil) -> Any
|
||||
{
|
||||
let copy = super.copy(with: zone) as! NavigationBarAppearance
|
||||
copy.ignoresUserInteraction = self.ignoresUserInteraction
|
||||
return copy
|
||||
}
|
||||
}
|
||||
|
||||
class NavigationBar: UINavigationBar
|
||||
{
|
||||
@IBInspectable var automaticallyAdjustsItemPositions: Bool = true
|
||||
|
||||
private let backgroundColorView = UIView()
|
||||
|
||||
override init(frame: CGRect)
|
||||
{
|
||||
super.init(frame: frame)
|
||||
@@ -44,39 +32,64 @@ class NavigationBar: UINavigationBar
|
||||
|
||||
private func initialize()
|
||||
{
|
||||
let standardAppearance = UINavigationBarAppearance()
|
||||
standardAppearance.configureWithDefaultBackground()
|
||||
standardAppearance.shadowColor = nil
|
||||
|
||||
let edgeAppearance = UINavigationBarAppearance()
|
||||
edgeAppearance.configureWithOpaqueBackground()
|
||||
edgeAppearance.backgroundColor = self.barTintColor
|
||||
edgeAppearance.shadowColor = nil
|
||||
|
||||
if let tintColor = self.barTintColor
|
||||
if #available(iOS 13, *)
|
||||
{
|
||||
let textAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
|
||||
let standardAppearance = UINavigationBarAppearance()
|
||||
standardAppearance.configureWithDefaultBackground()
|
||||
standardAppearance.shadowColor = nil
|
||||
|
||||
standardAppearance.backgroundColor = tintColor
|
||||
standardAppearance.titleTextAttributes = textAttributes
|
||||
standardAppearance.largeTitleTextAttributes = textAttributes
|
||||
let edgeAppearance = UINavigationBarAppearance()
|
||||
edgeAppearance.configureWithOpaqueBackground()
|
||||
edgeAppearance.backgroundColor = self.barTintColor
|
||||
edgeAppearance.shadowColor = nil
|
||||
|
||||
edgeAppearance.titleTextAttributes = textAttributes
|
||||
edgeAppearance.largeTitleTextAttributes = textAttributes
|
||||
if let tintColor = self.barTintColor
|
||||
{
|
||||
let textAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
|
||||
|
||||
standardAppearance.backgroundColor = tintColor
|
||||
standardAppearance.titleTextAttributes = textAttributes
|
||||
standardAppearance.largeTitleTextAttributes = textAttributes
|
||||
|
||||
edgeAppearance.titleTextAttributes = textAttributes
|
||||
edgeAppearance.largeTitleTextAttributes = textAttributes
|
||||
}
|
||||
else
|
||||
{
|
||||
standardAppearance.backgroundColor = nil
|
||||
}
|
||||
|
||||
self.scrollEdgeAppearance = edgeAppearance
|
||||
self.standardAppearance = standardAppearance
|
||||
}
|
||||
else
|
||||
{
|
||||
standardAppearance.backgroundColor = nil
|
||||
self.shadowImage = UIImage()
|
||||
|
||||
if let tintColor = self.barTintColor
|
||||
{
|
||||
self.backgroundColorView.backgroundColor = tintColor
|
||||
|
||||
// Top = -50 to cover status bar area above navigation bar on any device.
|
||||
// Bottom = -1 to prevent a flickering gray line from appearing.
|
||||
self.addSubview(self.backgroundColorView, pinningEdgesWith: UIEdgeInsets(top: -50, left: 0, bottom: -1, right: 0))
|
||||
}
|
||||
else
|
||||
{
|
||||
self.barTintColor = .white
|
||||
}
|
||||
}
|
||||
|
||||
self.scrollEdgeAppearance = edgeAppearance
|
||||
self.standardAppearance = standardAppearance
|
||||
}
|
||||
|
||||
override func layoutSubviews()
|
||||
{
|
||||
super.layoutSubviews()
|
||||
|
||||
if self.backgroundColorView.superview != nil
|
||||
{
|
||||
self.insertSubview(self.backgroundColorView, at: 1)
|
||||
}
|
||||
|
||||
if self.automaticallyAdjustsItemPositions
|
||||
{
|
||||
// We can't easily shift just the back button up, so we shift the entire content view slightly.
|
||||
@@ -87,15 +100,4 @@ class NavigationBar: UINavigationBar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?
|
||||
{
|
||||
if let appearance = self.topItem?.standardAppearance as? NavigationBarAppearance, appearance.ignoresUserInteraction
|
||||
{
|
||||
// Ignore touches.
|
||||
return nil
|
||||
}
|
||||
|
||||
return super.hitTest(point, with: event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,16 +14,7 @@ extension PillButton
|
||||
static let contentInsets = NSDirectionalEdgeInsets(top: 7, leading: 13, bottom: 7, trailing: 13)
|
||||
}
|
||||
|
||||
extension PillButton
|
||||
{
|
||||
enum Style
|
||||
{
|
||||
case pill
|
||||
case custom
|
||||
}
|
||||
}
|
||||
|
||||
class PillButton: UIButton
|
||||
final class PillButton: UIButton
|
||||
{
|
||||
override var accessibilityValue: String? {
|
||||
get {
|
||||
@@ -47,8 +38,11 @@ class PillButton: UIButton
|
||||
}
|
||||
|
||||
var progressTintColor: UIColor? {
|
||||
didSet {
|
||||
self.update()
|
||||
get {
|
||||
return self.progressView.progressTintColor
|
||||
}
|
||||
set {
|
||||
self.progressView.progressTintColor = newValue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,20 +58,6 @@ class PillButton: UIButton
|
||||
}
|
||||
}
|
||||
|
||||
var style: Style = .pill {
|
||||
didSet {
|
||||
guard self.style != oldValue else { return }
|
||||
|
||||
if self.style == .custom
|
||||
{
|
||||
// Reset insets for custom style.
|
||||
self.contentEdgeInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
|
||||
}
|
||||
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
private let progressView = UIProgressView(progressViewStyle: .default)
|
||||
|
||||
private lazy var displayLink: CADisplayLink = {
|
||||
@@ -105,32 +85,16 @@ class PillButton: UIButton
|
||||
self.displayLink.remove(from: .main, forMode: RunLoop.Mode.default)
|
||||
}
|
||||
|
||||
override init(frame: CGRect)
|
||||
{
|
||||
super.init(frame: frame)
|
||||
|
||||
self.initialize()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder)
|
||||
{
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
override func awakeFromNib()
|
||||
{
|
||||
super.awakeFromNib()
|
||||
|
||||
self.initialize()
|
||||
}
|
||||
|
||||
private func initialize()
|
||||
{
|
||||
self.layer.masksToBounds = true
|
||||
self.accessibilityTraits.formUnion([.updatesFrequently, .button])
|
||||
|
||||
self.contentEdgeInsets = UIEdgeInsets(top: Self.contentInsets.top, left: Self.contentInsets.leading, bottom: Self.contentInsets.bottom, right: Self.contentInsets.trailing)
|
||||
|
||||
self.activityIndicatorView.style = .medium
|
||||
self.activityIndicatorView.color = .white
|
||||
self.activityIndicatorView.isUserInteractionEnabled = false
|
||||
|
||||
self.progressView.progress = 0
|
||||
@@ -165,16 +129,8 @@ class PillButton: UIButton
|
||||
override func sizeThatFits(_ size: CGSize) -> CGSize
|
||||
{
|
||||
var size = super.sizeThatFits(size)
|
||||
|
||||
switch self.style
|
||||
{
|
||||
case .pill:
|
||||
// Enforce minimum size for pill style.
|
||||
size.width = max(size.width, PillButton.minimumSize.width)
|
||||
size.height = max(size.height, PillButton.minimumSize.height)
|
||||
|
||||
case .custom: break
|
||||
}
|
||||
size.width = max(size.width, PillButton.minimumSize.width)
|
||||
size.height = max(size.height, PillButton.minimumSize.height)
|
||||
|
||||
return size
|
||||
}
|
||||
@@ -195,17 +151,7 @@ private extension PillButton
|
||||
self.backgroundColor = self.tintColor.withAlphaComponent(0.15)
|
||||
}
|
||||
|
||||
self.progressView.progressTintColor = self.progressTintColor ?? self.tintColor
|
||||
|
||||
// Update font after init because the original titleLabel is replaced.
|
||||
self.titleLabel?.font = UIFont.boldSystemFont(ofSize: 14)
|
||||
|
||||
switch self.style
|
||||
{
|
||||
case .custom: break // Don't update insets in case client has updated them.
|
||||
case .pill:
|
||||
self.contentEdgeInsets = UIEdgeInsets(top: Self.contentInsets.top, left: Self.contentInsets.leading, bottom: Self.contentInsets.bottom, right: Self.contentInsets.trailing)
|
||||
}
|
||||
self.progressView.progressTintColor = self.tintColor
|
||||
}
|
||||
|
||||
@objc func updateCountdown()
|
||||
|
||||
@@ -16,13 +16,10 @@ extension TimeInterval
|
||||
static let longToastViewDuration = 8.0
|
||||
}
|
||||
|
||||
extension ToastView
|
||||
final class ToastView: RSTToastView
|
||||
{
|
||||
static let openErrorLogNotification = Notification.Name("ALTOpenErrorLogNotification")
|
||||
}
|
||||
|
||||
class ToastView: RSTToastView
|
||||
{
|
||||
var preferredDuration: TimeInterval
|
||||
|
||||
var opensErrorLog: Bool = false
|
||||
@@ -65,23 +62,32 @@ 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
|
||||
var error = error as NSError
|
||||
var underlyingError = error.underlyingError
|
||||
|
||||
if
|
||||
let unwrappedUnderlyingError = underlyingError,
|
||||
error.domain == AltServerErrorDomain && error.code == ALTServerError.Code.underlyingError.rawValue
|
||||
{
|
||||
// Treat underlyingError as the primary error, but keep localized title + failure.
|
||||
let nsError = error as NSError
|
||||
error = unwrappedUnderlyingError as NSError
|
||||
|
||||
if let localizedTitle = nsError.localizedTitle {
|
||||
error = error.withLocalizedTitle(localizedTitle)
|
||||
}
|
||||
if let localizedFailure = nsError.localizedFailure {
|
||||
error = error.withLocalizedFailure(localizedFailure)
|
||||
}
|
||||
|
||||
underlyingError = nil
|
||||
}
|
||||
let text = error.localizedTitle ?? NSLocalizedString("Operation Failed", comment: "")
|
||||
let detailText = ErrorProcessing(mode).getDescription(error: error)
|
||||
|
||||
let detailText = error.localizedDescription
|
||||
|
||||
|
||||
self.init(text: text, detailText: detailText)
|
||||
}
|
||||
|
||||
@@ -132,14 +138,10 @@ class ToastView: RSTToastView
|
||||
{
|
||||
self.show(in: view, duration: self.preferredDuration)
|
||||
}
|
||||
}
|
||||
|
||||
private extension ToastView
|
||||
{
|
||||
@objc func showErrorLog()
|
||||
{
|
||||
@objc
|
||||
func showErrorLog() {
|
||||
guard self.opensErrorLog else { return }
|
||||
|
||||
NotificationCenter.default.post(name: ToastView.openErrorLogNotification, object: self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
//
|
||||
// VibrantButton.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 3/22/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
private let preferredFont = UIFont.boldSystemFont(ofSize: 14)
|
||||
|
||||
class VibrantButton: UIButton
|
||||
{
|
||||
var title: String? {
|
||||
didSet {
|
||||
if #available(iOS 15, *)
|
||||
{
|
||||
self.configuration?.title = self.title
|
||||
}
|
||||
else
|
||||
{
|
||||
self.setTitle(self.title, for: .normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var image: UIImage? {
|
||||
didSet {
|
||||
if #available(iOS 15, *)
|
||||
{
|
||||
self.configuration?.image = self.image
|
||||
}
|
||||
else
|
||||
{
|
||||
self.setImage(self.image, for: .normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var contentInsets: NSDirectionalEdgeInsets = .zero {
|
||||
didSet {
|
||||
if #available(iOS 15, *)
|
||||
{
|
||||
self.configuration?.contentInsets = self.contentInsets
|
||||
}
|
||||
else
|
||||
{
|
||||
self.contentEdgeInsets = UIEdgeInsets(top: self.contentInsets.top, left: self.contentInsets.leading, bottom: self.contentInsets.bottom, right: self.contentInsets.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override var isIndicatingActivity: Bool {
|
||||
didSet {
|
||||
guard #available(iOS 15, *) else { return }
|
||||
self.updateConfiguration()
|
||||
}
|
||||
}
|
||||
|
||||
private let vibrancyView = UIVisualEffectView(effect: nil)
|
||||
|
||||
override init(frame: CGRect)
|
||||
{
|
||||
super.init(frame: frame)
|
||||
|
||||
self.initialize()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder)
|
||||
{
|
||||
super.init(coder: coder)
|
||||
|
||||
self.initialize()
|
||||
}
|
||||
|
||||
private func initialize()
|
||||
{
|
||||
let blurEffect = UIBlurEffect(style: .systemThinMaterial)
|
||||
let vibrancyEffect = UIVibrancyEffect(blurEffect: blurEffect, style: .fill) // .fill is more vibrant than .secondaryLabel
|
||||
|
||||
if #available(iOS 15, *)
|
||||
{
|
||||
var backgroundConfig = UIBackgroundConfiguration.clear()
|
||||
backgroundConfig.visualEffect = blurEffect
|
||||
|
||||
var config = UIButton.Configuration.plain()
|
||||
config.cornerStyle = .capsule
|
||||
config.background = backgroundConfig
|
||||
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { [weak self] (attributes) in
|
||||
var attributes = attributes
|
||||
attributes.font = preferredFont
|
||||
|
||||
if let self, self.isIndicatingActivity
|
||||
{
|
||||
// Hide title when indicating activity, but without changing intrinsicContentSize.
|
||||
attributes.foregroundColor = UIColor.clear
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
self.configuration = config
|
||||
}
|
||||
else
|
||||
{
|
||||
self.clipsToBounds = true
|
||||
self.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8) // Add padding.
|
||||
|
||||
let blurView = UIVisualEffectView(effect: blurEffect)
|
||||
blurView.isUserInteractionEnabled = false
|
||||
self.addSubview(blurView, pinningEdgesWith: .zero)
|
||||
self.insertSubview(blurView, at: 0)
|
||||
}
|
||||
|
||||
self.vibrancyView.effect = vibrancyEffect
|
||||
self.vibrancyView.isUserInteractionEnabled = false
|
||||
self.addSubview(self.vibrancyView, pinningEdgesWith: .zero)
|
||||
}
|
||||
|
||||
override func layoutSubviews()
|
||||
{
|
||||
super.layoutSubviews()
|
||||
|
||||
self.layer.cornerRadius = self.bounds.midY
|
||||
|
||||
// Make sure content subviews are inside self.vibrancyView.contentView.
|
||||
|
||||
if let titleLabel = self.titleLabel, titleLabel.superview != self.vibrancyView.contentView
|
||||
{
|
||||
self.vibrancyView.contentView.addSubview(titleLabel)
|
||||
}
|
||||
|
||||
if let imageView = self.imageView, imageView.superview != self.vibrancyView.contentView
|
||||
{
|
||||
self.vibrancyView.contentView.addSubview(imageView)
|
||||
}
|
||||
|
||||
if self.activityIndicatorView.superview != self.vibrancyView.contentView
|
||||
{
|
||||
self.vibrancyView.contentView.addSubview(self.activityIndicatorView)
|
||||
}
|
||||
|
||||
if #unavailable(iOS 15)
|
||||
{
|
||||
// Update font after init because the original titleLabel is replaced.
|
||||
self.titleLabel?.font = preferredFont
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
import Intents
|
||||
|
||||
// Requires iOS 14 in-app intent handling.
|
||||
@available(iOS 14, *)
|
||||
extension INInteraction
|
||||
{
|
||||
static func refreshAllApps() -> INInteraction
|
||||
|
||||
@@ -71,20 +71,20 @@ fileprivate struct BuildVersion: Comparable {
|
||||
}
|
||||
|
||||
extension ProcessInfo {
|
||||
public var shortVersion: String {
|
||||
var shortVersion: String {
|
||||
operatingSystemVersionString
|
||||
.replacingOccurrences(of: "Version ", with: "")
|
||||
.replacingOccurrences(of: "Build ", with: "")
|
||||
}
|
||||
|
||||
public var operatingSystemBuild: String {
|
||||
var operatingSystemBuild: String {
|
||||
if let start = shortVersion.range(of: "(")?.upperBound,
|
||||
let end = shortVersion.range(of: ")")?.lowerBound {
|
||||
shortVersion[start..<end].replacingOccurrences(of: "Build ", with: "")
|
||||
} else { "???" }
|
||||
}
|
||||
|
||||
public var sparseRestorePatched: Bool {
|
||||
var sparseRestorePatched: Bool {
|
||||
if operatingSystemVersion < OperatingSystemVersion(majorVersion: 18, minorVersion: 1, patchVersion: 0) { false }
|
||||
else if operatingSystemVersion > OperatingSystemVersion(majorVersion: 18, minorVersion: 1, patchVersion: 1) { true }
|
||||
else if operatingSystemVersion >= OperatingSystemVersion(majorVersion: 18, minorVersion: 1, patchVersion: 0),
|
||||
@@ -1,62 +0,0 @@
|
||||
//
|
||||
// UIColor+AltStore.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/23/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIColor
|
||||
{
|
||||
static let altBackground = UIColor(named: "Background")!
|
||||
}
|
||||
|
||||
extension UIColor
|
||||
{
|
||||
private static let brightnessMaxThreshold = 0.85
|
||||
private static let brightnessMinThreshold = 0.35
|
||||
|
||||
private static let saturationBrightnessThreshold = 0.5
|
||||
|
||||
var adjustedForDisplay: UIColor {
|
||||
guard self.isTooBright || self.isTooDark else { return self }
|
||||
|
||||
return UIColor { traits in
|
||||
var hue: CGFloat = 0
|
||||
var saturation: CGFloat = 0
|
||||
var brightness: CGFloat = 0
|
||||
guard self.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: nil) else { return self }
|
||||
|
||||
brightness = min(brightness, UIColor.brightnessMaxThreshold)
|
||||
|
||||
if traits.userInterfaceStyle == .dark
|
||||
{
|
||||
// Only raise brightness when in dark mode.
|
||||
brightness = max(brightness, UIColor.brightnessMinThreshold)
|
||||
}
|
||||
|
||||
let color = UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: 1.0)
|
||||
return color
|
||||
}
|
||||
}
|
||||
|
||||
var isTooBright: Bool {
|
||||
var saturation: CGFloat = 0
|
||||
var brightness: CGFloat = 0
|
||||
|
||||
guard self.getHue(nil, saturation: &saturation, brightness: &brightness, alpha: nil) else { return false }
|
||||
|
||||
let isTooBright = (brightness >= UIColor.brightnessMaxThreshold && saturation <= UIColor.saturationBrightnessThreshold)
|
||||
return isTooBright
|
||||
}
|
||||
|
||||
var isTooDark: Bool {
|
||||
var brightness: CGFloat = 0
|
||||
guard self.getHue(nil, saturation: nil, brightness: &brightness, alpha: nil) else { return false }
|
||||
|
||||
let isTooDark = brightness <= UIColor.brightnessMinThreshold
|
||||
return isTooDark
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ extension UIDevice
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
var supportsFugu14: Bool {
|
||||
#if targetEnvironment(simulator)
|
||||
return true
|
||||
@@ -39,6 +40,7 @@ extension UIDevice
|
||||
#endif
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
var isUntetheredJailbreakRequired: Bool {
|
||||
let ios14_4 = OperatingSystemVersion(majorVersion: 14, minorVersion: 4, patchVersion: 0)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ private extension SystemSoundID
|
||||
static let tryAgain = SystemSoundID(1102)
|
||||
}
|
||||
|
||||
@available(iOS 13, *)
|
||||
extension UIDevice
|
||||
{
|
||||
enum VibrationPattern
|
||||
@@ -25,6 +26,7 @@ extension UIDevice
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13, *)
|
||||
extension UIDevice
|
||||
{
|
||||
var isVibrationSupported: Bool {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
//
|
||||
// UIFontDescriptor+Bold.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 10/16/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIFontDescriptor
|
||||
{
|
||||
func bolded() -> UIFontDescriptor
|
||||
{
|
||||
guard let descriptor = self.withSymbolicTraits(.traitBold) else { return self }
|
||||
return descriptor
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
//
|
||||
// UINavigationBarAppearance+TintColor.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 4/4/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UINavigationBarAppearance
|
||||
{
|
||||
func configureWithTintColor(_ tintColor: UIColor)
|
||||
{
|
||||
let buttonAppearance = UIBarButtonItemAppearance(style: .plain)
|
||||
buttonAppearance.normal.titleTextAttributes = [.foregroundColor: tintColor]
|
||||
self.buttonAppearance = buttonAppearance
|
||||
|
||||
let backButtonImage = UIImage(systemName: "chevron.backward")?.withTintColor(tintColor, renderingMode: .alwaysOriginal)
|
||||
self.setBackIndicatorImage(backButtonImage, transitionMaskImage: backButtonImage)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
//
|
||||
// UTType+AltStore.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 11/3/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
extension UTType
|
||||
{
|
||||
static let ipa = UTType(importedAs: "com.apple.itunes.ipa")
|
||||
}
|
||||
@@ -7,9 +7,10 @@
|
||||
<key>ALTAppGroups</key>
|
||||
<array>
|
||||
<string>group.$(APP_GROUP_IDENTIFIER)</string>
|
||||
<string>group.com.SideStore.SideStore</string>
|
||||
</array>
|
||||
<key>ALTDeviceID</key>
|
||||
<string>00008120-001270DA119B401E</string>
|
||||
<string>00008101-000129D63698001E</string>
|
||||
<key>ALTPairingFile</key>
|
||||
<string><insert pairing file here></string>
|
||||
<key>ALTServerID</key>
|
||||
@@ -35,17 +36,6 @@
|
||||
</array>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIcons</key>
|
||||
<dict>
|
||||
<key>CFBundlePrimaryIcon</key>
|
||||
<dict>
|
||||
<key>NSAppIconComplementingColorNames</key>
|
||||
<array>
|
||||
<string>GradientTop</string>
|
||||
<string>GradientBottom</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
@@ -62,9 +52,10 @@
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>SideStore General</string>
|
||||
<string>AltStore General</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>altstore</string>
|
||||
<string>sidestore</string>
|
||||
</array>
|
||||
</dict>
|
||||
@@ -72,15 +63,16 @@
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>SideStore Backup</string>
|
||||
<string>AltStore Backup</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>altstore-com.rileytestut.AltStore</string>
|
||||
<string>sidestore-com.SideStore.SideStore</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<string>1</string>
|
||||
<key>INIntentsSupported</key>
|
||||
<array>
|
||||
<string>RefreshAllIntent</string>
|
||||
@@ -88,13 +80,19 @@
|
||||
</array>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>sidestore-com.SideStore.SideStore</string>
|
||||
<string>sidestore-com.SideStore.SideStore.Beta</string>
|
||||
<string>altstore-com.rileytestut.AltStore</string>
|
||||
<string>altstore-com.rileytestut.AltStore.Beta</string>
|
||||
<string>altstore-com.rileytestut.Delta</string>
|
||||
<string>altstore-com.rileytestut.Delta.Beta</string>
|
||||
<string>altstore-com.rileytestut.Delta.Lite</string>
|
||||
<string>altstore-com.rileytestut.Delta.Lite.Beta</string>
|
||||
<string>altstore-com.rileytestut.Clip</string>
|
||||
<string>altstore-com.rileytestut.Clip.Beta</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<false/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
@@ -111,42 +109,6 @@
|
||||
<string>RefreshAllIntent</string>
|
||||
<string>ViewAppIntent</string>
|
||||
</array>
|
||||
<key>OSLogPreferences</key>
|
||||
<dict>
|
||||
<key>com.SideStore.SideStore</key>
|
||||
<dict>
|
||||
<key>AltJIT</key>
|
||||
<dict>
|
||||
<key>Level</key>
|
||||
<dict>
|
||||
<key>Enable</key>
|
||||
<string>Info</string>
|
||||
<key>Persist</key>
|
||||
<string>Info</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>Main</key>
|
||||
<dict>
|
||||
<key>Level</key>
|
||||
<dict>
|
||||
<key>Enable</key>
|
||||
<string>Info</string>
|
||||
<key>Persist</key>
|
||||
<string>Info</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>Sideload</key>
|
||||
<dict>
|
||||
<key>Level</key>
|
||||
<dict>
|
||||
<key>Enable</key>
|
||||
<string>Info</string>
|
||||
<key>Persist</key>
|
||||
<string>Info</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
@@ -222,8 +184,6 @@
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<string>ipa</string>
|
||||
<key>public.mime-type</key>
|
||||
<string>application/x-ios-app</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
//
|
||||
// AppShortcuts.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/23/22.
|
||||
// Copyright © 2022 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import AppIntents
|
||||
|
||||
@available(iOS 17, *)
|
||||
public struct ShortcutsProvider: AppShortcutsProvider
|
||||
{
|
||||
public static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(intent: RefreshAllAppsIntent(),
|
||||
phrases: [
|
||||
"Refresh \(.applicationName)",
|
||||
"Refresh \(.applicationName) apps",
|
||||
"Refresh my \(.applicationName) apps",
|
||||
"Refresh apps with \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Refresh All Apps",
|
||||
systemImageName: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
|
||||
public static var shortcutTileColor: ShortcutTileColor {
|
||||
return .teal
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
//
|
||||
// RefreshAllAppsIntent.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/18/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import AppIntents
|
||||
import WidgetKit
|
||||
|
||||
import AltStoreCore
|
||||
|
||||
// Shouldn't conform types we don't own to protocols we don't own, so make custom
|
||||
// NSError subclass that conforms to CustomLocalizedStringResourceConvertible instead.
|
||||
//
|
||||
// Would prefer to just conform ALTLocalizedError to CustomLocalizedStringResourceConvertible,
|
||||
// but that can't be done without raising minimum version for ALTLocalizedError to iOS 16 :/
|
||||
@available(iOS 16, *)
|
||||
class IntentError: NSError, CustomLocalizedStringResourceConvertible
|
||||
{
|
||||
var localizedStringResource: LocalizedStringResource {
|
||||
return "\(self.localizedDescription)"
|
||||
}
|
||||
|
||||
init(_ error: some Error)
|
||||
{
|
||||
let serializedError = (error as NSError).sanitizedForSerialization()
|
||||
super.init(domain: serializedError.domain, code: serializedError.code, userInfo: serializedError.userInfo)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder)
|
||||
{
|
||||
super.init(coder: coder)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
extension RefreshAllAppsIntent
|
||||
{
|
||||
private actor OperationActor
|
||||
{
|
||||
private(set) var operation: BackgroundRefreshAppsOperation?
|
||||
|
||||
func set(_ operation: BackgroundRefreshAppsOperation?)
|
||||
{
|
||||
self.operation = operation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
struct RefreshAllAppsIntent: AppIntent, CustomIntentMigratedAppIntent, PredictableIntent, ProgressReportingIntent, ForegroundContinuableIntent
|
||||
{
|
||||
static let intentClassName = "RefreshAllIntent"
|
||||
|
||||
static var title: LocalizedStringResource = "Refresh All Apps"
|
||||
static var description = IntentDescription("Refreshes your sideloaded apps to prevent them from expiring.")
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Refresh All Apps")
|
||||
}
|
||||
|
||||
static var predictionConfiguration: some IntentPredictionConfiguration {
|
||||
IntentPrediction {
|
||||
DisplayRepresentation(
|
||||
title: "Refresh All Apps",
|
||||
subtitle: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let presentsNotifications: Bool
|
||||
|
||||
private let operationActor = OperationActor()
|
||||
|
||||
init(presentsNotifications: Bool)
|
||||
{
|
||||
self.presentsNotifications = presentsNotifications
|
||||
|
||||
self.progress.completedUnitCount = 0
|
||||
self.progress.totalUnitCount = 1
|
||||
}
|
||||
|
||||
init()
|
||||
{
|
||||
self.init(presentsNotifications: false)
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog
|
||||
{
|
||||
do
|
||||
{
|
||||
// Request foreground execution at ~27 seconds to gracefully handle timeout.
|
||||
let deadline: ContinuousClock.Instant = .now + .seconds(27)
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self) { taskGroup in
|
||||
taskGroup.addTask {
|
||||
try await self.refreshAllApps()
|
||||
}
|
||||
|
||||
taskGroup.addTask {
|
||||
try await Task.sleep(until: deadline)
|
||||
throw OperationError.timedOut
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
for try await _ in taskGroup.prefix(1)
|
||||
{
|
||||
// We only care about the first child task to complete.
|
||||
taskGroup.cancelAll()
|
||||
break
|
||||
}
|
||||
}
|
||||
catch OperationError.timedOut
|
||||
{
|
||||
// We took too long to finish and return the final result,
|
||||
// so we'll now present a normal notification when finished.
|
||||
let operation = await self.operationActor.operation
|
||||
operation?.presentsFinishedNotification = true
|
||||
|
||||
try await self.requestToContinueInForeground()
|
||||
}
|
||||
}
|
||||
|
||||
return .result(dialog: "All apps have been refreshed.")
|
||||
}
|
||||
catch
|
||||
{
|
||||
let intentError = IntentError(error)
|
||||
throw intentError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
private extension RefreshAllAppsIntent
|
||||
{
|
||||
func refreshAllApps() async throws
|
||||
{
|
||||
if !DatabaseManager.shared.isStarted
|
||||
{
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
DatabaseManager.shared.start { error in
|
||||
if let error
|
||||
{
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
else
|
||||
{
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
||||
let installedApps = await context.perform { InstalledApp.fetchAppsForRefreshingAll(in: context) }
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
let operation = AppManager.shared.backgroundRefresh(installedApps, presentsNotifications: self.presentsNotifications) { (result) in
|
||||
do
|
||||
{
|
||||
let results = try result.get()
|
||||
|
||||
for (_, result) in results
|
||||
{
|
||||
guard case let .failure(error) = result else { continue }
|
||||
throw error
|
||||
}
|
||||
|
||||
continuation.resume()
|
||||
}
|
||||
catch ~RefreshErrorCode.noInstalledApps
|
||||
{
|
||||
continuation.resume()
|
||||
}
|
||||
catch
|
||||
{
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
|
||||
operation.ignoresServerNotFoundError = false
|
||||
|
||||
self.progress.addChild(operation.progress, withPendingUnitCount: 1)
|
||||
|
||||
Task {
|
||||
await self.operationActor.set(operation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//
|
||||
// RefreshAllAppsWidgetIntent.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/18/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import AppIntents
|
||||
|
||||
@available(iOS 17, *)
|
||||
struct RefreshAllAppsWidgetIntent: AppIntent, ProgressReportingIntent
|
||||
{
|
||||
static var title: LocalizedStringResource { "Refresh Apps via Widget" }
|
||||
static var isDiscoverable: Bool { false } // Don't show in Shortcuts or Spotlight.
|
||||
|
||||
#if !WIDGET_EXTENSION
|
||||
private let intent = RefreshAllAppsIntent(presentsNotifications: true)
|
||||
#endif
|
||||
|
||||
func perform() async throws -> some IntentResult
|
||||
{
|
||||
#if !WIDGET_EXTENSION
|
||||
do
|
||||
{
|
||||
_ = try await self.intent.perform()
|
||||
}
|
||||
catch
|
||||
{
|
||||
print("Failed to refresh apps via widget.", error)
|
||||
}
|
||||
#endif
|
||||
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// To ensure this intent is handled by the app itself (and not widget extension)
|
||||
// we need to conform to either `ForegroundContinuableIntent` or `AudioPlaybackIntent`.
|
||||
// https://mastodon.social/@mgorbach/110812347476671807
|
||||
//
|
||||
// Unfortunately `ForegroundContinuableIntent` is marked as unavailable in app extensions,
|
||||
// so we "conform" RefreshAllAppsWidgetIntent to it in an `unavailable` extension ¯\_(ツ)_/¯
|
||||
@available(iOS, unavailable)
|
||||
extension RefreshAllAppsWidgetIntent: ForegroundContinuableIntent {}
|
||||
@@ -14,7 +14,7 @@ import AltStoreCore
|
||||
@available(iOS 14, *)
|
||||
final class IntentHandler: NSObject, RefreshAllIntentHandling
|
||||
{
|
||||
private let queue = DispatchQueue(label: "io.sidestore.IntentHandler")
|
||||
private let queue = DispatchQueue(label: "io.altstore.IntentHandler")
|
||||
|
||||
private var completionHandlers = [RefreshAllIntent: (RefreshAllIntentResponse) -> Void]()
|
||||
private var queuedResponses = [RefreshAllIntent: RefreshAllIntentResponse]()
|
||||
@@ -103,6 +103,7 @@ final class IntentHandler: NSObject, RefreshAllIntentHandling
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
private extension IntentHandler
|
||||
{
|
||||
func finish(_ intent: RefreshAllIntent, response: RefreshAllIntentResponse)
|
||||
@@ -127,7 +128,7 @@ private extension IntentHandler
|
||||
func refreshApps(intent: RefreshAllIntent)
|
||||
{
|
||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
||||
let installedApps = InstalledApp.fetchAppsForRefreshingAll(in: context)
|
||||
let installedApps = InstalledApp.fetchActiveApps(in: context)
|
||||
let operation = AppManager.shared.backgroundRefresh(installedApps, presentsNotifications: false) { (result) in
|
||||
do
|
||||
{
|
||||
@@ -10,75 +10,85 @@ import UIKit
|
||||
import Roxas
|
||||
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: UIViewController!
|
||||
|
||||
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,337 +96,225 @@ 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 {
|
||||
let toastView = ToastView(text: NSLocalizedString("Could not read data from file!", comment: ""), detailText: "\(file)")
|
||||
return toastView.show(in: self)
|
||||
func start_minimuxer_threads(_ pairing_file: String) {
|
||||
target_minimuxer_address()
|
||||
let documentsDirectory = FileManager.default.documentsDirectory.absoluteString
|
||||
do {
|
||||
try start(pairing_file, documentsDirectory)
|
||||
} 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 {
|
||||
let toastView = ToastView(text: NSLocalizedString("Could not parse data from file!", comment: ""), detailText: "\(file)")
|
||||
return toastView.show(in: self)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 errorDescription: String
|
||||
|
||||
if #available(iOS 14.5, *)
|
||||
{
|
||||
let errorMessages = [error.debugDescription] + error.underlyingErrors.map { ($0 as NSError).debugDescription }
|
||||
errorDescription = errorMessages.joined(separator: "\n\n")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func detectAndImportAccountFile() {
|
||||
let accountFileURL = FileManager.default.documentsDirectory.appendingPathComponent("Account.sideconf")
|
||||
#if !DEBUG
|
||||
importAccountAtFile(accountFileURL, remove: true)
|
||||
#else
|
||||
importAccountAtFile(accountFileURL)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension LaunchViewController {
|
||||
@MainActor
|
||||
func handleLaunchError(_ error: Error, retryCallback: (() async -> Void)? = nil) {
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
@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()
|
||||
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)
|
||||
toastView.addTarget(self.destinationViewController, action: #selector(TabBarController.presentSources), for: .touchUpInside)
|
||||
toastView.show(in: self.destinationViewController!.selectedViewController ?? self.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)
|
||||
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.destinationViewController.view.alpha = 1.0
|
||||
}
|
||||
updateKnownSources()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
didFinishLaunching = true
|
||||
|
||||
let destinationVC = destinationViewController!
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func updateKnownSources() {
|
||||
AppManager.shared.updateKnownSources { result in
|
||||
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)
|
||||
}
|
||||
guard !sourceErrors.isEmpty else { return }
|
||||
Task {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
self.didFinishLaunching = true
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,9 +30,7 @@ extension AppManager
|
||||
} else if self.errors.count == 1 {
|
||||
guard let source = self.errors.keys.first else { return }
|
||||
localizedTitle = String(format: NSLocalizedString("Failed to refresh Source '%@'", comment: ""), source.name)
|
||||
}
|
||||
else
|
||||
{
|
||||
} else {
|
||||
localizedTitle = String(format: NSLocalizedString("Failed to refresh %@ Sources", comment: ""), NSNumber(value: self.errors.count))
|
||||
}
|
||||
}
|
||||
@@ -44,9 +42,7 @@ extension AppManager
|
||||
return error.localizedDescription
|
||||
} else if let error = self.errors.values.first, self.errors.count == 1 {
|
||||
return error.localizedDescription
|
||||
}
|
||||
else
|
||||
{
|
||||
} else {
|
||||
var localizedDescription: String?
|
||||
|
||||
self.managedObjectContext?.performAndWait {
|
||||
|
||||
@@ -22,33 +22,36 @@ final class InstalledAppCollectionViewCell: UICollectionViewCell
|
||||
self.contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
self.contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
let deactivateBadge = UIView()
|
||||
deactivateBadge.translatesAutoresizingMaskIntoConstraints = false
|
||||
deactivateBadge.isHidden = true
|
||||
self.addSubview(deactivateBadge)
|
||||
|
||||
// Solid background to make the X opaque white.
|
||||
let backgroundView = UIView()
|
||||
backgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
backgroundView.backgroundColor = .white
|
||||
deactivateBadge.addSubview(backgroundView)
|
||||
|
||||
let badgeView = UIImageView(image: UIImage(systemName: "xmark.circle.fill"))
|
||||
badgeView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(scale: .large)
|
||||
badgeView.tintColor = .systemRed
|
||||
deactivateBadge.addSubview(badgeView, pinningEdgesWith: .zero)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
deactivateBadge.centerXAnchor.constraint(equalTo: self.bannerView.iconImageView.trailingAnchor),
|
||||
deactivateBadge.centerYAnchor.constraint(equalTo: self.bannerView.iconImageView.topAnchor),
|
||||
if #available(iOS 13.0, *)
|
||||
{
|
||||
let deactivateBadge = UIView()
|
||||
deactivateBadge.translatesAutoresizingMaskIntoConstraints = false
|
||||
deactivateBadge.isHidden = true
|
||||
self.addSubview(deactivateBadge)
|
||||
|
||||
backgroundView.centerXAnchor.constraint(equalTo: badgeView.centerXAnchor),
|
||||
backgroundView.centerYAnchor.constraint(equalTo: badgeView.centerYAnchor),
|
||||
backgroundView.widthAnchor.constraint(equalTo: badgeView.widthAnchor, multiplier: 0.5),
|
||||
backgroundView.heightAnchor.constraint(equalTo: badgeView.heightAnchor, multiplier: 0.5)
|
||||
])
|
||||
|
||||
self.deactivateBadge = deactivateBadge
|
||||
// Solid background to make the X opaque white.
|
||||
let backgroundView = UIView()
|
||||
backgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
backgroundView.backgroundColor = .white
|
||||
deactivateBadge.addSubview(backgroundView)
|
||||
|
||||
let badgeView = UIImageView(image: UIImage(systemName: "xmark.circle.fill"))
|
||||
badgeView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(scale: .large)
|
||||
badgeView.tintColor = .systemRed
|
||||
deactivateBadge.addSubview(badgeView, pinningEdgesWith: .zero)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
deactivateBadge.centerXAnchor.constraint(equalTo: self.bannerView.iconImageView.trailingAnchor),
|
||||
deactivateBadge.centerYAnchor.constraint(equalTo: self.bannerView.iconImageView.topAnchor),
|
||||
|
||||
backgroundView.centerXAnchor.constraint(equalTo: badgeView.centerXAnchor),
|
||||
backgroundView.centerYAnchor.constraint(equalTo: badgeView.centerYAnchor),
|
||||
backgroundView.widthAnchor.constraint(equalTo: badgeView.widthAnchor, multiplier: 0.5),
|
||||
backgroundView.heightAnchor.constraint(equalTo: badgeView.heightAnchor, multiplier: 0.5)
|
||||
])
|
||||
|
||||
self.deactivateBadge = deactivateBadge
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,21 +64,12 @@ final class InstalledAppsCollectionFooterView: UICollectionReusableView
|
||||
final class NoUpdatesCollectionViewCell: UICollectionViewCell
|
||||
{
|
||||
@IBOutlet var blurView: UIVisualEffectView!
|
||||
@IBOutlet var textLabel: UILabel!
|
||||
@IBOutlet var button: UIButton!
|
||||
|
||||
override func awakeFromNib()
|
||||
{
|
||||
super.awakeFromNib()
|
||||
|
||||
self.contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
let font = self.textLabel.font ?? UIFont.systemFont(ofSize: 17)
|
||||
let configuration = UIImage.SymbolConfiguration(font: font)
|
||||
let image = UIImage(systemName: "ellipsis.circle", withConfiguration: configuration)
|
||||
|
||||
self.button.setTitle("", for: .normal)
|
||||
self.button.setImage(image, for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,19 +21,13 @@ 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 versionDescriptionTitleLabel: UILabel!
|
||||
@IBOutlet var versionDescriptionTextView: CollapsingTextView!
|
||||
|
||||
@IBOutlet private var blurView: UIVisualEffectView!
|
||||
|
||||
@@ -48,7 +42,8 @@ extension UpdateCollectionViewCell
|
||||
self.contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
self.bannerView.backgroundEffectView.isHidden = true
|
||||
|
||||
self.bannerView.button.setTitle(NSLocalizedString("UPDATE", comment: ""), for: .normal)
|
||||
|
||||
self.blurView.layer.cornerRadius = 20
|
||||
self.blurView.layer.masksToBounds = true
|
||||
|
||||
@@ -91,17 +86,6 @@ extension UpdateCollectionViewCell
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -114,6 +98,7 @@ private extension UpdateCollectionViewCell
|
||||
case .expanded: self.versionDescriptionTextView.isCollapsed = false
|
||||
}
|
||||
|
||||
self.versionDescriptionTitleLabel.textColor = self.originalTintColor ?? self.tintColor
|
||||
self.blurView.backgroundColor = self.originalTintColor ?? self.tintColor
|
||||
self.bannerView.button.progressTintColor = self.originalTintColor ?? self.tintColor
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
<?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="15400" 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="15404"/>
|
||||
<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"/>
|
||||
</dependencies>
|
||||
<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,18 +29,45 @@
|
||||
<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">
|
||||
<rect key="frame" x="0.0" y="0.0" width="343" height="50"/>
|
||||
<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="88"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="88" id="EPP-7O-1Ad"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<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"/>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="firstBaseline" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="RSR-5W-7tt" userLabel="Release Notes">
|
||||
<rect key="frame" x="0.0" y="88" width="343" height="37"/>
|
||||
<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">
|
||||
<rect key="frame" x="15" y="0.0" width="313" height="26"/>
|
||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
||||
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="RKU-pY-wmQ">
|
||||
<rect key="frame" x="15" y="0.0" width="65" height="22"/>
|
||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="4GQ-XP-i7X">
|
||||
<rect key="frame" x="0.0" y="0.0" width="65" height="22"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="1000" verticalHuggingPriority="251" text="What's New" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="h1u-nj-qsP">
|
||||
<rect key="frame" x="0.0" y="0.0" width="65" height="22"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="65" id="C7Y-nh-TKJ"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="11"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="h1u-nj-qsP" firstAttribute="leading" secondItem="4GQ-XP-i7X" secondAttribute="leading" id="3cO-Mj-Yua"/>
|
||||
<constraint firstAttribute="trailing" secondItem="h1u-nj-qsP" secondAttribute="trailing" id="Hek-OE-YMc"/>
|
||||
<constraint firstAttribute="bottom" secondItem="h1u-nj-qsP" secondAttribute="bottom" id="bLg-Ut-aEb"/>
|
||||
<constraint firstItem="h1u-nj-qsP" firstAttribute="top" secondItem="4GQ-XP-i7X" secondAttribute="top" id="beL-ob-CQ7"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<vibrancyEffect style="secondaryLabel">
|
||||
<blurEffect style="systemChromeMaterial"/>
|
||||
</vibrancyEffect>
|
||||
</visualEffectView>
|
||||
<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="90" y="0.0" width="238" height="22"/>
|
||||
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="13"/>
|
||||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||
</textView>
|
||||
@@ -82,16 +108,14 @@
|
||||
<outlet property="bannerView" destination="Nop-pL-Icx" id="GiX-K1-5oz"/>
|
||||
<outlet property="blurView" destination="1xN-9h-DFd" id="HBI-nT-xYh"/>
|
||||
<outlet property="versionDescriptionTextView" destination="rNs-2O-k3V" id="4TC-A3-oxb"/>
|
||||
<outlet property="versionDescriptionTitleLabel" destination="h1u-nj-qsP" id="dnz-Yv-BdY"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="618.39999999999998" y="96.251874062968525"/>
|
||||
</collectionViewCell>
|
||||
</objects>
|
||||
<resources>
|
||||
<namedColor name="BlurTint">
|
||||
<color red="1" green="1" blue="1" alpha="0.30000001192092896" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<color red="1" green="1" blue="1" alpha="0.29999999999999999" 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"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user