Compare commits

..

33 Commits

Author SHA1 Message Date
ny
0b0c87d564 If it's gonna be that way... 2024-08-05 21:31:54 -04:00
ny
4a00955461 Add availability check to fix CI build(?) 2024-08-05 21:25:00 -04:00
ny
86f804391a Fix build issues given by develop 2024-08-05 21:15:42 -04:00
June Park
be75d8b4d9 Update AuthenticationOperation.swift
Signed-off-by: June Park <rjp2030@outlook.com>
2024-08-05 20:50:38 -04:00
ny
864e03cd4a Remove Settings bundle, add SwiftUI view instead
Fix refresh all shortcut intent
2024-08-05 20:50:36 -04:00
nythepegasus
be5e84537a Removes unnecessary StoreApp convenience properties as well as fix other issues 2024-08-05 20:49:34 -04:00
nythepegasus
1ae8d336be Removes unnecessary StoreApp convenience properties 2024-08-05 20:49:34 -04:00
nythepegasus
989b8e0010 Improves error message when file does not exist at AppVersion.downloadURL 2024-08-05 20:49:34 -04:00
nythepegasus
983355d356 Verifies min/max OS version before downloading app + asks user to download older app version if necessary 2024-08-05 20:49:34 -04:00
nythepegasus
a6ff416067 Conforms AppVersion to AppProtocol 2024-08-05 20:49:34 -04:00
nythepegasus
bbf712a822 Supports optional @Managed properties 2024-08-05 20:49:34 -04:00
nythepegasus
dd0436511a Supports non-NSManagedObjects for @Managed properties
This allows us to use @Managed with properties that may or may not be NSManagedObjects at runtime (e.g. protocols). If they are, Managed will keep strong reference to context like before.
2024-08-05 20:49:34 -04:00
nythepegasus
4667841c8d Parses AppVersion.minOSVersion/maxOSVersion from source JSON 2024-08-05 20:49:34 -04:00
nythepegasus
d2f0409452 Conforms OperatingSystemVersion to Comparable 2024-08-05 20:49:34 -04:00
Riley Testut
d718d2155f Replaces StoreApp.latestVersion with latestSupportedVersion + latestAvailableVersion
We now store the latest supported version as a relationship on StoreApp, rather than the latest available version. This allows us to reference the latest supported version in predicates and sort descriptors.

However, we kept the underlying Core Data property name the same to avoid extra migration.
2024-08-05 20:49:34 -04:00
nythepegasus
d0b424f408 Skips logging cancelled errors 2024-08-05 20:49:34 -04:00
nythepegasus
3e6ca7c673 Limits quitting other AltStore/SideStore processes to database migrations 2024-08-05 20:49:33 -04:00
nythepegasus
31a3323ef2 Fixes logging non-AltServerErrors as AltServerError.underlyingError 2024-08-05 20:49:33 -04:00
nythepegasus
7edb5716b7 Fix typo 2024-08-05 20:49:33 -04:00
nythepegasus
78bf461c5b Fix wrong color in AuthenticationViewController 2024-08-05 20:49:33 -04:00
nythepegasus
8cfe8a1ac0 Fix Error Log not showing UIAlertController on iOS <=13 2024-08-05 20:49:33 -04:00
nythepegasus
9b7c0387cf Fix Error Log showing UIAlertController on iOS 14+ 2024-08-05 20:49:33 -04:00
nythepegasus
1a36726361 Fixes incorrect Search FAQ URL 2024-08-05 20:49:33 -04:00
nythepegasus
e2a5e263f4 Fixes Error Log context menu appearing while scrolling 2024-08-05 20:49:33 -04:00
nythepegasus
16d13c430c Fixes Error Log context menu covering cell content 2024-08-05 20:49:33 -04:00
nythepegasus
b024e67fee Opens Error Log when tapping ToastView 2024-08-05 20:49:33 -04:00
nythepegasus
4365ba0f1a [skip ci] Update the no wifi message to include VPN 2024-08-05 20:49:31 -04:00
nythepegasus
36ceec3ae7 Fix minimuxer status checking 2024-08-05 20:48:16 -04:00
nythepegasus
99652aae65 Include "Enable JIT" errors in Error Log 2024-08-05 20:47:02 -04:00
nythepegasus
6c8a400aec Fix format strings I broke 2024-08-05 20:47:00 -04:00
nythepegasus
c24de874e6 Finish Riley's monster commit
3b38d725d7
May the Gods have mercy on my soul.
2024-08-05 20:46:08 -04:00
nythepegasus
775167415a Adds ResultOperation.localizedFailure 2024-08-05 20:32:13 -04:00
nythepegasus
459e378522 Change error from Swift.Error to NSError 2024-08-05 20:32:13 -04:00
369 changed files with 6355 additions and 31738 deletions

View File

@@ -7,7 +7,6 @@ body:
- type: markdown
attributes:
value: |
## Please note that the issue tracker is not for support
Thanks for taking the time to fill out this bug report! Before you continue filling out the report, please **[search in GitHub Issues](https://github.com/SideStore/SideStore/issues?q=is%3Aissue+is%3Aopen) for the bug you are experiencing** in case it has already been reported.
**Please use [Discord](https://discord.gg/sidestore-949183273383395328) or [GitHub Discussions](https://github.com/SideStore/SideStore/discussions) for support.**

View File

@@ -10,3 +10,6 @@
<!-- Example: -->
- [x] Finish UI changes
- [ ] Test
<!-- If your PR doesn't close an issue, you can remove the next line. -->
Closes #1234

View File

@@ -1,284 +0,0 @@
name: Alpha SideStore build
on:
push:
branches:
# - alpha
- rebase-2.0-wip
jobs:
build:
name: Build and upload SideStore Alpha releases
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
strategy:
fail-fast: false
matrix:
include:
- os: 'macos-14'
version: '16.1'
runs-on: ${{ matrix.os }}
steps:
- name: Set current build as ALPHA
run: echo "IS_ALPHA=1" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Install dependencies
run: brew install ldid
- name: Install xcbeautify
run: brew install xcbeautify
- name: Cache .alpha-build-num
uses: actions/cache@v4
with:
path: .alpha-build-num
key: alpha-build-num
- name: Get version
id: version-marketing
run: echo "VERSION_IPA=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_ENV
- name: Increase alpha build number and set as version
run: bash .github/workflows/increase-alpha-build-num.sh
- name: Get version
id: version
run: echo "version=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_OUTPUT
- name: Echo version
run: echo "${{ steps.version.outputs.version }}"
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1.6.0
with:
xcode-version: ${{ matrix.version }}
- name: Cache Build
uses: irgaly/xcode-cache@v1
with:
key: xcode-cache-deriveddata-${{ github.sha }}
restore-keys: xcode-cache-deriveddata-
swiftpm-cache-key: xcode-cache-sourcedata-${{ github.sha }}
swiftpm-cache-restore-keys: |
xcode-cache-sourcedata-
- name: Restore Pods from Cache (Exact match)
id: pods-restore
uses: actions/cache/restore@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-${{ hashFiles('Podfile') }}
# restore-keys: | # commented out to strictly check cache for this particular podfile
# pods-cache-
- name: Restore Pods from Cache (Last Available)
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
id: pods-restore-recent
uses: actions/cache/restore@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-
- name: Install CocoaPods
# if: ${{ steps.pods-restore.outputs.cache-hit != 'true'}}
id: pods-install
run: |
pod install
- name: Save Pods to Cache
id: save-pods
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-${{ hashFiles('Podfile') }}
- name: List Files and derived data
run: |
echo ">>>>>>>>> Workdir <<<<<<<<<<"
ls -la .
echo ""
echo ">>>>>>>>> Pods <<<<<<<<<<"
find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> SideStore <<<<<<<<<<"
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
echo ""
- name: Build SideStore
run: NSUnbufferedIO=YES make build 2>&1 | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
- name: Fakesign app
run: make fakesign
- name: Convert to IPA
run: make ipa
- name: Get current date
id: date
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
- name: Get current date in AltStore date form
id: date_altstore
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Create dSYMs zip
run: zip -r -9 ./SideStore.dSYMs.zip ./SideStore.xcarchive/dSYMs/*
- name: Upload to alpha release
uses: IsaacShelton/update-existing-release@v1.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
release: "Alpha"
tag: "alpha"
prerelease: true
files: SideStore.ipa SideStore.dSYMs.zip
body: |
This is an ⚠️ **EXPERIMENTAL** ⚠️ alpha build for commit [${{ github.sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}).
Alpha builds are **extremely experimental builds only meant to be used by developers and alpha testers. They often contain bugs and experimental features. Use at your own risk!**
If you want to try out new features early but want a lower chance of bugs, you can look at [SideStore Stable](https://github.com/${{ github.repository }}/releases?q=stable).
## Build Info
Built at (UTC): `${{ steps.date.outputs.date }}`
Built at (UTC date): `${{ steps.date_altstore.outputs.date }}`
Commit SHA: `${{ github.sha }}`
Version: `${{ steps.version.outputs.version }}`
- name: Add version to IPA file name
run: mv SideStore.ipa SideStore-${{ steps.version.outputs.version }}.ipa
- name: Upload SideStore.ipa Artifact
uses: actions/upload-artifact@v4
with:
name: SideStore-${{ steps.version.outputs.version }}.ipa
path: SideStore-${{ steps.version.outputs.version }}.ipa
- name: Upload *.dSYM Artifact
uses: actions/upload-artifact@v4
with:
name: SideStore-${{ steps.version.outputs.version }}-dSYM
path: ./SideStore.xcarchive/dSYMs/*
# Check if PUBLISH_ALPHA_UPDATES secret is set to non-zero
- name: Check if PUBLISH_ALPHA_UPDATES is set
id: check_publish
run: |
if [[ "${{ secrets.PUBLISH_ALPHA_UPDATES }}" != "__YES__" ]]; then
echo "PUBLISH_ALPHA_UPDATES is not set. Skipping deployment."
exit 1 # Exit with 1 to indicate no deployment
else
echo "PUBLISH_ALPHA_UPDATES is set. Proceeding with deployment."
exit 0 # Exit with 0 to indicate deployment should proceed
fi
continue-on-error: true # Continue even if exit code is 1
- name: Get short commit hash
if: ${{ steps.check_publish.outcome == 'success' }}
run: |
# SHORT_COMMIT="${{ github.sha }}"
SHORT_COMMIT=${GITHUB_SHA:0:7}
echo "Short commit hash: $SHORT_COMMIT"
echo "SHORT_COMMIT=$SHORT_COMMIT" >> $GITHUB_ENV
- name: Get formatted date
if: ${{ steps.check_publish.outcome == 'success' }}
run: |
FORMATTED_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "Formatted date: $FORMATTED_DATE"
echo "FORMATTED_DATE=$FORMATTED_DATE" >> $GITHUB_ENV
- name: Get size of IPA in bytes (macOS/Linux)
if: ${{ steps.check_publish.outcome == 'success' }}
run: |
if [[ "$(uname)" == "Darwin" ]]; then
# macOS
IPA_SIZE=$(stat -f %z SideStore-${{ steps.version.outputs.version }}.ipa)
else
# Linux
IPA_SIZE=$(stat -c %s SideStore-${{ steps.version.outputs.version }}.ipa)
fi
echo "IPA size in bytes: $IPA_SIZE"
echo "IPA_SIZE=$IPA_SIZE" >> $GITHUB_ENV
- name: Compute SHA-256 of IPA
if: ${{ steps.check_publish.outcome == 'success' }}
run: |
SHA256_HASH=$(shasum -a 256 SideStore-${{ steps.version.outputs.version }}.ipa | awk '{ print $1 }')
echo "SHA-256 Hash: $SHA256_HASH"
echo "SHA256_HASH=$SHA256_HASH" >> $GITHUB_ENV
- name: Set environment variables dynamically
if: ${{ steps.check_publish.outcome == 'success' }}
run: |
echo "VERSION_IPA=$VERSION_IPA" >> $GITHUB_ENV
echo "VERSION_DATE=$FORMATTED_DATE" >> $GITHUB_ENV
echo "BETA=true" >> $GITHUB_ENV
echo "COMMIT_ID=$SHORT_COMMIT" >> $GITHUB_ENV
echo "SIZE=$IPA_SIZE" >> $GITHUB_ENV
echo "SHA256=$SHA256_HASH" >> $GITHUB_ENV
echo "LOCALIZED_DESCRIPTION=This is alpha release for revision: ${{ github.sha }}" >> $GITHUB_ENV
echo "DOWNLOAD_URL=https://github.com/SideStore/SideStore/releases/download/alpha/SideStore.ipa" >> $GITHUB_ENV
- name: Checkout SideStore/apps-v2.json
if: ${{ steps.check_publish.outcome == 'success' }}
uses: actions/checkout@v4
with:
# Repository name with owner. For example, actions/checkout
# Default: ${{ github.repository }}
repository: 'SideStore/apps-v2.json'
ref: 'main'
# token: ${{ github.token }}
token: ${{ secrets.APPS_DEPLOY_KEY }}
path: 'SideStore/apps-v2.json'
- name: Publish to SideStore/apps-v2.json
if: ${{ steps.check_publish.outcome == 'success' }}
run: |
# Copy and execute the update script
pushd SideStore/apps-v2.json/
# Configure Git user (committer details)
git config user.name "GitHub Actions"
git config user.email "github-actions@github.com"
# Make the update script executable and run it
python3 ../../update_apps.py "./_includes/source.json"
# Commit changes and push using SSH
git add ./_includes/source.json
git commit -m " - updated for $SHORT_COMMIT deployment" || echo "No changes to commit"
git status
git push origin HEAD:main
popd

View File

@@ -20,58 +20,3 @@ jobs:
format: name
addTo: pull
# addTo: pullandissues
nightly-link-comment:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v6
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
script: |
async function upsertComment(owner, repo, issue_number, purpose, body) {
const {data: comments} = await github.rest.issues.listComments(
{owner, repo, issue_number});
const marker = `<!-- bot: ${purpose} -->`;
body = marker + "\n" + body;
const existing = comments.filter((c) => c.body.includes(marker));
if (existing.length > 0) {
const last = existing[existing.length - 1];
core.info(`Updating comment ${last.id}`);
await github.rest.issues.updateComment({
owner, repo,
body,
comment_id: last.id,
});
} else {
core.info(`Creating a comment in issue / PR #${issue_number}`);
await github.rest.issues.createComment({issue_number, body, owner, repo});
}
}
const {owner, repo} = context.repo;
const run_id = ${{github.event.workflow_run.id}};
const pull_requests = ${{ toJSON(github.event.workflow_run.pull_requests) }};
if (!pull_requests.length) {
return core.error("This workflow doesn't match any pull requests!");
}
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts, {owner, repo, run_id});
if (!artifacts.length) {
return core.error(`No artifacts found`);
}
let body = `Download the artifacts for this pull request (nightly.link):\n`;
for (const art of artifacts) {
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
core.info("Review thread message body:", body);
for (const pr of pull_requests) {
await upsertComment(owner, repo, pr.number,
"nightly-link", body);
}

View File

@@ -11,13 +11,13 @@ jobs:
fail-fast: false
matrix:
include:
- os: 'macos-14'
version: '15.4'
- os: 'macos-12'
version: '14.2'
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
submodules: recursive
@@ -35,17 +35,10 @@ jobs:
run: echo "${{ steps.version.outputs.version }}"
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
uses: maxim-lobanov/setup-xcode@v1.4.1
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
- name: Build SideStore
run: make build | xcpretty && exit ${PIPESTATUS[0]}
@@ -91,13 +84,13 @@ jobs:
run: mv SideStore.ipa SideStore-${{ steps.version.outputs.version }}.ipa
- name: Upload SideStore.ipa Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3.1.0
with:
name: SideStore-${{ steps.version.outputs.version }}.ipa
path: SideStore-${{ steps.version.outputs.version }}.ipa
- name: Upload *.dSYM Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3.1.0
with:
name: SideStore-${{ steps.version.outputs.version }}-dSYM
path: ./*.dSYM/

View File

@@ -1,28 +0,0 @@
#!/usr/bin/env bash
# Ensure we are in root directory
cd "$(dirname "$0")/../.."
DATE=`date -u +'%Y.%m.%d'`
BUILD_NUM=1
write() {
sed -e "/MARKETING_VERSION = .*/s/$/-alpha.$DATE.$BUILD_NUM+$(git rev-parse --short HEAD)/" -i '' Build.xcconfig
echo "$DATE,$BUILD_NUM" > .alpha-build-num
}
if [ ! -f ".alpha-build-num" ]; then
write
exit 0
fi
LAST_DATE=`cat .alpha-build-num | perl -n -e '/([^,]*),([^ ]*)$/ && print $1'`
LAST_BUILD_NUM=`cat .alpha-build-num | perl -n -e '/([^,]*),([^ ]*)$/ && print $2'`
if [[ "$DATE" != "$LAST_DATE" ]]; then
write
else
BUILD_NUM=`expr $LAST_BUILD_NUM + 1`
write
fi

View File

@@ -6,7 +6,7 @@ on:
jobs:
build:
name: Build and upload SideStore Nightly releases
name: Build and upload SideStore Nightly
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
@@ -14,36 +14,25 @@ jobs:
fail-fast: false
matrix:
include:
- os: 'macos-14'
version: '16.1'
- os: 'macos-12'
version: '14.2'
runs-on: ${{ matrix.os }}
steps:
- name: Set current build as BETA
run: echo "IS_BETA=1" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
submodules: recursive
- name: Install dependencies
run: brew install ldid
- name: Install xcbeautify
run: brew install xcbeautify
- name: Cache .nightly-build-num
uses: actions/cache@v4
uses: actions/cache@v3
with:
path: .nightly-build-num
key: nightly-build-num
- name: Get version
id: version-marketing
run: echo "VERSION_IPA=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_ENV
- name: Increase nightly build number and set as version
run: bash .github/workflows/increase-nightly-build-num.sh
@@ -55,85 +44,12 @@ jobs:
run: echo "${{ steps.version.outputs.version }}"
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1.6.0
uses: maxim-lobanov/setup-xcode@v1.4.1
with:
xcode-version: ${{ matrix.version }}
- name: Cache Build
uses: irgaly/xcode-cache@v1
with:
key: xcode-cache-deriveddata-${{ github.sha }}
restore-keys: xcode-cache-deriveddata-
swiftpm-cache-key: xcode-cache-sourcedata-${{ github.sha }}
swiftpm-cache-restore-keys: |
xcode-cache-sourcedata-
- name: Restore Pods from Cache (Exact match)
id: pods-restore
uses: actions/cache/restore@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-${{ hashFiles('Podfile') }}
# restore-keys: | # commented out to strictly check cache for this particular podfile
# pods-cache-
- name: Restore Pods from Cache (Last Available)
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
id: pods-restore-recent
uses: actions/cache/restore@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-
- name: Install CocoaPods
# if: ${{ steps.pods-restore.outputs.cache-hit != 'true'}}
id: pods-install
run: |
pod install
- name: Save Pods to Cache
id: save-pods
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-${{ hashFiles('Podfile') }}
- name: List Files and derived data
run: |
echo ">>>>>>>>> Workdir <<<<<<<<<<"
ls -la .
echo ""
echo ">>>>>>>>> Pods <<<<<<<<<<"
find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> SideStore <<<<<<<<<<"
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
echo ""
- name: Build SideStore
run: NSUnbufferedIO=YES make build 2>&1 | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
run: make build | xcpretty && exit ${PIPESTATUS[0]}
- name: Fakesign app
run: make fakesign
@@ -149,9 +65,6 @@ jobs:
id: date_altstore
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Create dSYMs zip
run: zip -r -9 ./SideStore.dSYMs.zip ./SideStore.xcarchive/dSYMs/*
- name: Upload to nightly release
uses: IsaacShelton/update-existing-release@v1.3.1
with:
@@ -159,127 +72,35 @@ jobs:
release: "Nightly"
tag: "nightly"
prerelease: true
files: SideStore.ipa SideStore.dSYMs.zip
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 beta testers. They often contain bugs and experimental features. Use at your own risk!**
If you want to try out new features early but want a lower chance of bugs, you can look at [SideStore Stable](https://github.com/${{ github.repository }}/releases?q=stable).
## Build Info
Built at (UTC): `${{ steps.date.outputs.date }}`
Built at (UTC date): `${{ steps.date_altstore.outputs.date }}`
Commit SHA: `${{ github.sha }}`
Version: `${{ steps.version.outputs.version }}`
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
uses: actions/upload-artifact@v3.1.0
with:
name: SideStore-${{ steps.version.outputs.version }}.ipa
path: SideStore-${{ steps.version.outputs.version }}.ipa
- name: Upload *.dSYM Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3.1.0
with:
name: SideStore-${{ steps.version.outputs.version }}-dSYM
path: ./SideStore.xcarchive/dSYMs/*
path: ./*.dSYM/
# Check if PUBLISH_BETA_UPDATES secret is set to non-zero
- name: Check if PUBLISH_BETA_UPDATES is set
id: check_publish
run: |
if [[ "${{ secrets.PUBLISH_BETA_UPDATES }}" != "__YES__" ]]; then
echo "PUBLISH_BETA_UPDATES is not set. Skipping deployment."
exit 1 # Exit with 1 to indicate no deployment
else
echo "PUBLISH_BETA_UPDATES is set. Proceeding with deployment."
exit 0 # Exit with 0 to indicate deployment should proceed
fi
continue-on-error: true # Continue even if exit code is 1
- name: Get short commit hash
if: ${{ steps.check_publish.outcome == 'success' }}
run: |
# SHORT_COMMIT="${{ github.sha }}"
SHORT_COMMIT=${GITHUB_SHA:0:7}
echo "Short commit hash: $SHORT_COMMIT"
echo "SHORT_COMMIT=$SHORT_COMMIT" >> $GITHUB_ENV
- name: Get formatted date
if: ${{ steps.check_publish.outcome == 'success' }}
run: |
FORMATTED_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "Formatted date: $FORMATTED_DATE"
echo "FORMATTED_DATE=$FORMATTED_DATE" >> $GITHUB_ENV
- name: Get size of IPA in bytes (macOS/Linux)
if: ${{ steps.check_publish.outcome == 'success' }}
run: |
if [[ "$(uname)" == "Darwin" ]]; then
# macOS
IPA_SIZE=$(stat -f %z SideStore-${{ steps.version.outputs.version }}.ipa)
else
# Linux
IPA_SIZE=$(stat -c %s SideStore-${{ steps.version.outputs.version }}.ipa)
fi
echo "IPA size in bytes: $IPA_SIZE"
echo "IPA_SIZE=$IPA_SIZE" >> $GITHUB_ENV
- name: Compute SHA-256 of IPA
if: ${{ steps.check_publish.outcome == 'success' }}
run: |
SHA256_HASH=$(shasum -a 256 SideStore-${{ steps.version.outputs.version }}.ipa | awk '{ print $1 }')
echo "SHA-256 Hash: $SHA256_HASH"
echo "SHA256_HASH=$SHA256_HASH" >> $GITHUB_ENV
- name: Set environment variables dynamically
if: ${{ steps.check_publish.outcome == 'success' }}
run: |
echo "VERSION_IPA=$VERSION_IPA" >> $GITHUB_ENV
echo "VERSION_DATE=$FORMATTED_DATE" >> $GITHUB_ENV
echo "BETA=true" >> $GITHUB_ENV
echo "COMMIT_ID=$SHORT_COMMIT" >> $GITHUB_ENV
echo "SIZE=$IPA_SIZE" >> $GITHUB_ENV
echo "SHA256=$SHA256_HASH" >> $GITHUB_ENV
echo "LOCALIZED_DESCRIPTION=This is nightly release for revision: ${{ github.sha }}" >> $GITHUB_ENV
echo "DOWNLOAD_URL=https://github.com/SideStore/SideStore/releases/download/nightly/SideStore.ipa" >> $GITHUB_ENV
# - name: Checkout SideStore/apps-v2.json
# if: ${{ steps.check_publish.outcome == 'success' }}
# uses: actions/checkout@v4
# with:
# # Repository name with owner. For example, actions/checkout
# # Default: ${{ github.repository }}
# repository: 'SideStore/apps-v2.json'
# # ref: 'main' # TODO: use branches for alpha and beta tracks? so as to avoid push collision?
# ref: 'nightly' # TODO: use branches for alpha and beta tracks? so as to avoid push collision?
# # token: ${{ github.token }}
# token: ${{ secrets.APPS_DEPLOY_KEY }}
# path: 'SideStore/apps-v2.json'
# - name: Publish to SideStore/apps-v2.json
# if: ${{ steps.check_publish.outcome == 'success' }}
# run: |
# # Copy and execute the update script
# pushd SideStore/apps-v2.json/
# # Configure Git user (committer details)
# git config user.name "GitHub Actions"
# git config user.email "github-actions@github.com"
# # Make the update script executable and run it
# python3 ../../update_apps.py "./_includes/source.json"
# # Commit changes and push using SSH
# git add ./_includes/source.json
# git commit -m " - updated for $SHORT_COMMIT deployment" || echo "No changes to commit"
# git status
# # git push origin HEAD:main
# git push origin HEAD:nightly
# popd
- name: Reset cache for apps.sidestore.io/nightly
run: sleep 10 && curl https://apps.sidestore.io/reset-cache/nightly/${{ secrets.SIDESOURCE_KEY }}

View File

@@ -9,22 +9,19 @@ jobs:
fail-fast: false
matrix:
include:
- os: 'macos-14'
version: '16.1'
- os: 'macos-12'
version: '14.2'
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
submodules: recursive
- 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:
@@ -38,85 +35,12 @@ jobs:
run: echo "${{ steps.version.outputs.version }}"
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1.6.0
uses: maxim-lobanov/setup-xcode@v1.4.1
with:
xcode-version: ${{ matrix.version }}
- name: Cache Build
uses: irgaly/xcode-cache@v1
with:
key: xcode-cache-deriveddata-${{ github.sha }}
restore-keys: xcode-cache-deriveddata-
swiftpm-cache-key: xcode-cache-sourcedata-${{ github.sha }}
swiftpm-cache-restore-keys: |
xcode-cache-sourcedata-
- name: Restore Pods from Cache (Exact match)
id: pods-restore
uses: actions/cache/restore@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-${{ hashFiles('Podfile') }}
# restore-keys: | # commented out to strictly check cache for this particular podfile
# pods-cache-
- name: Restore Pods from Cache (Last Available)
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
id: pods-restore-recent
uses: actions/cache/restore@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-
- name: Install CocoaPods
# if: ${{ steps.pods-restore.outputs.cache-hit != 'true'}}
id: pods-install
run: |
pod install
- name: Save Pods to Cache
id: save-pods
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-${{ hashFiles('Podfile') }}
- name: List Files and derived data
run: |
echo ">>>>>>>>> Workdir <<<<<<<<<<"
ls -la .
echo ""
echo ">>>>>>>>> Pods <<<<<<<<<<"
find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> SideStore <<<<<<<<<<"
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
echo ""
- name: Build SideStore
run: NSUnbufferedIO=YES make build 2>&1 | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
run: make build | xcpretty && exit ${PIPESTATUS[0]}
- name: Fakesign app
run: make fakesign
@@ -128,13 +52,13 @@ jobs:
run: mv SideStore.ipa SideStore-${{ steps.version.outputs.version }}.ipa
- name: Upload SideStore.ipa Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3.1.0
with:
name: SideStore-${{ steps.version.outputs.version }}.ipa
path: SideStore-${{ steps.version.outputs.version }}.ipa
- name: Upload *.dSYM Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3.1.0
with:
name: SideStore-${{ steps.version.outputs.version }}-dSYM
path: ./SideStore.xcarchive/dSYMs/*
path: ./*.dSYM/

View File

@@ -3,7 +3,6 @@ on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+' # example: 1.0.0
workflow_dispatch:
jobs:
build:
@@ -12,13 +11,13 @@ jobs:
fail-fast: false
matrix:
include:
- os: 'macos-14'
version: '15.4'
- os: 'macos-12'
version: '14.2'
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
submodules: recursive
@@ -36,21 +35,12 @@ jobs:
run: echo "${{ steps.version.outputs.version }}"
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1.6.0
uses: maxim-lobanov/setup-xcode@v1.4.1
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 | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
run: make build | xcpretty && exit ${PIPESTATUS[0]}
- name: Fakesign app
run: make fakesign
@@ -91,13 +81,13 @@ jobs:
run: mv SideStore.ipa SideStore-${{ steps.version.outputs.version }}.ipa
- name: Upload SideStore.ipa Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3.1.0
with:
name: SideStore-${{ steps.version.outputs.version }}.ipa
path: SideStore-${{ steps.version.outputs.version }}.ipa
- name: Upload *.dSYM Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3.1.0
with:
name: SideStore-${{ steps.version.outputs.version }}-dSYM
path: ./*.dSYM/

30
.gitignore vendored
View File

@@ -5,14 +5,10 @@
# Xcode
#
## CocoaPods
Pods/
## Build generated
build/
DerivedData
SideStore.xcarchive
archive.xcarchive
## Various settings
*.pbxuser
!default.pbxuser
@@ -40,27 +36,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

77
.gitmodules vendored
View File

@@ -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/libimobiledevice/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
View File

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

View File

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

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

View File

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

View File

@@ -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,17 +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>BuildRevision</key>
<string>$(BUILD_REVISION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>

View File

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

View File

@@ -82,25 +82,23 @@ class ViewController: UIViewController
self.activityIndicatorView.color = .altstoreText
self.activityIndicatorView.startAnimating()
// TODO: @mahee96: Disabled this buttons 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).

View 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

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

View 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
View 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)
}
}
}

View 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))
}
}
}
}

View 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
View 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()
}

View 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

View File

@@ -0,0 +1,2 @@
#!/bin/sh
launchctl load /Library/LaunchDaemons/com.rileytestut.altdaemon.plist

View 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
View File

@@ -0,0 +1,2 @@
#!/bin/sh
launchctl unload /Library/LaunchDaemons/com.rileytestut.altdaemon.plist

View File

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

Binary file not shown.

View File

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

View File

@@ -0,0 +1,111 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1150"
version = "1.3">
<BuildAction
parallelizeBuildables = "NO"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A7A6DC28A6D60809855FE404C6A3EA29"
BuildableName = "libPods-AltDaemon.a"
BlueprintName = "Pods-AltDaemon"
ReferencedContainer = "container:Pods/Pods.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF1E314F22A0616100370A3C"
BuildableName = "libAltKit.a"
BlueprintName = "AltKit"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF18BFE624857D7900DD5981"
BuildableName = "AltDaemon"
BlueprintName = "AltDaemon"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF18BFE624857D7900DD5981"
BuildableName = "AltDaemon"
BlueprintName = "AltDaemon"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</MacroExpansion>
<EnvironmentVariables>
<EnvironmentVariable
key = "THEOS"
value = "~/theos"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF18BFE624857D7900DD5981"
BuildableName = "AltDaemon"
BlueprintName = "AltDaemon"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1120"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF5C5FC4237DF5AE00EDD0C6"
BuildableName = "AltPlugin.mailbundle"
BlueprintName = "AltPlugin"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "1"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF5C5FC4237DF5AE00EDD0C6"
BuildableName = "AltPlugin.mailbundle"
BlueprintName = "AltPlugin"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -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"
@@ -15,9 +14,9 @@
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF58047A246A28F7008AE704"
BuildableName = "AltBackup.app"
BlueprintName = "AltBackup"
BlueprintIdentifier = "BF45868C229872EA00BD7491"
BuildableName = "AltServer.app"
BlueprintName = "AltServer"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
@@ -27,8 +26,20 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF45868C229872EA00BD7491"
BuildableName = "AltServer.app"
BlueprintName = "AltServer"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</MacroExpansion>
<AdditionalOptions>
</AdditionalOptions>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -44,42 +55,14 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF58047A246A28F7008AE704"
BuildableName = "AltBackup.app"
BlueprintName = "AltBackup"
BlueprintIdentifier = "BF45868C229872EA00BD7491"
BuildableName = "AltServer.app"
BlueprintName = "AltServer"
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>
<AdditionalOptions>
</AdditionalOptions>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -91,9 +74,9 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF58047A246A28F7008AE704"
BuildableName = "AltBackup.app"
BlueprintName = "AltBackup"
BlueprintIdentifier = "BF45868C229872EA00BD7491"
BuildableName = "AltServer.app"
BlueprintName = "AltServer"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>

View File

@@ -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,33 +53,9 @@
<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"

View File

@@ -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,19 +27,7 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<!-- shouldAutocreateTestPlan = "YES"> -->
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D586D39728EF58B0000E101F"
BuildableName = "AltTests.xctest"
BlueprintName = "AltTests"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
@@ -66,33 +53,9 @@
<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"

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1230"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BFF7C903257844C900E55F36"
BuildableName = "AltXPC.xpc"
BlueprintName = "AltXPC"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</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">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF45868C229872EA00BD7491"
BuildableName = "AltServer.app"
BlueprintName = "AltServer"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BFF7C903257844C900E55F36"
BuildableName = "AltXPC.xpc"
BlueprintName = "AltXPC"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,16 +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>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -2,14 +2,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.developer.kernel.extended-virtual-addressing</key>
<true/>
<key>com.apple.developer.kernel.increased-debugging-memory-limit</key>
<true/>
<key>com.apple.developer.kernel.increased-memory-limit</key>
<true/>
<key>aps-environment</key>
<string>development</string>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.siri</key>
<true/>
<key>com.apple.security.application-groups</key>

View File

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

View File

@@ -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
@@ -49,17 +51,32 @@ final class AppContentViewController: UITableViewController
@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()
{
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
self.descriptionTextView.text = self.app.localizedDescription
@@ -67,8 +84,8 @@ final class AppContentViewController: UITableViewController
if let version = self.app.latestAvailableVersion
{
self.versionDescriptionTextView.text = version.localizedDescription
self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), version.localizedVersion)
self.versionDateLabel.text = Date().relativeDateString(since: version.date)
self.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
@@ -90,67 +107,88 @@ final class AppContentViewController: UITableViewController
{
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
}
}
@@ -186,15 +224,23 @@ extension AppContentViewController
switch Row.allCases[indexPath.row]
{
case .screenshots:
guard !self.app.screenshots.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
}
}

View File

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

View File

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

View File

@@ -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()
@@ -82,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.
@@ -96,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
@@ -125,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)
@@ -147,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)
@@ -188,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
}
}
@@ -199,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.
@@ -207,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
@@ -280,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()
}
@@ -355,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
@@ -385,39 +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
{
// 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
{
@@ -428,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()
@@ -500,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
}
@@ -515,8 +466,6 @@ private extension AppViewController
func resetNavigationBarAnimation()
{
guard self.navigationBarAnimator != nil else { return }
self.navigationBarAnimator?.stopAnimation(true)
self.navigationBarAnimator = nil
@@ -537,14 +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
{
self.updateApp(installedApp, to: latestVersion)
}
else
{
self.open(installedApp)
}
self.open(installedApp)
}
else
{
@@ -556,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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: 260)
// 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

View File

@@ -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")
@@ -38,19 +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 }
}
@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
{
// 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()
DatabaseManager.shared.start { (error) in
if let error = error
@@ -66,11 +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?
// start_em_proxy(bind_addr: Consts.Proxy.serverURL)
SecureValueTransformer.register()
if UserDefaults.standard.firstLaunch == nil
@@ -81,9 +88,9 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
UserDefaults.standard.preferredServerID = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.serverID) as? String
// #if DEBUG || BETA
#if DEBUG || BETA
UserDefaults.standard.isDebugModeEnabled = true
// #endif
#endif
self.prepareForBackgroundFetch()
@@ -93,8 +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?
// stop_em_proxy()
guard let oneMonthAgo = Calendar.current.date(byAdding: .month, value: -1, to: Date()) else { return }
let midnightOneMonthAgo = Calendar.current.startOfDay(for: oneMonthAgo)
@@ -112,8 +118,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
{
AppManager.shared.update()
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
PatreonAPI.shared.refreshPatreonAccount()
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) -> Bool
@@ -123,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
@@ -132,6 +138,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
}
}
@available(iOS 13, *)
extension AppDelegate
{
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration
@@ -156,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
@@ -264,7 +244,7 @@ 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
}
@@ -378,12 +358,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
@@ -395,7 +373,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)
@@ -403,23 +381,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)

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="32700.99.1234" 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="20037" 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="22684"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
<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"/>
@@ -13,7 +13,7 @@
<scene sceneID="lNR-II-WoW">
<objects>
<navigationController storyboardIdentifier="navigationController" id="ZTo-53-dSL" sceneMemberID="viewController">
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" barStyle="black" largeTitles="YES" id="Aej-RF-PfV" customClass="NavigationBar" customModule="SideStore" customModuleProvider="target">
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" barStyle="black" largeTitles="YES" id="Aej-RF-PfV" customClass="NavigationBar" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="96"/>
<autoresizingMask key="autoresizingMask"/>
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
@@ -36,7 +36,7 @@
<!--Authentication View Controller-->
<scene sceneID="OCd-xc-Ms7">
<objects>
<viewController storyboardIdentifier="authenticationViewController" id="yO1-iT-7NP" customClass="AuthenticationViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<viewController storyboardIdentifier="authenticationViewController" id="yO1-iT-7NP" customClass="AuthenticationViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="mjy-4S-hyH">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
@@ -57,13 +57,13 @@
<rect key="frame" x="0.0" y="0.0" width="343" height="67.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Welcome to SideStore." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="EI2-V3-zQZ">
<rect key="frame" x="0.0" y="0.0" width="333.5" height="41"/>
<rect key="frame" x="0.0" y="0.0" width="332" height="41"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="34"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Sign in with your Apple ID to get started." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="SNU-tv-8Au">
<rect key="frame" x="0.0" y="47" width="308.5" height="20.5"/>
<rect key="frame" x="0.0" y="47" width="306.5" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
@@ -160,7 +160,7 @@
</stackView>
</subviews>
</stackView>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="2N5-zd-fUj">
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="2N5-zd-fUj">
<rect key="frame" x="0.0" y="191" width="343" height="51"/>
<color key="backgroundColor" name="SettingsHighlighted"/>
<constraints>
@@ -258,7 +258,7 @@
<!--How it works-->
<scene sceneID="dMt-EA-SGy">
<objects>
<viewController storyboardIdentifier="instructionsViewController" hidesBottomBarWhenPushed="YES" id="aFi-fb-W0B" customClass="InstructionsViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<viewController storyboardIdentifier="instructionsViewController" hidesBottomBarWhenPushed="YES" id="aFi-fb-W0B" customClass="InstructionsViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" id="Otz-hn-WGS">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
@@ -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="15.5" width="264" height="64"/>
<rect key="frame" x="79" y="17" width="264" height="61.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"/>
@@ -319,7 +319,7 @@
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Enable SideStore VPN in Wireguard and be able to use Sidestore on the go." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="4rk-ge-FSj">
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
<rect key="frame" x="0.0" y="25.5" width="264" height="36"/>
<fontDescription key="fontDescription" type="system" pointSize="16"/>
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
@@ -372,7 +372,7 @@
<nil key="highlightedColor"/>
</label>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="Xs6-pJ-PUz">
<rect key="frame" x="79" y="16" width="264" height="64"/>
<rect key="frame" x="79" y="17" width="264" height="62"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Apps Refresh Automatically" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="nvb-Aq-sYa">
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
@@ -381,7 +381,7 @@
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Apps are refreshed in the background while you are on SideStore VPN!" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="HU5-Hv-E3d">
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
<rect key="frame" x="0.0" y="25.5" width="264" height="36.5"/>
<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>
@@ -434,7 +434,7 @@
<!--Refresh AltStore-->
<scene sceneID="9Vh-dM-OqX">
<objects>
<viewController storyboardIdentifier="refreshAltStoreViewController" id="aoK-yE-UVT" customClass="RefreshAltStoreViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<viewController storyboardIdentifier="refreshAltStoreViewController" id="aoK-yE-UVT" customClass="RefreshAltStoreViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" id="R83-kV-365">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
@@ -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="AltStore" 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">
@@ -493,12 +493,12 @@
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Chr-7g-qEw" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="3025" y="734"/>
<point key="canvasLocation" x="2967" y="736"/>
</scene>
<!--Select a Team-->
<scene sceneID="ioQ-WB-CLJ">
<objects>
<viewController storyboardIdentifier="selectTeamViewController" hidesBottomBarWhenPushed="YES" id="kOD-4P-a6L" customClass="SelectTeamViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<viewController storyboardIdentifier="selectTeamViewController" hidesBottomBarWhenPushed="YES" id="kOD-4P-a6L" customClass="SelectTeamViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" indicatorStyle="white" dataMode="prototypes" style="grouped" separatorStyle="none" rowHeight="60" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="fWW-kX-ifH">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
@@ -506,11 +506,11 @@
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="separatorColor" white="1" alpha="0.25" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" reuseIdentifier="TeamCell" textLabel="6ip-34-gmM" detailTextLabel="knk-Wf-PKf" style="IBUITableViewCellStyleSubtitle" id="qeQ-eb-2SC" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" reuseIdentifier="TeamCell" textLabel="6ip-34-gmM" detailTextLabel="knk-Wf-PKf" style="IBUITableViewCellStyleSubtitle" id="qeQ-eb-2SC" customClass="InsetGroupTableViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="55.5" width="375" height="60"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="qeQ-eb-2SC" id="bT4-Fc-u6I">
<rect key="frame" x="0.0" y="0.0" width="334.5" height="60"/>
<rect key="frame" x="0.0" y="0.0" width="334" height="60"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Team 1" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="6ip-34-gmM">
@@ -550,19 +550,20 @@
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="yH5-jU-aez" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2114" y="734"/>
<point key="canvasLocation" x="1401" y="734"/>
</scene>
</scenes>
<color key="tintColor" name="Primary"/>
<resources>
<namedColor name="Primary">
<color red="0.64313725490196083" green="0.019607843137254902" blue="0.98039215686274506" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</scenes>
<color key="tintColor" name="Primary"/>
<resources>
<namedColor name="Primary">
<color red="0.0040000001899898052" green="0.50199997425079346" blue="0.51800000667572021" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<namedColor name="SettingsBackground">
<color red="0.45098039215686275" green="0.015686274509803921" blue="0.68627450980392157" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color red="0.0039215686274509803" green="0.50196078431372548" blue="0.51764705882352946" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<namedColor name="SettingsHighlighted">
<color red="0.38823529411764707" green="0.011764705882352941" blue="0.58823529411764708" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color red="0.0080000003799796104" green="0.32199999690055847" blue="0.40400001406669617" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@@ -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,10 +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.textLabel.textColor = .altPrimary
toastView.detailTextLabel.textColor = .altPrimary
self.toastView = toastView
self.signInButton.isIndicatingActivity = false

View File

@@ -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,34 +262,45 @@
<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"/>
@@ -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"/>
@@ -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">
@@ -535,7 +602,7 @@
</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"/>
@@ -548,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">
@@ -637,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">
@@ -677,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>
@@ -707,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>
@@ -734,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">
@@ -800,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">
@@ -864,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"/>
@@ -883,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">
@@ -921,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"/>
@@ -956,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>

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

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

View File

@@ -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,7 +248,7 @@ private extension BrowseViewController
let app = self.dataSource.item(at: indexPath)
if let installedApp = app.installedApp, !installedApp.isUpdateAvailable
if let installedApp = app.installedApp
{
self.open(installedApp)
}
@@ -561,25 +271,8 @@ private extension BrowseViewController
toastView.show(in: self)
return
}
Task<Void, Never>(priority: .userInitiated) { @MainActor in
if let installedApp = app.installedApp, installedApp.isUpdateAvailable
{
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
{
@@ -587,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)
@@ -616,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 }
@@ -635,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
@@ -669,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
}

View File

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

View File

@@ -1,745 +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
{
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
{
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
}

View File

@@ -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),
])
}
}

View File

@@ -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
{
@@ -149,8 +113,6 @@ extension AppBannerView
}
}
}
self.style = .app
let values = AppValues(app: app)
self.titleLabel.text = app.name // Don't use values.name since that already includes "beta".
@@ -166,209 +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
{
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
}
}
@@ -379,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
}
}

View File

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

View File

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

View File

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

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

View File

@@ -12,60 +12,24 @@ final class CollapsingTextView: UITextView
{
var isCollapsed = true {
didSet {
guard self.isCollapsed != oldValue else { return }
self.shouldResetLayout = true
self.setNeedsLayout()
}
}
var maximumNumberOfLines = 2 {
didSet {
self.shouldResetLayout = true
self.setNeedsLayout()
}
}
var lineSpacing: Double = 2 {
didSet {
self.shouldResetLayout = true
if #available(iOS 16, *)
{
self.updateText()
}
else
{
self.setNeedsLayout()
}
}
}
override var text: String! {
didSet {
self.shouldResetLayout = true
guard #available(iOS 16, *) else { return }
self.updateText()
self.setNeedsLayout()
}
}
let moreButton = UIButton(type: .system)
private var shouldResetLayout: Bool = false
private var previousSize: CGSize?
override init(frame: CGRect, textContainer: NSTextContainer?)
{
super.init(frame: frame, textContainer: textContainer)
self.initialize()
}
required init?(coder: NSCoder)
{
super.init(coder: coder)
}
override func awakeFromNib()
{
super.awakeFromNib()
@@ -115,45 +79,38 @@ final class CollapsingTextView: UITextView
height: font.lineHeight)
self.moreButton.frame = moreButtonFrame
if self.shouldResetLayout || self.previousSize != self.bounds.size
if self.isCollapsed
{
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)
if boundingSize.height.rounded() > maximumCollapsedHeight.rounded()
{
let boundingSize = self.attributedText.boundingRect(with: CGSize(width: self.textContainer.size.width, height: .infinity), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
let maximumCollapsedHeight = font.lineHeight * Double(self.maximumNumberOfLines) + self.lineSpacing * Double(self.maximumNumberOfLines - 1)
var exclusionFrame = moreButtonFrame
exclusionFrame.origin.y += self.moreButton.bounds.midY
exclusionFrame.size.width = self.bounds.width // Extra wide to make sure it wraps to next line.
self.textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)]
if boundingSize.height.rounded() > maximumCollapsedHeight.rounded()
{
self.textContainer.maximumNumberOfLines = self.maximumNumberOfLines
var exclusionFrame = moreButtonFrame
exclusionFrame.origin.y += self.moreButton.bounds.midY
exclusionFrame.size.width = self.bounds.width // Extra wide to make sure it wraps to next line.
self.textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)]
self.moreButton.isHidden = false
}
else
{
self.textContainer.maximumNumberOfLines = 0 // Fixes last line having slightly smaller line spacing.
self.textContainer.exclusionPaths = []
self.moreButton.isHidden = true
}
self.moreButton.isHidden = false
}
else
{
self.textContainer.maximumNumberOfLines = 0
self.textContainer.exclusionPaths = []
self.moreButton.isHidden = true
}
}
else
{
self.textContainer.maximumNumberOfLines = 0
self.textContainer.exclusionPaths = []
self.invalidateIntrinsicContentSize()
self.moreButton.isHidden = true
}
self.shouldResetLayout = false
self.previousSize = self.bounds.size
self.invalidateIntrinsicContentSize()
}
}

View File

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

View File

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

View File

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

View File

@@ -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
@@ -75,7 +72,6 @@ class ToastView: RSTToastView
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
@@ -142,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)
}
}

View File

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

View File

@@ -8,6 +8,8 @@
import Intents
// Requires iOS 14 in-app intent handling.
@available(iOS 14, *)
extension INInteraction
{
static func refreshAllApps() -> INInteraction

View File

@@ -1,96 +0,0 @@
//
// ProcessInfo+SideStore.swift
// SideStore
//
// Created by ny on 10/23/24.
// Copyright © 2024 SideStore. All rights reserved.
//
import Foundation
fileprivate struct BuildVersion: Comparable {
let prefix: String
let numericPart: Int
let suffix: Character?
init?(_ buildString: String) {
// Initialize indices
var index = buildString.startIndex
// Extract prefix (letters before the numeric part)
while index < buildString.endIndex, !buildString[index].isNumber {
index = buildString.index(after: index)
}
guard index > buildString.startIndex else { return nil }
self.prefix = String(buildString[buildString.startIndex..<index])
// Extract numeric part
let startOfNumeric = index
while index < buildString.endIndex, buildString[index].isNumber {
index = buildString.index(after: index)
}
guard let numericValue = Int(buildString[startOfNumeric..<index]) else { return nil }
self.numericPart = numericValue
// Extract suffix (if any)
if index < buildString.endIndex {
self.suffix = buildString[index]
} else {
self.suffix = nil
}
}
// Implement Comparable protocol
static func < (lhs: BuildVersion, rhs: BuildVersion) -> Bool {
// Compare prefixes
if lhs.prefix != rhs.prefix {
return lhs.prefix < rhs.prefix
}
// Compare numeric parts
if lhs.numericPart != rhs.numericPart {
return lhs.numericPart < rhs.numericPart
}
// Compare suffixes
switch (lhs.suffix, rhs.suffix) {
case let (l?, r?):
return l < r
case (nil, _?):
return true // nil is considered less than any character
case (_?, nil):
return false
default:
return false // Both are nil and equal
}
}
static func == (lhs: BuildVersion, rhs: BuildVersion) -> Bool {
return lhs.prefix == rhs.prefix &&
lhs.numericPart == rhs.numericPart &&
lhs.suffix == rhs.suffix
}
}
extension ProcessInfo {
var shortVersion: String {
operatingSystemVersionString
.replacingOccurrences(of: "Version ", with: "")
.replacingOccurrences(of: "Build ", with: "")
}
var operatingSystemBuild: String {
if let start = shortVersion.range(of: "(")?.upperBound,
let end = shortVersion.range(of: ")")?.lowerBound {
shortVersion[start..<end].replacingOccurrences(of: "Build ", with: "")
} else { "???" }
}
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),
let currentBuild = BuildVersion(operatingSystemBuild),
let targetBuild = BuildVersion("22B5054e") {
currentBuild >= targetBuild
} else { false }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>&lt;insert pairing file here&gt;</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,17 +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>
<key>BuildRevision</key>
<string>$(BUILD_REVISION)</string>
<string>1</string>
<key>INIntentsSupported</key>
<array>
<string>RefreshAllIntent</string>
@@ -90,8 +80,14 @@
</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/>
@@ -113,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>
@@ -224,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>

View File

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

View File

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

View File

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

View File

@@ -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)
@@ -117,9 +118,8 @@ private extension IntentHandler
{
// Queue response in case refreshing finishes after confirm() but before handle().
self.queuedResponses[intent] = response
DispatchQueue.main.async {
UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
}
UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
}
}
}
@@ -127,7 +127,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
{

View File

@@ -10,7 +10,6 @@ import UIKit
import Roxas
import EmotionalDamage
import minimuxer
import WidgetKit
import AltStoreCore
import UniformTypeIdentifiers
@@ -21,7 +20,7 @@ final class LaunchViewController: RSTLaunchViewController, UIDocumentPickerDeleg
{
private var didFinishLaunching = false
private var destinationViewController: TabBarController!
private var destinationViewController: UIViewController!
override var launchConditions: [RSTLaunchCondition] {
let isDatabaseStarted = RSTLaunchCondition(condition: { DatabaseManager.shared.isStarted }) { (completionHandler) in
@@ -160,7 +159,7 @@ final class LaunchViewController: RSTLaunchViewController, UIDocumentPickerDeleg
} 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)
let dialogMessage = UIAlertController(title: "Pairing File", message: "Select the pairing file for your device. For more information, go to https://wiki.sidestore.io/guides/getting-started/#pairing-file", preferredStyle: .alert)
// Create OK button with action handler
let ok = UIAlertAction(title: "OK", style: .default, handler: { (action) -> Void in
@@ -175,18 +174,7 @@ final class LaunchViewController: RSTLaunchViewController, UIDocumentPickerDeleg
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)
//Add OK button to a dialog message
dialogMessage.addAction(ok)
// Present Alert to
@@ -259,9 +247,6 @@ final class LaunchViewController: RSTLaunchViewController, UIDocumentPickerDeleg
else {
start_auto_mounter(documentsDirectory)
}
// Create destinationViewController now so view controllers can register for receiving Notifications.
self.destinationViewController = self.storyboard!.instantiateViewController(withIdentifier: "tabBarController") as? TabBarController
}
}
@@ -304,22 +289,9 @@ extension LaunchViewController
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 toastView = ToastView(error: error)
toastView.addTarget(self.destinationViewController, action: #selector(TabBarController.presentSources), for: .touchUpInside)
toastView.show(in: self.destinationViewController.selectedViewController ?? self.destinationViewController)
}
self.updateKnownSources()
WidgetCenter.shared.reloadAllTimelines()
// 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)
@@ -335,42 +307,3 @@ extension LaunchViewController
self.didFinishLaunching = true
}
}
private extension LaunchViewController
{
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 blockedSource = blockedSources.first { $0.identifier == source.identifier }
return SourceError.blocked(source, bundleIDs: blockedSource?.bundleIDs, existingSource: source)
}
guard !sourceErrors.isEmpty else { return }
Task {
for error in sourceErrors
{
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)
}
}
}
}
}
}
}

View File

@@ -1,81 +0,0 @@
//
// AppExtensionView.swift
// SideStore
//
// Created by June P on 8/17/24.
// Copyright © 2024 SideStore. All rights reserved.
//
import SwiftUI
import CAltSign
extension ALTApplication: Identifiable {}
struct AppExtensionView: View {
var extensions: Set<ALTApplication>
@State var selection: [ALTApplication] = []
var completion: (_ selection: [ALTApplication]) -> Any?
var body: some View {
NavigationView {
List {
ForEach(self.extensions.sorted {
$0.bundleIdentifier < $1.bundleIdentifier
}, id: \.self) { item in
MultipleSelectionRow(title: item.bundleIdentifier, isSelected: !selection.contains(item)) {
if self.selection.contains(item) {
self.selection.removeAll(where: { $0 == item })
}
else {
self.selection.append(item)
}
}
}
}
.navigationTitle("App Extensions")
.onDisappear {
_ = completion(selection)
}
}
}
}
struct MultipleSelectionRow: View {
var title: String
var isSelected: Bool
var action: () -> Void
var body: some View {
SwiftUI.Button(action: self.action) {
HStack {
Text(self.title)
if self.isSelected {
Spacer()
Image(systemName: "checkmark")
}
}
}
}
}
class AppExtensionViewHostingController: UIHostingController<AppExtensionView> {
var completion: Optional<(_ selection: [ALTApplication]) -> Any?> = nil
required init(extensions: Set<ALTApplication>, completion: @escaping (_ selection: [ALTApplication]) -> Any?) {
self.completion = completion
super.init(rootView: AppExtensionView(extensions: extensions, completion: completion))
}
@MainActor required dynamic init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
}
extension AppExtensionViewHostingController: UIPopoverPresentationControllerDelegate {
func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle {
return .none
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ extension UpdateCollectionViewCell
}
@IBOutlet var bannerView: AppBannerView!
@IBOutlet var versionDescriptionTitleLabel: UILabel!
@IBOutlet var versionDescriptionTextView: CollapsingTextView!
@IBOutlet private var blurView: UIVisualEffectView!
@@ -41,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
@@ -84,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
@@ -107,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

View File

@@ -1,11 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="22505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<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="22504"/>
<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>
@@ -31,17 +30,44 @@
<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="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="343" height="50"/>
<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>
<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="15" y="0.0" width="313" height="26"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<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.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@@ -19,9 +19,6 @@ final class NewsCollectionViewCell: UICollectionViewCell
{
super.awakeFromNib()
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .title2).bolded()
self.titleLabel.font = UIFont(descriptor: descriptor, size: 0.0)
self.contentView.preservesSuperviewLayoutMargins = true
self.contentBackgroundView.layer.cornerRadius = 30

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="22505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<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="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22504"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15404"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
@@ -22,31 +22,31 @@
<view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Xba-Qs-SQo">
<rect key="frame" x="16" y="0.0" width="303" height="299"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" distribution="equalSpacing" translatesAutoresizingMaskIntoConstraints="NO" id="tNk-9u-1tk">
<rect key="frame" x="0.0" y="0.0" width="303" height="299"/>
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="tNk-9u-1tk">
<rect key="frame" x="0.0" y="0.0" width="303" height="298.5"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" distribution="fillProportionally" alignment="top" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="akF-Tr-G5M">
<rect key="frame" x="0.0" y="0.0" width="303" height="97"/>
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" alignment="top" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="akF-Tr-G5M">
<rect key="frame" x="0.0" y="0.0" width="303" height="117.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="1000" text="Delta" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="AkN-BE-I1a">
<rect key="frame" x="25" y="25" width="50" height="26.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle2"/>
<rect key="frame" x="25" y="25" width="54.5" height="26.5"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="22"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" alpha="0.75" contentMode="left" horizontalHuggingPriority="251" verticalCompressionResistancePriority="999" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="SHB-kk-YhL">
<rect key="frame" x="25" y="61.5" width="37.5" height="15.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<rect key="frame" x="25" y="61.5" width="35.5" height="36"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<edgeInsets key="layoutMargins" top="25" left="25" bottom="20" right="25"/>
</stackView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="l36-Bm-De0">
<rect key="frame" x="0.0" y="97" width="303" height="202"/>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" placeholderIntrinsicWidth="335" placeholderIntrinsicHeight="200" translatesAutoresizingMaskIntoConstraints="NO" id="l36-Bm-De0">
<rect key="frame" x="0.0" y="117.5" width="303" height="181"/>
<constraints>
<constraint firstAttribute="width" secondItem="l36-Bm-De0" secondAttribute="height" multiplier="3:2" priority="999" id="QGD-YE-Hw2"/>
<constraint firstAttribute="width" secondItem="l36-Bm-De0" secondAttribute="height" multiplier="67:40" priority="999" id="QGD-YE-Hw2"/>
</constraints>
</imageView>
</subviews>
@@ -56,11 +56,12 @@
<constraint firstItem="tNk-9u-1tk" firstAttribute="top" secondItem="Xba-Qs-SQo" secondAttribute="top" id="Dw8-lF-Fzl"/>
<constraint firstAttribute="trailing" secondItem="tNk-9u-1tk" secondAttribute="trailing" id="Zt8-Wa-oB9"/>
<constraint firstItem="tNk-9u-1tk" firstAttribute="leading" secondItem="Xba-Qs-SQo" secondAttribute="leading" id="m6p-Ee-dTh"/>
<constraint firstAttribute="bottom" secondItem="tNk-9u-1tk" secondAttribute="bottom" id="v9g-yC-db9"/>
<constraint firstAttribute="bottom" secondItem="tNk-9u-1tk" secondAttribute="bottom" constant="0.5" id="v9g-yC-db9"/>
</constraints>
</view>
</subviews>
</view>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="Xba-Qs-SQo" firstAttribute="top" secondItem="wRF-2R-NUG" secondAttribute="top" id="0xe-Rt-MhF"/>
<constraint firstItem="Xba-Qs-SQo" firstAttribute="leading" secondItem="wRF-2R-NUG" secondAttribute="leadingMargin" id="5MO-c0-5rG"/>

View File

@@ -8,7 +8,6 @@
import UIKit
import SafariServices
import Combine
import AltStoreCore
import Roxas
@@ -42,39 +41,26 @@ private final class AppBannerFooterView: UICollectionReusableView
}
}
class NewsViewController: UICollectionViewController, PeekPopPreviewing
final class NewsViewController: UICollectionViewController
{
// Nil == Show news from all sources.
var source: Source?
private lazy var dataSource = self.makeDataSource()
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
private var retryButton: UIButton!
private var prototypeCell: NewsCollectionViewCell!
private var loadingState: LoadingState = .loading {
didSet {
self.update()
}
}
// Cache
private var cachedCellSizes = [String: CGSize]()
private var cancellables = Set<AnyCancellable>()
init?(source: Source?, coder: NSCoder)
{
self.source = source
super.init(coder: coder)
self.initialize()
}
required init?(coder: NSCoder)
{
super.init(coder: coder)
self.initialize()
}
private func initialize()
{
NotificationCenter.default.addObserver(self, selector: #selector(NewsViewController.importApp(_:)), name: AppDelegate.importAppDeepLinkNotification, object: nil)
}
@@ -82,59 +68,27 @@ class NewsViewController: UICollectionViewController, PeekPopPreviewing
{
super.viewDidLoad()
self.collectionView.backgroundColor = .altBackground
self.prototypeCell = NewsCollectionViewCell.instantiate(with: NewsCollectionViewCell.nib!)
self.prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
// Need to add dummy constraint + layout subviews before we can remove Interface Builder's width constraint.
self.prototypeCell.widthAnchor.constraint(greaterThanOrEqualToConstant: 0).isActive = true
self.prototypeCell.layoutIfNeeded()
let constraints = self.prototypeCell.constraintsAffectingLayout(for: .horizontal)
for constraint in constraints where constraint.identifier?.contains("Encapsulated-Layout-Width") == true
{
self.prototypeCell.removeConstraint(constraint)
}
self.collectionView.dataSource = self.dataSource
self.collectionView.prefetchDataSource = self.dataSource
self.collectionView.register(NewsCollectionViewCell.nib, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
self.collectionView.register(AppBannerFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "AppBanner")
(self as PeekPopPreviewing).registerForPreviewing(with: self, sourceView: self.collectionView)
self.registerForPreviewing(with: self, sourceView: self.collectionView)
let refreshControl = UIRefreshControl(frame: .zero)
refreshControl.addTarget(self, action: #selector(NewsViewController.updateSources), for: .primaryActionTriggered)
self.collectionView.refreshControl = refreshControl
self.retryButton = UIButton(type: .system)
self.retryButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
self.retryButton.setTitle(NSLocalizedString("Try Again", comment: ""), for: .normal)
self.retryButton.addTarget(self, action: #selector(NewsViewController.updateSources), for: .primaryActionTriggered)
self.placeholderView.stackView.addArrangedSubview(self.retryButton)
if let source = self.source
{
let tintColor = source.effectiveTintColor ?? .altPrimary
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
}
self.preparePipeline()
self.update()
}
override func viewWillAppear(_ animated: Bool)
{
super.viewWillAppear(animated)
self.fetchSource()
}
override func viewWillLayoutSubviews()
{
super.viewWillLayoutSubviews()
@@ -146,36 +100,30 @@ class NewsViewController: UICollectionViewController, PeekPopPreviewing
self.collectionView.contentInset.bottom = 20
}
}
@IBAction private func unwindFromSourcesViewController(_ segue: UIStoryboardSegue)
{
self.fetchSource()
}
}
private extension NewsViewController
{
func preparePipeline()
{
AppManager.shared.$updateSourcesResult
.receive(on: RunLoop.main) // Delay to next run loop so we receive _current_ value (not previous value).
.sink { result in
self.update()
}
.store(in: &self.cancellables)
}
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<NewsItem, UIImage>
{
let fetchRequest = NewsItem.sortedFetchRequest(for: self.source)
let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext
let fetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NewsItem>
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \NewsItem.date, ascending: false),
NSSortDescriptor(keyPath: \NewsItem.sortIndex, ascending: true),
NSSortDescriptor(keyPath: \NewsItem.sourceIdentifier, ascending: true)]
// Use fetchedResultsController to split NewsItems up into sections.
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: context, sectionNameKeyPath: #keyPath(NewsItem.objectID), cacheName: nil)
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(NewsItem.objectID), cacheName: nil)
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<NewsItem, UIImage>(fetchedResultsController: fetchedResultsController)
dataSource.proxy = self
dataSource.cellConfigurationHandler = { [weak self] (cell, newsItem, indexPath) in
guard let self else { return }
dataSource.cellConfigurationHandler = { (cell, newsItem, indexPath) in
let cell = cell as! NewsCollectionViewCell
cell.contentView.layoutMargins.left = self.view.layoutMargins.left
cell.contentView.layoutMargins.right = self.view.layoutMargins.right
cell.layoutMargins.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right
cell.titleLabel.text = newsItem.title
cell.captionLabel.text = newsItem.caption
@@ -210,15 +158,18 @@ private extension NewsViewController
guard let imageURL = newsItem.imageURL else { return nil }
return RSTAsyncBlockOperation() { (operation) in
ImagePipeline.shared.loadImage(with: imageURL, progress: nil) { result in
ImagePipeline.shared.loadImage(with: imageURL, 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 = { (cell, image, indexPath, error) in
@@ -236,51 +187,70 @@ private extension NewsViewController
return dataSource
}
@objc 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()
{
switch AppManager.shared.updateSourcesResult
switch self.loadingState
{
case nil:
case .loading:
self.placeholderView.textLabel.isHidden = true
self.placeholderView.detailTextLabel.isHidden = false
self.placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "")
self.retryButton.isHidden = true
self.placeholderView.activityIndicatorView.startAnimating()
case .failure(let error):
case .finished(.failure(let error)):
self.placeholderView.textLabel.isHidden = false
self.placeholderView.detailTextLabel.isHidden = false
self.placeholderView.textLabel.text = NSLocalizedString("Unable to Fetch News", comment: "")
self.placeholderView.detailTextLabel.text = error.localizedDescription
self.retryButton.isHidden = false
self.placeholderView.activityIndicatorView.stopAnimating()
case .success:
case .finished(.success):
self.placeholderView.textLabel.isHidden = true
self.placeholderView.detailTextLabel.isHidden = true
self.retryButton.isHidden = true
self.placeholderView.activityIndicatorView.stopAnimating()
}
}
@@ -319,7 +289,7 @@ private extension NewsViewController
let app = self.dataSource.item(at: indexPath)
guard let storeApp = app.storeApp else { return }
if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable
if let installedApp = app.storeApp?.installedApp
{
self.open(installedApp)
}
@@ -337,32 +307,13 @@ private extension NewsViewController
return
}
Task<Void, Never>(priority: .userInitiated) { @MainActor in
if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable
{
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
}
else
{
await AppManager.shared.installAsync(storeApp, presentingViewController: self, completionHandler: finish(_:))
}
UIView.performWithoutAnimation {
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
}
}
@MainActor
func finish(_ result: Result<InstalledApp, Error>)
{
_ = AppManager.shared.install(storeApp, presentingViewController: self) { (result) in
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)
ToastView(error: error, opensLog: true).show(in: self)
case .success: print("Installed app:", storeApp.bundleIdentifier)
}
@@ -372,6 +323,10 @@ private extension NewsViewController
}
}
}
UIView.performWithoutAnimation {
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
}
}
func open(_ installedApp: InstalledApp)
@@ -417,16 +372,43 @@ extension NewsViewController
footerView.layoutMargins.left = self.view.layoutMargins.left
footerView.layoutMargins.right = self.view.layoutMargins.right
footerView.bannerView.button.isIndicatingActivity = false
footerView.bannerView.configure(for: storeApp)
footerView.bannerView.tintColor = storeApp.tintColor
footerView.bannerView.button.addTarget(self, action: #selector(NewsViewController.performAppAction(_:)), for: .primaryActionTriggered)
footerView.tapGestureRecognizer.addTarget(self, action: #selector(NewsViewController.handleTapGesture(_:)))
Nuke.loadImage(with: storeApp.iconURL, into: footerView.bannerView.iconImageView) { result in
footerView.bannerView.iconImageView.isIndicatingActivity = false
footerView.bannerView.button.isIndicatingActivity = false
if storeApp.installedApp == nil
{
let buttonTitle = NSLocalizedString("Free", comment: "")
footerView.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal)
footerView.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), storeApp.name)
footerView.bannerView.button.accessibilityValue = buttonTitle
let progress = AppManager.shared.installationProgress(for: storeApp)
footerView.bannerView.button.progress = progress
if let versionDate = storeApp.latestSupportedVersion?.date, versionDate > Date()
{
footerView.bannerView.button.countdownDate = versionDate
}
else
{
footerView.bannerView.button.countdownDate = nil
}
}
else
{
footerView.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
footerView.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), storeApp.name)
footerView.bannerView.button.accessibilityValue = nil
footerView.bannerView.button.progress = nil
footerView.bannerView.button.countdownDate = nil
}
Nuke.loadImage(with: storeApp.iconURL, into: footerView.bannerView.iconImageView)
return footerView
}
@@ -437,13 +419,16 @@ extension NewsViewController: UICollectionViewDelegateFlowLayout
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
{
let item = self.dataSource.item(at: indexPath)
let globallyUniqueID = item.globallyUniqueID ?? item.identifier
if let previousSize = self.cachedCellSizes[globallyUniqueID]
if let previousSize = self.cachedCellSizes[item.identifier]
{
return previousSize
}
// Take layout margins into account.
self.prototypeCell.layoutMargins.left = self.view.layoutMargins.left
self.prototypeCell.layoutMargins.right = self.view.layoutMargins.right
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
NSLayoutConstraint.activate([widthConstraint])
defer { NSLayoutConstraint.deactivate([widthConstraint]) }
@@ -451,7 +436,7 @@ extension NewsViewController: UICollectionViewDelegateFlowLayout
self.dataSource.cellConfigurationHandler(self.prototypeCell, item, indexPath)
let size = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
self.cachedCellSizes[globallyUniqueID] = size
self.cachedCellSizes[item.identifier] = size
return size
}
@@ -484,7 +469,6 @@ extension NewsViewController: UICollectionViewDelegateFlowLayout
extension NewsViewController: UIViewControllerPreviewingDelegate
{
@available(iOS, deprecated: 13.0)
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController?
{
if let indexPath = self.collectionView.indexPathForItem(at: location), let cell = self.collectionView.cellForItem(at: indexPath)
@@ -531,7 +515,6 @@ extension NewsViewController: UIViewControllerPreviewingDelegate
}
}
@available(iOS, deprecated: 13.0)
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController)
{
if let safariViewController = viewControllerToCommit as? SFSafariViewController

View File

@@ -14,11 +14,6 @@ import AltStoreCore
import AltSign
import minimuxer
private extension UIColor
{
static let altInvertedPrimary = UIColor(named: "SettingsHighlighted")!
}
typealias AuthenticationError = AuthenticationErrorCode.Error
enum AuthenticationErrorCode: Int, ALTErrorEnum, CaseIterable
{
@@ -49,7 +44,10 @@ final class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate, A
private lazy var navigationController: UINavigationController = {
let navigationController = self.storyboard.instantiateViewController(withIdentifier: "navigationController") as! UINavigationController
navigationController.isModalInPresentation = true
if #available(iOS 13.0, *)
{
navigationController.isModalInPresentation = true
}
return navigationController
}()
@@ -205,11 +203,7 @@ final class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate, A
{
guard !self.isFinished else { return }
switch result
{
case .failure(let error): Logger.sideload.error("Failed to authenticate account. \(error.localizedDescription, privacy: .public)")
case .success((let team, _, _)): Logger.sideload.notice("Authenticated account for team \(team.identifier, privacy: .public).")
}
print("Finished authenticating with result:", result.error?.localizedDescription ?? "success")
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
context.perform {
@@ -221,6 +215,7 @@ final class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate, A
let account = Account.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Account.identifier), altTeam.account.identifier), in: context),
let team = Team.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Team.identifier), altTeam.identifier), in: context)
else { throw AuthenticationError(.noTeam) }
// Account
account.isActiveAccount = true
@@ -244,23 +239,15 @@ final class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate, A
{
team.isActiveTeam = false
}
let activeAppsMinimumVersion = OperatingSystemVersion(majorVersion: 13, minorVersion: 3, patchVersion: 1)
let isMinimumVersionMatching = ProcessInfo.processInfo.isOperatingSystemAtLeast(activeAppsMinimumVersion)
let isSparseRestorePatched = ProcessInfo().sparseRestorePatched
let isAppLimitDisabled = UserDefaults.standard.isAppLimitDisabled
UserDefaults.standard.activeAppsLimit = nil
// TODO: @mahee96: is the minimum ver match for ios 13.3.1 check required?
// if so what is the app limit? As nil app limit specifies unlimited apps?!
if team.type == .free//, isMinimumVersionMatching
if team.type == .free, ProcessInfo.processInfo.isOperatingSystemAtLeast(activeAppsMinimumVersion)
{
if (!isAppLimitDisabled && isSparseRestorePatched) ||
(isAppLimitDisabled && !isSparseRestorePatched)
{
UserDefaults.standard.activeAppsLimit = InstalledApp.freeAccountActiveAppsLimit
}
UserDefaults.standard.activeAppsLimit = ALTActiveAppsLimit
}
else
{
UserDefaults.standard.activeAppsLimit = nil
}
// Save
@@ -304,7 +291,7 @@ private extension AuthenticationOperation
{
guard let presentingViewController = self.presentingViewController else { return false }
self.navigationController.view.tintColor = .altInvertedPrimary
self.navigationController.view.tintColor = .white
if self.navigationController.viewControllers.isEmpty
{
@@ -361,8 +348,6 @@ private extension AuthenticationOperation
if let appleID = Keychain.shared.appleIDEmailAddress, let password = Keychain.shared.appleIDPassword
{
Logger.sideload.notice("Authenticating Apple ID...")
self.authenticate(appleID: appleID, password: password) { (result) in
switch result
{
@@ -505,12 +490,13 @@ private extension AuthenticationOperation
{
func requestCertificate()
{
let machineName: String = "SideStore - \(team.account.firstName)'s \(UIDevice.current.name)"
let machineName = "SideStore - " + UIDevice.current.name
ALTAppleAPI.shared.addCertificate(machineName: machineName, to: team, session: session) { (certificate, error) in
do
{
let certificate = try Result(certificate, error).get()
guard let privateKey = certificate.privateKey else { throw AuthenticationError(.missingPrivateKey) }
ALTAppleAPI.shared.fetchCertificates(for: team, session: session) { (certificates, error) in
do
{
@@ -538,50 +524,16 @@ private extension AuthenticationOperation
func replaceCertificate(from certificates: [ALTCertificate])
{
let ourCertificates = certificates.filter { a in
a.machineName?.starts(with: "SideStore") == true || a.machineName?.starts(with: "AltStore") == true
}
guard let certificate = certificates.first(where: { $0.machineName?.starts(with: "SideStore") == true }) ?? certificates.first else { return completionHandler(.failure(OperationError.notAuthenticated)) }
if ourCertificates.isEmpty {
return requestCertificate()
}
// We don't have private keys for any of the certificates,
// so we need to revoke one and create a new one.
var certsText = ""
for certificate in ourCertificates {
if let name = certificate.machineName {
certsText.append("\(name)\n")
}
}
DispatchQueue.main.async {
let alertController = UIAlertController(title: NSLocalizedString("Would you like to revoke your previous certificates?\n\(certsText)", comment: ""), message: nil, preferredStyle: .alert)
let noAction = UIAlertAction(title: NSLocalizedString("No", comment: ""), style: .default) { (action) in
requestCertificate()
}
let yesAction = UIAlertAction(title: NSLocalizedString("Yes", comment: ""), style: .default) { (action) in
for certificate in ourCertificates {
ALTAppleAPI.shared.revoke(certificate, for: team, session: session) { (success, error) in
if let error = error, !success
{
completionHandler(.failure(error))
}
}
}
requestCertificate()
}
alertController.addAction(noAction)
alertController.addAction(yesAction)
if self.navigationController.presentingViewController != nil
ALTAppleAPI.shared.revoke(certificate, for: team, session: session) { (success, error) in
if let error = error, !success
{
self.navigationController.present(alertController, animated: true, completion: nil)
completionHandler(.failure(error))
}
else
{
self.presentingViewController?.present(alertController, animated: true, completion: nil)
requestCertificate()
}
}
}

View File

@@ -58,8 +58,7 @@ final class BackgroundRefreshAppsOperation: ResultOperation<[String: Result<Inst
let installedApps: [InstalledApp]
private let managedObjectContext: NSManagedObjectContext
var presentsFinishedNotification: Bool = true
var ignoresServerNotFoundError: Bool = true
var presentsFinishedNotification: Bool = false
private let refreshIdentifier: String = UUID().uuidString
private var runningApplications: Set<String> = []
@@ -114,21 +113,18 @@ final class BackgroundRefreshAppsOperation: ResultOperation<[String: Result<Inst
}
self.managedObjectContext.perform {
Logger.sideload.notice("Refreshing apps in background: \(self.installedApps.map(\.bundleIdentifier), privacy: .public)")
print("Apps to refresh:", self.installedApps.map(\.bundleIdentifier))
self.startListeningForRunningApps()
// Wait for 2 seconds (1 now, 1 later in FindServerOperation) to:
// Wait for 3 seconds (2 now, 1 later in FindServerOperation) to:
// a) give us time to discover AltServers
// b) give other processes a chance to respond to requestAppState notification
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.managedObjectContext.perform {
let filteredApps = self.installedApps.filter { !self.runningApplications.contains($0.bundleIdentifier) }
if !self.runningApplications.isEmpty
{
Logger.sideload.notice("Skipping refreshing running apps: \(self.runningApplications, privacy: .public)")
}
print("Filtered Apps to Refresh:", filteredApps.map { $0.bundleIdentifier })
let group = AppManager.shared.refresh(filteredApps, presentingViewController: nil)
group.beginInstallationHandler = { (installedApp) in
@@ -156,8 +152,6 @@ final class BackgroundRefreshAppsOperation: ResultOperation<[String: Result<Inst
group.completionHandler = { (results) in
self.finish(.success(results))
}
self.progress.addChild(group.progress, withPendingUnitCount: 1)
}
}
}
@@ -214,7 +208,7 @@ private extension BackgroundRefreshAppsOperation
do
{
let results = try result.get()
shouldPresentAlert = !results.isEmpty
shouldPresentAlert = false
for (_, result) in results
{
@@ -229,27 +223,17 @@ private extension BackgroundRefreshAppsOperation
{
shouldPresentAlert = false
}
catch ~OperationError.Code.serverNotFound where self.ignoresServerNotFoundError
{
shouldPresentAlert = false
}
catch
{
print("Failed to refresh apps in background.", error)
Logger.sideload.error("Failed to refresh apps in background. \(error.localizedDescription, privacy: .public)")
content.title = NSLocalizedString("Failed to Refresh Apps", comment: "")
content.body = error.localizedDescription
shouldPresentAlert = true
}
if shouldPresentAlert
{
// Using nil if delay == 0 fixes race condition where multiple notifications can appear (or none).
let trigger = delay == 0 ? nil : UNTimeIntervalNotificationTrigger(timeInterval: delay + 1, repeats: false)
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay + 1, repeats: false)
let request = UNNotificationRequest(identifier: self.refreshIdentifier, content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)
@@ -282,7 +266,7 @@ private extension BackgroundRefreshAppsOperation
_ = RefreshAttempt(identifier: self.refreshIdentifier, result: result, context: context)
do { try context.save() }
catch { Logger.sideload.error("Failed to save refresh attempt. \(error.localizedDescription, privacy: .public)") }
catch { print("Failed to save refresh attempt.", error) }
}
}

Some files were not shown because too many files have changed in this diff Show More