Compare commits

..

34 Commits

Author SHA1 Message Date
Joseph Mattello
c4c2d17ffc snapshot
Signed-off-by: Joseph Mattello <mail@joemattiello.com>
2023-04-02 02:28:12 -04:00
Joe Mattiello
2c829895c9 spm hide daemon behind ifdef 2023-03-10 21:11:39 -05:00
Joe Mattiello
5463f2b935 spm SideBackup builds, if we need it? 2023-03-10 20:56:50 -05:00
Joe Mattiello
d644ee7ab0 spm import fixes after merging Shared 2023-03-10 20:43:59 -05:00
Joe Mattiello
351d4fd631 spm: wdiget almost builds again 2023-03-10 20:28:52 -05:00
Joe Mattiello
128b180c1f spm: complex refactor, document of package 2023-03-10 19:32:33 -05:00
Joe Mattiello
1f2693bea6 Fix enum to objc core data for app permission 2023-03-10 19:32:32 -05:00
Joe Mattiello
452cf89c95 Fix bundle again for swift module 2023-03-10 19:32:32 -05:00
Joe Mattiello
90ac0fb025 fix bundle for coredata model 2023-03-10 19:32:32 -05:00
Joe Mattiello
10f5ee1548 fix sboard errors, and notification retain error 2023-03-10 19:32:32 -05:00
Joe Mattiello
478b30c8fd Fix module ref in storyboards and xibs 2023-03-10 19:32:31 -05:00
Joe Mattiello
207f6aac32 Fix some Bundle refs 2023-03-10 19:32:31 -05:00
Joe Mattiello
e1ed6f5ba3 Fix package.swift mistake 2023-03-10 19:32:31 -05:00
Joe Mattiello
444aac1210 Update paths for ci cd 2023-03-10 19:32:30 -05:00
Joe Mattiello
f49fa24743 App builds in xcodeproj (todo widget) 2023-03-10 19:31:01 -05:00
Joe Mattiello
4c9c5b1a56 XCode project for app, moved app project to folder 2023-03-10 19:30:59 -05:00
Joe Mattiello
365cadbb31 spm ignore unused resources 2023-03-10 19:30:20 -05:00
Joe Mattiello
36e03a52a7 Add some spm plugins to test 2023-03-10 19:30:20 -05:00
Joe Mattiello
19cf1722fa Use em_proxy and minimuxer from gh release 2023-03-10 19:30:19 -05:00
Joe Mattiello
c28a45f100 refactor to SideStoreAppKit 2023-03-10 19:30:19 -05:00
Joe Mattiello
df5b0c3af1 spm App target builds and links! 2023-03-10 19:30:17 -05:00
Joe Mattiello
8b1e87d2dd spm app builds 2023-03-10 19:30:17 -05:00
Joe Mattiello
e036f07875 spm the libs build 2023-03-10 19:30:16 -05:00
Joe Mattiello
2d232fa702 fix submodule 2023-03-10 19:30:15 -05:00
Joe Mattiello
686d1ab42a libimobiledev libs almost build 2023-03-10 19:30:15 -05:00
Joe Mattiello
d22d12c234 move submodules 2023-03-10 19:30:14 -05:00
Joe Mattiello
364b11ec9d More spm fixes 2023-03-10 19:30:14 -05:00
Joe Mattiello
f3a70e1e47 recreate legacy project for testing 2023-03-10 19:30:09 -05:00
Joe Mattiello
493b3783f0 Create swift package 2023-03-10 19:30:09 -05:00
Joe Mattiello
4669227567 mv AltBackup to Sources/ 2023-03-10 19:30:04 -05:00
Joseph Mattello
dfcc6e714e delete unused roxas submodule for spm
Signed-off-by: Joseph Mattello <mail@joemattiello.com>
2023-03-10 19:30:04 -05:00
Joseph Mattello
3b824eac96 UIColorHex fix deprecations
Signed-off-by: Joseph Mattello <mail@joemattiello.com>
2023-03-10 19:30:04 -05:00
Joseph Mattello
a6559d8bb9 Replace some Roxas with Roxas UI
Signed-off-by: Joseph Mattello <mail@joemattiello.com>
2023-03-10 19:30:03 -05:00
Joseph Mattello
f270ecc537 Replace local code with updated Roxas and SideKit
Signed-off-by: Joseph Mattello <mail@joemattiello.com>
2023-03-10 19:30:03 -05:00
733 changed files with 31875 additions and 63178 deletions

21
.codecov.yml Normal file
View File

@@ -0,0 +1,21 @@
# https://docs.codecov.io/docs/codecov-yaml
codecov:
require_ci_to_pass: true
coverage:
precision: 2
round: down
range: "70...100"
ignore:
- Dependencies
status:
patch:
default:
if_no_uploads: error
changes: true
project:
default:
target: auto
if_no_uploads: error
comment: false

View File

@@ -1,39 +1,35 @@
# EditorConfig is awesome: https://EditorConfig.org
# http://editorconfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
indent_style = space
charset = utf-8
indent_size = 4
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
# Matches multiple files with brace expansion notation
# Set default charset
[*.{js,py}]
charset = utf-8# 4 space indentation
[*.{md,markdown}]
trim_trailing_whitespace = false
# Swift files
[*.swift]
[*.{c,h,m,mm}]
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
charset = utf-8# 4 space indentation
indent_size = 2
# 4 space indentation
[*.py]
indent_style = space
indent_size = 4
[*.js]
indent_size = 2
# Tab indentation (no size specified)
[Makefile]
[*.{swift}]
trim_trailing_whitespace = true
indent_style = tab
indent_size = 4
# Indentation override for all JS under lib directory
[lib/**.js]
indent_style = space
indent_size = 2
[Makefile]
trim_trailing_whitespace = true
indent_style = tab
indent_size = 8
# Matches the exact files either package.json or .travis.yml
[{package.json,.travis.yml}]
indent_style = space
[*.{yaml|yml}]
indent_size = 2

0
.env Normal file
View File

2
.github/CODEOWNERS vendored
View File

@@ -1 +1 @@
* @JoeMatt @lonkelle @nythepegasus @Spidy123222 @SternXD
* @JoeMatt @lonkelle

View File

@@ -2,15 +2,15 @@ name: Bug Report
description: Report a bug
title: "[BUG] "
labels: ["bug"]
assignees: []
assignees:
- naturecodevoid
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.**
**Please use [Discord](https://discord.gg/RgpFBX3Q3k) or [GitHub Discussions](https://github.com/SideStore/SideStore/discussions) for support.**
- type: textarea
id: description
attributes:

View File

@@ -3,7 +3,7 @@ blank_issues_enabled: false
contact_links:
- name: Discord
url: https://discord.gg/sidestore-949183273383395328
url: https://discord.gg/RgpFBX3Q3k
about: If you need support, please go here first instead of making an issue!
- name: GitHub Discussions
url: https://github.com/SideStore/SideStore/discussions

View File

@@ -2,14 +2,15 @@ name: Feature Request
description: Suggest a feature
title: "[FEATURE REQUEST] "
labels: ["enhancement"]
assignees: []
assignees:
- naturecodevoid
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this feature request! Before you continue filling out the form, please **[search in GitHub Issues](https://github.com/SideStore/SideStore/issues?q=is%3Aissue+is%3Aopen) for the feature you are suggestion** in case it has already been suggested.
**Please use [Discord](https://discord.gg/sidestore-949183273383395328) or [GitHub Discussions](https://github.com/SideStore/SideStore/discussions) for support.**
**Please use [Discord](https://discord.gg/RgpFBX3Q3k) or [GitHub Discussions](https://github.com/SideStore/SideStore/discussions) for support.**
- type: textarea
id: description
attributes:

View File

@@ -1,63 +0,0 @@
import requests
import sys
import os
# Your GitHub Personal Access Token
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
# Repository details
REPO_OWNER = "SideStore"
REPO_NAME = "SideStore"
API_URL = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/actions/caches"
# Common headers for GitHub API calls
HEADERS = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {GITHUB_TOKEN}"
}
def list_caches():
response = requests.get(API_URL, headers=HEADERS)
if response.status_code != 200:
print(f"Failed to list caches. HTTP {response.status_code}")
print("Response:", response.text)
sys.exit(1)
data = response.json()
return data.get("actions_caches", [])
def delete_cache(cache_id):
delete_url = f"{API_URL}/{cache_id}"
response = requests.delete(delete_url, headers=HEADERS)
return response.status_code
def main():
caches = list_caches()
if not caches:
print("No caches found.")
return
print("Found caches:")
for cache in caches:
print(f"ID: {cache.get('id')}, Key: {cache.get('key')}")
print("\nDeleting caches...")
for cache in caches:
cache_id = cache.get("id")
status = delete_cache(cache_id)
if status == 204:
print(f"Successfully deleted cache with ID: {cache_id}")
else:
print(f"Failed to delete cache with ID: {cache_id}. HTTP status code: {status}")
print("All caches processed.")
if __name__ == "__main__":
main()
### How to use
'''
just export the GITHUB_TOKEN and then run this script via `python3 cache.py' to delete the caches
'''

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

@@ -0,0 +1,20 @@
# .github/workflows/sidestore-project.yml
name: SideStore Project
on:
push:
branches:
- main
pull_request:
jobs:
build:
name: Build
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- uses: tuist/tuist-action@0.13.0
with:
command: 'build'
arguments: ''

View File

@@ -1,28 +0,0 @@
name: Alpha SideStore build
on:
push:
branches:
- develop-alpha
# cancel duplicate run if from same branch
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
Reusable-build:
uses: ./.github/workflows/reusable-sidestore-build.yml
with:
# bundle_id: "com.SideStore.SideStore.Alpha"
bundle_id: "com.SideStore.SideStore"
# bundle_id_suffix: ".Alpha"
is_beta: true
publish: ${{ vars.PUBLISH_ALPHA_UPDATES == 'true' }}
is_shared_build_num: false
release_tag: "alpha"
release_name: "Alpha"
upstream_tag: "nightly"
upstream_name: "Nightly"
secrets:
CROSS_REPO_PUSH_KEY: ${{ secrets.CROSS_REPO_PUSH_KEY }}
BUILD_LOG_ZIP_PASSWORD: ${{ secrets.BUILD_LOG_ZIP_PASSWORD }}

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

@@ -2,7 +2,7 @@ name: Beta SideStore build
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+-beta.[0-9]+' # example: 1.0.0-beta.1
- '[0-9]+.[0-9]+.[0-9]+-beta.[0-9]+' # example: 1.0.0-beta.1
jobs:
build:
@@ -11,93 +11,80 @@ 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
with:
submodules: recursive
- name: Checkout code
uses: actions/checkout@v2
with:
submodules: recursive
- name: Install dependencies
run: brew install ldid
- name: Install dependencies
run: brew install ldid
- name: Change version to tag
run: sed -e '/MARKETING_VERSION = .*/s/= .*/= ${{ github.ref_name }}/' -i '' Build.xcconfig
- name: Change version to tag
run: sed -e '/MARKETING_VERSION = .*/s/= .*/= ${{ github.ref_name }}/' -i '' Build.xcconfig
- name: Get version
id: version
run: echo "version=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_OUTPUT
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1.4.1
with:
xcode-version: ${{ matrix.version }}
- name: Echo version
run: echo "${{ steps.version.outputs.version }}"
- name: Build SideStore
run: make build | xcpretty && exit ${PIPESTATUS[0]}
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: ${{ matrix.version }}
- name: Fakesign app
run: make fakesign
- 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]}
- name: Fakesign app
run: make fakesign
- name: Convert to IPA
run: make ipa
- name: Get current date
id: date
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
- name: Get current date in AltStore date form
id: date_altstore
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Upload to new beta release
uses: softprops/action-gh-release@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
name: ${{ steps.version.outputs.version }}
tag_name: ${{ github.ref_name }}
draft: true
prerelease: true
files: SideStore.ipa
body: |
<!-- NOTE: to reset SideSource cache, go to `https://apps.sidestore.io/reset-cache/nightly/<sidesource key>`. This is not included in the GitHub Action since it makes draft releases so they can be edited and have a changelog. -->
Beta builds are hand-picked builds from development commits that will allow you to try out new features earlier than normal. However, **they might contain bugs and other issues. Use at your own risk!**
## Changelog
- TODO
## Build Info
Built at (UTC): `${{ steps.date.outputs.date }}`
Built at (UTC date): `${{ steps.date_altstore.outputs.date }}`
Commit SHA: `${{ github.sha }}`
Version: `${{ steps.version.outputs.version }}`
- name: Add version to IPA file name
run: mv SideStore.ipa SideStore-${{ steps.version.outputs.version }}.ipa
- name: Convert to IPA
run: make 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
uses: actions/upload-artifact@v3.1.0
with:
name: SideStore.ipa
path: SideStore.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
name: SideStore-dSYM
path: ./*.dSYM/
- name: Get version
id: version
run: echo "version=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_OUTPUT
- name: Get current date
id: date
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
- name: Get current date in SideStore date form
id: date_sidestore
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Upload to new beta release
uses: softprops/action-gh-release@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
name: ${{ steps.version.outputs.version }}
tag_name: ${{ github.ref_name }}
draft: true
prerelease: true
files: SideStore.ipa
body: |
<!-- NOTE: to reset SideSource cache, go to `https://apps.sidestore.io/reset-cache/nightly/<sidesource key>`. This is not included in the GitHub Action since it makes draft releases so they can be edited and have a changelog. -->
Beta builds are hand-picked builds from development commits that will allow you to try out new features earlier than normal. However, **they might contain bugs and other issues. Use at your own risk!**
## Changelog
- TODO
## Build Info
Built at (UTC): `${{ steps.date.outputs.date }}`
Built at (UTC date): `${{ steps.date_sidestore.outputs.date }}`
Commit SHA: `${{ github.sha }}`
Version: `${{ steps.version.outputs.version }}`

View File

@@ -1,34 +0,0 @@
#!/usr/bin/env bash
# Ensure we are in root directory
cd "$(dirname "$0")/../.."
DATE=`date -u +'%Y.%m.%d'`
BUILD_NUM=1
# Use RELEASE_CHANNEL from the environment variable or default to "beta"
RELEASE_CHANNEL=${RELEASE_CHANNEL:-"beta"}
write() {
sed -e "/MARKETING_VERSION = .*/s/$/-$RELEASE_CHANNEL.$DATE.$BUILD_NUM+$(git rev-parse --short HEAD)/" -i '' Build.xcconfig
echo "$DATE,$BUILD_NUM" > build_number.txt
}
if [ ! -f "build_number.txt" ]; then
write
exit 0
fi
LAST_DATE=`cat build_number.txt | perl -n -e '/([^,]*),([^ ]*)$/ && print $1'`
LAST_BUILD_NUM=`cat build_number.txt | perl -n -e '/([^,]*),([^ ]*)$/ && print $2'`
# if [[ "$DATE" != "$LAST_DATE" ]]; then
# write
# else
# BUILD_NUM=`expr $LAST_BUILD_NUM + 1`
# write
# fi
# Build number is always incremental
BUILD_NUM=`expr $LAST_BUILD_NUM + 1`
write

View File

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

View File

@@ -1,82 +1,100 @@
name: Nightly SideStore Build
name: Nightly SideStore build
on:
push:
branches:
- develop
schedule:
- cron: '0 0 * * *' # Runs every night at midnight UTC
workflow_dispatch: # Allows manual trigger
# cancel duplicate run if from same branch
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
- develop
jobs:
check-changes:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
outputs:
has_changes: ${{ steps.check.outputs.has_changes }}
build:
name: Build and upload SideStore Nightly
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
strategy:
fail-fast: false
matrix:
include:
- os: 'macos-12'
version: '14.2'
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v2
with:
submodules: recursive
- name: Install dependencies
run: brew install ldid
- name: Cache .nightly-build-num
uses: actions/cache@v3
with:
path: .nightly-build-num
key: nightly-build-num
- name: Increase nightly build number and set as version
run: bash .github/workflows/increase-nightly-build-num.sh
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1.4.1
with:
xcode-version: ${{ matrix.version }}
- name: Build SideStore
run: make build | xcpretty && exit ${PIPESTATUS[0]}
- name: Fakesign app
run: make fakesign
- name: Convert to IPA
run: make ipa
- name: Upload SideStore.ipa Artifact
uses: actions/upload-artifact@v3.1.0
with:
name: SideStore.ipa
path: SideStore.ipa
- name: Upload *.dSYM Artifact
uses: actions/upload-artifact@v3.1.0
with:
fetch-depth: 0 # Ensure full history
name: SideStore-dSYM
path: ./*.dSYM/
- name: Get last successful workflow run
id: get_last_success
run: |
LAST_SUCCESS=$(gh run list --workflow "Nightly SideStore Build" --json createdAt,conclusion \
--jq '[.[] | select(.conclusion=="success")][0].createdAt' || echo "")
echo "Last successful run: $LAST_SUCCESS"
echo "last_success=$LAST_SUCCESS" >> $GITHUB_ENV
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: version
run: echo "version=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_OUTPUT
- name: Check for new commits since last successful build
id: check
run: |
if [ -n "$LAST_SUCCESS" ]; then
NEW_COMMITS=$(git rev-list --count --since="$LAST_SUCCESS" origin/develop)
COMMIT_LOG=$(git log --since="$LAST_SUCCESS" --pretty=format:"%h %s" origin/develop)
else
NEW_COMMITS=1
COMMIT_LOG=$(git log -n 10 --pretty=format:"%h %s" origin/develop) # Show last 10 commits if no history
fi
echo "Has changes: $NEW_COMMITS"
echo "New commits since last successful build:"
echo "$COMMIT_LOG"
if [ "$NEW_COMMITS" -gt 0 ]; then
echo "has_changes=true" >> $GITHUB_OUTPUT
else
echo "has_changes=false" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LAST_SUCCESS: ${{ env.last_success }}
- name: Get current date
id: date
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
Reusable-build:
if: |
always() &&
(github.event_name == 'push' ||
(github.event_name == 'schedule' && needs.check-changes.result == 'success' && needs.check-changes.outputs.has_changes == 'true'))
needs: check-changes
uses: ./.github/workflows/reusable-sidestore-build.yml
with:
# bundle_id: "com.SideStore.SideStore.Nightly"
bundle_id: "com.SideStore.SideStore"
# bundle_id_suffix: ".Nightly"
is_beta: true
publish: ${{ vars.PUBLISH_NIGHTLY_UPDATES == 'true' }}
is_shared_build_num: false
release_tag: "nightly"
release_name: "Nightly"
upstream_tag: "0.5.10"
upstream_name: "Stable"
secrets:
CROSS_REPO_PUSH_KEY: ${{ secrets.CROSS_REPO_PUSH_KEY }}
BUILD_LOG_ZIP_PASSWORD: ${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
- name: Get current date in SideStore date form
id: date_sidestore
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Upload to nightly release
uses: IsaacShelton/update-existing-release@v1.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
release: "Nightly"
tag: "nightly"
prerelease: true
files: SideStore.ipa
body: |
This is an ⚠️ **EXPERIMENTAL** ⚠️ nightly build for commit [${{ github.sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}).
Nightly builds are **extremely experimental builds only meant to be used by developers and alpha testers. They often contain bugs and experimental features. Use at your own risk!**
If you want to try out new features early but want a lower chance of bugs, you can look at [SideStore Beta](https://github.com/${{ github.repository }}/releases?q=beta).
## Build Info
Built at (UTC): `${{ steps.date.outputs.date }}`
Built at (UTC date): `${{ steps.date_sidestore.outputs.date }}`
Commit SHA: `${{ github.sha }}`
Version: `${{ steps.version.outputs.version }}`
- name: Reset cache for apps.sidestore.io/nightly
run: sleep 10 && curl https://apps.sidestore.io/reset-cache/nightly/${{ secrets.SIDESOURCE_KEY }}

View File

@@ -1,80 +1,37 @@
name: Pull Request SideStore build
on:
pull_request:
# types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
types: [opened, synchronize, reopened, ready_for_review]
jobs:
build:
name: Build and upload SideStore
if: ${{ github.event.pull_request.draft == false }}
strategy:
fail-fast: false
matrix:
include:
- os: 'macos-14'
version: '16.1'
- 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:
COMMIT: ${{ github.event.pull_request.head.sha }}
- 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 }}"
run: sed -e '/MARKETING_VERSION = .*/s/$/-pr.${{ github.event.pull_request.number }}/' -i '' Build.xcconfig
- 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: List Files and derived data
run: |
echo ">>>>>>>>> Workdir <<<<<<<<<<"
ls -la .
echo ""
echo ">>>>>>>>> SideStore <<<<<<<<<<"
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
echo ""
- name: Build SideStore
run: NSUnbufferedIO=YES make build 2>&1 | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
run: make build | xcpretty && exit ${PIPESTATUS[0]}
- name: Fakesign app
run: make fakesign
@@ -82,17 +39,14 @@ jobs:
- name: Convert to IPA
run: make ipa
- 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: SideStore.ipa
path: SideStore.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/*
name: SideStore-dSYM
path: ./*.dSYM/

View File

@@ -1,104 +0,0 @@
name: Reusable SideStore Build
on:
workflow_call:
inputs:
is_beta:
required: false
default: false
type: boolean
publish:
required: false
default: false
type: boolean
is_shared_build_num:
required: false
default: true
type: boolean
release_name:
required: true
type: string
release_tag:
required: true
type: string
upstream_tag:
required: true
type: string
upstream_name:
required: true
type: string
bundle_id:
default: com.SideStore.SideStore
required: true
type: string
bundle_id_suffix:
default: ''
required: false
type: string
secrets:
# GITHUB_TOKEN:
# required: true
CROSS_REPO_PUSH_KEY:
required: true
BUILD_LOG_ZIP_PASSWORD:
required: false
# since build cache, test-build cache, test-run cache are involved, out of order exec if serialization is on individual jobs will wreak all sorts of havoc
# so we serialize on the entire workflow
concurrency:
group: serialize-workflow
jobs:
shared:
uses: ./.github/workflows/sidestore-shared.yml
secrets: inherit
build:
needs: shared
uses: ./.github/workflows/sidestore-build.yml
with:
is_beta: ${{ inputs.is_beta }}
is_shared_build_num: ${{ inputs.is_shared_build_num }}
release_tag: ${{ inputs.release_tag }}
short_commit: ${{ needs.shared.outputs.short-commit }}
bundle_id: ${{ inputs.bundle_id }}
bundle_id_suffix: ${{ inputs.bundle_id_suffix }}
secrets: inherit
tests-build:
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_BUILD == '1' }}
needs: shared
uses: ./.github/workflows/sidestore-tests-build.yml
with:
release_tag: ${{ inputs.release_tag }}
short_commit: ${{ needs.shared.outputs.short-commit }}
secrets: inherit
tests-run:
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
needs: [shared, tests-build]
uses: ./.github/workflows/sidestore-tests-run.yml
with:
release_tag: ${{ inputs.release_tag }}
short_commit: ${{ needs.shared.outputs.short-commit }}
secrets: inherit
deploy:
needs: [shared, build, tests-build, tests-run] # Keep tests-run in needs
if: ${{ always() && (needs.tests-run.result == 'skipped' || needs.tests-run.result == 'success') }}
uses: ./.github/workflows/sidestore-deploy.yml
with:
is_beta: ${{ inputs.is_beta }}
publish: ${{ inputs.publish }}
release_name: ${{ inputs.release_name }}
release_tag: ${{ inputs.release_tag }}
upstream_tag: ${{ inputs.upstream_tag }}
upstream_name: ${{ inputs.upstream_name }}
version: ${{ needs.build.outputs.version }}
short_commit: ${{ needs.shared.outputs.short-commit }}
release_channel: ${{ needs.build.outputs.release-channel }}
marketing_version: ${{ needs.build.outputs.marketing-version }}
bundle_id: ${{ inputs.bundle_id }}
secrets: inherit

View File

@@ -1,358 +0,0 @@
name: SideStore Build
on:
workflow_call:
inputs:
is_beta:
type: boolean
is_shared_build_num:
type: boolean
release_tag:
type: string
bundle_id:
type: string
bundle_id_suffix:
type: string
short_commit:
type: string
secrets:
CROSS_REPO_PUSH_KEY:
required: true
BUILD_LOG_ZIP_PASSWORD:
required: false
outputs:
version:
value: ${{ jobs.build.outputs.version }}
marketing-version:
value: ${{ jobs.build.outputs.marketing-version }}
release-channel:
value: ${{ jobs.build.outputs.release-channel }}
jobs:
build:
name: Build SideStore - ${{ inputs.release_tag }}
strategy:
fail-fast: false
matrix:
include:
- os: 'macos-26'
version: '26.0'
runs-on: ${{ matrix.os }}
outputs:
version: ${{ steps.version.outputs.version }}
marketing-version: ${{ steps.marketing-version.outputs.MARKETING_VERSION }}
release-channel: ${{ steps.release-channel.outputs.RELEASE_CHANNEL }}
steps:
- name: Set beta status
run: echo "IS_BETA=${{ inputs.is_beta }}" >> $GITHUB_ENV
shell: bash
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- name: Install dependencies - ldid & xcbeautify
run: |
brew install ldid xcbeautify
- name: Set ref based on is_shared_build_num
if: ${{ inputs.is_beta }}
id: set_ref
run: |
if [ "${{ inputs.is_shared_build_num }}" == "true" ]; then
echo "ref=main" >> $GITHUB_ENV
else
echo "ref=${{ inputs.release_tag }}" >> $GITHUB_ENV
fi
shell: bash
- name: Checkout SideStore/beta-build-num repo
if: ${{ inputs.is_beta }}
uses: actions/checkout@v4
with:
repository: 'SideStore/beta-build-num'
ref: ${{ env.ref }}
token: ${{ secrets.CROSS_REPO_PUSH_KEY }}
path: 'SideStore/beta-build-num'
- name: Copy build_number.txt to repo root
if: ${{ inputs.is_beta }}
run: |
cp SideStore/beta-build-num/build_number.txt .
echo "cat build_number.txt"
cat build_number.txt
shell: bash
- name: Echo Build.xcconfig
run: |
echo "cat Build.xcconfig"
cat Build.xcconfig
shell: bash
- name: Set Release Channel info for build number bumper
id: release-channel
run: |
RELEASE_CHANNEL="${{ inputs.release_tag }}"
echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}" >> $GITHUB_ENV
echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}" >> $GITHUB_OUTPUT
echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}"
shell: bash
- name: Increase build number for beta builds
if: ${{ inputs.is_beta }}
run: |
bash .github/workflows/increase-beta-build-num.sh
shell: bash
- name: Extract MARKETING_VERSION from Build.xcconfig
id: version
run: |
version=$(grep MARKETING_VERSION Build.xcconfig | sed -e 's/MARKETING_VERSION = //g')
echo "version=$version" >> $GITHUB_OUTPUT
echo "version=$version"
shell: bash
- name: Set MARKETING_VERSION
if: ${{ inputs.is_beta }}
id: marketing-version
run: |
# Extract version number (e.g., "0.6.0")
version=$(echo "${{ steps.version.outputs.version }}" | sed -E 's/^[^0-9]*([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
# Extract date (YYYYMMDD) (e.g., "20250205")
date=$(echo "${{ steps.version.outputs.version }}" | sed -E 's/.*\.([0-9]{4})\.([0-9]{2})\.([0-9]{2})\..*/\1\2\3/')
# Extract build number (e.g., "2")
build_num=$(echo "${{ steps.version.outputs.version }}" | sed -E 's/.*\.([0-9]+)\+.*/\1/')
# Combine them into the final output
MARKETING_VERSION="${version}-${date}.${build_num}+${{ inputs.short_commit }}"
echo "MARKETING_VERSION=$MARKETING_VERSION" >> $GITHUB_ENV
echo "MARKETING_VERSION=$MARKETING_VERSION" >> $GITHUB_OUTPUT
echo "MARKETING_VERSION=$MARKETING_VERSION"
shell: bash
- name: Echo Updated Build.xcconfig, build_number.txt
if: ${{ inputs.is_beta }}
run: |
cat Build.xcconfig
cat build_number.txt
shell: bash
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1.6.0
with:
xcode-version: ${{ matrix.version }}
- name: (Build) Restore Xcode & SwiftPM Cache (Exact match)
id: xcode-cache-restore
uses: actions/cache/restore@v3
with:
path: |
~/Library/Developer/Xcode/DerivedData
~/Library/Caches/org.swift.swiftpm
key: xcode-cache-build-${{ github.ref_name }}-${{ github.sha }}
- name: (Build) Restore Xcode & SwiftPM Cache (Last Available)
id: xcode-cache-restore-recent
uses: actions/cache/restore@v3
with:
path: |
~/Library/Developer/Xcode/DerivedData
~/Library/Caches/org.swift.swiftpm
key: xcode-cache-build-${{ github.ref_name }}-
# - name: (Build) Cache Build
# uses: irgaly/xcode-cache@v1.8.1
# with:
# key: xcode-cache-deriveddata-build-${{ github.ref_name }}-${{ github.sha }}
# restore-keys: xcode-cache-deriveddata-build-${{ github.ref_name }}-
# swiftpm-cache-key: xcode-cache-sourcedata-build-${{ github.ref_name }}-${{ github.sha }}
# swiftpm-cache-restore-keys: |
# xcode-cache-sourcedata-build-${{ github.ref_name }}-
- name: (Build) Clean previous build artifacts
# using 'tee' to intercept stdout and log for detailed build-log
run: |
make clean
mkdir -p build/logs
shell: bash
- name: (Build) List Files and derived data
if: always()
shell: bash
run: |
echo ">>>>>>>>> Workdir <<<<<<<<<<"
ls -la .
echo ""
echo ">>>>>>>>> SideStore <<<<<<<<<<"
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
echo ""
- name: Set BundleID Suffix for Sidestore build
run: |
echo "BUNDLE_ID_SUFFIX=${{ inputs.bundle_id_suffix }}" >> $GITHUB_ENV
shell: bash
- name: Build SideStore.xcarchive
# using 'tee' to intercept stdout and log for detailed build-log
run: |
NSUnbufferedIO=YES make -B build 2>&1 | tee -a build/logs/build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
shell: bash
- name: Fakesign app
run: make fakesign | tee -a build/logs/build.log
shell: bash
- name: Convert to IPA
run: make ipa | tee -a build/logs/build.log
shell: bash
- name: (Build) Save Xcode & SwiftPM Cache
id: cache-save
if: ${{ steps.xcode-cache-restore.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v3
with:
path: |
~/Library/Developer/Xcode/DerivedData
~/Library/Caches/org.swift.swiftpm
key: xcode-cache-build-${{ github.ref_name }}-${{ github.sha }}
- name: (Build) List Files and Build artifacts
run: |
echo ">>>>>>>>> Workdir <<<<<<<<<<"
ls -la .
echo ""
echo ">>>>>>>>> Build <<<<<<<<<<"
find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> SideStore <<<<<<<<<<"
find SideStore -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> SideStore.xcarchive <<<<<<<<<<"
find SideStore.xcarchive -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
echo ""
shell: bash
- name: Encrypt build-logs for upload
id: encrypt-build-log
run: |
DEFAULT_BUILD_LOG_PASSWORD=12345
BUILD_LOG_ZIP_PASSWORD=${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
BUILD_LOG_ZIP_PASSWORD=${BUILD_LOG_ZIP_PASSWORD:-$DEFAULT_BUILD_LOG_PASSWORD}
if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then
echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'."
fi
pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-build-logs.zip * || popd
echo "::set-output name=encrypted::true"
shell: bash
- name: Upload encrypted-build-logs.zip
id: attach-encrypted-build-log
if: ${{ always() && steps.encrypt-build-log.outputs.encrypted == 'true' }}
uses: actions/upload-artifact@v4
with:
name: encrypted-build-logs-${{ steps.version.outputs.version }}.zip
path: encrypted-build-logs.zip
- name: Upload SideStore.ipa Artifact
uses: actions/upload-artifact@v4
with:
name: SideStore-${{ steps.version.outputs.version }}.ipa
path: SideStore.ipa
- name: Zip dSYMs
run: zip -r -9 ./SideStore.dSYMs.zip ./SideStore.xcarchive/dSYMs
shell: bash
- name: Upload *.dSYM Artifact
uses: actions/upload-artifact@v4
with:
name: SideStore-${{ steps.version.outputs.version }}-dSYMs.zip
path: SideStore.dSYMs.zip
- name: Keep rolling the build numbers for each successful build
if: ${{ inputs.is_beta }}
run: |
pushd SideStore/beta-build-num/
echo "Configure Git user (committer details)"
git config user.name "GitHub Actions"
git config user.email "github-actions@github.com"
echo "Adding files to commit"
git add --verbose build_number.txt
git commit -m " - updated for ${{ inputs.release_tag }} - ${{ inputs.short_commit }} deployment" || echo "No changes to commit"
echo "Pushing to remote repo"
git push --verbose
popd
shell: bash
- name: Get last successful commit
id: get_last_commit
run: |
# Try to get the last successful workflow run commit
LAST_SUCCESS_SHA=$(gh run list --branch "${{ github.ref_name }}" --status success --json headSha --jq '.[0].headSha')
echo "LAST_SUCCESS_SHA=$LAST_SUCCESS_SHA" >> $GITHUB_OUTPUT
echo "LAST_SUCCESS_SHA=$LAST_SUCCESS_SHA" >> $GITHUB_ENV
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
- name: Create release notes
run: |
LAST_SUCCESS_SHA=${{ steps.get_last_commit.outputs.LAST_SUCCESS_SHA}}
echo "Last successful commit SHA: $LAST_SUCCESS_SHA"
FROM_COMMIT=$LAST_SUCCESS_SHA
# Check if we got a valid SHA
if [ -z "$LAST_SUCCESS_SHA" ] || [ "$LAST_SUCCESS_SHA" = "null" ]; then
echo "No successful run found, using initial commit of branch"
# Get the first commit of the branch (initial commit)
FROM_COMMIT=$(git rev-list --max-parents=0 HEAD)
fi
python3 update_release_notes.py $FROM_COMMIT ${{ inputs.release_tag }} ${{ github.ref_name }}
# cat release-notes.md
shell: bash
- name: Upload release-notes.md
uses: actions/upload-artifact@v4
with:
name: release-notes-${{ inputs.short_commit }}.md
path: release-notes.md
- name: Upload update_release_notes.py
uses: actions/upload-artifact@v4
with:
name: update_release_notes-${{ inputs.short_commit }}.py
path: update_release_notes.py
- name: Upload update_apps.py
uses: actions/upload-artifact@v4
with:
name: update_apps-${{ inputs.short_commit }}.py
path: update_apps.py

View File

@@ -1,264 +0,0 @@
name: SideStore Deploy
on:
workflow_call:
inputs:
is_beta:
type: boolean
publish:
type: boolean
release_name:
type: string
release_tag:
type: string
upstream_tag:
type: string
upstream_name:
type: string
version:
type: string
short_commit:
type: string
marketing_version:
type: string
release_channel:
type: string
bundle_id:
type: string
secrets:
CROSS_REPO_PUSH_KEY:
required: true
# GITHUB_TOKEN:
# required: true
jobs:
deploy:
name: Deploy SideStore - ${{ inputs.release_tag }}
runs-on: macos-15
steps:
- name: Download IPA artifact
uses: actions/download-artifact@v4
with:
name: SideStore-${{ inputs.version }}.ipa
- name: Download dSYM artifact
uses: actions/download-artifact@v4
with:
name: SideStore-${{ inputs.version }}-dSYMs.zip
- name: Download encrypted-build-logs artifact
uses: actions/download-artifact@v4
with:
name: encrypted-build-logs-${{ inputs.version }}.zip
- name: Download encrypted-tests-build-logs artifact
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_BUILD == '1' }}
uses: actions/download-artifact@v4
with:
name: encrypted-tests-build-logs-${{ inputs.short_commit }}.zip
- name: Download encrypted-tests-run-logs artifact
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
uses: actions/download-artifact@v4
with:
name: encrypted-tests-run-logs-${{ inputs.short_commit }}.zip
- name: Download tests-recording artifact
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
uses: actions/download-artifact@v4
with:
name: tests-recording-${{ inputs.short_commit }}.mp4
- name: Download test-results artifact
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
uses: actions/download-artifact@v4
with:
name: test-results-${{ inputs.short_commit }}.zip
- name: Download release-notes.md
uses: actions/download-artifact@v4
with:
name: release-notes-${{ inputs.short_commit }}.md
- name: Download update_release_notes.py
uses: actions/download-artifact@v4
with:
name: update_release_notes-${{ inputs.short_commit }}.py
- name: Download update_apps.py
uses: actions/download-artifact@v4
with:
name: update_apps-${{ inputs.short_commit }}.py
- name: Read release notes
id: release_notes
run: |
CONTENT=$(python3 update_release_notes.py --retrieve ${{ inputs.release_tag }})
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
shell: bash
- name: List files before upload
run: |
echo ">>>>>>>>> Workdir <<<<<<<<<<"
find . -maxdepth 4 -exec ls -ld {} + || true # List contents if directory exists
echo ""
shell: bash
- name: Get current date
id: date
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
shell: bash
- name: Get current date in AltStore date form
id: date_altstore
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
shell: bash
- name: List files to upload
id: list_uploads
run: |
echo ">>>>>>>>> Workdir <<<<<<<<<<"
find . -maxdepth 4 -exec ls -ld {} + || true # List contents if directory exists
echo ""
FILES="SideStore.ipa SideStore.dSYMs.zip encrypted-build-logs.zip"
if [[ "${{ vars.ENABLE_TESTS }}" == "1" && "${{ vars.ENABLE_TESTS_BUILD }}" == "1" ]]; then
FILES="$FILES encrypted-tests-build-logs.zip"
fi
if [[ "${{ vars.ENABLE_TESTS }}" == "1" && "${{ vars.ENABLE_TESTS_RUN }}" == "1" ]]; then
FILES="$FILES encrypted-tests-run-logs.zip test-results.zip tests-recording.mp4"
fi
echo "Final upload list:"
for f in $FILES; do
if [[ -f "$f" ]]; then
echo " ✓ $f"
else
echo " - $f (missing)"
fi
done
echo "files=$FILES" >> $GITHUB_OUTPUT
- name: Upload to releases
uses: IsaacShelton/update-existing-release@v1.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
release: ${{ inputs.release_name }}
tag: ${{ inputs.release_tag }}
prerelease: ${{ inputs.is_beta }}
files: ${{ steps.list_uploads.outputs.files }}
body: |
This is an ⚠️ **EXPERIMENTAL** ⚠️ ${{ inputs.release_name }} build for commit [${{ github.sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}).
${{ inputs.release_name }} builds are **extremely experimental builds only meant to be used by developers and beta testers. They often contain bugs and experimental features. Use at your own risk!**
If you want to try out new features early but want a lower chance of bugs, you can look at [SideStore ${{ inputs.upstream_name }}](https://github.com/${{ github.repository }}/releases?q=${{ inputs.upstream_tag }}).
## Build Info
Built at (UTC): `${{ steps.date.outputs.date }}`
Built at (UTC date): `${{ steps.date_altstore.outputs.date }}`
Commit SHA: `${{ github.sha }}`
Version: `${{ inputs.version }}`
${{ steps.release_notes.outputs.content }}
- name: Get formatted date
run: |
FORMATTED_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "Formatted date: $FORMATTED_DATE"
echo "FORMATTED_DATE=$FORMATTED_DATE" >> $GITHUB_ENV
shell: bash
- name: Get size of IPA in bytes (macOS/Linux)
run: |
if [[ "$(uname)" == "Darwin" ]]; then
# macOS
IPA_SIZE=$(stat -f %z SideStore.ipa)
else
# Linux
IPA_SIZE=$(stat -c %s SideStore.ipa)
fi
echo "IPA size in bytes: $IPA_SIZE"
echo "IPA_SIZE=$IPA_SIZE" >> $GITHUB_ENV
shell: bash
- name: Compute SHA-256 of IPA
run: |
SHA256_HASH=$(shasum -a 256 SideStore.ipa | awk '{ print $1 }')
echo "SHA-256 Hash: $SHA256_HASH"
echo "SHA256_HASH=$SHA256_HASH" >> $GITHUB_ENV
shell: bash
- name: Set Release Info variables
run: |
# Format localized description
LOCALIZED_DESCRIPTION=$(cat <<EOF
This is release for:
- version: "${{ inputs.version }}"
- revision: "${{ inputs.short_commit }}"
- timestamp: "${{ steps.date.outputs.date }}"
Release Notes:
${{ steps.release_notes.outputs.content }}
EOF
)
echo "IS_BETA=${{ inputs.is_beta }}" >> $GITHUB_ENV
echo "BUNDLE_IDENTIFIER=${{ inputs.bundle_id }}" >> $GITHUB_ENV
echo "VERSION_IPA=${{ inputs.marketing_version }}" >> $GITHUB_ENV
echo "VERSION_DATE=$FORMATTED_DATE" >> $GITHUB_ENV
echo "RELEASE_CHANNEL=${{ inputs.release_channel }}" >> $GITHUB_ENV
echo "SIZE=$IPA_SIZE" >> $GITHUB_ENV
echo "SHA256=$SHA256_HASH" >> $GITHUB_ENV
echo "DOWNLOAD_URL=https://github.com/SideStore/SideStore/releases/download/${{ inputs.release_tag }}/SideStore.ipa" >> $GITHUB_ENV
# multiline strings
echo "LOCALIZED_DESCRIPTION<<EOF" >> $GITHUB_ENV
echo "$LOCALIZED_DESCRIPTION" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
shell: bash
- name: Check if Publish updates is set
id: check_publish
run: |
echo "Publish updates to source.json = ${{ inputs.publish }}"
shell: bash
- name: Checkout SideStore/apps-v2.json
if: ${{ inputs.is_beta && inputs.publish }}
uses: actions/checkout@v4
with:
repository: 'SideStore/apps-v2.json'
ref: 'main' # this branch is shared by all beta builds, so beta build workflows are serialized
token: ${{ secrets.CROSS_REPO_PUSH_KEY }}
path: 'SideStore/apps-v2.json'
# for stable builds, let the user manually edit the source.json
- name: Publish to SideStore/apps-v2.json
if: ${{ inputs.is_beta && inputs.publish }}
id: publish-release
shell: bash
run: |
# Copy and execute the update script
pushd SideStore/apps-v2.json/
# Configure Git user (committer details)
git config user.name "GitHub Actions"
git config user.email "github-actions@github.com"
# update the source.json
python3 ../../update_apps.py "./_includes/source.json"
# Commit changes and push using SSH
git add --verbose ./_includes/source.json
git commit -m " - updated for ${{ inputs.short_commit }} deployment" || echo "No changes to commit"
git push --verbose
popd

View File

@@ -1,24 +0,0 @@
name: SideStore Shared
on:
workflow_call:
outputs:
short-commit:
value: ${{ jobs.shared.outputs.short-commit }}
jobs:
shared:
name: Shared Steps
strategy:
fail-fast: false
runs-on: 'macos-15'
steps:
- name: Set short commit hash
id: commit-id
run: |
# SHORT_COMMIT="${{ github.sha }}"
SHORT_COMMIT=${GITHUB_SHA:0:7}
echo "Short commit hash: $SHORT_COMMIT"
echo "SHORT_COMMIT=$SHORT_COMMIT" >> $GITHUB_OUTPUT
outputs:
short-commit: ${{ steps.commit-id.outputs.SHORT_COMMIT }}

View File

@@ -1,165 +0,0 @@
name: SideStore Tests Build
on:
workflow_call:
inputs:
release_tag:
type: string
short_commit:
type: string
secrets:
BUILD_LOG_ZIP_PASSWORD:
required: false
jobs:
tests-build:
name: Tests-Build SideStore - ${{ inputs.release_tag }}
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_BUILD == '1' }}
strategy:
fail-fast: false
matrix:
include:
- os: 'macos-26'
version: '26.0'
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Install dependencies - xcbeautify
run: |
brew install xcbeautify
shell: bash
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1.6.0
with:
xcode-version: '26.0'
# - name: (Tests-Build) Cache Build
# uses: irgaly/xcode-cache@v1.8.1
# with:
# key: xcode-cache-deriveddata-test-${{ github.ref_name }}-${{ github.sha }}
# # tests shouldn't restore cache unless it is same build
# # restore-keys: xcode-cache-deriveddata-test-${{ github.ref_name }}-
# swiftpm-cache-key: xcode-cache-sourcedata-test-${{ github.ref_name }}-${{ github.sha }}
# swiftpm-cache-restore-keys: |
# xcode-cache-sourcedata-test-${{ github.ref_name }}-
# delete-used-deriveddata-cache: true
- name: (Tests-Build) Restore Xcode & SwiftPM Cache (Exact match)
id: xcode-cache-restore
uses: actions/cache/restore@v3
with:
path: |
~/Library/Developer/Xcode/DerivedData
~/Library/Caches/org.swift.swiftpm
key: xcode-cache-tests-${{ github.ref_name }}-${{ github.sha }}
- name: (Tests-Build) Restore Xcode & SwiftPM Cache (Last Available)
id: xcode-cache-restore-recent
uses: actions/cache/restore@v3
with:
path: |
~/Library/Developer/Xcode/DerivedData
~/Library/Caches/org.swift.swiftpm
key: xcode-cache-tests-${{ github.ref_name }}-
- name: Clean Derived Data (if required)
if: ${{ vars.PERFORM_CLEAN_TESTS_BUILD == '1' }}
run: |
rm -rf ~/Library/Developer/Xcode/DerivedData/
make clean
xcodebuild clean
shell: bash
- name: (Tests-Build) Clean previous build artifacts
run: |
make clean
mkdir -p build/logs
shell: bash
- name: (Tests-Build) List Files and derived data
shell: bash
run: |
echo ">>>>>>>>> Workdir <<<<<<<<<<"
ls -la .
echo ""
echo ">>>>>>>>> SideStore <<<<<<<<<<"
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
echo ""
- name: Build SideStore Tests
# using 'tee' to intercept stdout and log for detailed build-log
shell: bash
run: |
NSUnbufferedIO=YES make -B build-tests 2>&1 | tee -a build/logs/tests-build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
- name: (Tests-Build) Save Xcode & SwiftPM Cache
id: cache-save
if: ${{ steps.xcode-cache-restore.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v3
with:
path: |
~/Library/Developer/Xcode/DerivedData
~/Library/Caches/org.swift.swiftpm
key: xcode-cache-tests-${{ github.ref_name }}-${{ github.sha }}
- name: (Tests-Build) List Files and Build artifacts
if: always()
shell: bash
run: |
echo ">>>>>>>>> Workdir <<<<<<<<<<"
ls -la .
echo ""
echo ">>>>>>>>> Build <<<<<<<<<<"
find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
find ~/Library/Developer/Xcode/DerivedData -maxdepth 8 -exec ls -ld {} + | grep "Build/Products" >> tests-build-deriveddata.txt || true
echo ""
- uses: actions/upload-artifact@v4
if: always()
with:
name: tests-build-deriveddata-${{ inputs.short_commit }}.txt
path: tests-build-deriveddata.txt
- name: Encrypt tests-build-logs for upload
id: encrypt-test-log
if: always()
shell: bash
run: |
DEFAULT_BUILD_LOG_PASSWORD=12345
BUILD_LOG_ZIP_PASSWORD=${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
BUILD_LOG_ZIP_PASSWORD=${BUILD_LOG_ZIP_PASSWORD:-$DEFAULT_BUILD_LOG_PASSWORD}
if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then
echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'."
fi
pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-tests-build-logs.zip * || popd
echo "::set-output name=encrypted::true"
- name: Upload encrypted-tests-build-logs.zip
id: attach-encrypted-test-log
if: always() && steps.encrypt-test-log.outputs.encrypted == 'true'
uses: actions/upload-artifact@v4
with:
name: encrypted-tests-build-logs-${{ inputs.short_commit }}.zip
path: encrypted-tests-build-logs.zip

View File

@@ -1,196 +0,0 @@
name: SideStore Tests Run
on:
workflow_call:
inputs:
release_tag:
type: string
short_commit:
type: string
secrets:
BUILD_LOG_ZIP_PASSWORD:
required: false
jobs:
tests-run:
name: Tests-Run SideStore - ${{ inputs.release_tag }}
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
strategy:
fail-fast: false
matrix:
include:
- os: 'macos-26'
version: '26.0'
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Boot Simulator async(nohup) for testing
run: |
mkdir -p build/logs
nohup make -B boot-sim-async </dev/null >> build/logs/tests-run.log 2>&1 &
shell: bash
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1.6.0
with:
xcode-version: '26.0'
# - name: (Tests-Run) Cache Build
# uses: irgaly/xcode-cache@v1.8.1
# with:
# # This comes from
# key: xcode-cache-deriveddata-test-${{ github.ref_name }}-${{ github.sha }}
# swiftpm-cache-key: xcode-cache-sourcedata-test-${{ github.ref_name }}-${{ github.sha }}
- name: (Tests-Build) Restore Xcode & SwiftPM Cache (Exact match) [from tests-build job]
id: xcode-cache-restore
uses: actions/cache/restore@v3
with:
path: |
~/Library/Developer/Xcode/DerivedData
~/Library/Caches/org.swift.swiftpm
key: xcode-cache-tests-${{ github.ref_name }}-${{ github.sha }}
- name: (Tests-Run) Clean previous build artifacts
run: |
make clean
mkdir -p build/logs
shell: bash
- name: (Tests-Run) List Files and derived data
shell: bash
run: |
echo ">>>>>>>>> Workdir <<<<<<<<<<"
ls -la .
echo ""
echo ">>>>>>>>> SideStore <<<<<<<<<<"
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
find ~/Library/Developer/Xcode/DerivedData -maxdepth 8 -exec ls -ld {} + | grep "Build/Products" >> tests-run-deriveddata.txt || true
echo ""
- uses: actions/upload-artifact@v4
if: always()
with:
name: tests-run-deriveddata-${{ inputs.short_commit }}.txt
path: tests-run-deriveddata.txt
# we expect simulator to have been booted by now, so exit otherwise
- name: Simulator Boot Check
run: |
mkdir -p build/logs
make -B sim-boot-check | tee -a build/logs/tests-run.log
exit ${PIPESTATUS[0]}
shell: bash
- name: Start Recording UI tests (if DEBUG_RECORD_TESTS is set to 1)
if: ${{ vars.DEBUG_RECORD_TESTS == '1' }}
run: |
nohup xcrun simctl io booted recordVideo -f tests-recording.mp4 --codec h264 </dev/null > tests-recording.log 2>&1 &
RECORD_PID=$!
echo "RECORD_PID=$RECORD_PID" >> $GITHUB_ENV
shell: bash
- name: Run SideStore Tests
# using 'tee' to intercept stdout and log for detailed build-log
run: |
make run-tests 2>&1 | tee -a build/logs/tests-run.log && exit ${PIPESTATUS[0]}
# NSUnbufferedIO=YES make -B run-tests 2>&1 | tee build/logs/tests-run.log | xcpretty -r junit --output ./build/tests/test-results.xml && exit ${PIPESTATUS[0]}
shell: bash
- name: Stop Recording tests
if: ${{ always() && env.RECORD_PID != '' }}
run: |
kill -INT ${{ env.RECORD_PID }}
shell: bash
- name: (Tests-Run) List Files and Build artifacts
if: always()
run: |
echo ">>>>>>>>> Workdir <<<<<<<<<<"
ls -la .
echo ""
echo ">>>>>>>>> Build <<<<<<<<<<"
find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
echo ""
shell: bash
- name: Encrypt tests-run-logs for upload
id: encrypt-test-log
if: always()
run: |
DEFAULT_BUILD_LOG_PASSWORD=12345
BUILD_LOG_ZIP_PASSWORD=${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
BUILD_LOG_ZIP_PASSWORD=${BUILD_LOG_ZIP_PASSWORD:-$DEFAULT_BUILD_LOG_PASSWORD}
if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then
echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'."
fi
pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-tests-run-logs.zip * || popd
echo "::set-output name=encrypted::true"
shell: bash
- name: Upload encrypted-tests-run-logs.zip
id: attach-encrypted-test-log
if: always() && steps.encrypt-test-log.outputs.encrypted == 'true'
uses: actions/upload-artifact@v4
with:
name: encrypted-tests-run-logs-${{ inputs.short_commit }}.zip
path: encrypted-tests-run-logs.zip
- name: Print tests-recording.log contents (if exists)
if: ${{ always() && env.RECORD_PID != '' }}
run: |
if [ -f tests-recording.log ]; then
echo "tests-recording.log found. Its contents:"
cat tests-recording.log
else
echo "tests-recording.log not found."
fi
shell: bash
- name: Check for tests-recording.mp4 presence
id: check-recording
if: ${{ always() && env.RECORD_PID != '' }}
run: |
if [ -f tests-recording.mp4 ]; then
echo "::set-output name=found::true"
echo "tests-recording.mp4 found."
else
echo "tests-recording.mp4 not found, skipping upload."
echo "::set-output name=found::false"
fi
shell: bash
- name: Upload tests-recording.mp4
id: upload-recording
if: ${{ always() && steps.check-recording.outputs.found == 'true' }}
uses: actions/upload-artifact@v4
with:
name: tests-recording-${{ inputs.short_commit }}.mp4
path: tests-recording.mp4
- name: Zip test-results
run: zip -r -9 ./test-results.zip ./build/tests
shell: bash
- name: Upload Test Artifacts
uses: actions/upload-artifact@v4
with:
name: test-results-${{ inputs.short_commit }}.zip
path: test-results.zip

View File

@@ -2,241 +2,86 @@ name: Stable SideStore build
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+' # example: 1.0.0
workflow_dispatch:
- '[0-9]+.[0-9]+.[0-9]+' # example: 1.0.0
jobs:
build:
name: Build SideStore - stable (on tag push)
name: Build and upload SideStore
strategy:
fail-fast: false
matrix:
include:
- os: 'macos-26'
version: '26.0'
- os: 'macos-12'
version: '14.2'
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Checkout code
uses: actions/checkout@v2
with:
submodules: recursive
- name: Echo Build.xcconfig
run: |
echo "cat Build.xcconfig"
cat Build.xcconfig
shell: bash
- name: Install dependencies
run: brew install ldid
# - name: Change MARKETING_VERSION to the pushed tag that triggered this build
# run: sed -e '/MARKETING_VERSION = .*/s/= .*/= ${{ github.ref_name }}/' -i '' Build.xcconfig
- name: Change version to tag
run: sed -e '/MARKETING_VERSION = .*/s/= .*/= ${{ github.ref_name }}/' -i '' Build.xcconfig
- name: Echo Updated Build.xcconfig
run: |
cat Build.xcconfig
shell: bash
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1.4.1
with:
xcode-version: ${{ matrix.version }}
- name: Extract MARKETING_VERSION from Build.xcconfig
id: version
run: |
version=$(grep MARKETING_VERSION Build.xcconfig | sed -e 's/MARKETING_VERSION = //g')
echo "version=$version" >> $GITHUB_OUTPUT
echo "version=$version"
- name: Build SideStore
run: make build | xcpretty && exit ${PIPESTATUS[0]}
echo "MARKETING_VERSION=$version" >> $GITHUB_ENV
echo "MARKETING_VERSION=$version" >> $GITHUB_OUTPUT
echo "MARKETING_VERSION=$version"
- name: Fakesign app
run: make fakesign
shell: bash
- name: Fail the build if pushed tag and embedded MARKETING_VERSION in Build.xcconfig are mismatching
run: |
if [ "$MARKETING_VERSION" != "${{ github.ref_name }}" ]; then
echo 'Version mismatch: $tag != $marketing_version ... '
echo " expected-tag : $MARKETING_VERSION"
echo " pushed-tag : ${{ github.ref_name }}"
exit 1
fi
echo 'Version matches: $tag == $marketing_version ... '
echo " expected-tag : $MARKETING_VERSION"
echo " pushed-tag : ${{ github.ref_name }}"
shell: bash
- name: Install dependencies - ldid & xcbeautify
run: |
brew install ldid xcbeautify
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1.6.0
with:
xcode-version: ${{ matrix.version }}
- name: (Build) Restore Xcode & SwiftPM Cache (Exact match)
id: xcode-cache-restore
uses: actions/cache/restore@v3
with:
path: |
~/Library/Developer/Xcode/DerivedData
~/Library/Caches/org.swift.swiftpm
key: xcode-cache-build-stable-${{ github.sha }}
- name: (Build) Restore Xcode & SwiftPM Cache (Last Available)
id: xcode-cache-restore-recent
uses: actions/cache/restore@v3
with:
path: |
~/Library/Developer/Xcode/DerivedData
~/Library/Caches/org.swift.swiftpm
key: xcode-cache-build-stable-
- name: (Build) Clean previous build artifacts
run: |
make clean
mkdir -p build/logs
shell: bash
- name: (Build) List Files and derived data
if: always()
shell: bash
run: |
echo ">>>>>>>>> Workdir <<<<<<<<<<"
ls -la .
echo ""
echo ">>>>>>>>> SideStore <<<<<<<<<<"
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
echo ""
- name: Build SideStore.xcarchive
# using 'tee' to intercept stdout and log for detailed build-log
run: |
NSUnbufferedIO=YES make -B build 2>&1 | tee -a build/logs/build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
shell: bash
- name: Fakesign app
run: make fakesign | tee -a build/logs/build.log
shell: bash
- name: Convert to IPA
run: make ipa | tee -a build/logs/build.log
shell: bash
- name: (Build) Save Xcode & SwiftPM Cache
id: cache-save
if: ${{ steps.xcode-cache-restore.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v3
with:
path: |
~/Library/Developer/Xcode/DerivedData
~/Library/Caches/org.swift.swiftpm
key: xcode-cache-build-stable-${{ github.sha }}
- name: (Build) List Files and Build artifacts
run: |
echo ">>>>>>>>> Workdir <<<<<<<<<<"
ls -la .
echo ""
echo ">>>>>>>>> Build <<<<<<<<<<"
find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> SideStore <<<<<<<<<<"
find SideStore -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> SideStore.xcarchive <<<<<<<<<<"
find SideStore.xcarchive -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
echo ""
shell: bash
- name: Encrypt build-logs for upload
id: encrypt-build-log
run: |
DEFAULT_BUILD_LOG_PASSWORD=12345
BUILD_LOG_ZIP_PASSWORD=${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
BUILD_LOG_ZIP_PASSWORD=${BUILD_LOG_ZIP_PASSWORD:-$DEFAULT_BUILD_LOG_PASSWORD}
if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then
echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'."
fi
pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-build-logs.zip * || popd
echo "::set-output name=encrypted::true"
shell: bash
- name: Upload encrypted-build-logs.zip
id: attach-encrypted-build-log
if: ${{ always() && steps.encrypt-build-log.outputs.encrypted == 'true' }}
uses: actions/upload-artifact@v4
with:
name: encrypted-build-logs-${{ steps.version.outputs.version }}.zip
path: encrypted-build-logs.zip
- name: Convert to IPA
run: make ipa
- name: Upload SideStore.ipa Artifact
uses: actions/upload-artifact@v4
with:
name: SideStore-${{ steps.version.outputs.version }}.ipa
path: SideStore.ipa
- name: Zip dSYMs
run: zip -r -9 ./SideStore.dSYMs.zip ./SideStore.xcarchive/dSYMs
shell: bash
uses: actions/upload-artifact@v3.1.0
with:
name: SideStore.ipa
path: SideStore.ipa
- name: Upload *.dSYM Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3.1.0
with:
name: SideStore-${{ steps.version.outputs.version }}-dSYMs.zip
path: SideStore.dSYMs.zip
name: SideStore-dSYM
path: ./*.dSYM/
- name: Get current date
id: date
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
shell: bash
- name: Get version
id: version
run: echo "version=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_OUTPUT
- name: Get current date in AltStore date form
id: date_altstore
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
shell: bash
- name: Get current date
id: date
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
- name: Upload to releases
uses: IsaacShelton/update-existing-release@v1.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
draft: true
release: ${{ github.ref_name }} # name
tag: ${{ github.ref_name }}
# stick with what the user pushed, do not use latest commit or anything,
# ex: if we want to go back to previous release due to hot issue, dev can create a new tag pointing to that older working tag/commit so as to keep it as an update (to revert major issue)
# in this case we do not want the tag to be auto-updated to latest
updateTag: false
prerelease: false
files: >
SideStore.ipa
SideStore.dSYMs.zip
encrypted-build-logs.zip
body: |
<!-- NOTE: to reset SideSource cache, go to `https://apps.sidestore.io/reset-cache/nightly/<sidesource key>`. This is not included in the GitHub Action since it makes draft releases so they can be edited and have a changelog. -->
## Changelog
- TODO
## Build Info
- name: Get current date in SideStore date form
id: date_sidestore
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
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: Upload to new stable release
uses: softprops/action-gh-release@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
name: ${{ steps.version.outputs.version }}
tag_name: ${{ github.ref_name }}
draft: true
files: SideStore.ipa
body: |
<!-- NOTE: to reset SideSource cache, go to `https://apps.sidestore.io/reset-cache/nightly/<sidesource key>`. This is not included in the GitHub Action since it makes draft releases so they can be edited and have a changelog. -->
## Changelog
- TODO
## Build Info
Built at (UTC): `${{ steps.date.outputs.date }}`
Built at (UTC date): `${{ steps.date_sidestore.outputs.date }}`
Commit SHA: `${{ github.sha }}`
Version: `${{ steps.version.outputs.version }}`

38
.gitignore vendored
View File

@@ -1,18 +1,14 @@
# macOS
#
**/*.DS_Store
*.DS_Store
# Xcode
#
## CocoaPods
Pods/
## Build generated
build/
DerivedData
SideStore.xcarchive
archive.xcarchive
## Various settings
*.pbxuser
!default.pbxuser
@@ -40,33 +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
# some more commandline build artifacts
test-recording.mp4
test-recording.log
altstore-sources.md
local-build.sh

74
.gitmodules vendored
View File

@@ -1,68 +1,6 @@
#-------------------------------
# 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
[submodule "Dependencies/libimobiledevice"]
path = Dependencies/libimobiledevice
url = https://github.com/SideStore/libimobiledevice
[submodule "Dependencies/libusbmuxd"]
path = Dependencies/libusbmuxd
url = https://github.com/libimobiledevice/libusbmuxd.git
[submodule "Dependencies/libplist"]
path = Dependencies/libplist
url = https://github.com/SideStore/libplist.git
[submodule "Dependencies/MarkdownAttributedString"]
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
[submodule "Dependencies/em_proxy"]
path = SideStoreApp/Dependencies/em_proxy
url = https://github.com/SideStore/em_proxy.git
[submodule "Dependencies/minimuxer"]
path = SideStoreApp/Dependencies/minimuxer
url = https://github.com/SideStore/minimuxer.git

28
.jazzy.yaml Normal file
View File

@@ -0,0 +1,28 @@
# ---- About ----
module: SideStore
module_version: 1.0,0
author: SideStore
readme: README.md
copyright: 'See [license](https://github.com/SideStore/SideStore/blob/develop/LICENSE) for more details.'
# ---- URLs ----
author_url: https://sidestore.io
dash_url: https://sidestore.io/docsets/SideStore.xml
github_url: https://github.com/SideStore/SideStore/
github_file_prefix: https://github.com/SideStore/SideStore/tree/1.0.2/
# ---- Sources ----
source_directory: Sources
documentation: .build/x86_64-apple-macosx/debug/SideStore.docc
# ---- Generation ----
clean: true
output: docs
min_acl: public
hide_documentation_coverage: false
skip_undocumented: false
objc: false
swift_version: 5.1.0
# ---- Formatting ----
theme: fullwidth

42
.swiftformat Normal file
View File

@@ -0,0 +1,42 @@
# .swiftformat
## file options
--exclude .build,.github,.swiftpm,.vscode,Configurations,Dependencies
## format options
--allman false
--binarygrouping 4,8
--commas always
--comments indent
--decimalgrouping 3,6
--elseposition same-line
--empty void
--exponentcase lowercase
--exponentgrouping disabled
--fractiongrouping disabled
--header ignore
--hexgrouping 4,8
--hexliteralcase uppercase
--ifdef indent
--importgrouping testable-bottom
--indent 4
--indentcase false
--linebreaks lf
--maxwidth none
--octalgrouping 4,8
--operatorfunc spaced
--patternlet hoist
--ranges spaced
--self remove
--semicolons inline
--stripunusedargs always
--swiftversion 5.1
--trimwhitespace always
--wraparguments preserve
--wrapcollections preserve
## rules
--enable isEmpty,andOperator,assertionFailures

76
.swiftlint.yml Normal file
View File

@@ -0,0 +1,76 @@
disabled_rules:
- block_based_kvo
- colon
- control_statement
- cyclomatic_complexity
- discarded_notification_center_observer
- file_length
- function_parameter_count
- generic_type_name
- identifier_name
- multiple_closures_with_trailing_closure
- nesting
- switch_case_alignment
- todo
- type_name
- type_body_length
- function_body_length
- unused_closure_parameter
# parameterized rules can be customized from this configuration file
line_length: 200
# parameterized rules are first parameterized as a warning level, then error level.
type_body_length:
- 300 # warning
- 600 # error
# parameterized rules are first parameterized as a warning level, then error level.
# identifier_name_max_length:
# - 40 # warning
# - 60 # error
# # parameterized rules are first parameterized as a warning level, then error level.
# identifier_name_min_length:
# - 3 # warning
# - 2 # error
function_body_length:
- 200 # warning
- 500 # error
large_tuple:
- 4 # warning
- 6 # error
opt_in_rules:
- empty_count
- force_unwrapping
excluded: # paths to ignore during linting. overridden byincluded.
- .build
- .github
- .swiftpm
- .vscode
- Dependencies
analyzer_rules: # Rules run by `swiftlint analyze` (experimental)
- explicit_self
# Override these rules to be warnings for now
force_cast: warning
force_try: warning
empty_count: warning
reporter: "xcode" # reporter type (xcode, json, csv, checkstyle, junit)
custom_rules:
placeholders_in_comments:
included: ".*\\.swift"
name: "No Placeholders in Comments"
regex: "<#([^#]+)#>"
match_kinds:
- comment
- doccomment
message: "Placeholder left in comment."
tiles_deprecated:
included: ".*\\.swift"
name: "Tiles are deprecated in favor of Frame"
regex: "([T,t]ile$|^[T,t]il[e,es])"
message: "Tiles are deprecated in favor of Frame"
severity: warning

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

@@ -1,212 +0,0 @@
//
// ViewController.swift
// AltBackup
//
// Created by Riley Testut on 5/11/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import UIKit
extension Bundle
{
var appName: String? {
let appName =
Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ??
Bundle.main.object(forInfoDictionaryKey: kCFBundleNameKey as String) as? String
return appName
}
}
extension ViewController
{
enum BackupOperation
{
case backup
case restore
}
}
class ViewController: UIViewController
{
private let backupController = BackupController()
private var currentOperation: BackupOperation? {
didSet {
DispatchQueue.main.async {
self.update()
}
}
}
private var textLabel: UILabel!
private var detailTextLabel: UILabel!
private var activityIndicatorView: UIActivityIndicatorView!
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?)
{
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.backup), name: AppDelegate.startBackupNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.restore), name: AppDelegate.startRestoreNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.didEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil)
}
required init?(coder: NSCoder) {
fatalError()
}
override func viewDidLoad()
{
super.viewDidLoad()
self.view.backgroundColor = .altstoreBackground
self.textLabel = UILabel(frame: .zero)
self.textLabel.font = UIFont.preferredFont(forTextStyle: .title2)
self.textLabel.textColor = .altstoreText
self.textLabel.textAlignment = .center
self.textLabel.numberOfLines = 0
self.detailTextLabel = UILabel(frame: .zero)
self.detailTextLabel.font = UIFont.preferredFont(forTextStyle: .body)
self.detailTextLabel.textColor = .altstoreText
self.detailTextLabel.textAlignment = .center
self.detailTextLabel.numberOfLines = 0
self.activityIndicatorView = UIActivityIndicatorView(style: .whiteLarge)
self.activityIndicatorView.color = .altstoreText
self.activityIndicatorView.startAnimating()
// TODO: @mahee96: Disabled these backup/restore buttons in altbackup.app screen which were present for debugging purpose.
// Can find something useful for these later, but these are not required by this backup/restore app
// #if DEBUG
// let button1 = UIButton(type: .system)
// button1.setTitle("Backup", for: .normal)
// button1.setTitleColor(.white, for: .normal)
// button1.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
// button1.addTarget(self, action: #selector(ViewController.backup), for: .primaryActionTriggered)
//
// let button2 = UIButton(type: .system)
// button2.setTitle("Restore", for: .normal)
// button2.setTitleColor(.white, for: .normal)
// button2.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
// button2.addTarget(self, action: #selector(ViewController.restore), for: .primaryActionTriggered)
//
// let arrangedSubviews = [self.textLabel!, self.detailTextLabel!, self.activityIndicatorView!, button1, button2]
// #else
let arrangedSubviews = [self.textLabel!, self.detailTextLabel!, self.activityIndicatorView!]
// #endif
let stackView = UIStackView(arrangedSubviews: arrangedSubviews)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.spacing = 22
stackView.axis = .vertical
stackView.alignment = .center
self.view.addSubview(stackView)
NSLayoutConstraint.activate([stackView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
stackView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor),
stackView.leadingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: self.view.safeAreaLayoutGuide.leadingAnchor, multiplier: 1.0),
self.view.safeAreaLayoutGuide.trailingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: stackView.trailingAnchor, multiplier: 1.0)])
self.update()
}
}
private extension ViewController
{
@objc func backup()
{
self.currentOperation = .backup
self.backupController.performBackup { (result) in
let appName = Bundle.main.appName ?? NSLocalizedString("App", comment: "")
let title = String(format: NSLocalizedString("%@ could not be backed up.", comment: ""), appName)
self.process(result, errorTitle: title)
}
}
@objc func restore()
{
self.currentOperation = .restore
self.backupController.restoreBackup { (result) in
let appName = Bundle.main.appName ?? NSLocalizedString("App", comment: "")
let title = String(format: NSLocalizedString("%@ could not be restored.", comment: ""), appName)
self.process(result, errorTitle: title)
}
}
func update()
{
switch self.currentOperation
{
case .backup:
self.textLabel.text = NSLocalizedString("Backing up app data…", comment: "")
self.detailTextLabel.isHidden = true
self.activityIndicatorView.startAnimating()
case .restore:
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: ""))
self.detailTextLabel.text = String(format: NSLocalizedString("Refresh %@ in SideStore to continue using it.", comment: ""),
Bundle.main.appName ?? NSLocalizedString("this app", comment: ""))
self.detailTextLabel.isHidden = false
self.activityIndicatorView.stopAnimating()
}
}
}
private extension ViewController
{
func process(_ result: Result<Void, Error>, errorTitle: String)
{
DispatchQueue.main.async {
switch result
{
case .success: break
case .failure(let error as NSError):
let message: String
if let sourceDescription = error.sourceDescription
{
message = error.localizedDescription + "\n\n" + sourceDescription
}
else
{
message = error.localizedDescription
}
let alertController = UIAlertController(title: errorTitle, message: message, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default, handler: nil))
self.present(alertController, animated: true, completion: nil)
}
NotificationCenter.default.post(name: AppDelegate.operationDidFinishNotification, object: nil, userInfo: [AppDelegate.operationResultKey: result])
}
}
// 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).
self.currentOperation = nil
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:AltStore.xcodeproj">
</FileRef>
<FileRef
location = "group:SideStore/AltSign">
</FileRef>
<FileRef
location = "group:Dependencies/Roxas/Roxas.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -1,8 +0,0 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
#import "NSAttributedString+Markdown.h"
#import "ALTAppPatcher.h"
#include "fragmentzip.h"

View File

@@ -1,203 +0,0 @@
//
// AppContentViewController.swift
// AltStore
//
// Created by Riley Testut on 7/22/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import AltStoreCore
import Roxas
import Nuke
extension AppContentViewController
{
private enum Row: Int, CaseIterable
{
case subtitle
case screenshots
case description
case versionDescription
case permissions
}
}
final class AppContentViewController: UITableViewController
{
var app: StoreApp!
// private lazy var screenshotsDataSource = self.makeScreenshotsDataSource()
private lazy var dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .none
return dateFormatter
}()
private lazy var byteCountFormatter: ByteCountFormatter = {
let formatter = ByteCountFormatter()
return formatter
}()
@IBOutlet private var subtitleLabel: UILabel!
// @IBOutlet private var descriptionTextView: CollapsingTextView!
@IBOutlet private var descriptionTextView: CollapsingMarkdownView!
// @IBOutlet private var versionDescriptionTextView: CollapsingTextView!
@IBOutlet private var versionDescriptionTextView: CollapsingMarkdownView!
@IBOutlet private var versionLabel: UILabel!
@IBOutlet private var versionDateLabel: UILabel!
@IBOutlet private var sizeLabel: UILabel!
@IBOutlet private(set) var appScreenshotsViewController: AppScreenshotsViewController!
@IBOutlet private var appScreenshotsHeightConstraint: NSLayoutConstraint!
@IBOutlet private(set) var appDetailCollectionViewController: AppDetailCollectionViewController!
@IBOutlet private var appDetailCollectionViewHeightConstraint: NSLayoutConstraint!
override func viewDidLoad() {
super.viewDidLoad()
self.tableView.contentInset.bottom = 20
self.subtitleLabel.text = self.app.subtitle
let desc = self.app.localizedDescription
self.descriptionTextView.text = desc
if let version = self.app.latestAvailableVersion {
self.versionDescriptionTextView.text = version.localizedDescription ?? "nil"
self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), version.localizedVersion)
self.versionDateLabel.text = Date().relativeDateString(since: version.date)
self.sizeLabel.text = ByteCountFormatter.string(fromByteCount: version.size, countStyle: .file)
} else {
self.versionDescriptionTextView.text = "nil"
self.versionLabel.text = nil
self.versionDateLabel.text = nil
self.sizeLabel.text = ByteCountFormatter.string(fromByteCount: 0, countStyle: .file)
}
self.descriptionTextView.maximumNumberOfLines = 5
self.versionDescriptionTextView.maximumNumberOfLines = 5
self.descriptionTextView.toggleButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
self.versionDescriptionTextView.toggleButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
}
override func viewDidLayoutSubviews()
{
super.viewDidLayoutSubviews()
var needsTableViewUpdate = false
let screenshotsHeight = self.appScreenshotsViewController.collectionView.contentSize.height
if self.appScreenshotsHeightConstraint.constant != screenshotsHeight && screenshotsHeight > 0
{
self.appScreenshotsHeightConstraint.constant = screenshotsHeight
needsTableViewUpdate = true
}
let permissionsHeight = self.appDetailCollectionViewController.collectionView.contentSize.height
if self.appDetailCollectionViewHeightConstraint.constant != permissionsHeight && permissionsHeight > 0
{
self.appDetailCollectionViewHeightConstraint.constant = permissionsHeight
needsTableViewUpdate = true
}
if needsTableViewUpdate
{
UIView.performWithoutAnimation {
// Update row height without animation.
self.tableView.beginUpdates()
self.tableView.endUpdates()
}
}
}
}
private extension AppContentViewController
{
@IBSegueAction
func makeAppScreenshotsViewController(_ coder: NSCoder, sender: Any?) -> UIViewController?
{
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 icon = UIImage(systemName: permission.symbolName ?? "lock")
cell.button.setImage(icon, for: .normal)
cell.textLabel.text = permission.localizedDisplayName
}
return dataSource
}
@IBSegueAction
func makeAppDetailCollectionViewController(_ coder: NSCoder, sender: Any?) -> UIViewController?
{
let appDetailViewController = AppDetailCollectionViewController(app: self.app, coder: coder)
self.appDetailCollectionViewController = appDetailViewController
return appDetailViewController
}
}
private extension AppContentViewController
{
@objc func toggleCollapsingSection(_ sender: UIButton)
{
let indexPath: IndexPath
switch sender
{
case self.descriptionTextView.toggleButton:
indexPath = IndexPath(row: Row.description.rawValue, section: 0)
case self.versionDescriptionTextView.toggleButton:
indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0)
default: return
}
// Disable animations to prevent some potentially strange ones.
UIView.performWithoutAnimation {
self.tableView.reloadRows(at: [indexPath], with: .none)
}
}
}
extension AppContentViewController
{
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath)
{
cell.tintColor = self.app.tintColor
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat
{
switch Row.allCases[indexPath.row]
{
case .screenshots:
guard !self.app.allScreenshots.isEmpty else { return 0.0 }
return UITableView.automaticDimension
case .permissions:
guard !self.app.permissions.isEmpty else { return 0.0 }
return UITableView.automaticDimension
default:
return super.tableView(tableView, heightForRowAt: indexPath)
}
}
}

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

@@ -1,669 +0,0 @@
//
// AppViewController.swift
// AltStore
//
// Created by Riley Testut on 7/22/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import AltStoreCore
import Roxas
import Nuke
final class AppViewController: UIViewController
{
var app: StoreApp!
private var contentViewController: AppContentViewController!
private var contentViewControllerShadowView: UIView!
private var blurAnimator: UIViewPropertyAnimator?
private var navigationBarAnimator: UIViewPropertyAnimator?
private var contentSizeObservation: NSKeyValueObservation?
@IBOutlet private var scrollView: UIScrollView!
@IBOutlet private var contentView: UIView!
@IBOutlet private var bannerView: AppBannerView!
@IBOutlet private var backButton: UIButton!
@IBOutlet private var backButtonContainerView: UIVisualEffectView!
@IBOutlet private var backgroundAppIconImageView: UIImageView!
@IBOutlet private var backgroundBlurView: UIVisualEffectView!
@IBOutlet private var navigationBarTitleView: UIView!
@IBOutlet private var navigationBarDownloadButton: PillButton!
@IBOutlet private var navigationBarAppIconImageView: UIImageView!
@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
}
}
override func viewDidLoad()
{
super.viewDidLoad()
self.navigationBarTitleView.sizeToFit()
self.navigationItem.titleView = self.navigationBarTitleView
// spacing in storyboard wasn't working, so had to do programatically
if let stackView = self.navigationBarTitleView as? UIStackView {
stackView.spacing = 8
}
self.contentViewControllerShadowView = UIView()
self.contentViewControllerShadowView.backgroundColor = .white
self.contentViewControllerShadowView.layer.cornerRadius = 38
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.contentViewController.view.superview?.insertSubview(self.contentViewControllerShadowView, at: 0)
self.contentView.addGestureRecognizer(self.scrollView.panGestureRecognizer)
self.contentViewController.view.layer.cornerRadius = 38
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.
self.view.bringSubviewToFront(self.scrollView)
self.scrollView.isUserInteractionEnabled = false
self.bannerView.frame = CGRect(x: 0, y: 0, width: 300, height: 93)
self.bannerView.backgroundEffectView.effect = UIBlurEffect(style: .regular)
self.bannerView.backgroundEffectView.backgroundColor = .clear
self.bannerView.iconImageView.image = nil
self.bannerView.iconImageView.tintColor = self.app.tintColor
self.bannerView.button.tintColor = self.app.tintColor
self.bannerView.tintColor = self.app.tintColor
self.bannerView.accessibilityTraits.remove(.button)
self.bannerView.button.addTarget(self, action: #selector(AppViewController.performAppAction(_:)), for: .primaryActionTriggered)
self.backButtonContainerView.tintColor = self.app.tintColor
self.navigationBarDownloadButton.tintColor = self.app.tintColor
self.navigationBarAppNameLabel.text = self.app.name
self.navigationBarAppIconImageView.tintColor = self.app.tintColor
self.contentSizeObservation = self.contentViewController.tableView.observe(\.contentSize) { [weak self] (tableView, change) in
self?.view.setNeedsLayout()
self?.view.layoutIfNeeded()
}
self.update()
NotificationCenter.default.addObserver(self, selector: #selector(AppViewController.didChangeApp(_:)), name: .NSManagedObjectContextObjectsDidChange, object: DatabaseManager.shared.viewContext)
NotificationCenter.default.addObserver(self, selector: #selector(AppViewController.willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(AppViewController.didBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil)
self._backgroundBlurEffect = self.backgroundBlurView.effect as? UIBlurEffect
self._backgroundBlurTintColor = self.backgroundBlurView.contentView.backgroundColor
// Load Images
for imageView in [self.bannerView.iconImageView!, self.backgroundAppIconImageView!, self.navigationBarAppIconImageView!]
{
imageView.isIndicatingActivity = true
Nuke.loadImage(with: self.app.iconURL, options: .shared, into: imageView, progress: nil) { [weak imageView] (result) in
switch result
{
case .success: imageView?.isIndicatingActivity = false
case .failure(let error): print("[ALTLog] Failed to load app icons.", error)
}
}
}
// Start with navigation bar hidden.
self.hideNavigationBar()
}
override func viewWillAppear(_ animated: Bool)
{
super.viewWillAppear(animated)
self.prepareBlur()
// 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
}
override func viewDidAppear(_ animated: Bool)
{
super.viewDidAppear(animated)
self._viewDidAppear = true
self._shouldResetLayout = true
self.view.setNeedsLayout()
self.view.layoutIfNeeded()
}
override func viewDidDisappear(_ animated: Bool)
{
super.viewDidDisappear(animated)
if self.navigationController == nil
{
self.resetNavigationBarAnimation()
}
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?)
{
guard segue.identifier == "embedAppContentViewController" else { return }
self.contentViewController = segue.destination as? AppContentViewController
self.contentViewController.app = self.app
if #available(iOS 15, *)
{
// Fix navigation bar + tab bar appearance on iOS 15.
self.setContentScrollView(self.scrollView)
}
}
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 = 12 as CGFloat
let padding = 20 as CGFloat
let backButtonSize = self.backButton.sizeThatFits(CGSize(width: 1000, height: 1000))
var backButtonFrame = CGRect(x: inset, y: statusBarHeight,
width: backButtonSize.width + 20, height: backButtonSize.height + 20)
var headerFrame = CGRect(x: inset, y: 0, width: self.view.bounds.width - inset * 2, height: self.bannerView.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 minimumHeaderY = backButtonFrame.maxY + 8
let minimumContentY = minimumHeaderY + headerFrame.height + padding
let maximumContentY = self.view.bounds.width * 0.667
// A full blur is too much, so we reduce the visible blur by 0.3, resulting in 70% blur.
let minimumBlurFraction = 0.3 as CGFloat
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
let blurThreshold = 0 as CGFloat
if self.scrollView.contentOffset.y < blurThreshold
{
// 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
}
// Animate navigation bar.
let showNavigationBarThreshold = (maximumContentY - minimumContentY) + 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 - minimumContentY)
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.tableView.contentOffset.y = difference
}
else
{
// Keep content table view's content offset at the top.
self.contentViewController.tableView.contentOffset.y = 0
}
// 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.superview?.frame = contentFrame
self.bannerView.frame = headerFrame
self.backgroundAppIconImageView.frame = backgroundIconFrame
self.backgroundBlurView.frame = backgroundIconFrame
self.backButtonContainerView.frame = backButtonFrame
self.contentViewControllerShadowView.frame = self.contentViewController.view.frame
self.backButtonContainerView.layer.cornerRadius = self.backButtonContainerView.bounds.midY
self.scrollView.verticalScrollIndicatorInsets.top = statusBarHeight
// Adjust content offset + size.
let contentOffset = self.scrollView.contentOffset
var contentSize = self.contentViewController.tableView.contentSize
contentSize.height += maximumContentY
self.scrollView.contentSize = contentSize
self.scrollView.contentOffset = contentOffset
self.bannerView.backgroundEffectView.backgroundColor = .clear
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?)
{
super.traitCollectionDidChange(previousTraitCollection)
if self._viewDidAppear
{
self._shouldResetLayout = true
}
}
deinit
{
self.blurAnimator?.stopAnimation(true)
self.navigationBarAnimator?.stopAnimation(true)
}
}
extension AppViewController
{
final class func makeAppViewController(app: StoreApp) -> AppViewController
{
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let appViewController = storyboard.instantiateViewController(withIdentifier: "appViewController") as! AppViewController
appViewController.app = app
return appViewController
}
}
private extension AppViewController
{
func update()
{
var buttonAction: AppBannerView.AppAction?
// if let installedApp = self.app.installedApp, let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion), !self.app.isPledgeRequired || self.app.isPledged
if let installedApp = self.app.installedApp, installedApp.hasUpdate
{
// Explicitly set button action to .update if there is an update available, even if it's not supported.
buttonAction = .update
}
for button in [self.bannerView.button!, self.navigationBarDownloadButton!]
{
button.tintColor = self.app.tintColor
button.isIndicatingActivity = false
}
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
let barButtonItem = self.navigationItem.rightBarButtonItem
self.navigationItem.rightBarButtonItem = nil
self.navigationItem.rightBarButtonItem = barButtonItem
}
func showNavigationBar()
{
self.navigationBarAppIconImageView.alpha = 1.0
self.navigationBarAppNameLabel.alpha = 1.0
self.navigationBarDownloadButton.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.navigationBarAppIconImageView.alpha = 0.0
self.navigationBarAppNameLabel.alpha = 0.0
self.navigationBarDownloadButton.alpha = 0.0
self.updateNavigationBarAppearance(isHidden: true)
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
}
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 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
}
}
extension AppViewController
{
@IBAction func popViewController(_ sender: UIButton)
{
self.navigationController?.popViewController(animated: true)
}
@IBAction func performAppAction(_ sender: PillButton)
{
if let installedApp = self.app.installedApp
{
// if let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion), !self.app.isPledgeRequired || self.app.isPledged
if let latestVersion = self.app.latestAvailableVersion, installedApp.hasUpdate
{
self.updateApp(installedApp, to: latestVersion)
}
else
{
self.open(installedApp)
}
}
else
{
self.downloadApp()
}
}
func downloadApp()
{
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)
}
}
DispatchQueue.main.async {
self.bannerView.button.progress = nil
self.navigationBarDownloadButton.progress = nil
self.update()
}
}
if !group.progress.isCancelled
{
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
{
@objc func didChangeApp(_ notification: Notification)
{
// Async so that AppManager.installationProgress(for:) is nil when we update.
DispatchQueue.main.async {
self.update()
}
}
@objc func willEnterForeground(_ notification: Notification)
{
guard let navigationController = self.navigationController, navigationController.topViewController == self else { return }
self._shouldResetLayout = true
self.view.setNeedsLayout()
}
@objc func didBecomeActive(_ notification: Notification)
{
guard let navigationController = self.navigationController, navigationController.topViewController == self else { return }
// Fixes Navigation Bar appearing after app becomes inactive -> active again.
self._shouldResetLayout = true
self.view.setNeedsLayout()
}
}
extension AppViewController: UIScrollViewDelegate
{
func scrollViewDidScroll(_ scrollView: UIScrollView)
{
self.view.setNeedsLayout()
self.view.layoutIfNeeded()
}
}

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

@@ -1,255 +0,0 @@
//
// AppIDsViewController.swift
// AltStore
//
// Created by Riley Testut on 1/27/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import UIKit
import AltStoreCore
import Roxas
final class AppIDsViewController: UICollectionViewController
{
private lazy var dataSource = self.makeDataSource()
private var didInitialFetch = false
private var isLoading = false {
didSet {
self.update()
}
}
@IBOutlet var activityIndicatorBarButtonItem: UIBarButtonItem!
override func viewDidLoad()
{
super.viewDidLoad()
self.collectionView.dataSource = self.dataSource
self.activityIndicatorBarButtonItem.isIndicatingActivity = true
let refreshControl = UIRefreshControl()
refreshControl.addTarget(self, action: #selector(AppIDsViewController.fetchAppIDs), for: .primaryActionTriggered)
self.collectionView.refreshControl = refreshControl
}
override func viewWillAppear(_ animated: Bool)
{
super.viewWillAppear(animated)
if !self.didInitialFetch
{
self.fetchAppIDs()
}
}
}
private extension AppIDsViewController
{
func makeDataSource() -> RSTFetchedResultsCollectionViewDataSource<AppID>
{
let fetchRequest = AppID.fetchRequest() as NSFetchRequest<AppID>
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \AppID.name, ascending: true),
NSSortDescriptor(keyPath: \AppID.bundleIdentifier, ascending: true),
NSSortDescriptor(keyPath: \AppID.expirationDate, ascending: true)]
fetchRequest.returnsObjectsAsFaults = false
if let team = DatabaseManager.shared.activeTeam()
{
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(AppID.team), team)
}
else
{
fetchRequest.predicate = NSPredicate(value: false)
}
let dataSource = RSTFetchedResultsCollectionViewDataSource<AppID>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
dataSource.proxy = self
dataSource.cellConfigurationHandler = { (cell, appID, indexPath) in
let tintColor = UIColor.altPrimary
let cell = cell as! AppBannerCollectionViewCell
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
cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "")
let attributedAccessibilityLabel = NSMutableAttributedString(string: appID.name + ". ")
if let expirationDate = appID.expirationDate
{
cell.bannerView.button.isHidden = false
cell.bannerView.button.isUserInteractionEnabled = false
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)
// formatter.includesTimeRemainingPhrase = true
attributedAccessibilityLabel.mutableString.append(timeIntervalText)
}
else
{
cell.bannerView.button.isHidden = true
cell.bannerView.button.isUserInteractionEnabled = true
cell.bannerView.buttonLabel.isHidden = true
}
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())
{
// Prefer to speak the team ID one character at a time.
let nsRange = NSRange(range, in: attributedBundleIdentifier.string)
attributedBundleIdentifier.addAttributes([.accessibilitySpeechSpellOut: true], range: nsRange)
}
attributedAccessibilityLabel.append(attributedBundleIdentifier)
cell.bannerView.accessibilityAttributedLabel = attributedAccessibilityLabel
// Make sure refresh button is correct size.
cell.layoutIfNeeded()
}
return dataSource
}
@objc func fetchAppIDs()
{
guard !self.isLoading else { return }
self.isLoading = true
AppManager.shared.fetchAppIDs { (result) in
do
{
let (_, context) = try result.get()
try context.save()
}
catch
{
DispatchQueue.main.async {
let toastView = ToastView(error: error)
toastView.show(in: self)
}
}
DispatchQueue.main.async {
self.isLoading = false
}
}
}
func update()
{
if !self.isLoading
{
self.collectionView.refreshControl?.endRefreshing()
self.activityIndicatorBarButtonItem.isIndicatingActivity = false
}
}
}
extension AppIDsViewController: UICollectionViewDelegateFlowLayout
{
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
{
return CGSize(width: collectionView.bounds.width, height: 80)
}
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)
// // Use this view to calculate the optimal size based on the collection view's width
// let size = headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingCompressedSize.height),
// withHorizontalFittingPriority: .required, // Width is fixed
// verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed
// return size
// NOTE: double dequeue of cell has been discontinued
// TODO: Using harcoded value until this is fixed
return CGSize(width: collectionView.bounds.width, height: 200)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize
{
return CGSize(width: collectionView.bounds.width, height: 50)
}
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
{
switch kind
{
case UICollectionView.elementKindSectionHeader:
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Header", for: indexPath) as! TextCollectionReusableView
headerView.layoutMargins.left = self.view.layoutMargins.left
headerView.layoutMargins.right = self.view.layoutMargins.right
if let activeTeam = DatabaseManager.shared.activeTeam(), activeTeam.type == .free
{
let text = NSLocalizedString("""
Each app and app extension installed with SideStore must register an App ID with Apple. Apple limits non-developer Apple IDs to 10 App IDs at a time.
**App IDs can't be deleted**, but they do expire after one week. SideStore will automatically renew App IDs for all active apps once they've expired.
""", comment: "")
let attributedText = NSAttributedString(markdownRepresentation: text, attributes: [.font: headerView.textLabel.font as Any])
headerView.textLabel.attributedText = attributedText
}
else
{
headerView.textLabel.text = NSLocalizedString("""
Each app and app extension installed with SideStore must register an App ID with Apple.
App IDs for paid developer accounts never expire, and there is no limit to how many you can create.
""", comment: "")
}
return headerView
case UICollectionView.elementKindSectionFooter:
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Footer", for: indexPath) as! TextCollectionReusableView
let count = self.dataSource.itemCount
if count == 1
{
footerView.textLabel.text = NSLocalizedString("1 App ID", comment: "")
}
else
{
footerView.textLabel.text = String(format: NSLocalizedString("%@ App IDs", comment: ""), NSNumber(value: count))
}
return footerView
default: fatalError()
}
}
}

View File

@@ -1,534 +0,0 @@
//
// AppDelegate.swift
// AltStore
//
// Created by Riley Testut on 5/9/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import UserNotifications
import AVFoundation
import Intents
import AltStoreCore
import AltSign
import Roxas
import EmotionalDamage
import Nuke
extension UIApplication: LegacyBackgroundFetching {}
extension AppDelegate
{
static let openPatreonSettingsDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".OpenPatreonSettingsDeepLinkNotification")
static let importAppDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".ImportAppDeepLinkNotification")
static let addSourceDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".AddSourceDeepLinkNotification")
static let appBackupDidFinish = Notification.Name(Bundle.Info.appbundleIdentifier + ".AppBackupDidFinish")
static let exportCertificateNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".ExportCertificateNotification")
static let importAppDeepLinkURLKey = "fileURL"
static let appBackupResultKey = "result"
static let addSourceDeepLinkURLKey = "sourceURL"
static let exportCertificateCallbackTemplateKey = "callback"
}
@UIApplicationMain
final class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private let intentHandler = IntentHandler()
private let viewAppIntentHandler = ViewAppIntentHandler()
public let consoleLog = ConsoleLog()
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool
{
// navigation bar buttons spacing is too much (so hack it to use minimal spacing)
// this is swift-5 specific behavior and might change
// https://stackoverflow.com/a/64988363/11971304
//
// Warning: this affects all screens through out the app, and basically overrides storyboard
let stackViewAppearance = UIStackView.appearance(whenContainedInInstancesOf: [UINavigationBar.self])
stackViewAppearance.spacing = -8 // adjust as needed
consoleLog.startCapturing()
print("===================================================")
print("| App is Starting up |")
print("===================================================")
print("| Console Logger started capturing output streams |")
print("===================================================")
print("\n ")
// Override point for customization after application launch.
// UserDefaults.standard.setValue(true, forKey: "com.apple.CoreData.MigrationDebug")
// UserDefaults.standard.setValue(true, forKey: "com.apple.CoreData.SQLDebug")
// Register default settings before doing anything else.
UserDefaults.registerDefaults()
// Recreate Database if requested
// NOTE: Userdefaults are local to the SideStore.app sandbox and are not shared
if UserDefaults.standard.recreateDatabaseOnNextStart{
// reset the state
UserDefaults.standard.recreateDatabaseOnNextStart = false
// re-create database
DatabaseManager.recreateDatabase()
}
DatabaseManager.shared.start { (error) in
if let error = error
{
print("Failed to start DatabaseManager. Error:", error as Any)
}
else
{
print("Started DatabaseManager.")
}
}
AnalyticsManager.shared.start()
self.setTintColor()
self.prepareImageCache()
// TODO: @mahee96: find if we need to start em_proxy as in altstore?
if UserDefaults.standard.enableEMPforWireguard {
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
}
SecureValueTransformer.register()
if UserDefaults.standard.firstLaunch == nil
{
Keychain.shared.reset()
UserDefaults.standard.firstLaunch = Date()
}
UserDefaults.standard.preferredServerID = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.serverID) as? String
#if DEBUG && targetEnvironment(simulator)
UserDefaults.standard.isDebugModeEnabled = true
#endif
self.prepareForBackgroundFetch()
return true
}
func applicationDidEnterBackground(_ application: UIApplication)
{
// Make sure to update SceneDelegate.sceneDidEnterBackground() as well.
// TODO: @mahee96: find if we need to stop em_proxy as in altstore?
if UserDefaults.standard.enableEMPforWireguard {
stop_em_proxy()
}
guard let oneMonthAgo = Calendar.current.date(byAdding: .month, value: -1, to: Date()) else { return }
let midnightOneMonthAgo = Calendar.current.startOfDay(for: oneMonthAgo)
DatabaseManager.shared.purgeLoggedErrors(before: midnightOneMonthAgo) { result in
switch result
{
case .success: break
case .failure(let error): print("[ALTLog] Failed to purge logged errors before \(midnightOneMonthAgo).", error)
}
}
}
func applicationWillEnterForeground(_ application: UIApplication)
{
AppManager.shared.update()
if UserDefaults.standard.enableEMPforWireguard {
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
}
PatreonAPI.shared.refreshPatreonAccount()
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) -> Bool
{
return self.open(url)
}
func application(_ application: UIApplication, handlerFor intent: INIntent) -> Any?
{
switch intent
{
case is RefreshAllIntent: return self.intentHandler
case is ViewAppIntent: return self.viewAppIntentHandler
default: return nil
}
}
func applicationWillTerminate(_ application: UIApplication) {
// Stop console logging and clean up resources
print("\n ")
print("===================================================")
print("| Console Logger stopped capturing output streams |")
print("===================================================")
print("| App is being terminated |")
print("===================================================")
consoleLog.stopCapturing()
}
}
extension AppDelegate
{
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration
{
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>)
{
// Called when the user discards a scene session.
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
}
private extension AppDelegate
{
func setTintColor()
{
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
{
guard url.pathExtension.lowercased() == "ipa" else { return false }
DispatchQueue.main.async {
NotificationCenter.default.post(name: AppDelegate.importAppDeepLinkNotification, object: nil, userInfo: [AppDelegate.importAppDeepLinkURLKey: url])
}
return true
}
else
{
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false }
guard let host = components.host?.lowercased() else { return false }
switch host
{
case "patreon":
DispatchQueue.main.async {
NotificationCenter.default.post(name: AppDelegate.openPatreonSettingsDeepLinkNotification, object: nil)
}
return true
case "appbackupresponse":
let result: Result<Void, Error>
switch url.path.lowercased()
{
case "/success": result = .success(())
case "/failure":
let queryItems = components.queryItems?.reduce(into: [String: String]()) { $0[$1.name] = $1.value } ?? [:]
guard
let errorDomain = queryItems["errorDomain"],
let errorCodeString = queryItems["errorCode"], let errorCode = Int(errorCodeString),
let errorDescription = queryItems["errorDescription"]
else { return false }
let error = NSError(domain: errorDomain, code: errorCode, userInfo: [NSLocalizedDescriptionKey: errorDescription])
result = .failure(error)
default: return false
}
NotificationCenter.default.post(name: AppDelegate.appBackupDidFinish, object: nil, userInfo: [AppDelegate.appBackupResultKey: result])
return true
case "install":
let queryItems = components.queryItems?.reduce(into: [String: String]()) { $0[$1.name.lowercased()] = $1.value } ?? [:]
guard let downloadURLString = queryItems["url"], let downloadURL = URL(string: downloadURLString) else { return false }
DispatchQueue.main.async {
NotificationCenter.default.post(name: AppDelegate.importAppDeepLinkNotification, object: nil, userInfo: [AppDelegate.importAppDeepLinkURLKey: downloadURL])
}
return true
case "source":
let queryItems = components.queryItems?.reduce(into: [String: String]()) { $0[$1.name.lowercased()] = $1.value } ?? [:]
guard let sourceURLString = queryItems["url"], let sourceURL = URL(string: sourceURLString) else { return false }
DispatchQueue.main.async {
NotificationCenter.default.post(name: AppDelegate.addSourceDeepLinkNotification, object: nil, userInfo: [AppDelegate.addSourceDeepLinkURLKey: sourceURL])
}
return true
case "pairing":
let queryItems = components.queryItems?.reduce(into: [String: String]()) { $0[$1.name.lowercased()] = $1.value } ?? [:]
guard let callbackTemplate = queryItems["urlName"]?.removingPercentEncoding else { return false }
DispatchQueue.main.async {
exportPairingFile(callbackTemplate)
}
return true
case "certificate":
let queryItems = components.queryItems?.reduce(into: [String: String]()) { $0[$1.name.lowercased()] = $1.value } ?? [:]
guard let callbackTemplate = queryItems["callback_template"]?.removingPercentEncoding else { return false }
DispatchQueue.main.async {
NotificationCenter.default.post(name: AppDelegate.exportCertificateNotification, object: nil, userInfo: [AppDelegate.exportCertificateCallbackTemplateKey: callbackTemplate])
}
return true
default: return false
}
}
}
}
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)
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { (success, error) in
}
#if DEBUG && targetEnvironment(simulator)
UIApplication.shared.registerForRemoteNotifications()
#endif
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)
{
let tokenParts = deviceToken.map { data -> String in
return String(format: "%02.2hhx", data)
}
let token = tokenParts.joined()
print("Push Token:", token)
}
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void)
{
self.application(application, performFetchWithCompletionHandler: completionHandler)
}
func application(_ application: UIApplication, performFetchWithCompletionHandler backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void)
{
if UserDefaults.standard.isBackgroundRefreshEnabled && !UserDefaults.standard.presentedLaunchReminderNotification
{
let threeHours: TimeInterval = 3 * 60 * 60
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: threeHours, repeats: false)
let content = UNMutableNotificationContent()
content.title = NSLocalizedString("App Refresh Tip", comment: "")
content.body = NSLocalizedString("The more you open SideStore, the more chances it's given to refresh apps in the background.", comment: "")
let request = UNNotificationRequest(identifier: "background-refresh-reminder5", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)
UserDefaults.standard.presentedLaunchReminderNotification = true
}
BackgroundTaskManager.shared.performExtendedBackgroundTask { (taskResult, taskCompletionHandler) in
if let error = taskResult.error
{
print("Error starting extended background task. Aborting.", error)
backgroundFetchCompletionHandler(.failed)
taskCompletionHandler()
return
}
if !DatabaseManager.shared.isStarted
{
DatabaseManager.shared.start() { (error) in
if error != nil
{
backgroundFetchCompletionHandler(.failed)
taskCompletionHandler()
}
else
{
self.performBackgroundFetch { (backgroundFetchResult) in
backgroundFetchCompletionHandler(backgroundFetchResult)
} refreshAppsCompletionHandler: { (refreshAppsResult) in
taskCompletionHandler()
}
}
}
}
else
{
self.performBackgroundFetch { (backgroundFetchResult) in
backgroundFetchCompletionHandler(backgroundFetchResult)
} refreshAppsCompletionHandler: { (refreshAppsResult) in
taskCompletionHandler()
}
}
}
}
func performBackgroundFetch(backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void,
refreshAppsCompletionHandler: @escaping (Result<[String: Result<InstalledApp, Error>], Error>) -> Void)
{
self.fetchSources { (result) in
switch result
{
case .failure: backgroundFetchCompletionHandler(.failed)
case .success: backgroundFetchCompletionHandler(.newData)
}
if !UserDefaults.standard.isBackgroundRefreshEnabled
{
refreshAppsCompletionHandler(.success([:]))
}
}
guard UserDefaults.standard.isBackgroundRefreshEnabled else { return }
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
let installedApps = InstalledApp.fetchAppsForBackgroundRefresh(in: context)
AppManager.shared.backgroundRefresh(installedApps, completionHandler: refreshAppsCompletionHandler)
}
}
}
private extension AppDelegate
{
func fetchSources(completionHandler: @escaping (Result<Set<Source>, Error>) -> Void)
{
AppManager.shared.fetchSources() { (result) in
do
{
let (sources, context) = try result.get()
let previousUpdatesFetchRequest = InstalledApp.supportedUpdatesFetchRequest() as! NSFetchRequest<NSFetchRequestResult>
previousUpdatesFetchRequest.includesPendingChanges = false
previousUpdatesFetchRequest.resultType = .dictionaryResultType
previousUpdatesFetchRequest.propertiesToFetch = [#keyPath(InstalledApp.bundleIdentifier),
#keyPath(InstalledApp.storeApp.latestSupportedVersion.version),
#keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion)]
let previousNewsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NSFetchRequestResult>
previousNewsItemsFetchRequest.includesPendingChanges = false
previousNewsItemsFetchRequest.resultType = .dictionaryResultType
previousNewsItemsFetchRequest.propertiesToFetch = [#keyPath(NewsItem.identifier)]
let previousUpdates = try context.fetch(previousUpdatesFetchRequest) as! [[String: String]]
let previousNewsItems = try context.fetch(previousNewsItemsFetchRequest) as! [[String: String]]
try context.save()
let updatesFetchRequest = InstalledApp.supportedUpdatesFetchRequest()
let newsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NewsItem>
let updates = try context.fetch(updatesFetchRequest)
let newsItems = try context.fetch(newsItemsFetchRequest)
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 }
}
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.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
for newsItem in newsItems
{
guard !previousNewsItems.contains(where: { $0[#keyPath(NewsItem.identifier)] == newsItem.identifier }) else { continue }
guard !newsItem.isSilent else { continue }
let content = UNMutableNotificationContent()
if let app = newsItem.storeApp
{
content.title = String(format: NSLocalizedString("%@ News", comment: ""), app.name)
}
else
{
content.title = NSLocalizedString("SideStore News", comment: "")
}
content.body = newsItem.title
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
DispatchQueue.main.async {
UIApplication.shared.applicationIconBadgeNumber = updates.count
}
completionHandler(.success(sources))
}
catch
{
print("Error fetching apps:", error)
completionHandler(.failure(error))
}
}
}
}

View File

@@ -1,185 +0,0 @@
//
// AuthenticationViewController.swift
// AltStore
//
// Created by Riley Testut on 9/5/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import AltSign
final class AuthenticationViewController: UIViewController
{
var authenticationHandler: ((String, String, @escaping (Result<(ALTAccount, ALTAppleAPISession), Error>) -> Void) -> Void)?
var completionHandler: (((ALTAccount, ALTAppleAPISession, String)?) -> Void)?
private weak var toastView: ToastView?
@IBOutlet private var appleIDTextField: UITextField!
@IBOutlet private var passwordTextField: UITextField!
@IBOutlet private var signInButton: UIButton!
@IBOutlet private var appleIDBackgroundView: UIView!
@IBOutlet private var passwordBackgroundView: UIView!
@IBOutlet private var scrollView: UIScrollView!
@IBOutlet private var contentStackView: UIStackView!
override func viewDidLoad()
{
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!]
{
view.clipsToBounds = true
view.layer.cornerRadius = 16
}
if UIScreen.main.isExtraCompactHeight
{
self.contentStackView.spacing = 20
}
NotificationCenter.default.addObserver(self, selector: #selector(AuthenticationViewController.textFieldDidChangeText(_:)), name: UITextField.textDidChangeNotification, object: self.appleIDTextField)
NotificationCenter.default.addObserver(self, selector: #selector(AuthenticationViewController.textFieldDidChangeText(_:)), name: UITextField.textDidChangeNotification, object: self.passwordTextField)
self.update()
}
override func viewDidDisappear(_ animated: Bool)
{
super.viewDidDisappear(animated)
self.signInButton.isIndicatingActivity = false
self.toastView?.dismiss()
}
}
private extension AuthenticationViewController
{
func update()
{
if let _ = self.validate()
{
self.signInButton.isEnabled = true
self.signInButton.alpha = 1.0
}
else
{
self.signInButton.isEnabled = false
self.signInButton.alpha = 0.6
}
}
func validate() -> (String, String)?
{
guard
let emailAddress = self.appleIDTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines), !emailAddress.isEmpty,
let password = self.passwordTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines), !password.isEmpty
else { return nil }
return (emailAddress, password)
}
}
private extension AuthenticationViewController
{
@IBAction func authenticate()
{
guard let (emailAddress, password) = self.validate() else { return }
self.appleIDTextField.resignFirstResponder()
self.passwordTextField.resignFirstResponder()
self.signInButton.isIndicatingActivity = true
self.authenticationHandler?(emailAddress, password) { (result) in
switch result
{
case .failure(ALTAppleAPIError.requiresTwoFactorAuthentication):
// Ignore
DispatchQueue.main.async {
self.signInButton.isIndicatingActivity = false
}
case .failure(let error as NSError):
DispatchQueue.main.async {
let error = error.withLocalizedTitle(NSLocalizedString("Failed to Log In", comment: ""))
let toastView = ToastView(error: error)
toastView.show(in: self)
toastView.backgroundColor = .white
toastView.textLabel.textColor = .altPrimary
toastView.detailTextLabel.textColor = .altPrimary
self.toastView = toastView
self.signInButton.isIndicatingActivity = false
}
case .success((let account, let session)):
self.completionHandler?((account, session, password))
}
DispatchQueue.main.async {
self.scrollView.setContentOffset(CGPoint(x: 0, y: -self.view.safeAreaInsets.top), animated: true)
}
}
}
@IBAction func cancel(_ sender: UIBarButtonItem)
{
self.completionHandler?(nil)
}
}
extension AuthenticationViewController: UITextFieldDelegate
{
func textFieldShouldReturn(_ textField: UITextField) -> Bool
{
switch textField
{
case self.appleIDTextField: self.passwordTextField.becomeFirstResponder()
case self.passwordTextField: self.authenticate()
default: break
}
self.update()
return false
}
func textFieldDidBeginEditing(_ textField: UITextField)
{
guard UIScreen.main.isExtraCompactHeight else { return }
// Position all the controls within visible frame.
var contentOffset = self.scrollView.contentOffset
contentOffset.y = 44
self.scrollView.setContentOffset(contentOffset, animated: true)
}
}
extension AuthenticationViewController
{
@objc func textFieldDidChangeText(_ notification: Notification)
{
self.update()
}
}

View File

@@ -1,54 +0,0 @@
//
// InstructionsViewController.swift
// AltStore
//
// Created by Riley Testut on 9/6/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
final class InstructionsViewController: UIViewController
{
var completionHandler: (() -> Void)?
var showsBottomButton: Bool = false
@IBOutlet private var contentStackView: UIStackView!
@IBOutlet private var dismissButton: UIButton!
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
override func viewDidLoad()
{
super.viewDidLoad()
if UIScreen.main.isExtraCompactHeight
{
self.contentStackView.layoutMargins.top = 0
self.contentStackView.layoutMargins.bottom = self.contentStackView.layoutMargins.left
}
self.dismissButton.clipsToBounds = true
self.dismissButton.layer.cornerRadius = 16
if self.showsBottomButton
{
self.navigationItem.hidesBackButton = true
}
else
{
self.dismissButton.isHidden = true
}
}
}
private extension InstructionsViewController
{
@IBAction func dismiss()
{
self.completionHandler?()
}
}

View File

@@ -1,61 +0,0 @@
//
// SelectTeamViewController.swift
// AltStore
//
// Created by Megarushing on 4/26/21.
// Copyright © 2021 Riley Testut. All rights reserved.
//
import UIKit
import SafariServices
import MessageUI
import Intents
import IntentsUI
import AltSign
final class SelectTeamViewController: UITableViewController
{
public var teams: [ALTTeam]?
public var completionHandler: ((Result<ALTTeam, Swift.Error>) -> Void)?
private var prototypeHeaderFooterView: SettingsHeaderFooterView!
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return teams?.count ?? 0
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
return self.completionHandler!(.success((self.teams?[indexPath.row])!))
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "TeamCell", for: indexPath) as! InsetGroupTableViewCell
cell.textLabel?.text = self.teams?[indexPath.row].name
cell.detailTextLabel?.text = self.teams?[indexPath.row].type.localizedDescription
if indexPath.row == 0
{
cell.style = InsetGroupTableViewCell.Style.top
} else if indexPath.row == self.tableView(self.tableView, numberOfRowsInSection: indexPath.section) - 1 {
cell.style = InsetGroupTableViewCell.Style.bottom
} else {
cell.style = InsetGroupTableViewCell.Style.middle
}
return cell
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
"Teams"
}
}

View File

@@ -1,692 +0,0 @@
//
// BrowseViewController.swift
// AltStore
//
// Created by Riley Testut on 7/15/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import Combine
import minimuxer
import AltStoreCore
import Roxas
import Nuke
class BrowseViewController: UICollectionViewController, PeekPopPreviewing
{
// 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 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 cachedItemSizes = [String: CGSize]()
@IBOutlet private var sourcesBarButtonItem: UIBarButtonItem!
override func viewDidLoad()
{
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)]
self.navigationItem.searchController = self.dataSource.searchController
self.prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
self.collectionView.register(AppCardCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
self.collectionView.dataSource = self.dataSource
self.collectionView.prefetchDataSource = self.dataSource
let collectionViewLayout = self.collectionViewLayout as! UICollectionViewFlowLayout
collectionViewLayout.minimumLineSpacing = 30
(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()
}
override func viewWillAppear(_ animated: Bool)
{
super.viewWillAppear(animated)
self.update()
}
override func viewDidDisappear(_ animated: Bool)
{
super.viewDidDisappear(animated)
self.navigationController?.navigationBar.tintColor = nil
}
}
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 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
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.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
let tintColor = app.tintColor ?? .altPrimary
cell.tintColor = tintColor
}
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
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 = { [weak dataSource] (cell, image, indexPath, error) in
let cell = cell as! AppCardCollectionViewCell
cell.bannerView.iconImageView.isIndicatingActivity = false
cell.bannerView.iconImageView.image = image
if let error = error, let dataSource
{
let app = dataSource.item(at: indexPath)
Logger.main.debug("Failed to load app icon from \(app.iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
}
}
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
}
func updateSources()
{
AppManager.shared.updateAllSources { result in
self.collectionView.refreshControl?.endRefreshing()
guard case .failure(let error) = result else { return }
if self.dataSource.itemCount > 0
{
let toastView = ToastView(error: error)
toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside)
toastView.show(in: self)
}
}
}
func update()
{
if self.searchPredicate != nil
{
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: "")
self.placeholderView.detailTextLabel.isHidden = false
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]
}
}
private extension BrowseViewController
{
@IBAction 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 app = self.dataSource.item(at: indexPath)
// if let installedApp = app.installedApp, !installedApp.isUpdateAvailable
if let installedApp = app.installedApp, !installedApp.hasUpdate
{
self.open(installedApp)
}
else
{
self.install(app, at: indexPath)
}
}
func install(_ app: StoreApp, at indexPath: IndexPath)
{
let previousProgress = AppManager.shared.installationProgress(for: app)
guard previousProgress == nil else {
previousProgress?.cancel()
return
}
if !minimuxer.ready() {
let toastView = ToastView(error: MinimuxerError.NoConnection)
toastView.show(in: self)
return
}
Task<Void, Never>(priority: .userInitiated) { @MainActor in
// if let installedApp = app.installedApp, installedApp.isUpdateAvailable
if let installedApp = app.installedApp, installedApp.hasUpdate
{
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
}
else
{
await AppManager.shared.installAsync(app, presentingViewController: self, completionHandler: finish(_:))
}
UIView.performWithoutAnimation {
self.collectionView.reloadItems(at: [indexPath])
}
}
@MainActor
func finish(_ result: Result<InstalledApp, Error>)
{
DispatchQueue.main.async {
switch result
{
case .failure(OperationError.cancelled): break // Ignore
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))
}
}
}
}
}
func open(_ installedApp: InstalledApp)
{
UIApplication.shared.open(installedApp.openAppURL)
}
}
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]
{
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)
widthConstraint.isActive = true
defer { widthConstraint.isActive = false }
// Manually update cell width & layout so we can accurately calculate screenshot sizes.
self.prototypeCell.frame.size.width = widthConstraint.constant
self.prototypeCell.layoutIfNeeded()
let itemSize = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
self.cachedItemSizes[itemID] = 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)
}
}
extension BrowseViewController: UIViewControllerPreviewingDelegate
{
@available(iOS, deprecated: 13.0)
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController?
{
guard
let indexPath = self.collectionView.indexPathForItem(at: location),
let cell = self.collectionView.cellForItem(at: indexPath)
else { return nil }
previewingContext.sourceRect = cell.frame
let app = self.dataSource.item(at: indexPath)
let appViewController = AppViewController.makeAppViewController(app: app)
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,747 +0,0 @@
//
// FeaturedViewController.swift
// AltStore
//
// Created by Riley Testut on 11/8/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import UIKit
import AltStoreCore
import Roxas
import Nuke
extension UIAction.Identifier
{
fileprivate static let showAllApps = Self("io.sidestore.ShowAllApps")
fileprivate static let showSourceDetails = Self("io.sidestore.ShowSourceDetails")
}
extension FeaturedViewController
{
// Open-ended because each Source is its own section
private struct Section: RawRepresentable, Equatable
{
static let recentlyUpdated = Section(rawValue: 0)
static let categories = Section(rawValue: 1)
static let featuredHeader = Section(rawValue: 2)
let rawValue: Int
var isFeaturedAppsSection: Bool {
return self.rawValue > Section.featuredHeader.rawValue
}
init(rawValue: Int)
{
self.rawValue = rawValue
}
}
private enum ReuseID: String
{
case recent = "RecentCell"
case category = "CategoryCell"
case featuredApp = "FeaturedAppCell"
}
private enum ElementKind: String
{
case sectionHeader
case sourceHeader
case button
}
}
class FeaturedViewController: UICollectionViewController
{
private lazy var dataSource = self.makeDataSource()
private lazy var recentlyUpdatedDataSource = self.makeRecentlyUpdatedDataSource()
private lazy var categoriesDataSource = self.makeCategoriesDataSource()
private lazy var featuredAppsDataSource = self.makeFeaturedAppsDataSource()
private var searchController: RSTSearchController!
private var searchBrowseViewController: BrowseViewController!
override func viewDidLoad()
{
super.viewDidLoad()
self.title = NSLocalizedString("Browse", comment: "")
let layout = Self.makeLayout()
self.collectionView.collectionViewLayout = layout
self.dataSource.proxy = self
self.collectionView.dataSource = self.dataSource
self.collectionView.prefetchDataSource = self.dataSource
self.collectionView.register(AppBannerCollectionViewCell.self, forCellWithReuseIdentifier: ReuseID.recent.rawValue)
self.collectionView.register(LargeIconCollectionViewCell.self, forCellWithReuseIdentifier: ReuseID.category.rawValue)
self.collectionView.register(AppCardCollectionViewCell.self, forCellWithReuseIdentifier: ReuseID.featuredApp.rawValue)
self.collectionView.register(UICollectionViewListCell.self, forSupplementaryViewOfKind: ElementKind.sectionHeader.rawValue, withReuseIdentifier: ElementKind.sectionHeader.rawValue)
self.collectionView.register(IconButtonCollectionReusableView.self, forSupplementaryViewOfKind: ElementKind.sourceHeader.rawValue, withReuseIdentifier: ElementKind.sourceHeader.rawValue)
self.collectionView.register(ButtonCollectionReusableView.self, forSupplementaryViewOfKind: ElementKind.button.rawValue, withReuseIdentifier: ElementKind.button.rawValue)
self.collectionView.backgroundColor = .altBackground
self.collectionView.directionalLayoutMargins.leading = 20
self.collectionView.directionalLayoutMargins.trailing = 20
let storyboard = UIStoryboard(name: "Main", bundle: nil)
self.searchBrowseViewController = storyboard.instantiateViewController(identifier: "browseViewController") { coder in
let browseViewController = BrowseViewController(coder: coder)
return browseViewController
}
self.searchController = RSTSearchController(searchResultsController: self.searchBrowseViewController)
self.searchController.searchableKeyPaths = [#keyPath(StoreApp.name),
#keyPath(StoreApp.developerName),
#keyPath(StoreApp.subtitle),
#keyPath(StoreApp.bundleIdentifier)]
self.searchController.searchHandler = { [weak searchBrowseViewController] (searchValue, _) in
searchBrowseViewController?.searchPredicate = searchValue.predicate
return nil
}
self.navigationItem.searchController = self.searchController
self.navigationItem.hidesSearchBarWhenScrolling = true
self.navigationItem.largeTitleDisplayMode = .always
}
override func viewDidAppear(_ animated: Bool)
{
super.viewDidAppear(animated)
self.navigationController?.navigationBar.tintColor = .altPrimary
}
}
private extension FeaturedViewController
{
class func makeLayout() -> UICollectionViewCompositionalLayout
{
let config = UICollectionViewCompositionalLayoutConfiguration()
config.interSectionSpacing = 0 // Must be 0 for Section.featuredHeader
config.contentInsetsReference = .layoutMargins
let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
let section = Section(rawValue: sectionIndex)
let spacing = 10.0
let interSectionSpacing = 30.0
let titleSize = NSCollectionLayoutSize(widthDimension: .estimated(100), heightDimension: .estimated(30))
switch section
{
case .recentlyUpdated:
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(AppBannerView.standardHeight))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(AppBannerView.standardHeight * 2 + spacing))
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item, item]) // 2 items per group
group.interItemSpacing = .fixed(spacing)
let layoutSection = NSCollectionLayoutSection(group: group)
layoutSection.interGroupSpacing = spacing
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
layoutSection.contentInsets.bottom = interSectionSpacing
layoutSection.boundarySupplementaryItems = [
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sectionHeader.rawValue, alignment: .topLeading)
]
return layoutSection
case .categories:
let itemWidth = (layoutEnvironment.container.effectiveContentSize.width - spacing) / 2
let itemHeight = 90.0
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth), heightDimension: .absolute(itemHeight))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(itemHeight))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, item]) // 2 items per group
group.interItemSpacing = .fixed(spacing)
let layoutSection = NSCollectionLayoutSection(group: group)
layoutSection.interGroupSpacing = spacing
layoutSection.orthogonalScrollingBehavior = .none
layoutSection.contentInsets.bottom = interSectionSpacing
layoutSection.boundarySupplementaryItems = [
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sectionHeader.rawValue, alignment: .topLeading)
]
return layoutSection
case .featuredHeader:
// We don't want to show any items, so set height to 1.0
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
let layoutSection = NSCollectionLayoutSection(group: group)
layoutSection.contentInsets.top = 0
layoutSection.contentInsets.bottom = 0
layoutSection.boundarySupplementaryItems = [
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sectionHeader.rawValue, alignment: .topLeading)
]
return layoutSection
case _ where section.isFeaturedAppsSection:
let itemHeight: NSCollectionLayoutDimension = if #available(iOS 17, *) { .uniformAcrossSiblings(estimate: 350) } else { .estimated(350) }
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: itemHeight)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: itemHeight)
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
group.interItemSpacing = .fixed(spacing)
let titleHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sourceHeader.rawValue, alignment: .topLeading)
let buttonSize = NSCollectionLayoutSize(widthDimension: .estimated(44), heightDimension: .estimated(20))
let buttonHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: buttonSize, elementKind: ElementKind.button.rawValue, alignment: .topTrailing)
let layoutSection = NSCollectionLayoutSection(group: group)
layoutSection.interGroupSpacing = spacing
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
layoutSection.contentInsets.top = 8
layoutSection.contentInsets.bottom = interSectionSpacing
layoutSection.boundarySupplementaryItems = [titleHeader, buttonHeader]
return layoutSection
default: return nil
}
}, configuration: config)
return layout
}
func makeDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>
{
let featuredHeaderDataSource = RSTDynamicCollectionViewDataSource<StoreApp>()
featuredHeaderDataSource.numberOfSectionsHandler = { 1 }
featuredHeaderDataSource.numberOfItemsHandler = { _ in 0 }
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>(dataSources: [self.recentlyUpdatedDataSource, self.categoriesDataSource, featuredHeaderDataSource, self.featuredAppsDataSource])
dataSource.predicate = StoreApp.visibleAppsPredicate // Ensure we never accidentally show hidden apps
return dataSource
}
func makeRecentlyUpdatedDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>
{
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
fetchRequest.returnsObjectsAsFaults = false
fetchRequest.sortDescriptors = [
NSSortDescriptor(keyPath: \StoreApp.latestSupportedVersion?.date, ascending: false),
NSSortDescriptor(keyPath: \StoreApp.name, ascending: true),
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true),
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true),
]
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
dataSource.cellIdentifierHandler = { _ in ReuseID.recent.rawValue }
dataSource.liveFetchLimit = 10 // Show 10 most recently updated apps
dataSource.cellConfigurationHandler = { cell, storeApp, indexPath in
let cell = cell as! AppBannerCollectionViewCell
cell.tintColor = storeApp.tintColor
cell.contentView.preservesSuperviewLayoutMargins = false
cell.contentView.layoutMargins = .zero
cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.configure(for: storeApp)
if let versionDate = storeApp.latestSupportedVersion?.date
{
cell.bannerView.subtitleLabel.text = Date().relativeDateString(since: versionDate, dateFormatter: Date.mediumDateFormatter)
}
cell.bannerView.button.addTarget(self, action: #selector(FeaturedViewController.performAppAction), for: .primaryActionTriggered)
cell.bannerView.iconImageView.image = nil
cell.bannerView.iconImageView.isIndicatingActivity = true
}
dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in
return RSTAsyncBlockOperation { (operation) in
storeApp.managedObjectContext?.perform {
ImagePipeline.shared.loadImage(with: storeApp.iconURL, progress: nil) { result in
guard !operation.isCancelled else { return operation.finish() }
switch result
{
case .success(let response): completion(response.image, nil)
case .failure(let error): completion(nil, error)
}
}
}
}
}
dataSource.prefetchCompletionHandler = { [weak dataSource] (cell, image, indexPath, error) in
let cell = cell as! AppBannerCollectionViewCell
cell.bannerView.iconImageView.image = image
cell.bannerView.iconImageView.isIndicatingActivity = false
if let error, let dataSource
{
let app = dataSource.item(at: indexPath)
Logger.main.debug("Failed to app icon from \(app.iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
}
}
return dataSource
}
func makeCategoriesDataSource() -> RSTCompositeCollectionViewDataSource<StoreApp>
{
let knownCategories = StoreCategory.allCases.filter { $0 != .other }.map { $0.rawValue }
let knownFetchRequest = StoreApp.fetchRequest()
knownFetchRequest.predicate = NSPredicate(format: "%K IN %@", #keyPath(StoreApp._category), knownCategories)
knownFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp._category, ascending: true),
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true),
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true)]
let unknownFetchRequest = StoreApp.fetchRequest()
unknownFetchRequest.predicate = StoreApp.otherCategoryPredicate
unknownFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp._category, ascending: true),
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true),
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true)]
let knownController = NSFetchedResultsController(fetchRequest: knownFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(StoreApp._category), cacheName: nil)
let knownDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: knownController)
knownDataSource.liveFetchLimit = 1 // One app per category
let unknownController = NSFetchedResultsController(fetchRequest: unknownFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: nil, cacheName: nil)
let unknownDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: unknownController)
unknownDataSource.liveFetchLimit = 1
// Use composite data source to ensure "Other" category is always last.
let dataSource = RSTCompositeCollectionViewDataSource<StoreApp>(dataSources: [knownDataSource, unknownDataSource])
dataSource.shouldFlattenSections = true // Combine into single section, with one StoreApp per category.
dataSource.cellIdentifierHandler = { _ in ReuseID.category.rawValue }
dataSource.cellConfigurationHandler = { cell, storeApp, indexPath in
let category = storeApp.category ?? .other
let cell = cell as! LargeIconCollectionViewCell
cell.textLabel.text = category.localizedName
cell.imageView.image = UIImage(systemName: category.symbolName)
var background = UIBackgroundConfiguration.clear()
background.backgroundColor = category.tintColor
background.cornerRadius = 16
cell.backgroundConfiguration = background
}
return dataSource
}
func makeFeaturedAppsDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>
{
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
fetchRequest.returnsObjectsAsFaults = false
fetchRequest.sortDescriptors = [
// Sort by Source first to group into sections.
NSSortDescriptor(keyPath: \StoreApp._source?.featuredSortID, ascending: true),
// Show uninstalled apps first.
// Sorting by StoreApp.installedApp crashes because InstalledApp does not respond to compare:
// Instead, sort by StoreApp.installedApp.storeApp.source.sourceIdentifier, which will be either nil OR source ID.
NSSortDescriptor(keyPath: \StoreApp.installedApp?.storeApp?.sourceIdentifier, ascending: true),
// Show featured apps first.
// Sorting by StoreApp.featuringSource crashes because Source does not respond to compare:
// Instead, sort by StoreApp.featuringSource.identifier, which will be either nil OR source ID.
NSSortDescriptor(keyPath: \StoreApp.featuringSource?.identifier, ascending: false),
// Randomize order within sections.
NSSortDescriptor(keyPath: \StoreApp.featuredSortID, ascending: true),
// Sanity check to ensure stable ordering
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true)
]
let sourceHasRemainingAppsPredicate = NSPredicate(format:
"""
SUBQUERY(%K, $app,
($app.%K != %@) AND ($app.%K == nil) AND (($app.%K == NO) OR ($app.%K == NO) OR ($app.%K == YES))
).@count > 0
""",
#keyPath(StoreApp._source._apps),
#keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID,
#keyPath(StoreApp.installedApp),
#keyPath(StoreApp.isPledgeRequired), #keyPath(StoreApp.isHiddenWithoutPledge), #keyPath(StoreApp.isPledged)
)
let primaryFetchRequest = fetchRequest.copy() as! NSFetchRequest<StoreApp>
primaryFetchRequest.predicate = sourceHasRemainingAppsPredicate
let primaryController = NSFetchedResultsController(fetchRequest: primaryFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(StoreApp._source.featuredSortID), cacheName: nil)
let primaryDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: primaryController)
primaryDataSource.liveFetchLimit = 5
let secondaryFetchRequest = fetchRequest.copy() as! NSFetchRequest<StoreApp>
secondaryFetchRequest.predicate = NSCompoundPredicate(notPredicateWithSubpredicate: sourceHasRemainingAppsPredicate)
let secondaryController = NSFetchedResultsController(fetchRequest: secondaryFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(StoreApp._source.featuredSortID), cacheName: nil)
let secondaryDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: secondaryController)
secondaryDataSource.liveFetchLimit = 5
// Ensure sources with no remaining apps always come last.
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>(dataSources: [primaryDataSource, secondaryDataSource])
dataSource.cellIdentifierHandler = { _ in ReuseID.featuredApp.rawValue }
dataSource.cellConfigurationHandler = { cell, storeApp, indexPath in
let cell = cell as! AppCardCollectionViewCell
cell.configure(for: storeApp)
cell.prefersPagingScreenshots = false
cell.bannerView.button.addTarget(self, action: #selector(FeaturedViewController.performAppAction), for: .primaryActionTriggered)
cell.bannerView.sourceIconImageView.isHidden = true
cell.bannerView.iconImageView.image = nil
cell.bannerView.iconImageView.isIndicatingActivity = true
}
dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in
return RSTAsyncBlockOperation { (operation) in
storeApp.managedObjectContext?.perform {
ImagePipeline.shared.loadImage(with: storeApp.iconURL, progress: nil) { result in
guard !operation.isCancelled else { return operation.finish() }
switch result
{
case .success(let response): completion(response.image, nil)
case .failure(let error): completion(nil, error)
}
}
}
}
}
dataSource.prefetchCompletionHandler = { [weak dataSource] (cell, image, indexPath, error) in
let cell = cell as! AppCardCollectionViewCell
cell.bannerView.iconImageView.image = image
cell.bannerView.iconImageView.isIndicatingActivity = false
if let error = error, let dataSource
{
let app = dataSource.item(at: indexPath)
Logger.main.debug("Failed to app icon from \(app.iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
}
}
return dataSource
}
}
private extension FeaturedViewController
{
@IBSegueAction
func makeBrowseViewController(_ coder: NSCoder, sender: Any) -> UIViewController?
{
if let category = sender as? StoreCategory
{
let browseViewController = BrowseViewController(category: category, coder: coder)
return browseViewController
}
else if let source = sender as? Source
{
let browseViewController = BrowseViewController(source: source, coder: coder)
return browseViewController
}
else
{
let browseViewController = BrowseViewController(coder: coder)
return browseViewController
}
}
@IBSegueAction
func makeSourceDetailViewController(_ coder: NSCoder, sender: Any?) -> UIViewController?
{
guard let source = sender as? Source else { return nil }
let sourceDetailViewController = SourceDetailViewController(source: source, coder: coder)
return sourceDetailViewController
}
func showAllApps(for source: Source)
{
self.performSegue(withIdentifier: "showBrowseViewController", sender: source)
}
func showSourceDetails(for source: Source)
{
self.performSegue(withIdentifier: "showSourceDetails", sender: source)
}
}
private extension FeaturedViewController
{
@objc func performAppAction(_ sender: PillButton)
{
let point = self.collectionView.convert(sender.center, from: sender.superview)
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
let storeApp = self.dataSource.item(at: indexPath)
// if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable
if let installedApp = storeApp.installedApp, !installedApp.hasUpdate
{
self.open(installedApp)
}
else
{
self.install(storeApp, at: indexPath)
}
}
@objc func install(_ storeApp: StoreApp, at indexPath: IndexPath)
{
let previousProgress = AppManager.shared.installationProgress(for: storeApp)
guard previousProgress == nil else {
previousProgress?.cancel()
return
}
// if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable
if let installedApp = storeApp.installedApp, installedApp.hasUpdate
{
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
}
else
{
AppManager.shared.install(storeApp, presentingViewController: self, completionHandler: finish(_:))
}
UIView.performWithoutAnimation {
self.collectionView.reloadItems(at: [indexPath])
}
func finish(_ result: Result<InstalledApp, Error>)
{
DispatchQueue.main.async {
switch result
{
case .failure(OperationError.cancelled): break // Ignore
case .failure(let error):
let toastView = ToastView(error: error)
toastView.opensErrorLog = true
toastView.show(in: self)
case .success:
Logger.main.info("Installed app \(storeApp.bundleIdentifier, privacy: .public) from FeaturedViewController.")
}
for indexPath in self.collectionView.indexPathsForVisibleItems
{
// Only need to reload if it's still visible.
let item = self.dataSource.item(at: indexPath)
guard item == storeApp else { continue }
UIView.performWithoutAnimation {
self.collectionView.reloadItems(at: [indexPath])
}
}
}
}
}
func open(_ installedApp: InstalledApp)
{
UIApplication.shared.open(installedApp.openAppURL)
}
}
extension FeaturedViewController
{
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
{
let section = Section(rawValue: indexPath.section)
switch kind
{
case ElementKind.sourceHeader.rawValue:
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! IconButtonCollectionReusableView
let indexPath = IndexPath(item: 0, section: indexPath.section)
let storeApp = self.dataSource.item(at: indexPath)
var content = UIListContentConfiguration.plainHeader()
content.text = storeApp.source?.name ?? NSLocalizedString("Unknown Source", comment: "")
content.textProperties.numberOfLines = 1
content.directionalLayoutMargins.leading = 0
content.imageToTextPadding = 8
content.imageProperties.reservedLayoutSize = CGSize(width: 26, height: 26)
content.imageProperties.maximumSize = CGSize(width: 26, height: 26)
content.imageProperties.cornerRadius = 13
UIView.performWithoutAnimation {
headerView.titleButton.setTitle(content.text, for: .normal)
headerView.titleButton.layoutIfNeeded()
}
headerView.iconButton.backgroundColor = storeApp.source?.effectiveTintColor?.adjustedForDisplay
headerView.iconButton.setImage(nil, for: .normal)
if let iconURL = storeApp.source?.effectiveIconURL
{
ImagePipeline.shared.loadImage(with: iconURL) { result in
guard case .success(let image) = result else { return }
headerView.iconButton.backgroundColor = .white
headerView.iconButton.setImage(image.image, for: .normal)
}
}
let buttons = [headerView.iconButton, headerView.titleButton]
for button in buttons
{
button.removeAction(identifiedBy: .showSourceDetails, for: .primaryActionTriggered)
if let source = storeApp.source
{
let action = UIAction(identifier: .showSourceDetails) { [weak self] _ in
self?.showSourceDetails(for: source)
}
button.addAction(action, for: .primaryActionTriggered)
}
}
return headerView
case ElementKind.sectionHeader.rawValue:
// Regular section header
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! UICollectionViewListCell
var content: UIListContentConfiguration = if #available(iOS 15, *) {
.prominentInsetGroupedHeader()
}
else {
.groupedHeader()
}
switch section
{
case .recentlyUpdated: content.text = NSLocalizedString("New & Updated", comment: "")
case .categories: content.text = NSLocalizedString("Categories", comment: "")
case .featuredHeader: content.text = NSLocalizedString("Featured", comment: "")
default: break
}
content.directionalLayoutMargins.leading = .zero
content.directionalLayoutMargins.trailing = .zero
headerView.contentConfiguration = content
return headerView
case ElementKind.button.rawValue where section.isFeaturedAppsSection:
let buttonView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! ButtonCollectionReusableView
let indexPath = IndexPath(item: 0, section: indexPath.section)
let storeApp = self.dataSource.item(at: indexPath)
buttonView.tintColor = storeApp.source?.effectiveTintColor?.adjustedForDisplay ?? .altPrimary
buttonView.button.setTitle(NSLocalizedString("See All", comment: ""), for: .normal)
buttonView.button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
buttonView.button.contentEdgeInsets.bottom = 8
buttonView.button.removeAction(identifiedBy: .showAllApps, for: .primaryActionTriggered)
if let source = storeApp.source
{
let action = UIAction(identifier: .showAllApps) { [weak self] _ in
self?.showAllApps(for: source)
}
buttonView.button.addAction(action, for: .primaryActionTriggered)
}
return buttonView
default: return UICollectionReusableView(frame: .zero)
}
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
{
let storeApp = self.dataSource.item(at: indexPath)
let section = Section(rawValue: indexPath.section)
switch section
{
case _ where section.isFeaturedAppsSection: fallthrough
case .recentlyUpdated:
let appViewController = AppViewController.makeAppViewController(app: storeApp)
self.navigationController?.pushViewController(appViewController, animated: true)
case .categories:
let category = storeApp.category ?? .other
self.performSegue(withIdentifier: "showBrowseViewController", sender: category)
default: break
}
}
}
@available(iOS 17, *)
#Preview(traits: .portrait) {
DatabaseManager.shared.startForPreview()
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let featuredViewController = storyboard.instantiateViewController(identifier: "featuredViewController")
let navigationController = UINavigationController(rootViewController: featuredViewController)
navigationController.navigationBar.prefersLargeTitles = true
navigationController.modalPresentationStyle = .fullScreen
let viewController = UIViewController()
AppManager.shared.fetchSources() { (result) in
do
{
let (_, context) = try result.get()
try context.save()
}
catch let error as NSError
{
Logger.main.error("Failed to fetch sources for preview. \(error.localizedDescription, privacy: .public)")
}
}
AppManager.shared.updateKnownSources { result in
Task {
do
{
let knownSources = try result.get()
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
await withThrowingTaskGroup(of: Void.self) { taskGroup in
for source in knownSources.0
{
guard let sourceURL = source.sourceURL else { continue }
taskGroup.addTask {
_ = try await AppManager.shared.fetchSource(sourceURL: sourceURL, managedObjectContext: context)
}
}
}
await context.performAsync {
try! context.save()
}
await MainActor.run {
viewController.present(navigationController, animated: true)
}
}
catch
{
Logger.main.error("Failed to fetch known sources for preview. \(error.localizedDescription, privacy: .public)")
}
}
}
return viewController
}

View File

@@ -1,44 +0,0 @@
//
// ScreenshotCollectionViewCell.swift
// AltStore
//
// Created by Riley Testut on 7/15/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import Roxas
@objc(ScreenshotCollectionViewCell)
class ScreenshotCollectionViewCell: UICollectionViewCell
{
let imageView = UIImageView(image: nil)
override init(frame: CGRect)
{
super.init(frame: frame)
self.initialize()
}
required init?(coder aDecoder: NSCoder)
{
super.init(coder: aDecoder)
self.initialize()
}
private func initialize()
{
self.imageView.layer.masksToBounds = true
self.addSubview(self.imageView, pinningEdgesWith: .zero)
}
override func layoutSubviews()
{
super.layoutSubviews()
self.imageView.layer.cornerRadius = 4
}
}

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

@@ -1,428 +0,0 @@
//
// AppBannerView.swift
// AltStore
//
// Created by Riley Testut on 8/29/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
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? {
get { return self.accessibilityView?.accessibilityLabel }
set { self.accessibilityView?.accessibilityLabel = newValue }
}
override open var accessibilityAttributedLabel: NSAttributedString? {
get { return self.accessibilityView?.accessibilityAttributedLabel }
set { self.accessibilityView?.accessibilityAttributedLabel = newValue }
}
override var accessibilityValue: String? {
get { return self.accessibilityView?.accessibilityValue }
set { self.accessibilityView?.accessibilityValue = newValue }
}
override open var accessibilityAttributedValue: NSAttributedString? {
get { return self.accessibilityView?.accessibilityAttributedValue }
set { self.accessibilityView?.accessibilityAttributedValue = newValue }
}
override open var accessibilityTraits: UIAccessibilityTraits {
get { return self.accessibilityView?.accessibilityTraits ?? [] }
set { self.accessibilityView?.accessibilityTraits = newValue }
}
var style: Style = .app
private var originalTintColor: UIColor?
@IBOutlet var titleLabel: UILabel!
@IBOutlet var subtitleLabel: UILabel!
@IBOutlet var iconImageView: AppIconImageView!
@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)
self.initialize()
}
required init?(coder: NSCoder)
{
super.init(coder: coder)
self.initialize()
}
private func initialize()
{
self.accessibilityView.accessibilityTraits.formUnion(.button)
self.isAccessibilityElement = false
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()
{
super.tintColorDidChange()
if self.tintAdjustmentMode != .dimmed
{
self.originalTintColor = self.tintColor
}
self.update()
}
}
extension AppBannerView
{
func configure(for app: AppProtocol, action: AppAction? = nil, showSourceIcon: Bool = true)
{
struct AppValues
{
var name: String
var developerName: String? = nil
var isBeta: Bool = false
init(app: AppProtocol)
{
self.name = app.name
guard let storeApp = (app as? StoreApp) ?? (app as? InstalledApp)?.storeApp else { return }
self.developerName = storeApp.developerName
if let track = storeApp.latestSupportedVersion?.channel,
ReleaseTracks.betaTracks.contains(track)
{
self.name = String(format: NSLocalizedString("%@ beta", comment: ""), app.name)
self.isBeta = true
}
}
}
self.style = .app
let values = AppValues(app: app)
self.titleLabel.text = app.name // Don't use values.name since that already includes "beta".
self.betaBadgeView.isHidden = !values.isBeta
if let developerName = values.developerName
{
self.subtitleLabel.text = developerName
self.accessibilityLabel = String(format: NSLocalizedString("%@ by %@", comment: ""), values.name, developerName)
}
else
{
self.subtitleLabel.text = NSLocalizedString("Sideloaded", comment: "")
self.accessibilityLabel = values.name
}
if let storeApp = app.storeApp, storeApp.isPledgeRequired
{
// Always show button label for Patreon apps.
self.buttonLabel.isHidden = false
if storeApp.isPledged
{
self.buttonLabel.text = NSLocalizedString("Pledged", comment: "")
}
else if storeApp.installedApp != nil
{
self.buttonLabel.text = NSLocalizedString("Pledge Expired", comment: "")
}
else
{
self.buttonLabel.text = NSLocalizedString("Join Patreon", comment: "")
}
}
else
{
self.buttonLabel.isHidden = true
}
if let source = app.storeApp?.source, showSourceIcon
{
self.sourceIconImageView.isHidden = false
self.sourceIconImageView.backgroundColor = source.effectiveTintColor?.adjustedForDisplay ?? .altPrimary
if let iconURL = source.effectiveIconURL
{
if let image = ImageCache.shared[iconURL]
{
self.sourceIconImageView.backgroundColor = .white
self.sourceIconImageView.image = image.image
}
else
{
self.sourceIconImageView.image = nil
Nuke.loadImage(with: iconURL, into: self.sourceIconImageView) { result in
switch result
{
case .failure(let error): Logger.main.error("Failed to fetch source icon from \(iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
case .success: self.sourceIconImageView.backgroundColor = .white // In case icon has transparent background.
}
}
}
}
}
else
{
self.sourceIconImageView.isHidden = true
}
let buttonAction: AppAction
if let action
{
buttonAction = action
}
else if let storeApp = app.storeApp
{
if let installedApp = storeApp.installedApp
{
// App is installed
// if installedApp.isUpdateAvailable
if installedApp.hasUpdate
{
buttonAction = .update
}
else
{
buttonAction = .open
}
}
else
{
// App is not installed
buttonAction = .install
}
}
else
{
// App is not from a source, fall back to .open
buttonAction = .open
}
UIView.performWithoutAnimation {
switch buttonAction
{
case .open:
let buttonTitle = NSLocalizedString("Open", comment: "")
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
self.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), values.name)
self.button.accessibilityValue = buttonTitle
self.button.countdownDate = nil
case .update:
let buttonTitle = NSLocalizedString("Update", comment: "")
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
self.button.accessibilityLabel = String(format: NSLocalizedString("Update %@", comment: ""), values.name)
self.button.accessibilityValue = buttonTitle
self.button.countdownDate = nil
case .custom(let buttonTitle):
self.button.setTitle(buttonTitle, for: .normal)
self.button.accessibilityLabel = buttonTitle
self.button.accessibilityValue = buttonTitle
self.button.countdownDate = nil
case .install:
if let storeApp = app.storeApp, storeApp.isPledgeRequired
{
// Pledge required
if storeApp.isPledged
{
let buttonTitle = NSLocalizedString("Install", comment: "")
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
self.button.accessibilityLabel = String(format: NSLocalizedString("Install %@", comment: ""), app.name)
self.button.accessibilityValue = buttonTitle
}
else if let amount = storeApp.pledgeAmount, let currencyCode = storeApp.pledgeCurrency, !storeApp.prefersCustomPledge, #available(iOS 15, *)
{
let price = amount.formatted(.currency(code: currencyCode).presentation(.narrow).precision(.fractionLength(0...2)))
let buttonTitle = String(format: NSLocalizedString("%@/mo", comment: ""), price)
self.button.setTitle(buttonTitle, for: .normal)
self.button.accessibilityLabel = String(format: NSLocalizedString("Pledge %@ a month", comment: ""), price)
self.button.accessibilityValue = String(format: NSLocalizedString("%@ a month", comment: ""), price)
}
else
{
let buttonTitle = NSLocalizedString("Pledge", comment: "")
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
self.button.accessibilityLabel = buttonTitle
self.button.accessibilityValue = buttonTitle
}
}
else
{
// Free app
let buttonTitle = NSLocalizedString("Free", comment: "")
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
self.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name)
self.button.accessibilityValue = buttonTitle
}
if let versionDate = app.storeApp?.latestSupportedVersion?.date, versionDate > Date()
{
self.button.countdownDate = versionDate
}
else
{
self.button.countdownDate = nil
}
}
// Ensure PillButton is correct size before assigning progress.
self.layoutIfNeeded()
}
if let progress = AppManager.shared.installationProgress(for: app), progress.fractionCompleted < 1.0
{
self.button.progress = progress
}
else
{
self.button.progress = nil
}
}
func configure(for source: Source)
{
self.style = .source
let subtitle: String
if let text = source.subtitle
{
subtitle = text
}
else if let scheme = source.sourceURL.scheme
{
subtitle = source.sourceURL.absoluteString.replacingOccurrences(of: scheme + "://", with: "")
}
else
{
subtitle = source.sourceURL.absoluteString
}
self.titleLabel.text = source.name
self.subtitleLabel.text = subtitle
let tintColor = source.effectiveTintColor ?? .altPrimary
self.tintColor = tintColor
let accessibilityLabel = source.name + "\n" + subtitle
self.accessibilityLabel = accessibilityLabel
}
}
private extension AppBannerView
{
func update()
{
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
}
}
}
}

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

@@ -1,69 +0,0 @@
//
// AppIconImageView.swift
// AltStore
//
// Created by Riley Testut on 5/9/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
extension AppIconImageView
{
enum Style
{
case icon
case circular
}
}
class AppIconImageView: UIImageView
{
var style: Style = .icon {
didSet {
self.setNeedsLayout()
}
}
init(style: Style)
{
self.style = style
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
}
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
}
}
}

View File

@@ -1,180 +0,0 @@
//
// CollapsingTextView.swift
// AltStore
//
// Created by Riley Testut on 7/23/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
final class CollapsingTextView: UITextView
{
var isCollapsed = true {
didSet {
guard self.isCollapsed != oldValue else { return }
self.setNeedsLayout()
}
}
var maximumNumberOfLines = 2 {
didSet {
self.setNeedsLayout()
}
}
var lineSpacing: Double = 2 {
didSet {
if #available(iOS 16, *)
{
self.updateText()
}
else
{
self.setNeedsLayout()
}
}
}
override var text: String! {
didSet {
guard #available(iOS 16, *) else { return }
self.updateText()
}
}
let moreButton = UIButton(type: .system)
override init(frame: CGRect, textContainer: NSTextContainer?)
{
super.init(frame: frame, textContainer: textContainer)
self.initialize()
}
required init?(coder: NSCoder)
{
super.init(coder: coder)
}
override func awakeFromNib()
{
super.awakeFromNib()
self.initialize()
}
private func initialize()
{
if #available(iOS 16, *)
{
self.updateText()
}
else
{
self.layoutManager.delegate = self
}
self.textContainerInset = .zero
self.textContainer.lineFragmentPadding = 0
self.textContainer.lineBreakMode = .byTruncatingTail
self.textContainer.heightTracksTextView = true
self.textContainer.widthTracksTextView = true
self.moreButton.setTitle(NSLocalizedString("More", comment: ""), for: .normal)
self.moreButton.addTarget(self, action: #selector(CollapsingTextView.toggleCollapsed(_:)), for: .primaryActionTriggered)
self.addSubview(self.moreButton)
self.setNeedsLayout()
}
override func layoutSubviews()
{
super.layoutSubviews()
guard let font = self.font else { return }
let buttonFont = UIFont.systemFont(ofSize: font.pointSize, weight: .medium)
self.moreButton.titleLabel?.font = buttonFont
let buttonY = (font.lineHeight + self.lineSpacing) * CGFloat(self.maximumNumberOfLines - 1)
let size = self.moreButton.sizeThatFits(CGSize(width: 1000, height: 1000))
let moreButtonFrame = CGRect(x: self.bounds.width - self.moreButton.bounds.width,
y: buttonY,
width: size.width,
height: font.lineHeight)
self.moreButton.frame = moreButtonFrame
if self.isCollapsed
{
let boundingSize = self.attributedText.boundingRect(with: CGSize(width: self.textContainer.size.width, height: .infinity), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
let maximumCollapsedHeight = font.lineHeight * Double(self.maximumNumberOfLines) + self.lineSpacing * Double(self.maximumNumberOfLines - 1)
if boundingSize.height.rounded() > maximumCollapsedHeight.rounded()
{
self.textContainer.maximumNumberOfLines = self.maximumNumberOfLines
var exclusionFrame = moreButtonFrame
exclusionFrame.origin.y += self.moreButton.bounds.midY
exclusionFrame.size.width = self.bounds.width // Extra wide to make sure it wraps to next line.
self.textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)]
self.moreButton.isHidden = false
}
else
{
self.textContainer.maximumNumberOfLines = 0 // Fixes last line having slightly smaller line spacing.
self.textContainer.exclusionPaths = []
self.moreButton.isHidden = true
}
}
else
{
self.textContainer.maximumNumberOfLines = 0
self.textContainer.exclusionPaths = []
self.moreButton.isHidden = true
}
self.invalidateIntrinsicContentSize()
}
}
private extension CollapsingTextView
{
@objc func toggleCollapsed(_ sender: UIButton)
{
self.isCollapsed.toggle()
}
@available(iOS 16, *)
func updateText()
{
do
{
let style = NSMutableParagraphStyle()
style.lineSpacing = self.lineSpacing
var attributedText = try AttributedString(self.attributedText, including: \.uiKit)
attributedText[AttributeScopes.UIKitAttributes.ParagraphStyleAttribute.self] = style
self.attributedText = NSAttributedString(attributedText)
}
catch
{
print("[ALTLog] Failed to update CollapsingTextView line spacing:", error)
}
}
}
extension CollapsingTextView: NSLayoutManagerDelegate
{
func layoutManager(_ layoutManager: NSLayoutManager, lineSpacingAfterGlyphAt glyphIndex: Int, withProposedLineFragmentRect rect: CGRect) -> CGFloat
{
return self.lineSpacing
}
}

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

@@ -1,101 +0,0 @@
//
// NavigationBar.swift
// AltStore
//
// Created by Riley Testut on 7/15/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import Roxas
class NavigationBarAppearance: UINavigationBarAppearance
{
// 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
override init(frame: CGRect)
{
super.init(frame: frame)
self.initialize()
}
required init?(coder aDecoder: NSCoder)
{
super.init(coder: aDecoder)
self.initialize()
}
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
{
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
}
override func layoutSubviews()
{
super.layoutSubviews()
if self.automaticallyAdjustsItemPositions
{
// We can't easily shift just the back button up, so we shift the entire content view slightly.
for contentView in self.subviews
{
guard NSStringFromClass(type(of: contentView)).contains("ContentView") else { continue }
contentView.center.y -= 2
}
}
}
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

@@ -1,261 +0,0 @@
//
// PillButton.swift
// AltStore
//
// Created by Riley Testut on 7/15/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
extension PillButton
{
static let minimumSize = CGSize(width: 77, height: 31)
static let contentInsets = NSDirectionalEdgeInsets(top: 7, leading: 13, bottom: 7, trailing: 13)
}
extension PillButton
{
enum Style
{
case pill
case custom
}
}
class PillButton: UIButton
{
override var accessibilityValue: String? {
get {
guard self.progress != nil else { return super.accessibilityValue }
return self.progressView.accessibilityValue
}
set { super.accessibilityValue = newValue }
}
var progress: Progress? {
didSet {
self.progressView.progress = Float(self.progress?.fractionCompleted ?? 0)
self.progressView.observedProgress = self.progress
let isUserInteractionEnabled = self.isUserInteractionEnabled
self.isIndicatingActivity = (self.progress != nil)
self.isUserInteractionEnabled = isUserInteractionEnabled
self.update()
}
}
var progressTintColor: UIColor? {
didSet {
self.update()
}
}
var countdownDate: Date? {
didSet {
self.isEnabled = (self.countdownDate == nil)
self.displayLink.isPaused = (self.countdownDate == nil)
if self.countdownDate == nil
{
self.setTitle(nil, for: .disabled)
}
}
}
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 = {
let displayLink = CADisplayLink(target: self, selector: #selector(PillButton.updateCountdown))
displayLink.preferredFramesPerSecond = 15
displayLink.isPaused = true
displayLink.add(to: .main, forMode: .common)
return displayLink
}()
private let dateComponentsFormatter: DateComponentsFormatter = {
let dateComponentsFormatter = DateComponentsFormatter()
dateComponentsFormatter.zeroFormattingBehavior = [.pad]
dateComponentsFormatter.collapsesLargestUnit = false
return dateComponentsFormatter
}()
override var intrinsicContentSize: CGSize {
let size = self.sizeThatFits(CGSize(width: Double.infinity, height: .infinity))
return size
}
deinit
{
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.activityIndicatorView.style = .medium
self.activityIndicatorView.color = .white
self.activityIndicatorView.isUserInteractionEnabled = false
self.progressView.progress = 0
self.progressView.trackImage = UIImage()
self.progressView.isUserInteractionEnabled = false
self.addSubview(self.progressView)
self.update()
}
override func layoutSubviews()
{
super.layoutSubviews()
self.progressView.bounds.size.width = self.bounds.width
let scale = self.bounds.height / self.progressView.bounds.height
self.progressView.transform = CGAffineTransform.identity.scaledBy(x: 1, y: scale)
self.progressView.center = CGPoint(x: self.bounds.midX, y: self.bounds.midY)
self.layer.cornerRadius = self.bounds.midY
}
override func tintColorDidChange()
{
super.tintColorDidChange()
self.update()
}
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
}
return size
}
}
private extension PillButton
{
func update()
{
if self.progress == nil
{
self.setTitleColor(.white, for: .normal)
self.backgroundColor = self.tintColor
}
else
{
self.setTitleColor(self.tintColor, for: .normal)
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)
}
}
@objc func updateCountdown()
{
guard let endDate = self.countdownDate else { return }
let startDate = Date()
let interval = endDate.timeIntervalSince(startDate)
guard interval > 0 else {
self.isEnabled = true
return
}
let text: String?
if interval < (1 * 60 * 60)
{
self.dateComponentsFormatter.unitsStyle = .positional
self.dateComponentsFormatter.allowedUnits = [.minute, .second]
text = self.dateComponentsFormatter.string(from: startDate, to: endDate)
}
else if interval < (2 * 24 * 60 * 60)
{
self.dateComponentsFormatter.unitsStyle = .positional
self.dateComponentsFormatter.allowedUnits = [.hour, .minute, .second]
text = self.dateComponentsFormatter.string(from: startDate, to: endDate)
}
else
{
self.dateComponentsFormatter.unitsStyle = .full
self.dateComponentsFormatter.allowedUnits = [.day]
let numberOfDays = endDate.numberOfCalendarDays(since: startDate)
text = String(format: NSLocalizedString("%@ DAYS", comment: ""), NSNumber(value: numberOfDays))
}
if let text = text
{
UIView.performWithoutAnimation {
self.isEnabled = false
self.setTitle(text, for: .disabled)
self.layoutIfNeeded()
}
}
else
{
self.isEnabled = true
}
}
}

View File

@@ -1,145 +0,0 @@
//
// ToastView.swift
// AltStore
//
// Created by Riley Testut on 7/19/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Roxas
import AltStoreCore
extension TimeInterval
{
static let shortToastViewDuration = 4.0
static let longToastViewDuration = 8.0
}
extension ToastView
{
static let openErrorLogNotification = Notification.Name("ALTOpenErrorLogNotification")
}
class ToastView: RSTToastView
{
var preferredDuration: TimeInterval
var opensErrorLog: Bool = false
convenience init(text: String, detailText: String?, opensLog: Bool = false) {
self.init(text: text, detailText: detailText)
self.opensErrorLog = opensLog
}
override init(text: String, detailText detailedText: String?)
{
if detailedText == nil
{
self.preferredDuration = .shortToastViewDuration
}
else
{
self.preferredDuration = .longToastViewDuration
}
super.init(text: text, detailText: detailedText)
self.isAccessibilityElement = true
self.layoutMargins = UIEdgeInsets(top: 8, left: 16, bottom: 10, right: 16)
self.setNeedsLayout()
if let stackView = self.textLabel.superview as? UIStackView
{
// RSTToastView does not expose stack view containing labels,
// so we access it indirectly as the labels' superview.
stackView.spacing = (detailedText != nil) ? 4.0 : 0.0
stackView.alignment = .leading
}
self.addTarget(self, action: #selector(ToastView.showErrorLog), for: .touchUpInside)
}
convenience init(error: Error, opensLog: Bool = false) {
self.init(error: error)
self.opensErrorLog = opensLog
}
enum InfoMode: String {
case fullError
case localizedDescription
}
convenience init(error: Error){
self.init(error: error, mode: .localizedDescription)
}
convenience init(error: Error, mode: InfoMode)
{
let error = error as NSError
let mode = mode == .fullError ? ErrorProcessing.InfoMode.fullError : ErrorProcessing.InfoMode.localizedDescription
let text = error.localizedTitle ?? NSLocalizedString("Operation Failed", comment: "")
let detailText = ErrorProcessing(mode).getDescription(error: error)
self.init(text: text, detailText: detailText)
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews()
{
super.layoutSubviews()
// Rough calculation to determine height of ToastView with one-line textLabel.
let minimumHeight = self.textLabel.font.lineHeight.rounded() + 18
self.layer.cornerRadius = minimumHeight/2
}
func show(in viewController: UIViewController)
{
self.show(in: viewController.navigationController?.view ?? viewController.view, duration: self.preferredDuration)
}
override func show(in view: UIView, duration: TimeInterval)
{
if opensErrorLog, #available(iOS 13.0, *), case let configuration = UIImage.SymbolConfiguration(font: self.textLabel.font),
let icon = UIImage(systemName: "chevron.right.circle", withConfiguration: configuration) {
let tintedIcon = icon.withTintColor(.white, renderingMode: .alwaysOriginal)
let moreIconImageView = UIImageView(image: tintedIcon)
moreIconImageView.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(moreIconImageView)
NSLayoutConstraint.activate([
moreIconImageView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -self.layoutMargins.right),
moreIconImageView.centerYAnchor.constraint(equalTo: self.textLabel.centerYAnchor),
moreIconImageView.leadingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: self.textLabel.trailingAnchor, multiplier: 1.0)
])
}
super.show(in: view, duration: duration)
let announcement = (self.textLabel.text ?? "") + ". " + (self.detailTextLabel.text ?? "")
self.accessibilityLabel = announcement
// Minimum 0.75 delay to prevent announcement being cut off by VoiceOver.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
UIAccessibility.post(notification: .announcement, argument: announcement)
}
}
override func show(in view: UIView)
{
self.show(in: view, duration: self.preferredDuration)
}
}
private extension ToastView
{
@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

@@ -1,17 +0,0 @@
//
// Proxy.swift
// SideStore
//
// Created by Joseph Mattiello on 11/7/22.
// Copyright © 2022 Riley Testut. All rights reserved.
//
import Foundation
public extension Consts {
enum Proxy {
static let address = "127.0.0.1"
static let port = "51820"
static let serverURL = "\(address):\(port)"
}
}

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

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

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

@@ -1,422 +0,0 @@
//
// LaunchViewController.swift
// AltStore
//
// Created by Riley Testut on 7/30/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import Roxas
import EmotionalDamage
import minimuxer
import WidgetKit
import AltSign
import AltStoreCore
import UniformTypeIdentifiers
let pairingFileName = "ALTPairingFile.mobiledevicepairing"
final class LaunchViewController: UIViewController, UIDocumentPickerDelegate {
private var didFinishLaunching = false
private var retries = 0
private var maxRetries = 3
private var splashView: SplashView!
private var destinationViewController: TabBarController?
private var startTime: Date!
override func viewDidLoad() {
super.viewDidLoad()
splashView = SplashView(frame: view.bounds, appName: "SideStore")
destinationViewController = storyboard!.instantiateViewController(withIdentifier: "tabBarController") as? TabBarController
view.addSubview(splashView)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard !didFinishLaunching else { return }
Task {
startTime = Date()
await runLaunchSequence()
doPostLaunch()
}
}
private func runLaunchSequence() async {
guard retries < maxRetries else { return }
retries += 1
await Task.detached {
if !DatabaseManager.shared.isStarted {
await withCheckedContinuation { continuation in
DatabaseManager.shared.start { error in
if let error {
Task { await self.handleLaunchError(error, retryCallback: self.runLaunchSequence) }
} else {
Task { await self.finishLaunching() }
}
continuation.resume(returning: ())
}
}
} else {
await self.finishLaunching()
}
}.value
}
private func doPostLaunch() {
SideJITManager.shared.checkAndPromptIfNeeded(presentingVC: self)
if #available(iOS 17, *), UserDefaults.standard.sidejitenable {
DispatchQueue.global().async { SideJITManager.shared.askForNetwork() }
print("SideJITServer Enabled")
}
#if !targetEnvironment(simulator)
detectAndImportAccountFile()
if UserDefaults.standard.enableEMPforWireguard {
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
}
guard let pf = fetchPairingFile() else {
displayError("Device pairing file not found.")
return
}
start_minimuxer_threads(pf)
#endif
}
func start_minimuxer_threads(_ pairing_file: String) {
target_minimuxer_address()
let documentsDirectory = FileManager.default.documentsDirectory.absoluteString
do {
let loggingEnabled = UserDefaults.standard.isMinimuxerConsoleLoggingEnabled
try minimuxer.startWithLogger(pairing_file, documentsDirectory, loggingEnabled)
} catch {
try! FileManager.default.removeItem(at: FileManager.default.documentsDirectory.appendingPathComponent(pairingFileName))
displayError("minimuxer failed to start, please restart SideStore. \((error as? LocalizedError)?.failureReason ?? "UNKNOWN ERROR")")
}
start_auto_mounter(documentsDirectory)
}
func fetchPairingFile() -> String? { PairingFileManager.shared.fetchPairingFile(presentingVC: self) }
func displayError(_ msg: String) {
print(msg)
let alert = UIAlertController(title: "Error launching SideStore", message: msg, preferredStyle: .alert)
self.present(alert, animated: true)
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
let url = urls[0]
let isSecuredURL = url.startAccessingSecurityScopedResource() == true
defer {
if (isSecuredURL) {
url.stopAccessingSecurityScopedResource()
}
}
do {
let data = try Data(contentsOf: url)
guard let pairingString = String(data: data, encoding: .utf8) else {
displayError("Unable to read pairing file")
return
}
try pairingString.write(to: FileManager.default.documentsDirectory.appendingPathComponent(pairingFileName), atomically: true, encoding: .utf8)
start_minimuxer_threads(pairingString)
} catch {
displayError("Unable to read pairing file")
}
controller.dismiss(animated: true, completion: nil)
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
displayError("Choosing a pairing file was cancelled. Please re-open the app and try again.")
}
func importAccountAtFile(_ file: URL, remove: Bool = false) {
_ = file.startAccessingSecurityScopedResource()
defer { file.stopAccessingSecurityScopedResource() }
guard let accountD = try? Data(contentsOf: file) else {
let toastView = ToastView(text: NSLocalizedString("Could not read data from file!", comment: ""), detailText: "\(file)")
return toastView.show(in: self)
}
guard let account = try? Foundation.JSONDecoder().decode(ImportedAccount.self, from: accountD) else {
let toastView = ToastView(text: NSLocalizedString("Could not parse data from file!", comment: ""), detailText: "\(file)")
return toastView.show(in: self)
}
print("We want to import this account probably: \(account)")
if remove {
try? FileManager.default.removeItem(at: file)
}
Keychain.shared.appleIDEmailAddress = account.email
Keychain.shared.appleIDPassword = account.password
Keychain.shared.adiPb = account.adiPB
Keychain.shared.identifier = account.local_user
if let altCert = ALTCertificate(p12Data: account.cert, password: account.certpass) {
Keychain.shared.signingCertificate = altCert.encryptedP12Data(withPassword: "")!
Keychain.shared.signingCertificatePassword = account.certpass
let toastView = ToastView(text: NSLocalizedString("Successfully imported '\(account.email)'!", comment: ""), detailText: "SideStore should be fully operational!")
return toastView.show(in: self)
} else {
let toastView = ToastView(text: NSLocalizedString("Failed to import account certificate!", comment: ""), detailText: "Failed to create ALTCertificate. Check if the password is correct. Still imported account/adi.pb details!")
return toastView.show(in: self)
}
}
func detectAndImportAccountFile() {
let accountFileURL = FileManager.default.documentsDirectory.appendingPathComponent("Account.sideconf")
#if !DEBUG
importAccountAtFile(accountFileURL, remove: true)
#else
importAccountAtFile(accountFileURL)
#endif
}
}
extension LaunchViewController {
@MainActor
func handleLaunchError(_ error: Error, retryCallback: (() async -> Void)? = nil) {
do { throw error } catch let error as NSError {
let title = error.userInfo[NSLocalizedFailureErrorKey] as? String ?? NSLocalizedString("Unable to Launch SideStore", comment: "")
let desc: String
if #available(iOS 14.5, *) {
desc = ([error.debugDescription] + error.underlyingErrors.map { ($0 as NSError).debugDescription }).joined(separator: "\n\n")
} else {
desc = error.debugDescription
}
let alert = UIAlertController(title: title, message: desc, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("Retry", comment: ""), style: .default) { _ in
Task { await retryCallback?() }
})
present(alert, animated: true)
}
}
@MainActor
func finishLaunching() async {
guard !didFinishLaunching else { return }
didFinishLaunching = true
AppManager.shared.update()
AppManager.shared.updatePatronsIfNeeded()
PatreonAPI.shared.refreshPatreonAccount()
AppManager.shared.updateAllSources { result in
guard case .failure(let error) = result else { return }
Logger.main.error("Failed to update sources on launch. \(error.localizedDescription, privacy: .public)")
let errorDesc = ErrorProcessing(.fullError).getDescription(error: error as NSError)
print("Failed to update sources on launch. \(errorDesc)")
var mode: ToastView.InfoMode = .fullError
if String(describing: error).contains("The Internet connection appears to be offline"){
mode = .localizedDescription // dont make noise!
}
let toastView = ToastView(error: error, mode: mode)
toastView.addTarget(self.destinationViewController, action: #selector(TabBarController.presentSources), for: .touchUpInside)
toastView.show(in: self.destinationViewController!.selectedViewController ?? self.destinationViewController!)
}
updateKnownSources()
WidgetCenter.shared.reloadAllTimelines()
didFinishLaunching = true
let destinationVC = destinationViewController!
let elapsed = abs(startTime.timeIntervalSinceNow)
let remaining = elapsed >= 1 ? 0 : 1 - elapsed
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
destinationVC.loadViewIfNeeded()
addChild(destinationVC)
destinationVC.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(destinationVC.view)
destinationVC.didMove(toParent: self)
// Pin edges BEFORE animation
NSLayoutConstraint.activate([
destinationVC.view.topAnchor.constraint(equalTo: view.topAnchor),
destinationVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
destinationVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
destinationVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
// Set initial alpha for fade-in
destinationVC.view.alpha = 0
UIView.transition(with: view, duration: 0.3, options: .transitionCrossDissolve) { [self] in
self.splashView.alpha = 0
destinationVC.view.alpha = 1
} completion: { _ in
self.splashView.removeFromSuperview()
self.destinationViewController = destinationVC
}
}
func updateKnownSources() {
AppManager.shared.updateKnownSources { result in
switch result {
case .failure(let error): print("[ALTLog] Failed to update known sources:", error)
case .success((_, let blockedSources)):
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
let blockedSourceIDs = Set(blockedSources.lazy.map { $0.identifier })
let blockedSourceURLs = Set(blockedSources.lazy.compactMap { $0.sourceURL })
let predicate = NSPredicate(format: "%K IN %@ OR %K IN %@", #keyPath(Source.identifier), blockedSourceIDs, #keyPath(Source.sourceURL), blockedSourceURLs)
let sourceErrors = Source.all(satisfying: predicate, in: context).map { source in
let blocked = blockedSources.first { $0.identifier == source.identifier }
return SourceError.blocked(source, bundleIDs: blocked?.bundleIDs, existingSource: source)
}
guard !sourceErrors.isEmpty else { return }
Task {
for error in sourceErrors {
let title = String(format: NSLocalizedString("“%@” Blocked", comment: ""), error.$source.name)
let message = [error.localizedDescription, error.recoverySuggestion].compactMap { $0 }.joined(separator: "\n\n")
await self.presentAlert(title: title, message: message)
}
}
}
}
}
}
}
// MARK: - SplashView
final class SplashView: UIView {
let iconView = UIImageView()
let titleLabel = UILabel()
init(frame: CGRect, appName: String) {
super.init(frame: frame)
backgroundColor = .systemBackground
setupIcon()
setupTitle(appName: appName)
}
required init?(coder: NSCoder) { fatalError() }
private func setupIcon() {
let container = UIView()
container.translatesAutoresizingMaskIntoConstraints = false
container.layer.shadowColor = UIColor.black.cgColor
container.layer.shadowOpacity = 0.25
container.layer.shadowOffset = CGSize(width: 0, height: 4)
container.layer.shadowRadius = 8
addSubview(container)
iconView.image = UIImage(named: "AppIcon") ?? UIImage(named: "AppIcon60x60") ?? UIImage(systemName: "app.fill")
iconView.contentMode = .scaleAspectFit
iconView.translatesAutoresizingMaskIntoConstraints = false
iconView.layer.cornerRadius = 24
iconView.clipsToBounds = true
container.addSubview(iconView)
NSLayoutConstraint.activate([
container.centerXAnchor.constraint(equalTo: centerXAnchor),
container.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -20),
container.widthAnchor.constraint(equalToConstant: 120),
container.heightAnchor.constraint(equalToConstant: 120),
iconView.topAnchor.constraint(equalTo: container.topAnchor),
iconView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
iconView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
iconView.trailingAnchor.constraint(equalTo: container.trailingAnchor)
])
}
private func setupTitle(appName: String) {
titleLabel.text = appName
titleLabel.font = .systemFont(ofSize: 24, weight: .bold)
titleLabel.textColor = .label
titleLabel.textAlignment = .center
titleLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(titleLabel)
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 12),
titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor)
])
}
}
// MARK: - PairingFileManager
final class PairingFileManager {
static let shared = PairingFileManager()
func fetchPairingFile(presentingVC: UIViewController) -> String? {
let fm = FileManager.default
let filename = pairingFileName
let documentsPath = fm.documentsDirectory.appendingPathComponent("/\(filename)")
if fm.fileExists(atPath: documentsPath.path),
let contents = try? String(contentsOf: documentsPath), !contents.isEmpty {
return contents
}
if let url = Bundle.main.url(forResource: "ALTPairingFile", withExtension: "mobiledevicepairing"),
fm.fileExists(atPath: url.path),
let data = fm.contents(atPath: url.path),
let contents = String(data: data, encoding: .utf8),
!contents.isEmpty, !UserDefaults.standard.isPairingReset { return contents }
if let plistString = Bundle.main.object(forInfoDictionaryKey: "ALTPairingFile") as? String,
!plistString.isEmpty, !plistString.contains("insert pairing file here"), !UserDefaults.standard.isPairingReset { return plistString }
presentPairingFileAlert(on: presentingVC)
return nil
}
private func presentPairingFileAlert(on vc: UIViewController) {
let alert = UIAlertController(title: "Pairing File", message: "Select the pairing file or select \"Help\" for help.", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Help", style: .default) { _ in
if let url = URL(string: "https://docs.sidestore.io/docs/advanced/pairing-file") { UIApplication.shared.open(url) }
sleep(2); exit(0)
})
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
var types = UTType.types(tag: "plist", tagClass: .filenameExtension, conformingTo: nil)
types.append(contentsOf: UTType.types(tag: "mobiledevicepairing", tagClass: .filenameExtension, conformingTo: .data))
types.append(.xml)
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types)
picker.delegate = vc as? UIDocumentPickerDelegate
picker.shouldShowFileExtensions = true
vc.present(picker, animated: true)
UserDefaults.standard.isPairingReset = false
})
vc.present(alert, animated: true)
}
}
// MARK: - SideJITManager
final class SideJITManager {
static let shared = SideJITManager()
func checkAndPromptIfNeeded(presentingVC: UIViewController) {
guard #available(iOS 17, *), !UserDefaults.standard.sidejitenable else { return }
DispatchQueue.global().async {
self.isSideJITServerDetected { result in
DispatchQueue.main.async {
switch result {
case .success():
let alert = UIAlertController(title: "SideJITServer Detected", message: "Would you like to enable SideJITServer", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in UserDefaults.standard.sidejitenable = true })
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
presentingVC.present(alert, animated: true)
case .failure(_): print("Cannot find sideJITServer")
}
}
}
}
}
func askForNetwork() {
let address = UserDefaults.standard.textInputSideJITServerurl ?? ""
let SJSURL = address.isEmpty ? "http://sidejitserver._http._tcp.local:8080" : address
URLSession.shared.dataTask(with: URL(string: "\(SJSURL)/re/")!) { data, resp, err in
print("data: \(String(describing: data)), response: \(String(describing: resp)), error: \(String(describing: err))")
}.resume()
}
func isSideJITServerDetected(completion: @escaping (Result<Void, Error>) -> Void) {
let address = UserDefaults.standard.textInputSideJITServerurl ?? ""
let SJSURL = address.isEmpty ? "http://sidejitserver._http._tcp.local:8080" : address
guard let url = URL(string: SJSURL) else { return }
URLSession.shared.dataTask(with: url) { _, _, error in
if let error = error { completion(.failure(error)); return }
completion(.success(()))
}.resume()
}
}

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

@@ -1,110 +0,0 @@
//
// AppManagerErrors.swift
// AltStore
//
// Created by Riley Testut on 8/27/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import CoreData
import AltStoreCore
extension AppManager
{
struct FetchSourcesError: LocalizedError, CustomNSError
{
var primaryError: Error?
var sources: Set<Source>?
var errors = [Source: Error]()
var managedObjectContext: NSManagedObjectContext?
var localizedTitle: String? {
var localizedTitle: String?
self.managedObjectContext?.performAndWait {
if self.sources?.count == 1 {
localizedTitle = NSLocalizedString("Failed to refresh Store", comment: "")
} 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
{
localizedTitle = String(format: NSLocalizedString("Failed to refresh %@ Sources", comment: ""), NSNumber(value: self.errors.count))
}
}
return localizedTitle
}
var errorDescription: String? {
if let error = self.primaryError {
return error.localizedDescription
} else if let error = self.errors.values.first, self.errors.count == 1 {
return error.localizedDescription
}
else
{
var localizedDescription: String?
self.managedObjectContext?.performAndWait {
if self.sources?.count == 1
{
localizedDescription = NSLocalizedString("Could not refresh store.", comment: "")
}
else if self.errors.count == 1
{
guard let source = self.errors.keys.first else { return }
localizedDescription = String(format: NSLocalizedString("Could not refresh source “%@”.", comment: ""), source.name)
}
else
{
localizedDescription = String(format: NSLocalizedString("Could not refresh %@ sources.", comment: ""), NSNumber(value: self.errors.count))
}
}
return localizedDescription
}
}
var recoverySuggestion: String? {
if let error = self.primaryError as NSError?
{
return error.localizedRecoverySuggestion
}
else if self.errors.count == 1
{
return nil
}
else
{
return NSLocalizedString("Tap to view source errors.", comment: "")
}
}
var errorUserInfo: [String : Any] {
let errors = Array(self.errors.values)
var userInfo = [String: Any]()
userInfo[ALTLocalizedTitleErrorKey] = self.localizedTitle
userInfo[NSUnderlyingErrorKey] = self.primaryError
if #available(iOS 14.5, *), !errors.isEmpty {
userInfo[NSMultipleUnderlyingErrorsKey] = errors
}
return userInfo
}
init(_ error: Error)
{
self.primaryError = error
}
init(sources: Set<Source>, errors: [Source: Error], context: NSManagedObjectContext)
{
self.sources = sources
self.errors = errors
self.managedObjectContext = context
}
}
}

View File

@@ -1,44 +0,0 @@
//
// InstalledAppsCollectionHeaderView.swift
// AltStore
//
// Created by Riley Testut on 3/9/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import UIKit
final class InstalledAppsCollectionHeaderView: UICollectionReusableView
{
let textLabel: UILabel
let button: UIButton
override init(frame: CGRect)
{
self.textLabel = UILabel()
self.textLabel.translatesAutoresizingMaskIntoConstraints = false
self.textLabel.font = UIFont.systemFont(ofSize: 24, weight: .bold)
self.textLabel.accessibilityTraits.insert(.header)
self.button = UIButton(type: .system)
self.button.translatesAutoresizingMaskIntoConstraints = false
self.button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium)
super.init(frame: frame)
self.addSubview(self.textLabel)
self.addSubview(self.button)
NSLayoutConstraint.activate([self.textLabel.leadingAnchor.constraint(equalTo: self.layoutMarginsGuide.leadingAnchor),
self.textLabel.bottomAnchor.constraint(equalTo: self.bottomAnchor)])
NSLayoutConstraint.activate([self.button.trailingAnchor.constraint(equalTo: self.layoutMarginsGuide.trailingAnchor),
self.button.firstBaselineAnchor.constraint(equalTo: self.textLabel.firstBaselineAnchor)])
self.preservesSuperviewLayoutMargins = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@@ -1,103 +0,0 @@
//
// MyAppsComponents.swift
// AltStore
//
// Created by Riley Testut on 7/17/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import Roxas
final class InstalledAppCollectionViewCell: UICollectionViewCell
{
private(set) var deactivateBadge: UIView?
@IBOutlet var bannerView: AppBannerView!
override func awakeFromNib()
{
super.awakeFromNib()
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),
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
}
}
final class InstalledAppsCollectionFooterView: UICollectionReusableView
{
@IBOutlet var textLabel: UILabel!
@IBOutlet var button: UIButton!
}
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)
}
}
final class UpdatesCollectionHeaderView: UICollectionReusableView
{
let button = PillButton(type: .system)
override init(frame: CGRect)
{
super.init(frame: frame)
self.button.translatesAutoresizingMaskIntoConstraints = false
self.button.setTitle(">", for: .normal)
self.addSubview(self.button)
NSLayoutConstraint.activate([self.button.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -20),
self.button.topAnchor.constraint(equalTo: self.topAnchor),
self.button.widthAnchor.constraint(equalToConstant: 50),
self.button.heightAnchor.constraint(equalToConstant: 26)])
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,123 +0,0 @@
//
// UpdateCollectionViewCell.swift
// AltStore
//
// Created by Riley Testut on 7/16/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
extension UpdateCollectionViewCell
{
enum Mode
{
case collapsed
case expanded
}
}
@objc final class UpdateCollectionViewCell: UICollectionViewCell
{
var mode: Mode = .expanded {
didSet {
switch self.mode {
case .collapsed:
self.versionDescriptionTextView.isCollapsed = true
case .expanded:
self.versionDescriptionTextView.isCollapsed = false
}
self.setNeedsLayout()
}
}
@IBOutlet var bannerView: AppBannerView!
// @IBOutlet var versionDescriptionTextView: CollapsingTextView!
@IBOutlet var versionDescriptionTextView: CollapsingMarkdownView!
@IBOutlet private var blurView: UIVisualEffectView!
private var originalTintColor: UIColor?
override func awakeFromNib()
{
super.awakeFromNib()
// Prevent temporary unsatisfiable constraint errors due to UIView-Encapsulated-Layout constraints.
self.contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.contentView.preservesSuperviewLayoutMargins = true
self.bannerView.backgroundEffectView.isHidden = true
self.blurView.layer.cornerRadius = 20
self.blurView.layer.masksToBounds = true
self.update()
}
override func tintColorDidChange()
{
super.tintColorDidChange()
if self.tintAdjustmentMode != .dimmed
{
self.originalTintColor = self.tintColor
}
self.update()
}
override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes)
{
// Animates transition to new attributes.
let animator = UIViewPropertyAnimator(springTimingParameters: UISpringTimingParameters()) {
self.layoutIfNeeded()
}
animator.startAnimation()
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?
{
let view = super.hitTest(point, with: event)
if view == self.versionDescriptionTextView
{
// Forward touches on the text view (but not on the nested "more" button)
// so cell selection works as expected.
return self
}
else
{
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
{
func update()
{
switch self.mode
{
case .collapsed: self.versionDescriptionTextView.isCollapsed = true
case .expanded: self.versionDescriptionTextView.isCollapsed = false
}
self.blurView.backgroundColor = self.originalTintColor ?? self.tintColor
self.bannerView.button.progressTintColor = self.originalTintColor ?? self.tintColor
self.setNeedsLayout()
self.layoutIfNeeded()
}
}

View File

@@ -1,33 +0,0 @@
//
// NewsCollectionViewCell.swift
// AltStore
//
// Created by Riley Testut on 8/29/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
final class NewsCollectionViewCell: UICollectionViewCell
{
@IBOutlet var titleLabel: UILabel!
@IBOutlet var captionLabel: UILabel!
@IBOutlet var imageView: UIImageView!
@IBOutlet var contentBackgroundView: UIView!
override func awakeFromNib()
{
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
self.contentBackgroundView.clipsToBounds = true
self.imageView.layer.cornerRadius = 30
self.imageView.clipsToBounds = true
}
}

View File

@@ -1,548 +0,0 @@
//
// NewsViewController.swift
// AltStore
//
// Created by Riley Testut on 8/29/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import SafariServices
import Combine
import AltStoreCore
import Roxas
import Nuke
private final class AppBannerFooterView: UICollectionReusableView
{
let bannerView = AppBannerView(frame: .zero)
let tapGestureRecognizer = UITapGestureRecognizer(target: nil, action: nil)
override init(frame: CGRect)
{
super.init(frame: frame)
self.addGestureRecognizer(self.tapGestureRecognizer)
self.bannerView.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(self.bannerView)
NSLayoutConstraint.activate([
self.bannerView.topAnchor.constraint(equalTo: self.topAnchor),
self.bannerView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
self.bannerView.leadingAnchor.constraint(equalTo: self.layoutMarginsGuide.leadingAnchor),
self.bannerView.trailingAnchor.constraint(equalTo: self.layoutMarginsGuide.trailingAnchor)
])
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class NewsViewController: UICollectionViewController, PeekPopPreviewing
{
// 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!
// 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)
}
override func viewDidLoad()
{
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)
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 viewWillLayoutSubviews()
{
super.viewWillLayoutSubviews()
if self.collectionView.contentInset.bottom != 20
{
// Triggers collection view update in iOS 13, which crashes if we do it in viewDidLoad()
// since the database might not be loaded yet.
self.collectionView.contentInset.bottom = 20
}
}
}
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
// Use fetchedResultsController to split NewsItems up into sections.
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: context, 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 }
let cell = cell as! NewsCollectionViewCell
cell.contentView.layoutMargins.left = self.view.layoutMargins.left
cell.contentView.layoutMargins.right = self.view.layoutMargins.right
cell.titleLabel.text = newsItem.title
cell.captionLabel.text = newsItem.caption
cell.contentBackgroundView.backgroundColor = newsItem.tintColor
cell.imageView.image = nil
if newsItem.imageURL != nil
{
cell.imageView.isIndicatingActivity = true
cell.imageView.isHidden = false
}
else
{
cell.imageView.isIndicatingActivity = false
cell.imageView.isHidden = true
}
cell.isAccessibilityElement = true
cell.accessibilityLabel = (cell.titleLabel.text ?? "") + ". " + (cell.captionLabel.text ?? "")
if newsItem.storeApp != nil || newsItem.externalURL != nil
{
cell.accessibilityTraits.insert(.button)
}
else
{
cell.accessibilityTraits.remove(.button)
}
}
dataSource.prefetchHandler = { (newsItem, indexPath, completionHandler) in
guard let imageURL = newsItem.imageURL else { return nil }
return RSTAsyncBlockOperation() { (operation) in
ImagePipeline.shared.loadImage(with: imageURL, 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! NewsCollectionViewCell
cell.imageView.isIndicatingActivity = false
cell.imageView.image = image
if let error = error
{
print("Error loading image:", error)
}
}
dataSource.placeholderView = self.placeholderView
return dataSource
}
@objc func updateSources()
{
AppManager.shared.updateAllSources() { result in
self.collectionView.refreshControl?.endRefreshing()
guard case .failure(let error) = result else { return }
if self.dataSource.itemCount > 0
{
let toastView = ToastView(error: error)
toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside)
toastView.show(in: self)
}
}
}
func update()
{
switch AppManager.shared.updateSourcesResult
{
case nil:
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):
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:
self.placeholderView.textLabel.isHidden = true
self.placeholderView.detailTextLabel.isHidden = true
self.retryButton.isHidden = true
self.placeholderView.activityIndicatorView.stopAnimating()
}
}
}
private extension NewsViewController
{
@objc func handleTapGesture(_ gestureRecognizer: UITapGestureRecognizer)
{
guard let footerView = gestureRecognizer.view as? UICollectionReusableView else { return }
let indexPaths = self.collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter)
guard let indexPath = indexPaths.first(where: { (indexPath) -> Bool in
let supplementaryView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath)
return supplementaryView == footerView
}) else { return }
let item = self.dataSource.item(at: indexPath)
guard let storeApp = item.storeApp else { return }
let appViewController = AppViewController.makeAppViewController(app: storeApp)
self.navigationController?.pushViewController(appViewController, animated: true)
}
@objc func performAppAction(_ sender: PillButton)
{
let point = self.collectionView.convert(sender.center, from: sender.superview)
let indexPaths = self.collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter)
guard let indexPath = indexPaths.first(where: { (indexPath) -> Bool in
let supplementaryView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath)
return supplementaryView?.frame.contains(point) ?? false
}) else { return }
let app = self.dataSource.item(at: indexPath)
guard let storeApp = app.storeApp else { return }
// if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable
if let installedApp = storeApp.installedApp, !installedApp.hasUpdate
{
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
}
Task<Void, Never>(priority: .userInitiated) { @MainActor in
// if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable
if let installedApp = storeApp.installedApp, installedApp.hasUpdate
{
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>)
{
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: print("Installed app:", storeApp.bundleIdentifier)
}
UIView.performWithoutAnimation {
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
}
}
}
}
func open(_ installedApp: InstalledApp)
{
UIApplication.shared.open(installedApp.openAppURL)
}
}
private extension NewsViewController
{
@objc func importApp(_ notification: Notification)
{
self.presentedViewController?.dismiss(animated: true, completion: nil)
}
}
extension NewsViewController
{
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
{
let newsItem = self.dataSource.item(at: indexPath)
if let externalURL = newsItem.externalURL
{
let safariViewController = SFSafariViewController(url: externalURL)
safariViewController.preferredControlTintColor = newsItem.tintColor
self.present(safariViewController, animated: true, completion: nil)
}
else if let storeApp = newsItem.storeApp
{
let appViewController = AppViewController.makeAppViewController(app: storeApp)
self.navigationController?.pushViewController(appViewController, animated: true)
}
}
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
{
let item = self.dataSource.item(at: indexPath)
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "AppBanner", for: indexPath) as! AppBannerFooterView
guard let storeApp = item.storeApp else { return footerView }
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
}
return footerView
}
}
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]
{
return previousSize
}
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
NSLayoutConstraint.activate([widthConstraint])
defer { NSLayoutConstraint.deactivate([widthConstraint]) }
self.dataSource.cellConfigurationHandler(self.prototypeCell, item, indexPath)
let size = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
self.cachedCellSizes[globallyUniqueID] = size
return size
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize
{
let item = self.dataSource.item(at: IndexPath(row: 0, section: section))
if item.storeApp != nil
{
return CGSize(width: 88, height: 88)
}
else
{
return .zero
}
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets
{
var insets = UIEdgeInsets(top: 30, left: 0, bottom: 13, right: 0)
if section == 0
{
insets.top = 10
}
return insets
}
}
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)
{
// Previewing news item.
previewingContext.sourceRect = cell.frame
let newsItem = self.dataSource.item(at: indexPath)
if let externalURL = newsItem.externalURL
{
let safariViewController = SFSafariViewController(url: externalURL)
safariViewController.preferredControlTintColor = newsItem.tintColor
return safariViewController
}
else if let storeApp = newsItem.storeApp
{
let appViewController = AppViewController.makeAppViewController(app: storeApp)
return appViewController
}
return nil
}
else
{
// Previewing app banner (or nothing).
let indexPaths = self.collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter)
guard let indexPath = indexPaths.first(where: { (indexPath) -> Bool in
let layoutAttributes = self.collectionView.layoutAttributesForSupplementaryElement(ofKind: UICollectionView.elementKindSectionFooter, at: indexPath)
return layoutAttributes?.frame.contains(location) ?? false
}) else { return nil }
guard let layoutAttributes = self.collectionView.layoutAttributesForSupplementaryElement(ofKind: UICollectionView.elementKindSectionFooter, at: indexPath) else { return nil }
previewingContext.sourceRect = layoutAttributes.frame
let item = self.dataSource.item(at: indexPath)
guard let storeApp = item.storeApp else { return nil }
let appViewController = AppViewController.makeAppViewController(app: storeApp)
return appViewController
}
}
@available(iOS, deprecated: 13.0)
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController)
{
if let safariViewController = viewControllerToCommit as? SFSafariViewController
{
self.present(safariViewController, animated: true, completion: nil)
}
else
{
self.navigationController?.pushViewController(viewControllerToCommit, animated: true)
}
}
}

View File

@@ -1,836 +0,0 @@
//
// AuthenticationOperation.swift
// AltStore
//
// Created by Riley Testut on 6/5/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import Roxas
import Network
import AltStoreCore
import AltSign
import minimuxer
private extension UIColor
{
static let altInvertedPrimary = UIColor(named: "SettingsHighlighted")!
}
typealias AuthenticationError = AuthenticationErrorCode.Error
enum AuthenticationErrorCode: Int, ALTErrorEnum, CaseIterable
{
case noTeam
case noCertificate
case teamSelectorError
case missingPrivateKey
case missingCertificate
var errorFailureReason: String {
switch self {
case .noTeam: return NSLocalizedString("Your Apple ID has no developer teams?", comment: "")
case .noCertificate: return NSLocalizedString("The developer certificate could not be found.", comment: "")
case .teamSelectorError: return NSLocalizedString("Error presenting team selector view.", comment: "")
case .missingPrivateKey: return NSLocalizedString("The certificate's private key could not be found.", comment: "")
case .missingCertificate: return NSLocalizedString("The certificate could not be found.", comment: "")
}
}
}
@objc(AuthenticationOperation)
final class AuthenticationOperation: ResultOperation<(ALTTeam, ALTCertificate, ALTAppleAPISession)>
{
let context: AuthenticatedOperationContext
private weak var presentingViewController: UIViewController?
private lazy var navigationController: UINavigationController = {
let navigationController = self.storyboard.instantiateViewController(withIdentifier: "navigationController") as! UINavigationController
navigationController.isModalInPresentation = true
return navigationController
}()
private lazy var storyboard = UIStoryboard(name: "Authentication", bundle: nil)
private var appleIDEmailAddress: String?
private var appleIDPassword: String?
private var shouldShowInstructions = false
private let operationQueue = OperationQueue()
private var submitCodeAction: UIAlertAction?
init(context: AuthenticatedOperationContext, presentingViewController: UIViewController?)
{
self.context = context
self.presentingViewController = presentingViewController
super.init()
self.context.authenticationOperation = self
self.operationQueue.name = "com.altstore.AuthenticationOperation"
self.progress.totalUnitCount = 4
}
override func main()
{
super.main()
if let error = self.context.error
{
self.finish(.failure(error))
return
}
Task {
// try to use cached session
if
let certificate = Keychain.shared.certificate,
let session = Keychain.shared.session,
let team = Keychain.shared.team
{
if session.anisetteData.date.timeIntervalSinceNow < -40.0 {
let anisetteData = try await withUnsafeThrowingContinuation { (c: UnsafeContinuation<ALTAnisetteData, any Error>) in
let fetchAnisetteDataOperation = FetchAnisetteDataOperation(context: self.context)
fetchAnisetteDataOperation.resultHandler = { (result) in
c.resume(with: result)
}
self.operationQueue.addOperation(fetchAnisetteDataOperation)
}
session.anisetteData = anisetteData
}
self.context.team = team
self.context.session = session
self.context.certificate = certificate
self.finish(.success((team, certificate, session)))
return
}
// new login
do {
let (account, session) = try await withUnsafeThrowingContinuation { c in
self.signIn() { (result) in
c.resume(with: result)
}
}
self.context.session = session
self.progress.completedUnitCount += 1
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
let team = try await withUnsafeThrowingContinuation { c in
self.fetchTeam(for: account, session: session) { (result) in
c.resume(with: result)
}
}
self.context.team = team
self.progress.completedUnitCount += 1
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
let certificate = try await withUnsafeThrowingContinuation { c in
self.fetchCertificate(for: team, session: session) { (result) in
c.resume(with: result)
}
}
self.context.certificate = certificate
self.progress.completedUnitCount += 1
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
let _ = try await withUnsafeThrowingContinuation { c in
self.registerCurrentDevice(for: team, session: session) { (result) in
c.resume(with: result)
}
}
self.progress.completedUnitCount += 1
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
try await withUnsafeThrowingContinuation { c in
self.save(team) { (result) in
c.resume(with: result)
}
}
guard !self.isCancelled else { return self.finish(.failure(OperationError.cancelled)) }
try await withUnsafeThrowingContinuation { c in
self.cacheAppIDs(team: team, session: session) { (result) in
c.resume(with: result)
}
}
Keychain.shared.team = team
Keychain.shared.certificate = certificate
Keychain.shared.session = session
self.finish(.success((team, certificate, session)))
} catch {
self.finish(.failure(error))
}
}
}
func save(_ altTeam: ALTTeam, completionHandler: @escaping (Result<Void, Error>) -> Void)
{
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
context.performAndWait {
do
{
let account: Account
let team: Team
if let tempAccount = Account.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Account.identifier), altTeam.account.identifier), in: context)
{
account = tempAccount
}
else
{
account = Account(altTeam.account, context: context)
}
if let tempTeam = Team.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Team.identifier), altTeam.identifier), in: context)
{
team = tempTeam
}
else
{
team = Team(altTeam, account: account, context: context)
}
account.update(account: altTeam.account)
if let providedEmailAddress = self.appleIDEmailAddress
{
// Save the user's provided email address instead of the one associated with their account (which may be outdated).
account.appleID = providedEmailAddress
}
team.update(team: altTeam)
try context.save()
completionHandler(.success(()))
}
catch
{
completionHandler(.failure(error))
}
}
}
override func finish(_ result: Result<(ALTTeam, ALTCertificate, ALTAppleAPISession), Error>)
{
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).")
}
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
context.perform {
do
{
let (altTeam, altCertificate, session) = try result.get()
guard
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
let otherAccountsFetchRequest = Account.fetchRequest() as NSFetchRequest<Account>
otherAccountsFetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(Account.identifier), account.identifier)
let otherAccounts = try context.fetch(otherAccountsFetchRequest)
for account in otherAccounts
{
account.isActiveAccount = false
}
// Team
team.isActiveTeam = true
let otherTeamsFetchRequest = Team.fetchRequest() as NSFetchRequest<Team>
otherTeamsFetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(Team.identifier), team.identifier)
let otherTeams = try context.fetch(otherTeamsFetchRequest)
for team in otherTeams
{
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 (!isAppLimitDisabled && isSparseRestorePatched) ||
(isAppLimitDisabled && !isSparseRestorePatched)
{
UserDefaults.standard.activeAppsLimit = InstalledApp.freeAccountActiveAppsLimit
}
}
// Save
try context.save()
// Update keychain
Keychain.shared.appleIDEmailAddress = self.appleIDEmailAddress ?? altTeam.account.appleID // Prefer the user's provided email address over the one associated with their account (which may be outdated).
Keychain.shared.appleIDPassword = self.appleIDPassword
Keychain.shared.signingCertificate = altCertificate.p12Data()
Keychain.shared.signingCertificatePassword = altCertificate.machineIdentifier
self.showInstructionsIfNecessary() { (didShowInstructions) in
let signer = ALTSigner(team: altTeam, certificate: altCertificate)
// Refresh screen must go last since a successful refresh will cause the app to quit.
self.showRefreshScreenIfNecessary(signer: signer, session: session) { (didShowRefreshAlert) in
super.finish(result)
DispatchQueue.main.async {
self.navigationController.dismiss(animated: true, completion: nil)
}
}
}
}
catch
{
super.finish(result)
DispatchQueue.main.async {
self.navigationController.dismiss(animated: true, completion: nil)
}
}
}
}
}
private extension AuthenticationOperation
{
func present(_ viewController: UIViewController) -> Bool
{
guard let presentingViewController = self.presentingViewController else { return false }
self.navigationController.view.tintColor = .altInvertedPrimary
if self.navigationController.viewControllers.isEmpty
{
guard presentingViewController.presentedViewController == nil else { return false }
self.navigationController.setViewControllers([viewController], animated: false)
presentingViewController.present(self.navigationController, animated: true, completion: nil)
}
else
{
viewController.navigationItem.leftBarButtonItem = nil
self.navigationController.pushViewController(viewController, animated: true)
}
return true
}
}
private extension AuthenticationOperation
{
func signIn(completionHandler: @escaping (Result<(ALTAccount, ALTAppleAPISession), Swift.Error>) -> Void)
{
func authenticate()
{
DispatchQueue.main.async {
let authenticationViewController = self.storyboard.instantiateViewController(withIdentifier: "authenticationViewController") as! AuthenticationViewController
authenticationViewController.authenticationHandler = { (appleID, password, completionHandler) in
self.authenticate(appleID: appleID, password: password) { (result) in
completionHandler(result)
}
}
authenticationViewController.completionHandler = { (result) in
if let (account, session, password) = result
{
// We presented the Auth UI and the user signed in.
// In this case, we'll assume we should show the instructions again.
self.shouldShowInstructions = true
self.appleIDPassword = password
completionHandler(.success((account, session)))
}
else
{
completionHandler(.failure(OperationError.cancelled))
}
}
if !self.present(authenticationViewController)
{
completionHandler(.failure(OperationError.notAuthenticated))
}
}
}
if let adsid = Keychain.shared.appleIDAdsid, let xcodeToken = Keychain.shared.appleIDXcodeToken {
Logger.sideload.notice("Authenticating Apple ID with tokens...")
let semaphore = DispatchSemaphore(value: 0)
var shouldContinue = true
Task {
defer {
semaphore.signal()
}
do {
let (account, session) = try await self.authenticateWithToken(adsid: adsid, xcodeToken: xcodeToken)
completionHandler(.success((account, session)))
shouldContinue = false
} catch {
Logger.sideload.notice("Authentication failed with token. Fall back to email and password login: \(error)")
}
}
semaphore.wait()
if !shouldContinue {
return
}
}
if let appleID = Keychain.shared.appleIDEmailAddress, let password = Keychain.shared.appleIDPassword
{
Logger.sideload.notice("Authenticating Apple ID...")
self.authenticate(appleID: appleID, password: password) { (result) in
switch result
{
case .success((let account, let session)):
self.appleIDPassword = password
completionHandler(.success((account, session)))
case .failure(ALTAppleAPIError.incorrectCredentials), .failure(ALTAppleAPIError.appSpecificPasswordRequired):
authenticate()
case .failure(let error):
completionHandler(.failure(error))
}
}
}
else
{
authenticate()
}
}
func authenticateWithToken(adsid: String, xcodeToken: String) async throws -> (ALTAccount, ALTAppleAPISession) {
let anisetteData = try await withUnsafeThrowingContinuation { (c: UnsafeContinuation<ALTAnisetteData, any Error>) in
let fetchAnisetteDataOperation = FetchAnisetteDataOperation(context: self.context)
fetchAnisetteDataOperation.resultHandler = { (result) in
c.resume(with: result)
}
self.operationQueue.addOperation(fetchAnisetteDataOperation)
}
let session = ALTAppleAPISession(dsid: adsid, authToken: xcodeToken, anisetteData: anisetteData)
let account = try await withUnsafeThrowingContinuation { (c: UnsafeContinuation<ALTAccount, any Error>) in
ALTAppleAPI.shared.fetchAccount2(session: session) { result in
c.resume(with: result)
}
}
return (account, session)
}
func authenticate(appleID: String, password: String, completionHandler: @escaping (Result<(ALTAccount, ALTAppleAPISession), Swift.Error>) -> Void)
{
self.appleIDEmailAddress = appleID
let fetchAnisetteDataOperation = FetchAnisetteDataOperation(context: self.context)
fetchAnisetteDataOperation.resultHandler = { (result) in
switch result
{
case .failure(let error): completionHandler(.failure(error))
case .success(let anisetteData):
let verificationHandler: ((@escaping (String?) -> Void) -> Void)?
if let presentingViewController = self.presentingViewController
{
verificationHandler = { (completionHandler) in
DispatchQueue.main.async {
let alertController = UIAlertController(title: NSLocalizedString("Please enter the 6-digit verification code that was sent to your Apple devices.", comment: ""), message: nil, preferredStyle: .alert)
alertController.addTextField { (textField) in
textField.autocorrectionType = .no
textField.autocapitalizationType = .none
textField.keyboardType = .numberPad
NotificationCenter.default.addObserver(self, selector: #selector(AuthenticationOperation.textFieldTextDidChange(_:)), name: UITextField.textDidChangeNotification, object: textField)
}
let submitAction = UIAlertAction(title: NSLocalizedString("Continue", comment: ""), style: .default) { (action) in
let textField = alertController.textFields?.first
let code = textField?.text ?? ""
completionHandler(code)
}
submitAction.isEnabled = false
alertController.addAction(submitAction)
self.submitCodeAction = submitAction
alertController.addAction(UIAlertAction(title: RSTSystemLocalizedString("Cancel"), style: .cancel) { (action) in
completionHandler(nil)
})
if self.navigationController.presentingViewController != nil
{
self.navigationController.present(alertController, animated: true, completion: nil)
}
else
{
presentingViewController.present(alertController, animated: true, completion: nil)
}
}
}
}
else
{
// No view controller to present security code alert, so don't provide verificationHandler.
verificationHandler = nil
}
ALTAppleAPI.shared.authenticate(appleID: appleID, password: password, anisetteData: anisetteData,
verificationHandler: verificationHandler) { (account, session, error) in
if let account = account, let session = session
{
Keychain.shared.appleIDAdsid = session.dsid
Keychain.shared.appleIDXcodeToken = session.authToken
completionHandler(.success((account, session)))
}
else
{
completionHandler(.failure(error ?? OperationError.unknown()))
}
}
}
}
self.operationQueue.addOperation(fetchAnisetteDataOperation)
}
func fetchTeam(for account: ALTAccount, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTTeam, Swift.Error>) -> Void)
{
func selectTeam(from teams: [ALTTeam])
{
if teams.count <= 1 {
if let team = teams.first {
return completionHandler(.success(team))
} else {
return completionHandler(.failure(AuthenticationError(.noTeam)))
}
} else {
DispatchQueue.main.async {
let selectTeamViewController = self.storyboard.instantiateViewController(withIdentifier: "selectTeamViewController") as! SelectTeamViewController
selectTeamViewController.teams = teams
selectTeamViewController.completionHandler = completionHandler
if !self.present(selectTeamViewController)
{
return completionHandler(.failure(AuthenticationError(.noTeam)))
}
}
}
}
ALTAppleAPI.shared.fetchTeams(for: account, session: session) { (teams, error) in
switch Result(teams, error)
{
case .failure(let error): completionHandler(.failure(error))
case .success(let teams):
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
if let activeTeam = DatabaseManager.shared.activeTeam(in: context), let altTeam = teams.first(where: { $0.identifier == activeTeam.identifier })
{
completionHandler(.success(altTeam))
}
else
{
selectTeam(from: teams)
}
}
}
}
}
func fetchCertificate(for team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTCertificate, Swift.Error>) -> Void)
{
func requestCertificate()
{
let machineName: String = "SideStore - \(team.account.firstName)'s \(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
{
let certificates = try Result(certificates, error).get()
guard let certificate = certificates.first(where: { $0.serialNumber == certificate.serialNumber }) else {
throw AuthenticationError(.missingCertificate)
}
certificate.privateKey = privateKey
completionHandler(.success(certificate))
}
catch
{
completionHandler(.failure(error))
}
}
}
catch
{
completionHandler(.failure(error))
}
}
}
func replaceCertificate(from certificates: [ALTCertificate])
{
let ourCertificates = certificates.filter { a in
a.machineName?.starts(with: "SideStore") == true || a.machineName?.starts(with: "AltStore") == true
}
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
{
self.navigationController.present(alertController, animated: true, completion: nil)
}
else
{
self.presentingViewController?.present(alertController, animated: true, completion: nil)
}
}
}
ALTAppleAPI.shared.fetchCertificates(for: team, session: session) { (certificates, error) in
do
{
let certificates = try Result(certificates, error).get()
if
let data = Keychain.shared.signingCertificate,
let localCertificate = ALTCertificate(p12Data: data, password: nil),
let certificate = certificates.first(where: { $0.serialNumber == localCertificate.serialNumber })
{
// We have a certificate stored in the keychain and it hasn't been revoked.
localCertificate.machineIdentifier = certificate.machineIdentifier
completionHandler(.success(localCertificate))
}
else if
let serialNumber = Keychain.shared.signingCertificateSerialNumber,
let privateKey = Keychain.shared.signingCertificatePrivateKey,
let certificate = certificates.first(where: { $0.serialNumber == serialNumber })
{
// LEGACY
// We have the private key for one of the certificates, so add it to certificate and use it.
certificate.privateKey = privateKey
completionHandler(.success(certificate))
}
else if
let serialNumber = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.certificateID) as? String,
let certificate = certificates.first(where: { $0.serialNumber == serialNumber }),
let machineIdentifier = certificate.machineIdentifier,
FileManager.default.fileExists(atPath: Bundle.main.certificateURL.path),
let data = try? Data(contentsOf: Bundle.main.certificateURL),
let localCertificate = ALTCertificate(p12Data: data, password: machineIdentifier)
{
// We have an embedded certificate that hasn't been revoked.
localCertificate.machineIdentifier = machineIdentifier
completionHandler(.success(localCertificate))
}
else if certificates.isEmpty
{
// No certificates, so request a new one.
requestCertificate()
}
else
{
// We don't have private keys for any of the certificates,
// so we need to revoke one and create a new one.
replaceCertificate(from: certificates)
}
}
catch
{
completionHandler(.failure(error))
}
}
}
func registerCurrentDevice(for team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTDevice, Error>) -> Void)
{
guard let udid = fetch_udid()?.toString() else {
return completionHandler(.failure(OperationError.unknownUDID))
}
ALTAppleAPI.shared.fetchDevices(for: team, types: [.iphone, .ipad], session: session) { (devices, error) in
do
{
let devices = try Result(devices, error).get()
if let device = devices.first(where: { $0.identifier == udid })
{
completionHandler(.success(device))
}
else
{
ALTAppleAPI.shared.registerDevice(name: UIDevice.current.name, identifier: udid, type: .iphone, team: team, session: session) { (device, error) in
completionHandler(Result(device, error))
}
}
}
catch
{
completionHandler(.failure(error))
}
}
}
func cacheAppIDs(team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<Void, Error>) -> Void)
{
let fetchAppIDsOperation = FetchAppIDsOperation(context: self.context)
fetchAppIDsOperation.resultHandler = { (result) in
do
{
let (_, context) = try result.get()
try context.save()
completionHandler(.success(()))
}
catch
{
completionHandler(.failure(error))
}
}
self.operationQueue.addOperation(fetchAppIDsOperation)
}
func showInstructionsIfNecessary(completionHandler: @escaping (Bool) -> Void)
{
guard self.shouldShowInstructions else { return completionHandler(false) }
DispatchQueue.main.async {
let instructionsViewController = self.storyboard.instantiateViewController(withIdentifier: "instructionsViewController") as! InstructionsViewController
instructionsViewController.showsBottomButton = true
instructionsViewController.completionHandler = {
completionHandler(true)
}
if !self.present(instructionsViewController)
{
completionHandler(false)
}
}
}
func showRefreshScreenIfNecessary(signer: ALTSigner, session: ALTAppleAPISession, completionHandler: @escaping (Bool) -> Void)
{
guard let application = ALTApplication(fileURL: Bundle.main.bundleURL), let provisioningProfile = application.provisioningProfile else { return completionHandler(false) }
// If we're not using the same certificate used to install AltStore, warn user that they need to refresh.
guard !provisioningProfile.certificates.contains(signer.certificate) else { return completionHandler(false) }
// #if DEBUG && targetEnvironment(simulator)
// completionHandler(false)
// #else
DispatchQueue.main.async {
let context = AuthenticatedOperationContext(context: self.context)
context.operations.removeAllObjects() // Prevent deadlock due to endless waiting on previous operations to finish.
let refreshViewController = self.storyboard.instantiateViewController(withIdentifier: "refreshAltStoreViewController") as! RefreshAltStoreViewController
refreshViewController.context = context
refreshViewController.completionHandler = { _ in
completionHandler(true)
}
if !self.present(refreshViewController)
{
completionHandler(false)
}
}
// #endif
}
}
extension AuthenticationOperation
{
@objc func textFieldTextDidChange(_ notification: Notification)
{
guard let textField = notification.object as? UITextField else { return }
self.submitCodeAction?.isEnabled = (textField.text ?? "").count == 6
}
}
extension ALTAppleAPI {
func fetchAccount2(session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTAccount, Error>) -> Void)
{
let url = URL(string: "viewDeveloper.action", relativeTo: self.baseURL)!
self.sendRequest(with: url, additionalParameters: nil, session: session, team: nil) { (responseDictionary, requestError) in
do
{
guard let responseDictionary = responseDictionary else { throw requestError ?? ALTAppleAPIError.unknown() }
guard let account = try self.processResponse(responseDictionary, parseHandler: { () -> Any? in
guard let dictionary = responseDictionary["developer"] as? [String: Any] else { return nil }
let account = ALTAccount(responseDictionary: dictionary)
return account
}, resultCodeHandler: nil) as? ALTAccount else {
throw ALTAppleAPIError.unknown()
}
completionHandler(.success(account))
} catch {
completionHandler(.failure(error))
}
}
}
}

View File

@@ -1,218 +0,0 @@
//
// ClearAppCacheOperation.swift
// AltStore
//
// Created by Riley Testut on 9/27/22.
// Copyright © 2022 Riley Testut. All rights reserved.
//
import Foundation
import AltStoreCore
import Nuke
struct BatchError: ALTLocalizedError
{
enum Code: Int, ALTErrorCode
{
typealias Error = BatchError
case batchError
}
var code: Code = .batchError
var underlyingErrors: [Error]
var errorTitle: String?
var errorFailure: String?
init(errors: [Error])
{
self.underlyingErrors = errors
}
var errorFailureReason: String {
guard !self.underlyingErrors.isEmpty else { return NSLocalizedString("An unknown error occured.", comment: "") }
let errorMessages = self.underlyingErrors.map { $0.localizedDescription }
let message = errorMessages.joined(separator: "\n\n")
return message
}
}
@objc(ClearAppCacheOperation)
class ClearAppCacheOperation: ResultOperation<Void>
{
private let coordinator = NSFileCoordinator()
private let coordinatorQueue = OperationQueue()
override init()
{
self.coordinatorQueue.name = "AltStore - ClearAppCacheOperation Queue"
}
override func main()
{
super.main()
self.clearNukeCache()
var allErrors = [Error]()
self.clearTemporaryDirectory { result in
switch result
{
//case .failure(let batchError as BatchError): allErrors.append(contentsOf: batchError.underlyingErrors)
case .failure(let error): allErrors.append(error)
case .success: break
}
self.removeUninstalledAppBackupDirectories { result in
switch result
{
//case .failure(let batchError as BatchError): allErrors.append(contentsOf: batchError.underlyingErrors)
case .failure(let error): allErrors.append(error)
case .success: break
}
if allErrors.isEmpty
{
self.finish(.success(()))
}
else
{
self.finish(.failure(OperationError.cacheClearError(errors: allErrors.map({ error in
return error.localizedDescription
}))))
}
}
}
}
}
private extension ClearAppCacheOperation
{
func clearNukeCache()
{
guard let dataCache = ImagePipeline.shared.configuration.dataCache as? DataCache else { return }
dataCache.removeAll()
}
func clearTemporaryDirectory(completion: @escaping (Result<Void, Error>) -> Void)
{
let intent = NSFileAccessIntent.writingIntent(with: FileManager.default.temporaryDirectory, options: [.forDeleting])
self.coordinator.coordinate(with: [intent], queue: self.coordinatorQueue) { (error) in
do
{
if let error
{
throw error
}
let fileURLs = try FileManager.default.contentsOfDirectory(at: intent.url,
includingPropertiesForKeys: [],
options: [.skipsSubdirectoryDescendants, .skipsHiddenFiles])
var errors = [Error]()
for fileURL in fileURLs
{
do
{
Logger.main.debug("Removing item from temporary directory: \(fileURL.lastPathComponent, privacy: .public)")
try FileManager.default.removeItem(at: fileURL)
}
catch
{
Logger.main.error("Failed to remove \(fileURL.lastPathComponent) from temporary directory. \(error.localizedDescription, privacy: .public)")
errors.append(error)
}
}
if !errors.isEmpty
{
completion(.failure(OperationError.cacheClearError(errors: errors.map({ error in
return error.localizedDescription
}))))
}
else
{
completion(.success(()))
}
}
catch
{
completion(.failure(error))
}
}
}
func removeUninstalledAppBackupDirectories(completion: @escaping (Result<Void, Error>) -> Void)
{
guard let backupsDirectory = FileManager.default.appBackupsDirectory else { return completion(.failure(OperationError.missingAppGroup)) }
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
let installedAppBundleIDs = Set(InstalledApp.all(in: context).map { $0.bundleIdentifier })
let intent = NSFileAccessIntent.writingIntent(with: backupsDirectory, options: [.forDeleting])
self.coordinator.coordinate(with: [intent], queue: self.coordinatorQueue) { (error) in
do
{
if let error
{
throw error
}
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: intent.url.path, isDirectory: &isDirectory), isDirectory.boolValue else {
completion(.success(()))
return
}
let fileURLs = try FileManager.default.contentsOfDirectory(at: intent.url,
includingPropertiesForKeys: [.isDirectoryKey, .nameKey],
options: [.skipsSubdirectoryDescendants, .skipsHiddenFiles])
var errors = [Error]()
for backupDirectory in fileURLs
{
do
{
let resourceValues = try backupDirectory.resourceValues(forKeys: [.isDirectoryKey, .nameKey])
guard let isDirectory = resourceValues.isDirectory, let bundleID = resourceValues.name else { continue }
if isDirectory && !installedAppBundleIDs.contains(bundleID) && !AppManager.shared.isActivelyManagingApp(withBundleID: bundleID)
{
Logger.main.debug("Removing backup directory for uninstalled app: \(bundleID, privacy: .public)")
try FileManager.default.removeItem(at: backupDirectory)
}
}
catch
{
Logger.main.error("Failed to remove app backup directory. \(error.localizedDescription, privacy: .public)")
errors.append(error)
}
}
if !errors.isEmpty
{
completion(.failure(OperationError.cacheClearError(errors: errors.map({ error in
return error.localizedDescription
}))))
}
else
{
completion(.success(()))
}
}
catch
{
Logger.main.error("Failed to remove app backup directory. \(error.localizedDescription, privacy: .public)")
completion(.failure(error))
}
}
}
}
}

View File

@@ -1,615 +0,0 @@
//
// DownloadAppOperation.swift
// AltStore
//
// Created by Riley Testut on 6/10/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import WebKit
import UniformTypeIdentifiers
import AltStoreCore
import AltSign
import Roxas
@objc(DownloadAppOperation)
final class DownloadAppOperation: ResultOperation<ALTApplication>
{
@Managed
private(set) var app: AppProtocol
let context: InstallAppOperationContext
private let appName: String
private let bundleIdentifier: String
private var sourceURL: URL?
private let destinationURL: URL
private let session = URLSession(configuration: .default)
private let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
private var downloadPatreonAppContinuation: CheckedContinuation<URL, Error>?
init(app: AppProtocol, destinationURL: URL, context: InstallAppOperationContext)
{
self.app = app
self.context = context
self.appName = app.name
self.bundleIdentifier = app.bundleIdentifier
self.sourceURL = app.url
self.destinationURL = destinationURL
super.init()
// App = 3, Dependencies = 1
self.progress.totalUnitCount = 4
}
override func main()
{
super.main()
if let error = self.context.error
{
self.finish(.failure(error))
return
}
print("Downloading App:", self.bundleIdentifier)
// Set _after_ checking self.context.error to prevent overwriting localized failure for previous errors.
self.localizedFailure = String(format: NSLocalizedString("%@ could not be downloaded.", comment: ""), self.appName)
self.$app.perform { app in
do
{
var appVersion: AppVersion?
if let version = app as? AppVersion
{
appVersion = version
}
else if let storeApp = app as? StoreApp
{
guard let latestVersion = storeApp.latestAvailableVersion else {
let failureReason = String(format: NSLocalizedString("The latest version of %@ could not be determined.", comment: ""), self.appName)
throw OperationError.unknown(failureReason: failureReason)
}
// Attempt to download latest _available_ version, and fall back to older versions if necessary.
appVersion = latestVersion
}
if let appVersion
{
try self.verify(appVersion)
}
self.download(appVersion ?? app)
}
catch let error as VerificationError where error.code == .iOSVersionNotSupported
{
guard let presentingViewController = self.context.presentingViewController, let storeApp = app.storeApp, let latestSupportedVersion = storeApp.latestSupportedVersion,
case let version = latestSupportedVersion.version,
version != storeApp.installedApp?.version
else { return self.finish(.failure(error)) }
if let installedApp = storeApp.installedApp
{
// guard !installedApp.matches(latestSupportedVersion) else { return self.finish(.failure(error)) }
guard installedApp.hasUpdate else { return self.finish(.failure(error)) }
}
let title = NSLocalizedString("Unsupported iOS Version", comment: "")
let message = error.localizedDescription + "\n\n" + NSLocalizedString("Would you like to download the last version compatible with this device instead?", comment: "")
let localizedVersion = latestSupportedVersion.localizedVersion
DispatchQueue.main.async {
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { _ in
self.finish(.failure(OperationError.cancelled))
})
alertController.addAction(UIAlertAction(title: String(format: NSLocalizedString("Download %@ %@", comment: ""), self.appName, localizedVersion), style: .default) { _ in
self.download(latestSupportedVersion)
})
presentingViewController.present(alertController, animated: true)
}
}
catch
{
self.finish(.failure(error))
}
}
}
override func finish(_ result: Result<ALTApplication, Error>)
{
if(FileManager.default.fileExists(atPath: self.temporaryDirectory.path)){
do
{
try FileManager.default.removeItem(at: self.temporaryDirectory)
}
catch
{
print("Failed to remove DownloadAppOperation temporary directory: \(self.temporaryDirectory).", error)
}
}
super.finish(result)
}
}
private extension DownloadAppOperation
{
func verify(_ version: AppVersion) throws
{
if let minOSVersion = version.minOSVersion, !ProcessInfo.processInfo.isOperatingSystemAtLeast(minOSVersion)
{
throw VerificationError.iOSVersionNotSupported(app: version, requiredOSVersion: minOSVersion)
}
else if let maxOSVersion = version.maxOSVersion, ProcessInfo.processInfo.operatingSystemVersion > maxOSVersion
{
throw VerificationError.iOSVersionNotSupported(app: version, requiredOSVersion: maxOSVersion)
}
}
func printWithTid(_ msg: String){
print("DownloadAppOperation: Thread: \(Thread.current.name ?? Thread.current.description) - " + msg)
}
func download(@Managed _ app: AppProtocol)
{
guard let sourceURL = self.sourceURL else {
return self.finish(.failure(OperationError.appNotFound(name: self.appName)))
}
if let appVersion = app as? AppVersion
{
// All downloads go through this path, and `app` is
// always an AppVersion if downloading from a source,
// so context.appVersion != nil means downloading from source.
self.context.appVersion = appVersion
}
downloadIPA(from: sourceURL) { result in
do
{
let application = try result.get()
if self.context.bundleIdentifier == StoreApp.dolphinAppID, self.context.bundleIdentifier != application.bundleIdentifier
{
if var infoPlist = NSDictionary(contentsOf: application.bundle.infoPlistURL) as? [String: Any]
{
// Manually update the app's bundle identifier to match the one specified in the source.
// This allows people who previously installed the app to still update and refresh normally.
infoPlist[kCFBundleIdentifierKey as String] = StoreApp.dolphinAppID
(infoPlist as NSDictionary).write(to: application.bundle.infoPlistURL, atomically: true)
}
}
self.downloadDependencies(for: application) { result in
do
{
_ = try result.get()
try FileManager.default.copyItem(at: application.fileURL, to: self.destinationURL, shouldReplace: true)
guard let copiedApplication = ALTApplication(fileURL: self.destinationURL) else { throw OperationError.invalidApp }
self.finish(.success(copiedApplication))
self.progress.completedUnitCount += 1
}
catch
{
self.finish(.failure(error))
}
}
}
catch
{
self.finish(.failure(error))
}
}
func downloadIPA(from sourceURL: URL, completionHandler: @escaping (Result<ALTApplication, Error>) -> Void)
{
Task<Void, Never>.detached(priority: .userInitiated) {
do
{
let fileURL: URL
if sourceURL.isFileURL
{
fileURL = sourceURL
self.progress.completedUnitCount += 3
}
else if let host = sourceURL.host, host.lowercased().hasSuffix("patreon.com") && sourceURL.path.lowercased() == "/file"
{
// Patreon app
fileURL = try await downloadPatreonApp(from: sourceURL)
self.printWithTid("downloadPatreonApp: completed at \(fileURL.path)")
}
else
{
// Regular app
fileURL = try await downloadFile(from: sourceURL)
self.printWithTid("downloadFile: completed at \(fileURL.path)")
}
defer {
if !sourceURL.isFileURL && FileManager.default.fileExists(atPath: fileURL.path)
{
try? FileManager.default.removeItem(at: fileURL)
}
}
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) else {
throw OperationError.appNotFound(name: self.appName)
}
try FileManager.default.createDirectory(at: self.temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
let appBundleURL: URL
if isDirectory.boolValue
{
// Directory, so assuming this is .app bundle.
guard Bundle(url: fileURL) != nil else { throw OperationError.invalidApp }
appBundleURL = self.temporaryDirectory.appendingPathComponent(fileURL.lastPathComponent)
try FileManager.default.copyItem(at: fileURL, to: appBundleURL)
}
else
{
// File, so assuming this is a .ipa file.
appBundleURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: self.temporaryDirectory)
// Use context's temporaryDirectory to ensure .ipa isn't deleted before we're done installing.
let ipaURL = self.context.temporaryDirectory.appendingPathComponent("App.ipa")
try FileManager.default.copyItem(at: fileURL, to: ipaURL)
self.context.ipaURL = ipaURL
}
guard let application = ALTApplication(fileURL: appBundleURL) else { throw OperationError.invalidApp }
// perform cleanup of the temp files
if(FileManager.default.fileExists(atPath: fileURL.path)){
self.printWithTid("Removing downloaded temp file at: \(fileURL.path)")
do{
try FileManager.default.removeItem(at: fileURL)
} catch{
self.printWithTid("Removing downloaded temp error: \(error)")
}
}
completionHandler(.success(application))
}
catch
{
completionHandler(.failure(error))
}
}
}
func downloadFile(from downloadURL: URL) async throws -> URL
{
try await withCheckedThrowingContinuation { continuation in
let downloadTask = self.session.downloadTask(with: downloadURL) { (fileURL, response, error) in
do
{
if let response = response as? HTTPURLResponse
{
guard response.statusCode != 403 else { throw URLError(.noPermissionsToReadFile) }
guard response.statusCode != 404 else { throw CocoaError(.fileNoSuchFile, userInfo: [NSURLErrorKey: downloadURL]) }
}
let (fileURL, _) = try Result((fileURL, response), error).get()
continuation.resume(returning: fileURL)
// self.printWithTid("downloadtask completed: fileURL: \(fileURL) URL: \(downloadURL)")
}
catch
{
// self.printWithTid("downloadtask Error: \(error) URL:\(downloadURL)")
continuation.resume(throwing: error)
}
}
self.progress.addChild(downloadTask.progress, withPendingUnitCount: 3)
downloadTask.resume()
self.printWithTid("download started: \(downloadURL)")
}
}
func downloadPatreonApp(from patreonURL: URL) async throws -> URL
{
guard !UserDefaults.shared.skipPatreonDownloads else {
// Skip all hacks, take user straight to Patreon post.
return try await downloadFromPatreonPost()
}
do
{
// User is pledged to this app, attempt to download.
let fileURL = try await downloadFile(from: patreonURL)
return fileURL
}
catch URLError.noPermissionsToReadFile
{
guard let presentingViewController = self.context.presentingViewController else { throw OperationError.pledgeRequired(appName: self.appName) }
// Attempt to sign-in again in case our Patreon session has expired.
try await withCheckedThrowingContinuation { continuation in
PatreonAPI.shared.authenticate(presentingViewController: presentingViewController) { result in
do
{
let account = try result.get()
try account.managedObjectContext?.save()
continuation.resume()
}
catch
{
continuation.resume(throwing: error)
}
}
}
do
{
// Success, so try to download once more now that we're definitely authenticated.
let fileURL = try await downloadFile(from: patreonURL)
return fileURL
}
catch URLError.noPermissionsToReadFile
{
// We know authentication succeeded, so failure must mean user isn't patron/on the correct tier,
// or that our hacky workaround for downloading Patreon attachments has failed.
// Either way, taking them directly to the post serves as a decent fallback.
return try await downloadFromPatreonPost()
}
}
func downloadFromPatreonPost() async throws -> URL
{
guard let presentingViewController = self.context.presentingViewController else { throw OperationError.pledgeRequired(appName: self.appName) }
let downloadURL: URL
if let components = URLComponents(url: patreonURL, resolvingAgainstBaseURL: false),
let postItem = components.queryItems?.first(where: { $0.name == "h" }),
let postID = postItem.value,
let patreonPostURL = URL(string: "https://www.patreon.com/posts/" + postID)
{
downloadURL = patreonPostURL
}
else
{
downloadURL = patreonURL
}
return try await downloadFromPatreon(downloadURL, presentingViewController: presentingViewController)
}
}
@MainActor
func downloadFromPatreon(_ patreonURL: URL, presentingViewController: UIViewController) async throws -> URL
{
let webViewController = WebViewController(url: patreonURL)
webViewController.delegate = self
webViewController.webView.navigationDelegate = self
let navigationController = UINavigationController(rootViewController: webViewController)
presentingViewController.present(navigationController, animated: true)
let downloadURL: URL
do
{
defer {
navigationController.dismiss(animated: true)
}
downloadURL = try await withCheckedThrowingContinuation { continuation in
self.downloadPatreonAppContinuation = continuation
}
}
let fileURL = try await downloadFile(from: downloadURL)
return fileURL
}
}
}
extension DownloadAppOperation: WebViewControllerDelegate
{
func webViewControllerDidFinish(_ webViewController: WebViewController)
{
guard let continuation = self.downloadPatreonAppContinuation else { return }
self.downloadPatreonAppContinuation = nil
continuation.resume(throwing: CancellationError())
}
}
extension DownloadAppOperation: WKNavigationDelegate
{
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy
{
guard #available(iOS 14.5, *), navigationAction.shouldPerformDownload else { return .allow }
guard let continuation = self.downloadPatreonAppContinuation else { return .allow }
self.downloadPatreonAppContinuation = nil
if let downloadURL = navigationAction.request.url
{
continuation.resume(returning: downloadURL)
}
else
{
continuation.resume(throwing: URLError(.badURL))
}
return .cancel
}
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy
{
// Called for Patreon attachments
guard !navigationResponse.canShowMIMEType else { return .allow }
guard let continuation = self.downloadPatreonAppContinuation else { return .allow }
self.downloadPatreonAppContinuation = nil
guard let response = navigationResponse.response as? HTTPURLResponse, let responseURL = response.url,
let mimeType = response.mimeType, let type = UTType(mimeType: mimeType),
type.conforms(to: .ipa) || type.conforms(to: .zip) || type.conforms(to: .application)
else {
continuation.resume(throwing: OperationError.invalidApp)
return .cancel
}
continuation.resume(returning: responseURL)
return .cancel
}
}
private extension DownloadAppOperation
{
struct AltStorePlist: Decodable
{
private enum CodingKeys: String, CodingKey
{
case dependencies = "ALTDependencies"
}
var dependencies: [Dependency]
}
struct Dependency: Decodable
{
var downloadURL: URL
var path: String?
var preferredFilename: String {
let preferredFilename = self.path.map { ($0 as NSString).lastPathComponent } ?? self.downloadURL.lastPathComponent
return preferredFilename
}
init(from decoder: Decoder) throws
{
enum CodingKeys: String, CodingKey
{
case downloadURL
case path
}
let container = try decoder.container(keyedBy: CodingKeys.self)
let urlString = try container.decode(String.self, forKey: .downloadURL)
let path = try container.decodeIfPresent(String.self, forKey: .path)
guard let downloadURL = URL(string: urlString) else {
throw DecodingError.dataCorruptedError(forKey: .downloadURL, in: container, debugDescription: "downloadURL is not a valid URL.")
}
self.downloadURL = downloadURL
self.path = path
}
}
func downloadDependencies(for application: ALTApplication, completionHandler: @escaping (Result<Set<URL>, Error>) -> Void)
{
guard FileManager.default.fileExists(atPath: application.bundle.altstorePlistURL.path) else {
return completionHandler(.success([]))
}
do
{
let data = try Data(contentsOf: application.bundle.altstorePlistURL)
let altstorePlist = try PropertyListDecoder().decode(AltStorePlist.self, from: data)
var dependencyURLs = Set<URL>()
var dependencyError: Error?
let dispatchGroup = DispatchGroup()
let progress = Progress(totalUnitCount: Int64(altstorePlist.dependencies.count), parent: self.progress, pendingUnitCount: 1)
for dependency in altstorePlist.dependencies
{
dispatchGroup.enter()
self.download(dependency, for: application, progress: progress) { result in
switch result
{
case .failure(let error): dependencyError = error
case .success(let fileURL): dependencyURLs.insert(fileURL)
}
dispatchGroup.leave()
}
}
dispatchGroup.notify(qos: .userInitiated, queue: .global()) {
if let dependencyError = dependencyError
{
completionHandler(.failure(dependencyError))
}
else
{
completionHandler(.success(dependencyURLs))
}
}
}
catch let error as DecodingError
{
let nsError = (error as NSError).withLocalizedFailure(String(format: NSLocalizedString("Could not determine dependencies for %@.", comment: ""), application.name))
completionHandler(.failure(nsError))
}
catch
{
completionHandler(.failure(error))
}
}
func download(_ dependency: Dependency, for application: ALTApplication, progress: Progress, completionHandler: @escaping (Result<URL, Error>) -> Void)
{
let downloadTask = self.session.downloadTask(with: dependency.downloadURL) { (fileURL, response, error) in
do
{
let (fileURL, _) = try Result((fileURL, response), error).get()
defer { try? FileManager.default.removeItem(at: fileURL) }
let path = dependency.path ?? dependency.preferredFilename
let destinationURL = application.fileURL.appendingPathComponent(path)
let directoryURL = destinationURL.deletingLastPathComponent()
if !FileManager.default.fileExists(atPath: directoryURL.path)
{
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
}
try FileManager.default.copyItem(at: fileURL, to: destinationURL, shouldReplace: true)
completionHandler(.success(destinationURL))
}
catch let error as NSError
{
let localizedFailure = String(format: NSLocalizedString("The dependency '%@' could not be downloaded.", comment: ""), dependency.preferredFilename)
completionHandler(.failure(error.withLocalizedFailure(localizedFailure)))
}
}
progress.addChild(downloadTask.progress, withPendingUnitCount: 1)
downloadTask.resume()
}
}

View File

@@ -1,148 +0,0 @@
//
// EnableJITOperation.swift
// EnableJITOperation
//
// Created by Riley Testut on 9/1/21.
// Copyright © 2021 Riley Testut. All rights reserved.
//
import UIKit
import Combine
import minimuxer
import UniformTypeIdentifiers
import AltStoreCore
enum SideJITServerErrorType: Error {
case invalidURL
case errorConnecting
case deviceNotFound
case other(String)
}
@available(iOS 14, *)
protocol EnableJITContext
{
var installedApp: InstalledApp? { get }
var error: Error? { get }
}
@available(iOS 14, *)
final class EnableJITOperation<Context: EnableJITContext>: ResultOperation<Void>
{
let context: Context
private var cancellable: AnyCancellable?
init(context: Context)
{
self.context = context
}
override func main()
{
super.main()
if let error = self.context.error
{
self.finish(.failure(error))
return
}
guard let installedApp = self.context.installedApp else {
return self.finish(.failure(OperationError.invalidParameters("EnableJITOperation.main: self.context.installedApp is nil")))
}
let userdefaults = UserDefaults.standard
if #available(iOS 17, *), userdefaults.sidejitenable {
let SideJITIP = userdefaults.textInputSideJITServerurl ?? "http://sidejitserver._http._tcp.local:8080"
installedApp.managedObjectContext?.perform {
enableJITSideJITServer(serverURL: URL(string: SideJITIP)!, installedApp: installedApp) { result in
switch result {
case .failure(let error):
switch error {
case .invalidURL, .errorConnecting:
self.finish(.failure(OperationError.unableToConnectSideJIT))
case .deviceNotFound:
self.finish(.failure(OperationError.unableToRespondSideJITDevice))
case .other(let message):
if let startRange = message.range(of: "<p>"),
let endRange = message.range(of: "</p>", range: startRange.upperBound..<message.endIndex) {
let pContent = message[startRange.upperBound..<endRange.lowerBound]
self.finish(.failure(OperationError.SideJITIssue(error: String(pContent))))
print(message + " + " + String(pContent))
} else {
print(message)
self.finish(.failure(OperationError.SideJITIssue(error: message)))
}
}
case .success():
self.finish(.success(()))
print("JIT Enabled Successfully :3 (code made by Stossy11!)")
}
}
return
}
} else {
installedApp.managedObjectContext?.perform {
var retries = 3
while (retries > 0){
do {
try debug_app(installedApp.resignedBundleIdentifier)
self.finish(.success(()))
retries = 0
} catch {
retries -= 1
if (retries <= 0){
self.finish(.failure(error))
}
}
}
}
}
}
}
@available(iOS 17, *)
func enableJITSideJITServer(serverURL: URL, installedApp: InstalledApp, completion: @escaping (Result<Void, SideJITServerErrorType>) -> Void) {
guard let udid = fetch_udid()?.toString() else {
completion(.failure(.other("Unable to get UDID")))
return
}
let serverURLWithUDID = serverURL.appendingPathComponent(udid)
let fullURL = serverURLWithUDID.appendingPathComponent(installedApp.resignedBundleIdentifier)
let task = URLSession.shared.dataTask(with: fullURL) { (data, response, error) in
if let error = error {
completion(.failure(.errorConnecting))
return
}
guard let data = data, let dataString = String(data: data, encoding: .utf8) else {
return
}
if dataString == "Enabled JIT for '\(installedApp.name)'!" {
let content = UNMutableNotificationContent()
content.title = "JIT Successfully Enabled"
content.subtitle = "JIT Enabled For \(installedApp.name)"
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
let request = UNNotificationRequest(identifier: "EnabledJIT", content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
completion(.success(()))
} else {
let errorType: SideJITServerErrorType = dataString == "Could not find device!"
? .deviceNotFound
: .other(dataString)
completion(.failure(errorType))
}
}
task.resume()
}

View File

@@ -1,399 +0,0 @@
//
// OperationError.swift
// AltStore
//
// Created by Riley Testut on 6/7/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import AltSign
import AltStoreCore
import minimuxer
extension OperationError
{
enum Code: Int, ALTErrorCode, CaseIterable {
typealias Error = OperationError
// General
case unknown = 1000
case unknownResult = 1001
// case cancelled = 1002
case timedOut = 1003
case notAuthenticated = 1004
case appNotFound = 1005
case unknownUDID = 1006
case invalidApp = 1007
case invalidParameters = 1008
case maximumAppIDLimitReached = 1009
case noSources = 1010
case openAppFailed = 1011
case missingAppGroup = 1012
case forbidden = 1013
case sourceNotAdded = 1014
// Connection
/* Connection */
case serverNotFound = 1200
case connectionFailed = 1201
case connectionDropped = 1202
/* Pledges */
case pledgeRequired = 1401
case pledgeInactive = 1402
/* SideStore Only */
case unableToConnectSideJIT
case unableToRespondSideJITDevice
case wrongSideJITIP
case SideJITIssue // (error: String)
case refreshsidejit
case refreshAppFailed
case tooNewError
case anisetteV1Error//(message: String)
case provisioningError//(result: String, message: String?)
case anisetteV3Error//(message: String)
case cacheClearError//(errors: [String])
case noWiFi
case invalidOperationContext
}
static var cancelled: CancellationError { CancellationError() }
static let unknownResult: OperationError = .init(code: .unknownResult)
static let timedOut: OperationError = .init(code: .timedOut)
static let unableToConnectSideJIT: OperationError = .init(code: .unableToConnectSideJIT)
static let unableToRespondSideJITDevice: OperationError = .init(code: .unableToRespondSideJITDevice)
static let wrongSideJITIP: OperationError = .init(code: .wrongSideJITIP)
static let notAuthenticated: OperationError = .init(code: .notAuthenticated)
static let unknownUDID: OperationError = .init(code: .unknownUDID)
static let invalidApp: OperationError = .init(code: .invalidApp)
static let noSources: OperationError = .init(code: .noSources)
static let missingAppGroup: OperationError = .init(code: .missingAppGroup)
static let noWiFi: OperationError = .init(code: .noWiFi)
static let tooNewError: OperationError = .init(code: .tooNewError)
static let provisioningError: OperationError = .init(code: .provisioningError)
static let anisetteV1Error: OperationError = .init(code: .anisetteV1Error)
static let anisetteV3Error: OperationError = .init(code: .anisetteV3Error)
static let cacheClearError: OperationError = .init(code: .cacheClearError)
static func unknown(failureReason: String? = nil, file: String = #fileID, line: UInt = #line) -> OperationError {
OperationError(code: .unknown, failureReason: failureReason, sourceFile: file, sourceLine: line)
}
static func appNotFound(name: String?) -> OperationError {
OperationError(code: .appNotFound, appName: name)
}
static func openAppFailed(name: String?) -> OperationError {
OperationError(code: .openAppFailed, appName: name)
}
static let domain = OperationError(code: .unknown)._domain
static func SideJITIssue(error: String?) -> OperationError {
var o = OperationError(code: .SideJITIssue)
o.errorFailure = error
return o
}
static func maximumAppIDLimitReached(appName: String, requiredAppIDs: Int, availableAppIDs: Int, expirationDate: Date) -> OperationError {
OperationError(code: .maximumAppIDLimitReached, appName: appName, requiredAppIDs: requiredAppIDs, availableAppIDs: availableAppIDs, expirationDate: expirationDate)
}
static func provisioningError(result: String, message: String?) -> OperationError {
var o = OperationError(code: .provisioningError, failureReason: result)
o.errorTitle = message
return o
}
static func cacheClearError(errors: [String]) -> OperationError {
OperationError(code: .cacheClearError, failureReason: errors.joined(separator: "\n"))
}
static func anisetteV1Error(message: String) -> OperationError {
OperationError(code: .anisetteV1Error, failureReason: message)
}
static func anisetteV3Error(message: String) -> OperationError {
OperationError(code: .anisetteV3Error, failureReason: message)
}
static func refreshAppFailed(message: String) -> OperationError {
OperationError(code: .refreshAppFailed, failureReason: message)
}
static func invalidParameters(_ message: String? = nil) -> OperationError {
OperationError(code: .invalidParameters, failureReason: message)
}
static func invalidOperationContext(_ message: String? = nil) -> OperationError {
OperationError(code: .invalidOperationContext, failureReason: message)
}
static func forbidden(failureReason: String? = nil, file: String = #fileID, line: UInt = #line) -> OperationError {
OperationError(code: .forbidden, failureReason: failureReason, sourceFile: file, sourceLine: line)
}
static func sourceNotAdded(@Managed _ source: Source, file: String = #fileID, line: UInt = #line) -> OperationError {
OperationError(code: .sourceNotAdded, sourceName: $source.name, sourceFile: file, sourceLine: line)
}
static func pledgeRequired(appName: String, file: String = #fileID, line: UInt = #line) -> OperationError {
OperationError(code: .pledgeRequired, appName: appName, sourceFile: file, sourceLine: line)
}
static func pledgeInactive(appName: String, file: String = #fileID, line: UInt = #line) -> OperationError {
OperationError(code: .pledgeInactive, appName: appName, sourceFile: file, sourceLine: line)
}
}
struct OperationError: ALTLocalizedError {
let code: Code
var errorTitle: String?
var errorFailure: String?
@UserInfoValue
var appName: String?
@UserInfoValue
var sourceName: String?
var requiredAppIDs: Int?
var availableAppIDs: Int?
var expirationDate: Date?
var sourceFile: String?
var sourceLine: UInt?
private var _failureReason: String?
private init(code: Code, failureReason: String? = nil,
appName: String? = nil, sourceName: String? = nil, requiredAppIDs: Int? = nil,
availableAppIDs: Int? = nil, expirationDate: Date? = nil, sourceFile: String? = nil, sourceLine: UInt? = nil){
self.code = code
self._failureReason = failureReason
self.appName = appName
self.sourceName = sourceName
self.requiredAppIDs = requiredAppIDs
self.availableAppIDs = availableAppIDs
self.expirationDate = expirationDate
self.sourceFile = sourceFile
self.sourceLine = sourceLine
}
var errorFailureReason: String {
switch self.code {
case .unknown:
var failureReason = self._failureReason ?? NSLocalizedString("An unknown error occurred.", comment: "")
guard let sourceFile, let sourceLine else { return failureReason }
failureReason += " (\(sourceFile) line \(sourceLine)"
return failureReason
case .unknownResult: return NSLocalizedString("The operation returned an unknown result.", comment: "")
case .timedOut: return NSLocalizedString("The operation timed out.", comment: "")
case .notAuthenticated: return NSLocalizedString("You are not signed in.", comment: "")
case .unknownUDID: return NSLocalizedString("SideStore could not determine this device's UDID. Please replace your pairing using iloader.", comment: "")
case .invalidApp: return NSLocalizedString("The app is in an invalid format.", comment: "")
case .maximumAppIDLimitReached: return NSLocalizedString("Cannot register more than 10 App IDs within a 7 day period.", comment: "")
case .noSources: return NSLocalizedString("There are no SideStore sources.", comment: "")
case .missingAppGroup: return NSLocalizedString("SideStore's shared app group could not be accessed.", comment: "")
case .forbidden:
guard let failureReason = self._failureReason else { return NSLocalizedString("The operation is forbidden.", comment: "") }
return failureReason
case .sourceNotAdded:
let sourceName = self.sourceName.map { String(format: NSLocalizedString("The source “%@”", comment: ""), $0) } ?? NSLocalizedString("The source", comment: "")
return String(format: NSLocalizedString("%@ is not added to SideStore.", comment: ""), sourceName)
case .appNotFound:
let appName = self.appName ?? NSLocalizedString("The app", comment: "")
return String(format: NSLocalizedString("%@ could not be found.", comment: ""), appName)
case .openAppFailed:
let appName = self.appName ?? NSLocalizedString("The app", comment: "")
return String(format: NSLocalizedString("SideStore was denied permission to launch %@.", comment: ""), appName)
case .noWiFi: return NSLocalizedString("You do not appear to be connected to Wi-Fi and/or LocalDevVPN!\nSideStore cannot install or refresh applications without Wi-Fi and LocalDevVPN. If both are connected, replace your pairing with iloader.", comment: "")
case .tooNewError: return NSLocalizedString("iOS 17.0-17.3.1 changed how JIT is enabled so SideStore cannot enable JIT without SideJITServer on these versions, sorry for any inconvenience.", comment: "")
case .unableToConnectSideJIT: return NSLocalizedString("Unable to connect to SideJITServer. Please check that you are on the same Wi-Fi of and your Firewall has been set correctly on your server.", comment: "")
case .unableToRespondSideJITDevice: return NSLocalizedString("SideJITServer is unable to connect to your iDevice. Please make sure you have paired your iDevice by running 'SideJITServer -y', or try refreshing SideJITServer from Settings.", comment: "")
case .wrongSideJITIP: return NSLocalizedString("Incorrect SideJITServer IP. Please make sure that you are on the same Wi-Fi as SideJITServer", comment: "")
case .refreshsidejit: return NSLocalizedString("Unable to find app; Please try refreshing SideJITServer from Settings.", comment: "")
case .anisetteV1Error: return NSLocalizedString("An error occurred while getting anisette data from a V1 server: %@. Try using another anisette server.", comment: "")
case .provisioningError: return NSLocalizedString("An error occurred while provisioning: %@ %@. Please try again. If the issue persists, report it on GitHub Issues!", comment: "")
case .anisetteV3Error: return NSLocalizedString("An error occurred while getting anisette data from a V3 server: %@. Please try again. If the issue persists, report it on GitHub Issues!", comment: "")
case .cacheClearError: return NSLocalizedString("An error occurred while clearing the cache: %@", comment: "")
case .SideJITIssue: return NSLocalizedString("An error occurred while using SideJIT: %@", comment: "")
case .refreshAppFailed:
let message = self._failureReason ?? ""
return String(format: NSLocalizedString("Unable to refresh App\n%@", comment: ""), message)
case .invalidParameters:
let message = self._failureReason.map { ": \n\($0)" } ?? "."
return String(format: NSLocalizedString("Invalid parameters%@", comment: ""), message)
case .invalidOperationContext:
let message = self._failureReason.map { ": \n\($0)" } ?? "."
return String(format: NSLocalizedString("Invalid Operation Context%@", comment: ""), message)
case .serverNotFound: return NSLocalizedString("AltServer could not be found.", comment: "")
case .connectionFailed: return NSLocalizedString("A connection to AltServer could not be established.", comment: "")
case .connectionDropped: return NSLocalizedString("The connection to AltServer was dropped.", comment: "")
case .pledgeRequired:
let appName = self.appName ?? NSLocalizedString("This app", comment: "")
return String(format: NSLocalizedString("%@ requires an active pledge in order to be installed.", comment: ""), appName)
case .pledgeInactive:
let appName = self.appName ?? NSLocalizedString("this app", comment: "")
return String(format: NSLocalizedString("Your pledge is no longer active. Please renew it to continue using %@ normally.", comment: ""), appName)
}
}
var recoverySuggestion: String? {
switch self.code
{
case .noWiFi: return NSLocalizedString("Make sure LocalDevVPN is connected and that you are connected to any Wi-Fi network!", comment: "")
case .serverNotFound: return NSLocalizedString("Make sure you're on the same Wi-Fi network as a computer running AltServer, or try connecting this device to your computer via USB.", comment: "")
case .maximumAppIDLimitReached:
let baseMessage = NSLocalizedString("Delete sideloaded apps to free up App ID slots.", comment: "")
guard let appName, let requiredAppIDs, let availableAppIDs, let expirationDate else { return baseMessage }
var message: String
if requiredAppIDs > 1
{
let availableText: String
switch availableAppIDs
{
case 0: availableText = NSLocalizedString("none are available", comment: "")
case 1: availableText = NSLocalizedString("only 1 is available", comment: "")
default: availableText = String(format: NSLocalizedString("only %@ are available", comment: ""), NSNumber(value: availableAppIDs))
}
let prefixMessage = String(format: NSLocalizedString("%@ requires %@ App IDs, but %@.", comment: ""), appName, NSNumber(value: requiredAppIDs), availableText)
message = prefixMessage + " " + baseMessage + "\n\n"
}
else
{
message = baseMessage + " "
}
let dateComponents = Calendar.current.dateComponents([.day, .hour, .minute], from: Date(), to: expirationDate)
let dateFormatter = DateComponentsFormatter()
dateFormatter.maximumUnitCount = 1
dateFormatter.unitsStyle = .full
let remainingTime = dateFormatter.string(from: dateComponents)!
message += String(format: NSLocalizedString("You can register another App ID in %@.", comment: ""), remainingTime)
return message
default: return nil
}
}
}
extension MinimuxerError: LocalizedError {
public var failureReason: String? {
switch self {
case .NoDevice:
return NSLocalizedString("Cannot fetch the device from the muxer", comment: "")
case .NoConnection:
return NSLocalizedString("Unable to connect to the device, make sure LocalDevVPN is enabled and you're connected to Wi-Fi. This could mean an invalid pairing.", comment: "")
case .PairingFile:
return NSLocalizedString("Invalid pairing file. Your pairing file either didn't have a UDID, or it wasn't a valid plist. Please use iloader to replace it.", comment: "")
case .CreateDebug:
return self.createService(name: "debug")
case .LookupApps:
return self.getFromDevice(name: "installed apps")
case .FindApp:
return self.getFromDevice(name: "path to the app")
case .BundlePath:
return self.getFromDevice(name: "bundle path")
case .MaxPacket:
return self.setArgument(name: "max packet")
case .WorkingDirectory:
return self.setArgument(name: "working directory")
case .Argv:
return self.setArgument(name: "argv")
case .LaunchSuccess:
return self.getFromDevice(name: "launch success")
case .Detach:
return NSLocalizedString("Unable to detach from the app's process", comment: "")
case .Attach:
return NSLocalizedString("Unable to attach to the app's process", comment: "")
case .CreateInstproxy:
return self.createService(name: "instproxy")
case .CreateAfc:
return self.createService(name: "AFC")
case .RwAfc:
return NSLocalizedString("AFC was unable to manage files on the device. Ensure Wi-Fi and LocalDevVPN are connected. If they both are, replace your pairing using iloader.", comment: "")
case .InstallApp(let message):
return NSLocalizedString("Unable to install the app: \(message.toString())", comment: "")
case .UninstallApp:
return NSLocalizedString("Unable to uninstall the app", comment: "")
case .CreateMisagent:
return self.createService(name: "misagent")
case .ProfileInstall:
return NSLocalizedString("Unable to manage profiles on the device", comment: "")
case .ProfileRemove:
return NSLocalizedString("Unable to manage profiles on the device", comment: "")
case .CreateLockdown:
return NSLocalizedString("Unable to connect to lockdown", comment: "")
case .CreateCoreDevice:
return NSLocalizedString("Unable to connect to core device proxy", comment: "")
case .CreateSoftwareTunnel:
return NSLocalizedString("Unable to create software tunnel", comment: "")
case .CreateRemoteServer:
return NSLocalizedString("Unable to connect to remote server", comment: "")
case .CreateProcessControl:
return NSLocalizedString("Unable to connect to process control", comment: "")
case .GetLockdownValue:
return NSLocalizedString("Unable to get value from lockdown", comment: "")
case .Connect:
return NSLocalizedString("Unable to connect to TCP port", comment: "")
case .Close:
return NSLocalizedString("Unable to close TCP port", comment: "")
case .XpcHandshake:
return NSLocalizedString("Unable to get services from XPC", comment: "")
case .NoService:
return NSLocalizedString("Device did not contain service", comment: "")
case .InvalidProductVersion:
return NSLocalizedString("Service version was in an unexpected format", comment: "")
case .CreateFolder:
return NSLocalizedString("Unable to create DDI folder", comment: "")
case .DownloadImage:
return NSLocalizedString("Unable to download DDI", comment: "")
case .ImageLookup:
return NSLocalizedString("Unable to lookup DDI images", comment: "")
case .ImageRead:
return NSLocalizedString("Unable to read images to memory", comment: "")
case .Mount:
return NSLocalizedString("Mount failed", comment: "")
}
}
fileprivate func createService(name: String) -> String {
return String(format: NSLocalizedString("Cannot start a %@ server on the device.", comment: ""), name)
}
fileprivate func getFromDevice(name: String) -> String {
return String(format: NSLocalizedString("Cannot fetch %@ from the device.", comment: ""), name)
}
fileprivate func setArgument(name: String) -> String {
return String(format: NSLocalizedString("Cannot set %@ on the device.", comment: ""), name)
}
}

View File

@@ -1,231 +0,0 @@
//
// SourceError.swift
// AltStoreCore
//
// Created by Riley Testut on 5/3/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import AltStoreCore
extension SourceError
{
enum Code: Int, ALTErrorCode
{
typealias Error = SourceError
case unsupported
case duplicateBundleID
case duplicateVersion
case blocked
case changedID
case duplicate
case missingPermissionUsageDescription
case missingScreenshotSize
case marketplaceNotSupported = 101
case marketplaceRequired
}
static func unsupported(_ source: Source) -> SourceError { SourceError(code: .unsupported, source: source) }
static func duplicateBundleID(_ bundleID: String, source: Source) -> SourceError { SourceError(code: .duplicateBundleID, source: source, bundleID: bundleID) }
static func duplicateVersion(_ version: String, for app: StoreApp, source: Source) -> SourceError { SourceError(code: .duplicateVersion, source: source, app: app, version: version) }
static func blocked(_ source: Source, bundleIDs: [String]?, existingSource: Source?) -> SourceError { SourceError(code: .blocked, source: source, existingSource: existingSource, bundleIDs: bundleIDs) }
static func changedID(_ identifier: String, previousID: String, source: Source) -> SourceError { SourceError(code: .changedID, source: source, sourceID: identifier, previousSourceID: previousID) }
static func duplicate(_ source: Source, existingSource: Source?) -> SourceError { SourceError(code: .duplicate, source: source, existingSource: existingSource) }
static func missingPermissionUsageDescription(for permission: any ALTAppPermission, app: StoreApp, source: Source) -> SourceError {
SourceError(code: .missingPermissionUsageDescription, source: source, app: app, permission: permission)
}
static func missingScreenshotSize(for screenshot: AppScreenshot, source: Source) -> SourceError {
SourceError(code: .missingScreenshotSize, source: source, app: screenshot.app, screenshotURL: screenshot.imageURL)
}
static func marketplaceNotSupported(source: Source) -> SourceError {
return SourceError(code: .marketplaceNotSupported, source: source)
}
static func marketplaceRequired(source: Source) -> SourceError {
return SourceError(code: .marketplaceRequired, source: source)
}
}
struct SourceError: ALTLocalizedError
{
let code: Code
var errorTitle: String?
var errorFailure: String?
@Managed var source: Source
@Managed var app: StoreApp?
@Managed var existingSource: Source?
var version: String?
var bundleID: String?
var bundleIDs: [String]?
// Store in userInfo so they can be viewed from Error Log.
@UserInfoValue var sourceID: String?
@UserInfoValue var previousSourceID: String?
@UserInfoValue
var permission: (any ALTAppPermission)?
@UserInfoValue
var screenshotURL: URL?
var errorFailureReason: String {
switch self.code
{
case .unsupported: return String(format: NSLocalizedString("The source “%@” is not supported by this version of SideStore.", comment: ""), self.$source.name)
case .duplicateBundleID:
let bundleIDFragment = self.bundleID.map { String(format: NSLocalizedString("the bundle identifier %@", comment: ""), $0) } ?? NSLocalizedString("the same bundle identifier", comment: "")
let failureReason = String(format: NSLocalizedString("The source “%@” contains multiple apps with %@.", comment: ""), self.$source.name, bundleIDFragment)
return failureReason
case .duplicateVersion:
var versionFragment = NSLocalizedString("duplicate versions", comment: "")
if let version
{
versionFragment += " (\(version))"
}
let appFragment: String
if let name = self.$app.name, let bundleID = self.$app.bundleIdentifier
{
appFragment = name + " (\(bundleID))"
}
else
{
appFragment = NSLocalizedString("one or more apps", comment: "")
}
let failureReason = String(format: NSLocalizedString("The source “%@” contains %@ for %@.", comment: ""), self.$source.name, versionFragment, appFragment)
return failureReason
case .blocked:
let failureReason = String(format: NSLocalizedString("The source “%@” has been blocked by SideStore for security reasons.", comment: ""), self.$source.name)
return failureReason
case .changedID:
let failureReason = String(format: NSLocalizedString("The identifier of the source “%@” has changed.", comment: ""), self.$source.name)
return failureReason
case .duplicate:
let baseMessage = String(format: NSLocalizedString("A source with the identifier '%@' already exists", comment: ""), self.$source.identifier)
guard let existingSourceName = self.$existingSource.name else { return baseMessage + "." }
let failureReason = baseMessage + " (“\(existingSourceName)”)."
return failureReason
case .missingPermissionUsageDescription:
let appName = self.$app.name ?? String(format: NSLocalizedString("an app in source “%@”", comment: ""), self.$source.name)
guard let permission else {
return String(format: NSLocalizedString("A permission for %@ is missing a usage description.", comment: ""), appName)
}
let permissionType = permission.type.localizedName ?? NSLocalizedString("Permission", comment: "")
let failureReason = String(format: NSLocalizedString("The %@ '%@' for %@ is missing a usage description.", comment: ""), permissionType.lowercased(), permission.rawValue, appName)
return failureReason
case .missingScreenshotSize:
let appName = self.$app.name ?? String(format: NSLocalizedString("an app in source “%@”", comment: ""), self.$source.name)
let baseMessage = String(format: NSLocalizedString("An iPad screenshot for %@ does not specify its size", comment: ""), appName)
guard let screenshotURL else { return baseMessage + "." }
let failureReason = baseMessage + ": \(screenshotURL.absoluteString)"
return failureReason
case .marketplaceNotSupported:
let failureReason = String(format: NSLocalizedString("The source “%@” contains notarized apps, which are not supported by this version of SideStore.", comment: ""), self.$source.name)
return failureReason
case .marketplaceRequired:
let failureReason = String(format: NSLocalizedString("One or more apps in source “%@” are missing a marketplaceID. This most likely means they are not notarized, which is not supported by this version of SideStore.", comment: ""), self.$source.name)
return failureReason
}
}
var recoverySuggestion: String? {
switch self.code
{
case .blocked:
if self.existingSource != nil
{
// Source already added, so tell them to remove it + any installed apps.
let baseMessage = NSLocalizedString("For your protection, please remove the source and uninstall", comment: "")
if let blockedAppNames = self.blockedAppNames
{
let recoverySuggestion = baseMessage + " " + NSLocalizedString("the following apps:", comment: "") + "\n\n" + blockedAppNames.joined(separator: "\n")
return recoverySuggestion
}
else
{
let recoverySuggestion = baseMessage + " " + NSLocalizedString("all apps downloaded from it.", comment: "")
return recoverySuggestion
}
}
else
{
// Source is not already added, so no need to tell users to remove it.
// Instead, we just list all affected apps (if provided).
guard let blockedAppNames else { return nil }
let recoverySuggestion = NSLocalizedString("The following apps have been flagged:", comment: "") + "\n\n" + blockedAppNames.joined(separator: "\n")
return recoverySuggestion
}
case .changedID: return NSLocalizedString("A source cannot change its identifier once added. This source can no longer be updated.", comment: "")
case .duplicate:
let recoverySuggestion = NSLocalizedString("Please remove the existing source in order to add this one.", comment: "")
return recoverySuggestion
case .marketplaceRequired:
let failureReason = String(format: NSLocalizedString("SideStore can only install marketplace apps that have been notarized by Apple.", comment: ""), self.$source.name)
return failureReason
default: return nil
}
}
}
private extension SourceError
{
var blockedAppNames: [String]? {
let blockedAppNames: [String]?
if let existingSource
{
// Blocked apps = all installed apps from this source.
blockedAppNames = self.$existingSource.perform { _ in
let storeApps = existingSource.apps.lazy.filter { $0.installedApp != nil }
guard !storeApps.isEmpty else { return nil }
let appNames = storeApps.map { "\($0.name) (\($0.bundleIdentifier))" }
return Array(appNames)
}
}
else if let bundleIDs
{
// Blocked apps = explicitly listed bundleIDs in blocked source JSON entry.
blockedAppNames = self.$source.perform { source in
bundleIDs.compactMap { (bundleID) in
guard let storeApp = source._apps.lazy.compactMap({ $0 as? StoreApp }).first(where: { $0.bundleIdentifier == bundleID }) else { return nil }
return "\(storeApp.name) (\(storeApp.bundleIdentifier))"
}
}
}
else
{
blockedAppNames = nil
}
let sortedNames = blockedAppNames?.sorted { $0.localizedCompare($1) == .orderedAscending }
return sortedNames
}
}

View File

@@ -1,252 +0,0 @@
//
// VerificationError.swift
// AltStore
//
// Created by Riley Testut on 5/11/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import AltStoreCore
import AltSign
extension VerificationError
{
enum Code: Int, ALTErrorCode, CaseIterable
{
typealias Error = VerificationError
// Legacy
// case privateEntitlements = 0
case mismatchedBundleIdentifiers = 1
case iOSVersionNotSupported = 2
case mismatchedHash = 3
case mismatchedVersion = 4
case mismatchedBuildVersion = 5
case undeclaredPermissions = 6
case addedPermissions = 7
}
// static func privateEntitlements(_ entitlements: [String: Any], app: ALTApplication) -> VerificationError {
// VerificationError(code: .privateEntitlements, app: app, entitlements: entitlements)
// }
static func mismatchedBundleIdentifiers(sourceBundleID: String, app: ALTApplication) -> VerificationError {
VerificationError(code: .mismatchedBundleIdentifiers, app: app, sourceBundleID: sourceBundleID)
}
static func iOSVersionNotSupported(app: AppProtocol, osVersion: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion, requiredOSVersion: OperatingSystemVersion?) -> VerificationError {
VerificationError(code: .iOSVersionNotSupported, app: app, deviceOSVersion: osVersion, requiredOSVersion: requiredOSVersion)
}
static func mismatchedHash(_ hash: String, expectedHash: String, app: AppProtocol) -> VerificationError {
VerificationError(code: .mismatchedHash, app: app, hash: hash, expectedHash: expectedHash)
}
static func mismatchedVersion(version: String,
expectedVersion: String,
app: AppProtocol) -> VerificationError
{
VerificationError(code: .mismatchedVersion, app: app,
version: version,
expectedVersion: expectedVersion
)
}
static func mismatchedBuildVersion(_ version: String, expectedVersion: String, app: AppProtocol) -> VerificationError {
VerificationError(code: .mismatchedBuildVersion, app: app, version: version, expectedVersion: expectedVersion)
}
static func undeclaredPermissions(_ permissions: [any ALTAppPermission], app: AppProtocol) -> VerificationError {
VerificationError(code: .undeclaredPermissions, app: app, permissions: permissions)
}
static func addedPermissions(_ permissions: [any ALTAppPermission], appVersion: AppVersion) -> VerificationError {
VerificationError(code: .addedPermissions, app: appVersion, permissions: permissions)
}
}
struct VerificationError: ALTLocalizedError
{
let code: Code
var errorTitle: String?
var errorFailure: String?
@Managed var app: AppProtocol?
var sourceBundleID: String?
var deviceOSVersion: OperatingSystemVersion?
var requiredOSVersion: OperatingSystemVersion?
@UserInfoValue var hash: String?
@UserInfoValue var expectedHash: String?
@UserInfoValue var version: String?
@UserInfoValue var expectedVersion: String?
@UserInfoValue
var permissions: [any ALTAppPermission]?
var errorDescription: String? {
//TODO: Make this automatic somehow with ALTLocalizedError
guard self.errorFailure == nil else { return nil }
switch self.code
{
case .iOSVersionNotSupported:
guard let deviceOSVersion else { break }
var failureReason = self.errorFailureReason
if self.app == nil
{
// failureReason does not start with app name, so make first letter lowercase.
let firstLetter = failureReason.prefix(1).lowercased()
failureReason = firstLetter + failureReason.dropFirst()
}
let localizedDescription = String(format: NSLocalizedString("This device is running iOS %@, but %@", comment: ""), deviceOSVersion.stringValue, failureReason)
return localizedDescription
default: break
}
return self.errorFailureReason
}
var errorFailureReason: String {
switch self.code
{
// case .privateEntitlements:
// let appName = self.$app.name ?? NSLocalizedString("The app", comment: "")
// return String(formatted: "%@ requires private permissions.", appName)
case .mismatchedBundleIdentifiers:
if let appBundleID = self.$app.bundleIdentifier, let bundleID = self.sourceBundleID
{
return String(format: NSLocalizedString("The bundle ID “%@” does not match the one specified by the source (“%@”).", comment: ""), appBundleID, bundleID)
}
else
{
return NSLocalizedString("The bundle ID does not match the one specified by the source.", comment: "")
}
case .iOSVersionNotSupported:
let appName = self.$app.name ?? NSLocalizedString("The app", comment: "")
let deviceOSVersion = self.deviceOSVersion ?? ProcessInfo.processInfo.operatingSystemVersion
guard let requiredOSVersion else {
return String(format: NSLocalizedString("%@ does not support iOS %@.", comment: ""), appName, deviceOSVersion.stringValue)
}
if deviceOSVersion > requiredOSVersion
{
// Device OS version is higher than maximum supported OS version.
let failureReason = String(format: NSLocalizedString("%@ requires iOS %@ or earlier.", comment: ""), appName, requiredOSVersion.stringValue)
return failureReason
}
else
{
// Device OS version is lower than minimum supported OS version.
let failureReason = String(format: NSLocalizedString("%@ requires iOS %@ or later.", comment: ""), appName, requiredOSVersion.stringValue)
return failureReason
}
case .mismatchedHash:
let appName = self.$app.name ?? NSLocalizedString("the downloaded app", comment: "")
return String(format: NSLocalizedString("The SHA-256 hash of %@ does not match the hash specified by the source.", comment: ""), appName)
case .mismatchedVersion:
let appName = self.$app.name ?? NSLocalizedString("the app", comment: "")
return String(format: NSLocalizedString("The downloaded version of %@ does not match the version specified by the source.\nExpected version: %@\nFound version: %@", comment: ""), appName, expectedVersion ?? "nil", version ?? "nil")
case .mismatchedBuildVersion:
let appName = self.$app.name ?? NSLocalizedString("the app", comment: "")
return String(format: NSLocalizedString("The downloaded version of %@ does not match the build number specified by the source.\nExpected version: %@\nFound version: %@", comment: ""), appName, expectedVersion ?? "nil", version ?? "nil")
case .undeclaredPermissions:
let appName = self.$app.name ?? NSLocalizedString("The app", comment: "")
return String(format: NSLocalizedString("%@ requires additional permissions not specified by the source.", comment: ""), appName)
case .addedPermissions:
let appName: String
let installedVersion: String?
if let appVersion = self.app as? AppVersion
{
let (name, version, previousVersion) = self.$app.perform { _ in (appVersion.name, appVersion.localizedVersion, appVersion.app?.installedApp?.localizedVersion) }
appName = name + " \(version)"
installedVersion = previousVersion.map { "(\(name) \($0))" } // Include app name because it looks weird to include build # in double parentheses without it.
}
else
{
appName = self.$app.name ?? NSLocalizedString("The app", comment: "")
installedVersion = nil
}
let baseMessage = String(format: NSLocalizedString("%@ requires more permissions than the version that is already installed", comment: ""), appName)
let failureReason = [baseMessage, installedVersion].compactMap { $0 }.joined(separator: " ") + "."
return failureReason
}
}
var recoverySuggestion: String? {
switch self.code
{
case .undeclaredPermissions:
guard let permissionsDescription else { return nil }
let baseMessage = NSLocalizedString("These permissions must be declared by the source in order for SideStore to install this app:", comment: "")
let recoverySuggestion = [baseMessage, permissionsDescription].joined(separator: "\n\n")
return recoverySuggestion
case .addedPermissions:
let recoverySuggestion = self.permissionsDescription
return recoverySuggestion
default: return nil
}
}
}
private extension VerificationError
{
var permissionsDescription: String? {
guard let permissions, !permissions.isEmpty else { return nil }
let permissionsByType = Dictionary(grouping: permissions) { $0.type }
let permissionSections = [ALTAppPermissionType.entitlement, .privacy].compactMap { (type) -> String? in
guard let permissions = permissionsByType[type] else { return nil }
// "Privacy:"
var sectionText = "\(type.localizedName ?? type.rawValue):\n"
// Sort permissions + join into single string.
let sortedList = permissions.map { permission -> String in
if let localizedName = permission.localizedName
{
// "Entitlement Name (com.apple.entitlement.name)"
return "\(localizedName) (\(permission.rawValue))"
}
else
{
// "com.apple.entitlement.name"
return permission.rawValue
}
}
.sorted { $0.localizedStandardCompare($1) == .orderedAscending } // Case-insensitive sorting
.joined(separator: "\n")
sectionText += sortedList
return sectionText
}
let permissionsDescription = permissionSections.joined(separator: "\n\n")
return permissionsDescription
}
}

View File

@@ -1,573 +0,0 @@
//
// FetchAnisetteDataOperation.swift
// AltStore
//
// Created by Riley Testut on 1/7/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import CommonCrypto
import Starscream
import AltStoreCore
import AltSign
import Roxas
class ANISETTE_VERBOSITY: Operation {} // dummy tag iface
@objc(FetchAnisetteDataOperation)
final class FetchAnisetteDataOperation: ResultOperation<ALTAnisetteData>, WebSocketDelegate
{
let context: OperationContext
var socket: WebSocket!
var url: URL?
var startProvisioningURL: URL?
var endProvisioningURL: URL?
var clientInfo: String?
var userAgent: String?
var mdLu: String?
var deviceId: String?
init(context: OperationContext)
{
self.context = context
}
override func main()
{
super.main()
if let error = self.context.error
{
self.finish(.failure(error))
return
}
// TODO: Pass in proper view context to show the Toast messages
let viewContext = context.presentingViewController
getAnisetteServerUrl(viewContext){ url, error in
guard let urlString = url else {
self.finish(.failure(error!))
return
}
// set as preferred
UserDefaults.standard.menuAnisetteURL = urlString
let url = URL(string: urlString)
self.url = url
self.printOut("Anisette URL: \(self.url!.absoluteString)")
if let identifier = Keychain.shared.identifier,
let adiPb = Keychain.shared.adiPb {
self.fetchAnisetteV3(identifier, adiPb)
} else {
self.provision()
}
}
}
func getAnisetteServerUrl(_ viewContext: UIViewController?, completion: @escaping (String?, Error?) -> Void) {
var serverUrls = UserDefaults.standard.menuAnisetteServersList
let currentServer = UserDefaults.standard.menuAnisetteURL
// Prioritize the current server by moving it to the top of the list
if let currentServerIndex = serverUrls.firstIndex(of: currentServer) {
serverUrls.remove(at: currentServerIndex)
serverUrls.insert(currentServer, at: 0)
}
tryNextServer(from: serverUrls, viewContext, currentIndex: 0, completion: completion)
}
private func showToast(viewContext: UIViewController?, message: String){
if let viewContext = viewContext{
let error = OperationError.anisetteV1Error(message: message)
let toastView = ToastView(error: error)
// toastView.textLabel.textColor = .altPrimary
// toastView.detailTextLabel.textColor = .altPrimary
DispatchQueue.main.async {
toastView.show(in: viewContext)
}
}
}
private func tryNextServer(from serverUrls: [String], _ viewContext: UIViewController?,currentIndex: Int, completion: @escaping (String?, Error?) -> Void) {
// Check if all URLs have been exhausted
guard currentIndex < serverUrls.count else {
let error = NSError(domain: "AnisetteError", code: 0, userInfo: [NSLocalizedDescriptionKey: "No valid server found."])
completion(nil, error)
return
}
let currentServerUrlString = serverUrls[currentIndex]
guard let url = URL(string: currentServerUrlString) else {
// Invalid URL, skip to next
let errmsg = "Skipping invalid URL: \(currentServerUrlString)"
self.printOut(errmsg)
showToast(viewContext: viewContext, message: errmsg)
tryNextServer(from: serverUrls, viewContext, currentIndex: currentIndex + 1, completion: completion)
return
}
// Attempt to ping the current URL
pingServer(url) { success, error in
if success {
// If the server is reachable, return the URL
let okmsg = "Found working server: \(url.absoluteString)"
self.printOut(okmsg)
if(currentIndex > 0){
// notify user if available server is different the user-specified one
self.showToast(viewContext: viewContext, message: okmsg)
}
completion(url.absoluteString, nil)
} else {
// If not, try the next URL
let errmsg = "Failed to reach server: \(url.absoluteString), trying next server."
self.printOut(errmsg)
self.showToast(viewContext: viewContext, message: errmsg)
self.tryNextServer(from: serverUrls, viewContext, currentIndex: currentIndex + 1, completion: completion)
}
}
}
func pingServer(_ url: URL, completion: @escaping (Bool, Error?) -> Void) {
var request = URLRequest(url: url)
request.timeoutInterval = 10 // Timeout after 10 seconds
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
if let error = error {
completion(false, error)
return
}
let httpResponse = response as? HTTPURLResponse
let statusCode = httpResponse?.statusCode
guard let statusCode = statusCode,
(200...299).contains(statusCode) else {
let serverError = OperationError.anisetteV3Error(message: "Server unreachable or invalid response: \(String(describing: statusCode ?? nil))")
completion(false, serverError)
return
}
completion(true, nil)
}
task.resume()
}
// MARK: - COMMON
func extractAnisetteData(_ data: Data, _ response: HTTPURLResponse?, v3: Bool) throws {
// make sure this JSON is in the format we expect
// convert data to json
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: String] {
if v3 {
if json["result"] == "GetHeadersError" {
let message = json["message"]
self.printOut("Error getting V3 headers: \(message ?? "no message")")
if let message = message,
message.contains("-45061") {
self.printOut("Error message contains -45061 (not provisioned), resetting adi.pb and retrying")
Keychain.shared.adiPb = nil
return provision()
} else { throw OperationError.anisetteV3Error(message: message ?? "Unknown error") }
}
}
// try to read out a dictionary
// for some reason serial number isn't needed but it doesn't work unless it has a value
var formattedJSON: [String: String] = ["deviceSerialNumber": "0"]
if let machineID = json["X-Apple-I-MD-M"] { formattedJSON["machineID"] = machineID }
if let oneTimePassword = json["X-Apple-I-MD"] { formattedJSON["oneTimePassword"] = oneTimePassword }
if let routingInfo = json["X-Apple-I-MD-RINFO"] { formattedJSON["routingInfo"] = routingInfo }
if v3 {
formattedJSON["deviceDescription"] = self.clientInfo!
formattedJSON["localUserID"] = self.mdLu!
formattedJSON["deviceUniqueIdentifier"] = self.deviceId!
// Generate date stuff on client
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = Calendar(identifier: .gregorian)
formatter.timeZone = TimeZone.init(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
let dateString = formatter.string(from: Date())
formattedJSON["date"] = dateString
formattedJSON["locale"] = Locale.current.identifier
formattedJSON["timeZone"] = TimeZone.current.abbreviation()
} else {
if let deviceDescription = json["X-MMe-Client-Info"] { formattedJSON["deviceDescription"] = deviceDescription }
if let localUserID = json["X-Apple-I-MD-LU"] { formattedJSON["localUserID"] = localUserID }
if let deviceUniqueIdentifier = json["X-Mme-Device-Id"] { formattedJSON["deviceUniqueIdentifier"] = deviceUniqueIdentifier }
if let date = json["X-Apple-I-Client-Time"] { formattedJSON["date"] = date }
if let locale = json["X-Apple-Locale"] { formattedJSON["locale"] = locale }
if let timeZone = json["X-Apple-I-TimeZone"] { formattedJSON["timeZone"] = timeZone }
}
if let response = response,
let version = response.value(forHTTPHeaderField: "Implementation-Version") {
self.printOut("Implementation-Version: \(version)")
} else { self.printOut("No Implementation-Version header") }
self.printOut("Anisette used: \(formattedJSON)")
self.printOut("Original JSON: \(json)")
if let anisette = ALTAnisetteData(json: formattedJSON) {
self.printOut("Anisette is valid!")
self.finish(.success(anisette))
} else {
self.printOut("Anisette is invalid!!!!")
if v3 {
throw OperationError.anisetteV3Error(message: "Invalid anisette (the returned data may not have all the required fields)")
} else {
throw OperationError.anisetteV1Error(message: "Invalid anisette (the returned data may not have all the required fields)")
}
}
} else {
if v3 {
throw OperationError.anisetteV3Error(message: "Invalid anisette (the returned data may not be in JSON)")
} else {
throw OperationError.anisetteV1Error(message: "Invalid anisette (the returned data may not be in JSON)")
}
}
}
// MARK: - V1
func handleV1() {
self.printOut("Server is V1")
if UserDefaults.shared.trustedServerURL == AnisetteManager.currentURLString {
self.printOut("Server has already been trusted, fetching anisette")
return self.fetchAnisetteV1()
}
self.printOut("Alerting user about outdated server")
let alert = UIAlertController(title: "WARNING: Outdated anisette server", message: "We've detected you are using an older anisette server. Using this server has a higher likelihood of locking your account and causing other issues. Are you sure you want to continue?", preferredStyle: UIAlertController.Style.alert)
alert.addAction(UIAlertAction(title: "Continue", style: UIAlertAction.Style.destructive, handler: { action in
self.printOut("Fetching anisette via V1")
UserDefaults.shared.trustedServerURL = AnisetteManager.currentURLString
self.fetchAnisetteV1()
}))
alert.addAction(UIAlertAction(title: "Cancel", style: UIAlertAction.Style.cancel, handler: { action in
self.printOut("Cancelled anisette operation")
self.finish(.failure(OperationError.cancelled))
}))
let keyWindow = UIApplication.shared.windows.filter { $0.isKeyWindow }.first
DispatchQueue.main.async {
if let presentingController = keyWindow?.rootViewController?.presentedViewController {
presentingController.present(alert, animated: true)
} else {
keyWindow?.rootViewController?.present(alert, animated: true)
}
}
}
func fetchAnisetteV1() {
self.printOut("Fetching anisette V1")
URLSession.shared.dataTask(with: self.url!) { data, response, error in
do {
guard let data = data, error == nil else { throw OperationError.anisetteV1Error(message: "Unable to fetch data\(error != nil ? " (\(error!.localizedDescription))" : "")") }
try self.extractAnisetteData(data, response as? HTTPURLResponse, v3: false)
} catch let error as NSError {
self.printOut("Failed to load: \(error.localizedDescription)")
self.finish(.failure(error))
}
}.resume()
}
// MARK: - V3: PROVISIONING
func provision() {
fetchClientInfo {
self.printOut("Getting provisioning URLs")
var request = self.buildAppleRequest(url: URL(string: "https://gsa.apple.com/grandslam/GsService2/lookup")!)
request.httpMethod = "GET"
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data,
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? Dictionary<String, Dictionary<String, Any>>,
let startProvisioningString = plist["urls"]?["midStartProvisioning"] as? String,
let startProvisioningURL = URL(string: startProvisioningString),
let endProvisioningString = plist["urls"]?["midFinishProvisioning"] as? String,
let endProvisioningURL = URL(string: endProvisioningString) {
self.startProvisioningURL = startProvisioningURL
self.endProvisioningURL = endProvisioningURL
self.printOut("startProvisioningURL: \(self.startProvisioningURL!.absoluteString)")
self.printOut("endProvisioningURL: \(self.endProvisioningURL!.absoluteString)")
self.printOut("Starting a provisioning session")
self.startProvisioningSession()
} else {
self.printOut("Apple didn't give valid URLs! Got response: \(String(data: data ?? Data("nothing".utf8), encoding: .utf8) ?? "not utf8")")
self.finish(.failure(OperationError.provisioningError(result: "Apple didn't give valid URLs. Please try again later", message: nil)))
}
}.resume()
}
}
func startProvisioningSession() {
let provisioningSessionURL = self.url!.appendingPathComponent("v3").appendingPathComponent("provisioning_session")
var wsRequest = URLRequest(url: provisioningSessionURL)
wsRequest.timeoutInterval = 5
self.socket = WebSocket(request: wsRequest)
self.socket.delegate = self
self.socket.connect()
}
func didReceive(event: WebSocketEvent, client: WebSocketClient) {
switch event {
case .text(let string):
do {
if let json = try JSONSerialization.jsonObject(with: string.data(using: .utf8)!, options: []) as? [String: Any] {
guard let result = json["result"] as? String else {
self.printOut("The server didn't give us a result")
client.disconnect(closeCode: 0)
self.finish(.failure(OperationError.provisioningError(result: "The server didn't give us a result", message: nil)))
return
}
self.printOut("Received result: \(result)")
switch result {
case "GiveIdentifier":
self.printOut("Giving identifier")
client.json(["identifier": Keychain.shared.identifier!])
case "GiveStartProvisioningData":
self.printOut("Getting start provisioning data")
let body = [
"Header": [String: Any](),
"Request": [String: Any](),
]
var request = self.buildAppleRequest(url: self.startProvisioningURL!)
request.httpMethod = "POST"
request.httpBody = try! PropertyListSerialization.data(fromPropertyList: body, format: .xml, options: 0)
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data,
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? Dictionary<String, Dictionary<String, Any>>,
let spim = plist["Response"]?["spim"] as? String {
self.printOut("Giving start provisioning data")
client.json(["spim": spim])
} else {
self.printOut("Apple didn't give valid start provisioning data! Got response: \(String(data: data ?? Data("nothing".utf8), encoding: .utf8) ?? "not utf8")")
client.disconnect(closeCode: 0)
self.finish(.failure(OperationError.provisioningError(result: "Apple didn't give valid start provisioning data. Please try again later", message: nil)))
}
}.resume()
case "GiveEndProvisioningData":
self.printOut("Getting end provisioning data")
guard let cpim = json["cpim"] as? String else {
self.printOut("The server didn't give us a cpim")
client.disconnect(closeCode: 0)
self.finish(.failure(OperationError.provisioningError(result: "The server didn't give us a cpim", message: nil)))
return
}
let body = [
"Header": [String: Any](),
"Request": [
"cpim": cpim,
],
]
var request = self.buildAppleRequest(url: self.endProvisioningURL!)
request.httpMethod = "POST"
request.httpBody = try! PropertyListSerialization.data(fromPropertyList: body, format: .xml, options: 0)
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data,
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? Dictionary<String, Dictionary<String, Any>>,
let ptm = plist["Response"]?["ptm"] as? String,
let tk = plist["Response"]?["tk"] as? String {
self.printOut("Giving end provisioning data")
client.json(["ptm": ptm, "tk": tk])
} else {
self.printOut("Apple didn't give valid end provisioning data! Got response: \(String(data: data ?? Data("nothing".utf8), encoding: .utf8) ?? "not utf8")")
client.disconnect(closeCode: 0)
self.finish(.failure(OperationError.provisioningError(result: "Apple didn't give valid end provisioning data. Please try again later", message: nil)))
}
}.resume()
case "ProvisioningSuccess":
self.printOut("Provisioning succeeded!")
client.disconnect(closeCode: 0)
guard let adiPb = json["adi_pb"] as? String else {
self.printOut("The server didn't give us an adi.pb file")
self.finish(.failure(OperationError.provisioningError(result: "The server didn't give us an adi.pb file", message: nil)))
return
}
Keychain.shared.adiPb = adiPb
self.fetchAnisetteV3(Keychain.shared.identifier!, Keychain.shared.adiPb!)
default:
if result.contains("Error") || result.contains("Invalid") || result == "ClosingPerRequest" || result == "Timeout" || result == "TextOnly" {
self.printOut("Failing because of \(result)")
self.finish(.failure(OperationError.provisioningError(result: result, message: json["message"] as? String)))
}
}
}
} catch let error as NSError {
self.printOut("Failed to handle text: \(error.localizedDescription)")
self.finish(.failure(OperationError.provisioningError(result: error.localizedDescription, message: nil)))
}
case .connected:
self.printOut("Connected")
case .disconnected(let string, let code):
self.printOut("Disconnected: \(code); \(string)")
case .error(let error):
self.printOut("Got error: \(String(describing: error))")
default:
self.printOut("Unknown event: \(event)")
}
}
func buildAppleRequest(url: URL) -> URLRequest {
var request = URLRequest(url: url)
request.setValue(self.clientInfo!, forHTTPHeaderField: "X-Mme-Client-Info")
request.setValue(self.userAgent!, forHTTPHeaderField: "User-Agent")
request.setValue("text/x-xml-plist", forHTTPHeaderField: "Content-Type")
request.setValue("*/*", forHTTPHeaderField: "Accept")
request.setValue(self.mdLu!, forHTTPHeaderField: "X-Apple-I-MD-LU")
request.setValue(self.deviceId!, forHTTPHeaderField: "X-Mme-Device-Id")
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = Calendar(identifier: .gregorian)
formatter.timeZone = TimeZone(identifier: "UTC")
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
let dateString = formatter.string(from: Date())
request.setValue(dateString, forHTTPHeaderField: "X-Apple-I-Client-Time")
request.setValue(Locale.current.identifier, forHTTPHeaderField: "X-Apple-Locale")
request.setValue(TimeZone.current.abbreviation(), forHTTPHeaderField: "X-Apple-I-TimeZone")
return request
}
// MARK: - V3: FETCHING
func fetchClientInfo(_ callback: @escaping () -> Void) {
if self.clientInfo != nil &&
self.userAgent != nil &&
self.mdLu != nil &&
self.deviceId != nil &&
Keychain.shared.identifier != nil {
self.printOut("Skipping client_info fetch since all the properties we need aren't nil")
return callback()
}
self.printOut("Trying to get client_info")
let clientInfoURL = self.url!.appendingPathComponent("v3").appendingPathComponent("client_info")
URLSession.shared.dataTask(with: clientInfoURL) { data, response, error in
do {
guard let data = data, error == nil else {
return self.finish(.failure(OperationError.anisetteV3Error(message: "Couldn't fetch client info. The server may be down\(error != nil ? " (\(error!.localizedDescription))" : "")")))
}
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: String] {
if let clientInfo = json["client_info"] {
self.printOut("Server is V3")
self.clientInfo = clientInfo
self.userAgent = json["user_agent"]!
self.printOut("Client-Info: \(self.clientInfo!)")
self.printOut("User-Agent: \(self.userAgent!)")
if Keychain.shared.identifier == nil {
self.printOut("Generating identifier")
var bytes = [Int8](repeating: 0, count: 16)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
if status != errSecSuccess {
self.printOut("ERROR GENERATING IDENTIFIER!!! \(status)")
return self.finish(.failure(OperationError.provisioningError(result: "Couldn't generate identifier", message: nil)))
}
Keychain.shared.identifier = Data(bytes: &bytes, count: bytes.count).base64EncodedString()
}
let decoded = Data(base64Encoded: Keychain.shared.identifier!)!
self.mdLu = decoded.sha256().hexEncodedString()
self.printOut("X-Apple-I-MD-LU: \(self.mdLu!)")
let uuid: UUID = decoded.object()
self.deviceId = uuid.uuidString.uppercased()
self.printOut("X-Mme-Device-Id: \(self.deviceId!)")
callback()
} else { self.handleV1() }
} else { self.finish(.failure(OperationError.anisetteV3Error(message: "Couldn't fetch client info. The returned data may not be in JSON"))) }
} catch let error as NSError {
self.printOut("Failed to load: \(error.localizedDescription)")
self.handleV1()
}
}.resume()
}
func fetchAnisetteV3(_ identifier: String, _ adiPb: String) {
fetchClientInfo {
self.printOut("Fetching anisette V3")
let url = UserDefaults.standard.menuAnisetteURL
var request = URLRequest(url: self.url!.appendingPathComponent("v3").appendingPathComponent("get_headers"))
request.httpMethod = "POST"
request.httpBody = try! JSONSerialization.data(withJSONObject: [
"identifier": identifier,
"adi_pb": adiPb
], options: [])
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
URLSession.shared.dataTask(with: request) { data, response, error in
do {
guard let data = data, error == nil else { throw OperationError.anisetteV3Error(message: "Couldn't fetch anisette") }
try self.extractAnisetteData(data, response as? HTTPURLResponse, v3: true)
} catch let error as NSError {
self.printOut("Failed to load: \(error.localizedDescription)")
self.finish(.failure(error))
}
}.resume()
}
}
private func printOut(_ text: String?){
let isInternalLoggingEnabled = OperationsLoggingControl.getFromDatabase(for: ANISETTE_VERBOSITY.self)
if(isInternalLoggingEnabled){
// logging enabled, so log it
text.map{ _ in print(text!) } ?? print()
}
}
}
extension WebSocketClient {
func json(_ dictionary: [String: String]) {
let data = try! JSONSerialization.data(withJSONObject: dictionary, options: [])
self.write(string: String(data: data, encoding: .utf8)!)
}
}
extension Data {
// https://stackoverflow.com/a/25391020
func sha256() -> Data {
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
self.withUnsafeBytes {
_ = CC_SHA256($0.baseAddress, CC_LONG(self.count), &hash)
}
return Data(hash)
}
// https://stackoverflow.com/a/40089462
func hexEncodedString() -> String {
return self.map { String(format: "%02hhX", $0) }.joined()
}
// https://stackoverflow.com/a/59127761
func object<T>() -> T { self.withUnsafeBytes { $0.load(as: T.self) } }
}

View File

@@ -1,621 +0,0 @@
//
// FetchProvisioningProfilesOperation.swift
// AltStore
//
// Created by Riley Testut on 2/27/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import AltStoreCore
import AltSign
import Roxas
@objc(FetchProvisioningProfilesOperation)
class FetchProvisioningProfilesOperation: ResultOperation<[String: ALTProvisioningProfile]>
{
let context: AppOperationContext
var additionalEntitlements: [ALTEntitlement: Any]?
internal let appGroupsLock = NSLock()
// this class is abstract or shouldn't be instantiated outside, use the subclasses
fileprivate init(context: AppOperationContext)
{
self.context = context
super.init()
self.progress.totalUnitCount = 1
}
override func main()
{
super.main()
if let error = self.context.error
{
self.finish(.failure(error))
return
}
guard let team = self.context.team,
let session = self.context.session else {
return self.finish(.failure(
OperationError.invalidParameters("FetchProvisioningProfilesOperation.main: self.context.team or self.context.session is nil"))
)
}
guard let app = self.context.app else { return self.finish(.failure(OperationError.appNotFound(name: nil))) }
Logger.sideload.notice("Fetching provisioning profiles for app \(self.context.bundleIdentifier, privacy: .public)...")
self.progress.totalUnitCount = Int64(1 + app.appExtensions.count)
self.prepareProvisioningProfile(for: app, parentApp: nil, team: team, session: session) { (result) in
do
{
self.progress.completedUnitCount += 1
let profile = try result.get()
var profiles = [app.bundleIdentifier: profile]
var error: Error?
let dispatchGroup = DispatchGroup()
if !self.context.useMainProfile {
for appExtension in app.appExtensions
{
dispatchGroup.enter()
self.prepareProvisioningProfile(for: appExtension, parentApp: app, team: team, session: session) { (result) in
switch result
{
case .failure(let e): error = e
case .success(let profile): profiles[appExtension.bundleIdentifier] = profile
}
dispatchGroup.leave()
self.progress.completedUnitCount += 1
}
}
}
dispatchGroup.notify(queue: .global()) {
if let error = error
{
self.finish(.failure(error))
}
else
{
self.finish(.success(profiles))
}
}
}
catch
{
self.finish(.failure(error))
}
}
}
func process<T>(_ result: Result<T, Error>) -> T?
{
switch result
{
case .failure(let error):
self.finish(.failure(error))
return nil
case .success(let value):
guard !self.isCancelled else {
self.finish(.failure(OperationError.cancelled))
return nil
}
return value
}
}
internal func fetchProvisioningProfile(for appID: ALTAppID, app: ALTApplication, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void)
{
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, deviceType: .iphone, team: team, session: session) { (profile, error) in
switch Result(profile, error)
{
case .failure(let error): completionHandler(.failure(error))
case .success(let profile):
// Delete existing profile
ALTAppleAPI.shared.delete(profile, for: team, session: session) { (success, error) in
switch Result(success, error)
{
case .failure:
// As of March 20, 2023, the free provisioning profile is re-generated each fetch, and you can no longer delete it.
// So instead, we just return the fetched profile from above.
completionHandler(.success(profile))
case .success:
Logger.sideload.notice("Generating new free provisioning profile for App ID \(appID.bundleIdentifier, privacy: .public).")
// Fetch new provisioning profile
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, deviceType: .iphone, team: team, session: session) { (profile, error) in
completionHandler(Result(profile, error))
}
}
}
}
}
}
}
extension FetchProvisioningProfilesOperation
{
private func prepareProvisioningProfile(for app: ALTApplication,
parentApp: ALTApplication?,
team: ALTTeam,
session: ALTAppleAPISession, c
completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void)
{
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
let preferredBundleID: String?
// Check if we have already installed this app with this team before.
let predicate = NSPredicate(format: "%K == %@", #keyPath(InstalledApp.bundleIdentifier), app.bundleIdentifier)
if let installedApp = InstalledApp.first(satisfying: predicate, in: context)
{
// Teams match if installedApp.team has same identifier as team,
// or if installedApp.team is nil but resignedBundleIdentifier contains the team's identifier.
let teamsMatch = installedApp.team?.identifier == team.identifier || (installedApp.team == nil && installedApp.resignedBundleIdentifier.contains(team.identifier))
// TODO: @mahee96: Try to keep the debug build and release build operations similar, refactor later with proper reasoning
// for now, restricted it to debug on simulator only
#if DEBUG && targetEnvironment(simulator)
if app.isAltStoreApp
{
// Use legacy bundle ID format for AltStore.
preferredBundleID = teamsMatch ? installedApp.resignedBundleIdentifier : nil
}
else
{
preferredBundleID = teamsMatch ? installedApp.resignedBundleIdentifier : nil
}
#else
if teamsMatch
{
// This app is already installed with the same team, so use the same resigned bundle identifier as before.
// This way, if we change the identifier format (again), AltStore will continue to use
// the old bundle identifier to prevent it from installing as a new app.
preferredBundleID = installedApp.resignedBundleIdentifier
}
else
{
preferredBundleID = nil
}
#endif
}
else
{
preferredBundleID = nil
}
let bundleID: String
if let preferredBundleID = preferredBundleID
{
bundleID = preferredBundleID
}
else
{
// This app isn't already installed, so create the resigned bundle identifier ourselves.
// Or, if the app _is_ installed but with a different team, we need to create a new
// bundle identifier anyway to prevent collisions with the previous team.
let parentBundleID = parentApp?.bundleIdentifier ?? app.bundleIdentifier
let updatedParentBundleID: String
if app.isAltStoreApp
{
// Use legacy bundle ID format for AltStore (and its extensions).
updatedParentBundleID = parentBundleID + "." + team.identifier // Append just team identifier to make it harder to track.
}
else
{
updatedParentBundleID = parentBundleID + "." + team.identifier // Append just team identifier to make it harder to track.
}
bundleID = app.bundleIdentifier.replacingOccurrences(of: parentBundleID, with: updatedParentBundleID)
}
let preferredName: String
if let parentApp = parentApp
{
preferredName = parentApp.name + " " + app.name
}
else
{
preferredName = app.name
}
// Register
self.registerAppID(for: app, name: preferredName, bundleIdentifier: bundleID, team: team, session: session) { (result) in
switch result
{
case .failure(let error): completionHandler(.failure(error))
case .success(let appID):
//process
self.fetchProvisioningProfile(
for: appID, app: app, team: team, session: session, completionHandler: completionHandler
)
}
}
}
}
private func registerAppID(for application: ALTApplication,
name: String,
bundleIdentifier: String,
team: ALTTeam,
session: ALTAppleAPISession,
completionHandler: @escaping (Result<ALTAppID, Error>) -> Void)
{
ALTAppleAPI.shared.fetchAppIDs(for: team, session: session) { (appIDs, error) in
do
{
let appIDs = try Result(appIDs, error).get()
if let appID = appIDs.first(where: { $0.bundleIdentifier.lowercased() == bundleIdentifier.lowercased() })
{
Logger.sideload.notice("Using existing App ID \(appID.bundleIdentifier, privacy: .public)")
completionHandler(.success(appID))
}
else
{
let requiredAppIDs = 1 + application.appExtensions.count
let availableAppIDs = max(0, Team.maximumFreeAppIDs - appIDs.count)
let sortedExpirationDates = appIDs.compactMap { $0.expirationDate }.sorted(by: { $0 < $1 })
if team.type == .free
{
if requiredAppIDs > availableAppIDs
{
if let expirationDate = sortedExpirationDates.first
{
throw OperationError.maximumAppIDLimitReached(appName: application.name, requiredAppIDs: requiredAppIDs, availableAppIDs: availableAppIDs, expirationDate: expirationDate)
}
else
{
throw ALTAppleAPIError(.maximumAppIDLimitReached)
}
}
}
//App ID name must be ascii. If the name is not ascii, using bundleID instead
let appIDName: String
if !name.allSatisfy({ $0.isASCII }) {
//Contains non ASCII (Such as Chinese/Japanese...), using bundleID
appIDName = bundleIdentifier
}else {
//ASCII text, keep going as usual
appIDName = name
}
ALTAppleAPI.shared.addAppID(withName: appIDName, bundleIdentifier: bundleIdentifier, team: team, session: session) { (appID, error) in
do
{
do
{
let appID = try Result(appID, error).get()
Logger.sideload.notice("Registered new App ID \(appID.bundleIdentifier, privacy: .public)")
completionHandler(.success(appID))
}
catch ALTAppleAPIError.maximumAppIDLimitReached
{
if let expirationDate = sortedExpirationDates.first
{
throw OperationError.maximumAppIDLimitReached(appName: application.name, requiredAppIDs: requiredAppIDs, availableAppIDs: availableAppIDs, expirationDate: expirationDate)
}
else
{
throw ALTAppleAPIError(.maximumAppIDLimitReached)
}
}
}
catch ALTAppleAPIError.bundleIdentifierUnavailable {
ALTAppleAPI.shared.fetchAppIDs(for: team, session: session) {res, err in
if let err = err {
return completionHandler(.failure(err))
}
guard let res = res else {return completionHandler(.failure(ALTError(.unknown)))}
for appid in res {
if appid.bundleIdentifier == bundleIdentifier {
completionHandler(.success(appid))
}
}
}
}
catch
{
completionHandler(.failure(error))
}
}
}
}
catch
{
completionHandler(.failure(error))
}
}
}
}
class FetchProvisioningProfilesInstallOperation: FetchProvisioningProfilesOperation, @unchecked Sendable{
override init(context: AppOperationContext)
{
super.init(context: context)
}
// modify Operations are allowed for the app groups and other stuffs
override func fetchProvisioningProfile(for appID: ALTAppID,
app: ALTApplication,
team: ALTTeam,
session: ALTAppleAPISession,
completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void)
{
// Update features
self.updateFeatures(for: appID, app: app, team: team, session: session) { (result) in
switch result
{
case .failure(let error): completionHandler(.failure(error))
case .success(let appID):
// Update app groups
self.updateAppGroups(for: appID, app: app, team: team, session: session) { (result) in
switch result
{
case .failure(let error): completionHandler(.failure(error))
case .success(let appID):
// Fetch Provisioning Profile
super.fetchProvisioningProfile(for: appID, app: app, team: team, session: session, completionHandler: completionHandler)
}
}
}
}
}
private func updateFeatures(for appID: ALTAppID, app: ALTApplication, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void)
{
var entitlements = app.entitlements
for (key, value) in additionalEntitlements ?? [:]
{
entitlements[key] = value
}
let requiredFeatures = entitlements.compactMap { (entitlement, value) -> (ALTFeature, Any)? in
guard let feature = ALTFeature(entitlement: entitlement) else { return nil }
return (feature, value)
}
var features = requiredFeatures.reduce(into: [ALTFeature: Any]()) { $0[$1.0] = $1.1 }
if let applicationGroups = entitlements[.appGroups] as? [String], !applicationGroups.isEmpty
{
// App uses app groups, so assign `true` to enable the feature.
features[.appGroups] = true
}
else
{
// App has no app groups, so assign `false` to disable the feature.
features[.appGroups] = false
}
var updateFeatures = false
// Determine whether the required features are already enabled for the AppID.
for (feature, value) in features
{
if let appIDValue = appID.features[feature] as AnyObject?, (value as AnyObject).isEqual(appIDValue)
{
// AppID already has this feature enabled and the values are the same.
continue
}
else if appID.features[feature] == nil, let shouldEnableFeature = value as? Bool, !shouldEnableFeature
{
// AppID doesn't already have this feature enabled, but we want it disabled anyway.
continue
}
else
{
// AppID either doesn't have this feature enabled or the value has changed,
// so we need to update it to reflect new values.
updateFeatures = true
break
}
}
appID.entitlements = entitlements
if updateFeatures || true
{
let appID = appID.copy() as! ALTAppID
appID.features = features
ALTAppleAPI.shared.update(appID, team: team, session: session) { (updatedAppID, error) in
let result = Result(updatedAppID, error)
switch result
{
case .success(let appID): Logger.sideload.notice("Updated features for App ID \(appID.bundleIdentifier, privacy: .public).")
case .failure(let error): Logger.sideload.error("Failed to update features for App ID \(appID.bundleIdentifier, privacy: .public). \(error.localizedDescription, privacy: .public)")
}
completionHandler(result)
}
}
else
{
completionHandler(.success(appID))
}
}
private func updateAppGroups(for appID: ALTAppID, app: ALTApplication, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void)
{
var entitlements = app.entitlements
for (key, value) in additionalEntitlements ?? [:]
{
entitlements[key] = value
}
guard var applicationGroups = entitlements[.appGroups] as? [String], !applicationGroups.isEmpty else {
Logger.sideload.notice("App ID \(appID.bundleIdentifier, privacy: .public) has no app groups, skipping assignment.")
// Assigning an App ID to an empty app group array fails,
// so just do nothing if there are no app groups.
return completionHandler(.success(appID))
}
if app.isAltStoreApp
{
print("Application groups before modifying for SideStore: \(applicationGroups)")
// Remove app groups that contain AltStore since they can be problematic (cause SideStore to expire early)
for (index, group) in applicationGroups.enumerated() {
if group.contains("AltStore") {
print("Removing application group: \(group)")
applicationGroups.remove(at: index)
}
}
// Make sure we add .AltWidget for the widget
var altStoreAppGroupID = Bundle.baseAltStoreAppGroupID
for (_, group) in applicationGroups.enumerated() {
if group.contains("AltWidget") {
altStoreAppGroupID += ".AltWidget"
break
}
}
// Potentially updating app groups for this specific AltStore.
// Find the (unique) AltStore app group, then replace it
// with the correct "base" app group ID.
// Otherwise, we may append a duplicate team identifier to the end.
if let index = applicationGroups.firstIndex(where: { $0.contains(Bundle.baseAltStoreAppGroupID) })
{
applicationGroups[index] = altStoreAppGroupID
}
else
{
applicationGroups.append(altStoreAppGroupID)
}
}
print("Application groups: \(applicationGroups)")
// Dispatch onto global queue to prevent appGroupsLock deadlock.
DispatchQueue.global().async {
// Ensure we're not concurrently fetching and updating app groups,
// which can lead to race conditions such as adding an app group twice.
self.appGroupsLock.lock()
func finish(_ result: Result<ALTAppID, Error>)
{
self.appGroupsLock.unlock()
completionHandler(result)
}
ALTAppleAPI.shared.fetchAppGroups(for: team, session: session) { (groups, error) in
switch Result(groups, error)
{
case .failure(let error):
Logger.sideload.error("Failed to fetch app groups for team \(team.identifier, privacy: .public). \(error.localizedDescription, privacy: .public)")
finish(.failure(error))
case .success(let fetchedGroups):
let dispatchGroup = DispatchGroup()
var groups = [ALTAppGroup]()
var errors = [Error]()
for groupIdentifier in applicationGroups
{
let adjustedGroupIdentifier = groupIdentifier + "." + team.identifier
if let group = fetchedGroups.first(where: { $0.groupIdentifier == adjustedGroupIdentifier })
{
groups.append(group)
}
else
{
dispatchGroup.enter()
// Not all characters are allowed in group names, so we replace periods with spaces (like Apple does).
let name = "AltStore " + groupIdentifier.replacingOccurrences(of: ".", with: " ")
ALTAppleAPI.shared.addAppGroup(withName: name, groupIdentifier: adjustedGroupIdentifier, team: team, session: session) { (group, error) in
switch Result(group, error)
{
case .success(let group):
Logger.sideload.notice("Created new App Group \(group.groupIdentifier, privacy: .public).")
groups.append(group)
case .failure(let error):
Logger.sideload.notice("Failed to create new App Group \(adjustedGroupIdentifier, privacy: .public). \(error.localizedDescription, privacy: .public)")
errors.append(error)
}
dispatchGroup.leave()
}
}
}
dispatchGroup.notify(queue: .global()) {
if let error = errors.first
{
finish(.failure(error))
}
else
{
ALTAppleAPI.shared.assign(appID, to: Array(groups), team: team, session: session) { (success, error) in
let groupIDs = groups.map { $0.groupIdentifier }
switch Result(success, error)
{
case .success:
Logger.sideload.notice("Assigned App ID \(appID.bundleIdentifier, privacy: .public) to App Groups \(groupIDs.description, privacy: .public).")
finish(.success(appID))
case .failure(let error):
Logger.sideload.error("Failed to assign App ID \(appID.bundleIdentifier, privacy: .public) to App Groups \(groupIDs.description, privacy: .public). \(error.localizedDescription, privacy: .public)")
finish(.failure(error))
}
}
}
}
}
}
}
}
}
// <TEST> : users were reporting that refresh (though seemed like it refreshed the app becomes no longer available)
// possibly, this is caused since refesh was not updating appFeatures and AppGroups in the new profile? not sure.
// for now we are reverting by keeping same operation that happens during fetch in install path to see if it fixes issue #893
// class FetchProvisioningProfilesRefreshOperation: FetchProvisioningProfilesOperation, @unchecked Sendable {
class FetchProvisioningProfilesRefreshOperation: FetchProvisioningProfilesInstallOperation, @unchecked Sendable {
override init(context: AppOperationContext)
{
super.init(context: context)
}
}

View File

@@ -1,344 +0,0 @@
//
// FetchSourceOperation.swift
// AltStore
//
// Created by Riley Testut on 7/30/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import CoreData
import AltStoreCore
import Roxas
import SemanticVersion
@objc(FetchSourceOperation)
final class FetchSourceOperation: ResultOperation<Source>
{
let sourceURL: URL
let managedObjectContext: NSManagedObjectContext
// Non-nil when updating an existing source.
@Managed
private var source: Source?
private let session: URLSession
private weak var dataTask: URLSessionDataTask?
private lazy var dateFormatter: ISO8601DateFormatter = {
let dateFormatter = ISO8601DateFormatter()
return dateFormatter
}()
// New source
convenience init(sourceURL: URL, managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext())
{
self.init(sourceURL: sourceURL, source: nil, managedObjectContext: managedObjectContext)
}
// Existing source
convenience init(source: Source, managedObjectContext: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext())
{
self.init(sourceURL: source.sourceURL, source: source, managedObjectContext: managedObjectContext)
}
private init(sourceURL: URL, source: Source?, managedObjectContext: NSManagedObjectContext)
{
self.sourceURL = sourceURL
self.managedObjectContext = managedObjectContext
self.source = source
let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.urlCache = nil
self.session = URLSession(configuration: configuration)
}
override func cancel()
{
super.cancel()
self.dataTask?.cancel()
}
override func main()
{
super.main()
if let source = self.source
{
// Check if source is blocked before fetching it.
do
{
try self.managedObjectContext.performAndWait {
// Source must be from self.managedObjectContext
let source = self.managedObjectContext.object(with: source.objectID) as! Source
try self.verifySourceNotBlocked(source, response: nil)
}
}
catch
{
self.managedObjectContext.perform {
self.finish(.failure(error))
}
return
}
}
var request = URLRequest(url: self.sourceURL)
request.cachePolicy = .reloadIgnoringLocalCacheData // don't use local caching
let dataTask = self.session.dataTask(with: request) { (data, response, error) in
let childContext = DatabaseManager.shared.persistentContainer.newBackgroundContext(withParent: self.managedObjectContext)
childContext.mergePolicy = NSOverwriteMergePolicy
childContext.perform {
do
{
let (data, response) = try Result((data, response), error).get()
let decoder = AltStoreCore.JSONDecoder()
decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
let container = try decoder.singleValueContainer()
let text = try container.decode(String.self)
// Full ISO8601 Format.
self.dateFormatter.formatOptions = [.withFullDate, .withFullTime, .withTimeZone]
if let date = self.dateFormatter.date(from: text)
{
return date
}
// Just date portion of ISO8601.
self.dateFormatter.formatOptions = [.withFullDate]
if let date = self.dateFormatter.date(from: text)
{
return date
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Date is in invalid format.")
})
decoder.managedObjectContext = childContext
decoder.sourceURL = self.sourceURL
if #available(iOS 15, *)
{
decoder.allowsJSON5 = true
}
let source: Source
do
{
source = try decoder.decode(Source.self, from: data)
}
catch let error as DecodingError
{
let nsError = error as NSError
guard var codingPath = nsError.userInfo[ALTNSCodingPathKey] as? [CodingKey] else { throw error }
if case .keyNotFound(let key, _) = error
{
// Add missing key to error for better debugging.
codingPath.append(key)
}
let rawComponents = codingPath.map { $0.intValue?.description ?? $0.stringValue }
let pathDescription = rawComponents.joined(separator: " > ")
var userInfo = nsError.userInfo
if let debugDescription = nsError.localizedDebugDescription
{
let detailedDescription = debugDescription + "\n\n" + pathDescription
userInfo[NSDebugDescriptionErrorKey] = detailedDescription
}
else
{
userInfo[NSDebugDescriptionErrorKey] = pathDescription
}
// TODO: @mahee96: Need to account for invalid/missing json fields error
// and show meaningful message to user instead of just showing decoder error
throw NSError(domain: nsError.domain, code: nsError.code, userInfo: userInfo)
}
let identifier = source.identifier
if identifier == Source.altStoreIdentifier, let skipPatreonDownloads = source.userInfo?[.skipPatreonDownloads]
{
UserDefaults.shared.skipPatreonDownloads = (skipPatreonDownloads == "true")
}
try self.verify(source, response: response)
try self.verifyPledges(for: source, in: childContext)
try childContext.save()
self.managedObjectContext.perform {
if let source = Source.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(Source.identifier), identifier), in: self.managedObjectContext)
{
self.finish(.success(source))
}
else
{
self.finish(.failure(OperationError.noSources))
}
}
}
catch
{
self.managedObjectContext.perform {
self.finish(.failure(error))
}
}
}
}
self.progress.addChild(dataTask.progress, withPendingUnitCount: 1)
dataTask.resume()
self.dataTask = dataTask
}
}
private extension FetchSourceOperation
{
func verify(_ source: Source, response: URLResponse) throws
{
try self.verifySourceNotBlocked(source, response: response)
var bundleIDs = Set<String>()
for app in source.apps
{
guard !bundleIDs.contains(app.bundleIdentifier) else { throw SourceError.duplicateBundleID(app.bundleIdentifier, source: source) }
bundleIDs.insert(app.bundleIdentifier)
var versions = Set<String>()
for version in app.versions
{
guard !versions.contains(version.versionID) else { throw SourceError.duplicateVersion(version.localizedVersion, for: app, source: source) }
versions.insert(version.versionID)
}
for permission in app.permissions where permission.type == .privacy
{
// Privacy permissions MUST have a usage description.
guard permission.usageDescription != nil else { throw SourceError.missingPermissionUsageDescription(for: permission.permission, app: app, source: source) }
}
for screenshot in app.screenshots(for: .ipad)
{
// All iPad screenshots MUST have an explicit size.
guard screenshot.size != nil else { throw SourceError.missingScreenshotSize(for: screenshot, source: source) }
}
#if MARKETPLACE
guard app.marketplaceID != nil else { throw SourceError.marketplaceRequired(source: source) }
#else
guard app.marketplaceID == nil else { throw SourceError.marketplaceNotSupported(source: source) }
#endif
}
let incomingSourceID = source.identifier
if let previousSourceID = self.$source.identifier,
incomingSourceID != previousSourceID
{
// if let version = BuildInfo().marketing_version,
// SemanticVersion(version)! <= SemanticVersion("0.6.1")!
// {
// // delete the source, so that incoming will be saved.
// self.source?.managedObjectContext?.delete(self.source!)
// }
// else
// {
throw SourceError.changedID(source.identifier, previousID: self.$source.identifier ?? "nil", source: source)
// }
}
}
func verifyPledges(for source: Source, in context: NSManagedObjectContext) throws
{
guard let patreonURL = source.patreonURL, let patreonAccount = DatabaseManager.shared.patreonAccount(in: context) else { return }
let normalizedPatreonURL = try patreonURL.normalized()
guard let pledge = patreonAccount.pledges.first(where: { pledge in
do
{
let normalizedCampaignURL = try pledge.campaignURL.normalized()
return normalizedCampaignURL == normalizedPatreonURL
}
catch
{
Logger.main.error("Failed to normalize Patreon URL \(pledge.campaignURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
return false
}
}) else { return }
// User is pledged to this source's Patreon, so check which apps they're pledged to.
// We only assign `isPledged = true` because false is already the default,
// and only one check needs to be true for isPledged to be true.
for app in source.apps where app.isPledgeRequired
{
if let requiredAppPledge = app.pledgeAmount
{
if pledge.amount >= requiredAppPledge
{
app.isPledged = true
continue
}
}
if let tierIDs = app._tierIDs
{
let tier = pledge.tiers.first { tierIDs.contains($0.identifier) }
if tier != nil
{
app.isPledged = true
continue
}
}
if let rewardID = app._rewardID
{
let reward = pledge.rewards.first { $0.identifier == rewardID }
if reward != nil
{
app.isPledged = true
continue
}
}
}
}
func verifySourceNotBlocked(_ source: Source, response: URLResponse?) throws
{
guard let blockedSources = UserDefaults.shared.blockedSources else { return }
for blockedSource in blockedSources
{
guard
source.identifier != blockedSource.identifier,
source.sourceURL.absoluteString.lowercased() != blockedSource.sourceURL?.absoluteString.lowercased()
else { throw SourceError.blocked(source, bundleIDs: blockedSource.bundleIDs, existingSource: self.source) }
if let responseURL = response?.url
{
// responseURL may differ from source.sourceURL (e.g. due to redirects), so double-check it's also not blocked.
guard responseURL.absoluteString.lowercased() != blockedSource.sourceURL?.absoluteString.lowercased() else {
throw SourceError.blocked(source, bundleIDs: blockedSource.bundleIDs, existingSource: self.source)
}
}
}
}
}

View File

@@ -1,283 +0,0 @@
//
// InstallAppOperation.swift
// AltStore
//
// Created by Riley Testut on 6/19/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import Network
import AltStoreCore
import AltSign
import Roxas
import minimuxer
@objc(InstallAppOperation)
final class InstallAppOperation: ResultOperation<InstalledApp>
{
let context: InstallAppOperationContext
private var didCleanUp = false
init(context: InstallAppOperationContext)
{
self.context = context
super.init()
self.progress.totalUnitCount = 100
}
override func main()
{
super.main()
if let error = self.context.error
{
self.finish(.failure(error))
return
}
guard
let certificate = self.context.certificate,
let resignedApp = self.context.resignedApp,
let provisioningProfiles = self.context.provisioningProfiles
else {
return self.finish(.failure(OperationError.invalidParameters("InstallAppOperation.main: self.context.certificate or self.context.resignedApp or self.context.provisioningProfiles is nil")))
}
@Managed var appVersion = self.context.appVersion
let storeBuildVersion = $appVersion.buildVersion
let backgroundContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()
backgroundContext.perform {
/* App */
let installedApp: InstalledApp
// Fetch + update rather than insert + resolve merge conflicts to prevent potential context-level conflicts.
if let app = InstalledApp.first(satisfying: NSPredicate(format: "%K == %@", #keyPath(InstalledApp.bundleIdentifier), self.context.bundleIdentifier), in: backgroundContext)
{
installedApp = app
}
else
{
installedApp = InstalledApp(resignedApp: resignedApp,
originalBundleIdentifier: self.context.bundleIdentifier,
certificateSerialNumber: certificate.serialNumber,
storeBuildVersion: storeBuildVersion,
context: backgroundContext)
}
installedApp.update(resignedApp: resignedApp, certificateSerialNumber: certificate.serialNumber, storeBuildVersion: storeBuildVersion)
installedApp.useMainProfile = self.context.useMainProfile
installedApp.needsResign = false
if let team = DatabaseManager.shared.activeTeam(in: backgroundContext)
{
installedApp.team = team
}
/* App Extensions */
var installedExtensions = Set<InstalledExtension>()
if
let bundle = Bundle(url: resignedApp.fileURL),
let directory = bundle.builtInPlugInsURL,
let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: nil, options: [.skipsSubdirectoryDescendants])
{
for case let fileURL as URL in enumerator
{
guard let appExtensionBundle = Bundle(url: fileURL) else { continue }
guard let appExtension = ALTApplication(fileURL: appExtensionBundle.bundleURL) else { continue }
let parentBundleID = self.context.bundleIdentifier
let resignedParentBundleID = resignedApp.bundleIdentifier
let resignedBundleID = appExtension.bundleIdentifier
let originalBundleID = resignedBundleID.replacingOccurrences(of: resignedParentBundleID, with: parentBundleID)
print("`parentBundleID`: \(parentBundleID)")
print("`resignedParentBundleID`: \(resignedParentBundleID)")
print("`resignedBundleID`: \(resignedBundleID)")
print("`originalBundleID`: \(originalBundleID)")
let installedExtension: InstalledExtension
if let appExtension = installedApp.appExtensions.first(where: { $0.bundleIdentifier == originalBundleID })
{
installedExtension = appExtension
}
else
{
installedExtension = InstalledExtension(resignedAppExtension: appExtension, originalBundleIdentifier: originalBundleID, context: backgroundContext)
}
installedExtension.update(resignedAppExtension: appExtension)
installedExtensions.insert(installedExtension)
}
}
installedApp.appExtensions = installedExtensions
// Remove stale "PlugIns" (Extensions) from currently installed App
if let installedAppExns = ALTApplication(fileURL: installedApp.fileURL)?.appExtensions {
let currentAppExns = Set(installedApp.appExtensions).map{ $0.bundleIdentifier }
let staleAppExns = installedAppExns.filter{ !currentAppExns.contains($0.bundleIdentifier) }
for staleAppExn in staleAppExns {
do {
try FileManager.default.removeItem(at: staleAppExn.fileURL)
print("InstallAppOperation.appExtensions: removed stale app-extension: \(staleAppExn.fileURL)")
} catch {
print("InstallAppOperation.appExtensions processing error Error: \(error)")
}
}
}
self.context.beginInstallationHandler?(installedApp)
// Temporary directory and resigned .ipa no longer needed, so delete them now to ensure AltStore doesn't quit before we get the chance to.
self.cleanUp()
if let sideloadedAppsLimit = UserDefaults.standard.activeAppsLimit, provisioningProfiles.contains(where: { $1.isFreeProvisioningProfile == true })
{
// When installing these new profiles, AltServer will remove all non-active profiles to ensure we remain under limit.
let fetchRequest = InstalledApp.activeAppsFetchRequest()
fetchRequest.includesPendingChanges = false
var activeApps = InstalledApp.fetch(fetchRequest, in: backgroundContext)
if !activeApps.contains(installedApp)
{
let activeAppsCount = activeApps.map { $0.requiredActiveSlots }.reduce(0, +)
let availableActiveApps = max(sideloadedAppsLimit - activeAppsCount, 0)
if installedApp.requiredActiveSlots <= availableActiveApps
{
// This app has not been explicitly activated, but there are enough slots available,
// so implicitly activate it.
installedApp.isActive = true
activeApps.append(installedApp)
}
else
{
installedApp.isActive = false
}
}
}
else
{
installedApp.isActive = true
}
var installing = true
if installedApp.storeApp?.bundleIdentifier.range(of: Bundle.Info.appbundleIdentifier) != nil {
// Reinstalling ourself will hang until we leave the app, so we need to exit it without force closing
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
if UIApplication.shared.applicationState != .active {
print("We are not in the foreground, let's not do anything")
return
}
if !installing {
print("Installing finished")
return
}
print("We are still installing after 3 seconds")
UNUserNotificationCenter.current().getNotificationSettings { settings in
switch (settings.authorizationStatus) {
case .authorized, .ephemeral, .provisional:
print("Notifications are enabled")
let content = UNMutableNotificationContent()
content.title = "Refreshing..."
content.body = "SideStore will automatically move to the homescreen to finish refreshing!"
let notification = UNNotificationRequest(identifier: Bundle.Info.appbundleIdentifier + ".FinishRefreshNotification", content: content, trigger: UNTimeIntervalNotificationTrigger(timeInterval: 2, repeats: false))
UNUserNotificationCenter.current().add(notification)
break
default:
print("Notifications are not enabled")
let alert = UIAlertController(title: "Finish Refresh", message: "Please reopen SideStore after the process is finished.To finish refreshing, SideStore must be moved to the background. To do this, you can either go to the Home Screen manually or by hitting Continue. Please reopen SideStore after doing this.", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("Continue", comment: ""), style: .default, handler: { _ in
print("Going home")
UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
}))
DispatchQueue.main.async {
let keyWindow = UIApplication.shared.windows.filter { $0.isKeyWindow }.first
if var topController = keyWindow?.rootViewController {
while let presentedViewController = topController.presentedViewController {
topController = presentedViewController
}
topController.present(alert, animated: true)
} else {
print("No key window? Let's just go home")
}
}
}
}
UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
}
}
do {
try install_ipa(installedApp.bundleIdentifier)
installing = false
installedApp.refreshedDate = Date()
self.finish(.success(installedApp))
} catch let error {
installing = false
self.finish(.failure(error))
}
}
}
override func finish(_ result: Result<InstalledApp, Error>)
{
self.cleanUp()
// Only remove refreshed IPA when finished.
if let app = self.context.app
{
let fileURL = InstalledApp.refreshedIPAURL(for: app)
do
{
if(FileManager.default.fileExists(atPath: fileURL.path)){
try FileManager.default.removeItem(at: fileURL)
print("Removed refreshed IPA")
}
}
catch
{
print("Failed to remove refreshed .ipa: \(error)")
}
}
super.finish(result)
}
}
private extension InstallAppOperation
{
func cleanUp()
{
guard !self.didCleanUp else { return }
self.didCleanUp = true
do
{
try FileManager.default.removeItem(at: self.context.temporaryDirectory)
}
catch
{
print("Failed to remove temporary directory.", error)
}
}
}

View File

@@ -1,110 +0,0 @@
//
// Operation.swift
// AltStore
//
// Created by Riley Testut on 6/7/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import Roxas
class ResultOperation<ResultType>: Operation
{
var resultHandler: ((Result<ResultType, Error>) -> Void)?
// Should only be set by subclasses
var localizedFailure: String?
@available(*, unavailable)
override func finish()
{
super.finish()
}
func finish(_ result: Result<ResultType, Error>)
{
guard !self.isFinished else { return }
var result = result
if self.isCancelled
{
result = .failure(OperationError.cancelled)
}
else if case .failure(let nsError as NSError) = result, let localizedFailure, nsError.localizedFailure == nil {
// Error doesn't have its own localizedFailure, so we give it the Operation's (if it exists)
let error = nsError.withLocalizedFailure(localizedFailure)
result = .failure(error)
}
// Diagnostics: perform verbose logging of the operations only if enabled (so as to not flood console logs)
let isLoggingEnabledForThisOperation = OperationsLoggingControl.getFromDatabase(for: type(of: self))
if UserDefaults.standard.isVerboseOperationsLoggingEnabled && isLoggingEnabledForThisOperation {
// diagnostics logging
let resultStatus = String(describing: result).prefix("success".count).uppercased()
print("\n ====> OPERATION: `\(type(of: self))` completed with: \(resultStatus) <====\n\n" +
" Result: \(result)\n")
}
self.resultHandler?(result)
super.finish()
}
}
class Operation: RSTOperation, ProgressReporting
{
let progress = Progress.discreteProgress(totalUnitCount: 1)
private var backgroundTaskID: UIBackgroundTaskIdentifier?
override var isAsynchronous: Bool {
return true
}
override init()
{
super.init()
self.progress.cancellationHandler = { [weak self] in self?.cancel() }
}
override func cancel()
{
super.cancel()
if !self.progress.isCancelled
{
self.progress.cancel()
}
}
override func main()
{
super.main()
let name = "com.altstore." + NSStringFromClass(type(of: self))
self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
guard let backgroundTask = self?.backgroundTaskID else { return }
self?.cancel()
UIApplication.shared.endBackgroundTask(backgroundTask)
self?.backgroundTaskID = .invalid
}
}
override func finish()
{
guard !self.isFinished else { return }
super.finish()
if let backgroundTaskID = self.backgroundTaskID
{
UIApplication.shared.endBackgroundTask(backgroundTaskID)
self.backgroundTaskID = .invalid
}
}
}

View File

@@ -1,258 +0,0 @@
//
// PatchAppOperation.swift
// AltStore
//
// Created by Riley Testut on 10/13/21.
// Copyright © 2021 Riley Testut. All rights reserved.
//
import UIKit
import Combine
import AppleArchive
import System
import AltStoreCore
import AltSign
import Roxas
protocol PatchAppContext
{
var bundleIdentifier: String { get }
var temporaryDirectory: URL { get }
var resignedApp: ALTApplication? { get }
var error: Error? { get }
}
extension PatchAppError
{
enum Code: Int, ALTErrorCode, CaseIterable {
typealias Error = PatchAppError
case unsupportedOperatingSystemVersion
}
static func unsupportedOperatingSystemVersion(_ osVersion: OperatingSystemVersion) -> PatchAppError {
PatchAppError(code: .unsupportedOperatingSystemVersion, osVersion: osVersion)
}
}
struct PatchAppError: ALTLocalizedError {
let code: Code
var errorTitle: String?
var errorFailure: String?
var osVersion: OperatingSystemVersion?
var errorFailureReason: String {
switch self.code {
case .unsupportedOperatingSystemVersion:
let osVersionString: String
if let osVersion = self.osVersion?.stringValue {
osVersionString = NSLocalizedString("iOS", comment: "") + " " + osVersion
} else {
osVersionString = NSLocalizedString("your device's iOS version", comment: "")
}
return String(format: NSLocalizedString("The OTA download URL for %@ could not be determined.", comment: ""), osVersionString)
}
}
}
private struct OTAUpdate
{
var url: URL
var archivePath: String
}
@available(iOS 14, *)
final class PatchAppOperation: ResultOperation<Void>
{
let context: PatchAppContext
var progressHandler: ((Progress, String) -> Void)?
private let appPatcher = ALTAppPatcher()
private lazy var patchDirectory: URL = self.context.temporaryDirectory.appendingPathComponent("Patch", isDirectory: true)
private var cancellable: AnyCancellable?
init(context: PatchAppContext)
{
self.context = context
super.init()
self.progress.totalUnitCount = 100
}
override func main()
{
super.main()
if let error = self.context.error
{
self.finish(.failure(error))
return
}
guard let resignedApp = self.context.resignedApp else {
return self.finish(.failure(OperationError.invalidParameters("PatchAppOperation.main: self.context.resignedApp is nil")))
}
self.progressHandler?(self.progress, NSLocalizedString("Downloading iOS firmware...", comment: ""))
self.cancellable = self.fetchOTAUpdate()
.flatMap { self.downloadArchive(from: $0) }
.flatMap { self.extractSpotlightFromArchive(at: $0) }
.flatMap { self.patch(resignedApp, withBinaryAt: $0) }
.tryMap { try FileManager.default.zipAppBundle(at: $0) }
.tryMap { (fileURL) in
let app = AnyApp(name: resignedApp.name, bundleIdentifier: self.context.bundleIdentifier, url: resignedApp.fileURL, storeApp: nil)
let destinationURL = InstalledApp.refreshedIPAURL(for: app)
try FileManager.default.copyItem(at: fileURL, to: destinationURL, shouldReplace: true)
}
.receive(on: RunLoop.main)
.sink { completion in
switch completion
{
case .failure(let error): self.finish(.failure(error))
case .finished: self.finish(.success(()))
}
} receiveValue: { _ in }
}
override func cancel()
{
super.cancel()
self.cancellable?.cancel()
self.cancellable = nil
}
}
private let ALTFragmentZipCallback: @convention(c) (UInt32) -> Void = { (percentageComplete) in
guard let progress = Progress.current() else { return }
if percentageComplete == 100 && progress.completedUnitCount == 0
{
// Ignore first percentageComplete, which is always 100.
return
}
progress.completedUnitCount = Int64(percentageComplete)
}
private extension PatchAppOperation
{
func fetchOTAUpdate() -> AnyPublisher<OTAUpdate, Error>
{
Just(()).tryMap {
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
switch (osVersion.majorVersion, osVersion.minorVersion)
{
case (14, 3):
return OTAUpdate(url: URL(string: "https://updates.cdn-apple.com/2020WinterFCS/patches/001-87330/99E29969-F6B6-422A-B946-70DE2E2D73BE/com_apple_MobileAsset_SoftwareUpdate/67f9e42f5e57a20e0a87eaf81b69dd2a61311d3f.zip")!,
archivePath: "AssetData/payloadv2/payload.042")
case (14, 4):
return OTAUpdate(url: URL(string: "https://updates.cdn-apple.com/2021WinterFCS/patches/001-98606/43AF99A1-F286-43B1-A101-F9F856EA395A/com_apple_MobileAsset_SoftwareUpdate/c4985c32c344beb7b49c61919b4e39d1fd336c90.zip")!,
archivePath: "AssetData/payloadv2/payload.042")
case (14, 5):
return OTAUpdate(url: URL(string: "https://updates.cdn-apple.com/2021SpringFCS/patches/061-84483/AB525139-066E-46F8-8E85-DCE802C03BA8/com_apple_MobileAsset_SoftwareUpdate/788573ae93113881db04269acedeecabbaa643e3.zip")!,
archivePath: "AssetData/payloadv2/payload.043")
default: throw PatchAppError.unsupportedOperatingSystemVersion(osVersion)
}
}
.eraseToAnyPublisher()
}
func downloadArchive(from update: OTAUpdate) -> AnyPublisher<URL, Error>
{
Just(()).tryMap {
#if targetEnvironment(simulator)
throw PatchAppError.unsupportedOperatingSystemVersion(ProcessInfo.processInfo.operatingSystemVersion)
#else
try FileManager.default.createDirectory(at: self.patchDirectory, withIntermediateDirectories: true, attributes: nil)
let archiveURL = self.patchDirectory.appendingPathComponent("ota.archive")
try archiveURL.withUnsafeFileSystemRepresentation { archivePath in
guard let fz = fragmentzip_open((update.url.absoluteString as NSString).utf8String!) else {
throw URLError(.cannotConnectToHost, userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("The connection failed because a connection cannot be made to the host.", comment: ""),
NSURLErrorKey: update.url])
}
defer { fragmentzip_close(fz) }
self.progress.becomeCurrent(withPendingUnitCount: 100)
defer { self.progress.resignCurrent() }
guard fragmentzip_download_file(fz, update.archivePath, archivePath!, ALTFragmentZipCallback) == 0 else {
throw URLError(.networkConnectionLost, userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("The connection failed because the network connection was lost.", comment: ""),
NSURLErrorKey: update.url])
}
}
Logger.fugu14.notice("Downloaded iOS OTA archive.")
return archiveURL
#endif
}
.mapError { ($0 as NSError).withLocalizedFailure(NSLocalizedString("Could not download OTA archive.", comment: "")) }
.eraseToAnyPublisher()
}
func extractSpotlightFromArchive(at archiveURL: URL) -> AnyPublisher<URL, Error>
{
Just(()).tryMap {
#if targetEnvironment(simulator)
throw PatchAppError.unsupportedOperatingSystemVersion(ProcessInfo.processInfo.operatingSystemVersion)
#else
let spotlightPath = "Applications/Spotlight.app/Spotlight"
let spotlightFileURL = self.patchDirectory.appendingPathComponent(spotlightPath)
guard let readFileStream = ArchiveByteStream.fileStream(path: FilePath(archiveURL.path), mode: .readOnly, options: [], permissions: FilePermissions(rawValue: 0o644)),
let decompressStream = ArchiveByteStream.decompressionStream(readingFrom: readFileStream),
let decodeStream = ArchiveStream.decodeStream(readingFrom: decompressStream),
let readStream = ArchiveStream.extractStream(extractingTo: FilePath(self.patchDirectory.path))
else { throw CocoaError(.fileReadCorruptFile, userInfo: [NSURLErrorKey: archiveURL]) }
_ = try ArchiveStream.process(readingFrom: decodeStream, writingTo: readStream) { message, filePath, data in
guard filePath == FilePath(spotlightPath) else { return .skip }
return .ok
}
Logger.fugu14.notice("Extracted Spotlight from OTA archive.")
return spotlightFileURL
#endif
}
.mapError { ($0 as NSError).withLocalizedFailure(NSLocalizedString("Could not extract Spotlight from OTA archive.", comment: "")) }
.eraseToAnyPublisher()
}
func patch(_ app: ALTApplication, withBinaryAt patchFileURL: URL) -> AnyPublisher<URL, Error>
{
Just(()).tryMap {
// executableURL may be nil, so use infoDictionary instead to determine executable name.
// guard let appName = app.bundle.executableURL?.lastPathComponent else { throw OperationError.invalidApp }
guard let appName = app.bundle.infoDictionary?[kCFBundleExecutableKey as String] as? String else { throw OperationError.invalidApp }
let temporaryAppURL = self.patchDirectory.appendingPathComponent("Patched.app", isDirectory: true)
try FileManager.default.copyItem(at: app.fileURL, to: temporaryAppURL)
let appBinaryURL = temporaryAppURL.appendingPathComponent(appName, isDirectory: false)
try self.appPatcher.patchAppBinary(at: appBinaryURL, withBinaryAt: patchFileURL)
Logger.fugu14.notice("Patched \(app.name, privacy: .public)!")
return temporaryAppURL
}
.mapError { ($0 as NSError).withLocalizedFailure(String(format: NSLocalizedString("Could not patch %@ placeholder.", comment: ""), app.name)) }
.eraseToAnyPublisher()
}
}

View File

@@ -1,495 +0,0 @@
//
// PatchViewController.swift
// AltStore
//
// Created by Riley Testut on 10/20/21.
// Copyright © 2021 Riley Testut. All rights reserved.
//
import UIKit
import Combine
import AltStoreCore
import AltSign
import Roxas
extension PatchViewController
{
enum Step
{
case confirm
case install
case openApp
case patchApp
case reboot
case refresh
case finish
}
}
@available(iOS 14.0, *)
final class PatchViewController: UIViewController
{
var patchApp: AnyApp?
var installedApp: InstalledApp?
var completionHandler: ((Result<Void, Error>) -> Void)?
private let context = AuthenticatedOperationContext()
private var currentStep: Step = .confirm {
didSet {
DispatchQueue.main.async {
self.update()
}
}
}
private var buttonHandler: (() -> Void)?
private var resignedApp: ALTApplication?
private lazy var temporaryDirectory: URL = FileManager.default.uniqueTemporaryURL()
private var didEnterBackgroundObservation: NSObjectProtocol?
private weak var cancellableProgress: Progress?
@IBOutlet private var placeholderView: RSTPlaceholderView!
@IBOutlet private var taskDescriptionLabel: UILabel!
@IBOutlet private var pillButton: PillButton!
@IBOutlet private var cancelBarButtonItem: UIBarButtonItem!
@IBOutlet private var cancelButton: UIButton!
override func viewDidLoad()
{
super.viewDidLoad()
self.isModalInPresentation = true
self.placeholderView.stackView.spacing = 20
self.placeholderView.textLabel.textColor = .white
self.placeholderView.detailTextLabel.textAlignment = .left
self.placeholderView.detailTextLabel.textColor = UIColor.white.withAlphaComponent(0.6)
self.buttonHandler = { [weak self] in
self?.startProcess()
}
do
{
try FileManager.default.createDirectory(at: self.temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
}
catch
{
Logger.fugu14.error("Failed to create temporary directory \(self.temporaryDirectory.lastPathComponent, privacy: .public). \(error.localizedDescription, privacy: .public)")
}
self.update()
}
override func viewWillAppear(_ animated: Bool)
{
super.viewWillAppear(animated)
if self.installedApp != nil
{
self.refreshApp()
}
}
}
private extension PatchViewController
{
func update()
{
self.cancelButton.alpha = 0.0
switch self.currentStep
{
case .confirm:
guard let app = self.patchApp else { break }
if UIDevice.current.isUntetheredJailbreakRequired
{
self.placeholderView.textLabel.text = NSLocalizedString("Jailbreak Requires Untethering", comment: "")
self.placeholderView.detailTextLabel.text = String(format: NSLocalizedString("This jailbreak is untethered, which means %@ will never expire — even after 7 days or rebooting the device.\n\nInstalling an untethered jailbreak requires a few extra steps, but SideStore will walk you through the process.\n\nWould you like to continue? ", comment: ""), app.name)
}
else
{
self.placeholderView.textLabel.text = NSLocalizedString("Jailbreak Supports Untethering", comment: "")
self.placeholderView.detailTextLabel.text = String(format: NSLocalizedString("This jailbreak has an untethered version, which means %@ will never expire — even after 7 days or rebooting the device.\n\nInstalling an untethered jailbreak requires a few extra steps, but SideStore will walk you through the process.\n\nWould you like to continue? ", comment: ""), app.name)
}
self.pillButton.setTitle(NSLocalizedString("Install Untethered Jailbreak", comment: ""), for: .normal)
self.cancelButton.alpha = 1.0
case .install:
guard let app = self.patchApp else { break }
self.placeholderView.textLabel.text = String(format: NSLocalizedString("Installing %@ placeholder…", comment: ""), app.name)
self.placeholderView.detailTextLabel.text = NSLocalizedString("A placeholder app needs to be installed in order to prepare your device for untethering.\n\nThis may take a few moments.", comment: "")
case .openApp:
self.placeholderView.textLabel.text = NSLocalizedString("Continue in App", comment: "")
self.placeholderView.detailTextLabel.text = NSLocalizedString("Please open the placeholder app and follow the instructions to continue jailbreaking your device.", comment: "")
self.pillButton.setTitle(NSLocalizedString("Open Placeholder", comment: ""), for: .normal)
case .patchApp:
guard let app = self.patchApp else { break }
self.placeholderView.textLabel.text = String(format: NSLocalizedString("Patching %@ placeholder…", comment: ""), app.name)
self.placeholderView.detailTextLabel.text = NSLocalizedString("This will take a few moments. Please do not turn off the screen or leave the app until patching is complete.", comment: "")
self.pillButton.setTitle(NSLocalizedString("Patch Placeholder", comment: ""), for: .normal)
case .reboot:
self.placeholderView.textLabel.text = NSLocalizedString("Continue in App", comment: "")
self.placeholderView.detailTextLabel.text = NSLocalizedString("Please open the placeholder app and follow the instructions to continue jailbreaking your device.", comment: "")
self.pillButton.setTitle(NSLocalizedString("Open Placeholder", comment: ""), for: .normal)
case .refresh:
guard let installedApp = self.installedApp else { break }
self.placeholderView.textLabel.text = String(format: NSLocalizedString("Finish installing %@?", comment: ""), installedApp.name)
self.placeholderView.detailTextLabel.text = String(format: NSLocalizedString("In order to finish jailbreaking this device, you need to install %@ then follow the instructions in the app.", comment: ""), installedApp.name)
self.pillButton.setTitle(String(format: NSLocalizedString("Install %@", comment: ""), installedApp.name), for: .normal)
case .finish:
guard let installedApp = self.installedApp else { break }
self.placeholderView.textLabel.text = String(format: NSLocalizedString("Finish in %@", comment: ""), installedApp.name)
self.placeholderView.detailTextLabel.text = String(format: NSLocalizedString("Follow the instructions in %@ to finish jailbreaking this device.", comment: ""), installedApp.name)
self.pillButton.setTitle(String(format: NSLocalizedString("Open %@", comment: ""), installedApp.name), for: .normal)
}
}
func present(_ error: Error, title: String)
{
DispatchQueue.main.async {
let nsError = error as NSError
let alertController = UIAlertController(title: nsError.localizedFailure ?? title, message: error.localizedDescription, preferredStyle: .alert)
alertController.addAction(.ok)
self.present(alertController, animated: true, completion: nil)
self.setProgress(nil, description: nil)
}
}
func setProgress(_ progress: Progress?, description: String?)
{
DispatchQueue.main.async {
self.pillButton.progress = progress
self.taskDescriptionLabel.text = description ?? " " // Use non-empty string to prevent label resizing itself.
}
}
func finish(with result: Result<Void, Error>)
{
do
{
try FileManager.default.removeItem(at: self.temporaryDirectory)
}
catch
{
Logger.fugu14.error("Failed to remove temporary directory \(self.temporaryDirectory.lastPathComponent, privacy: .public). \(error.localizedDescription, privacy: .public)")
}
if let observation = self.didEnterBackgroundObservation
{
NotificationCenter.default.removeObserver(observation)
}
self.completionHandler?(result)
self.completionHandler = nil
}
}
private extension PatchViewController
{
@IBAction func performButtonAction()
{
self.buttonHandler?()
}
@IBAction func cancel()
{
self.finish(with: .success(()))
self.cancellableProgress?.cancel()
}
@IBAction func installRegularJailbreak()
{
guard let app = self.patchApp else { return }
let title: String
let message: String
if UIDevice.current.isUntetheredJailbreakRequired
{
title = NSLocalizedString("Untethering Required", comment: "")
message = String(format: NSLocalizedString("%@ can not jailbreak this device unless you untether it first. Are you sure you want to install without untethering?", comment: ""), app.name)
}
else
{
title = NSLocalizedString("Untethering Recommended", comment: "")
message = String(format: NSLocalizedString("Untethering this jailbreak will prevent %@ from expiring, even after 7 days or rebooting the device. Are you sure you want to install without untethering?", comment: ""), app.name)
}
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: NSLocalizedString("Install Without Untethering", comment: ""), style: .default) { _ in
self.finish(with: .failure(OperationError.cancelled))
})
alertController.addAction(.cancel)
self.present(alertController, animated: true, completion: nil)
}
}
private extension PatchViewController
{
func startProcess()
{
guard let patchApp = self.patchApp else { return }
self.currentStep = .install
if let progress = AppManager.shared.installationProgress(for: patchApp)
{
// Cancel pending jailbreak app installation so we can start a new one.
progress.cancel()
}
let appURL = InstalledApp.fileURL(for: patchApp)
let cachedAppURL = self.temporaryDirectory.appendingPathComponent("Cached.app")
do
{
// Make copy of original app, so we can replace the cached patch app with it later.
try FileManager.default.copyItem(at: appURL, to: cachedAppURL, shouldReplace: true)
}
catch
{
self.present(error, title: NSLocalizedString("Could not back up jailbreak app.", comment: ""))
return
}
var unzippingError: Error?
let refreshGroup = AppManager.shared.install(patchApp, presentingViewController: self, context: self.context) { result in
do
{
_ = try result.get()
if let unzippingError = unzippingError
{
throw unzippingError
}
// Replace cached patch app with original app so we can resume installing it post-reboot.
try FileManager.default.copyItem(at: cachedAppURL, to: appURL, shouldReplace: true)
self.openApp()
}
catch
{
self.present(error, title: String(format: NSLocalizedString("Could not install %@ placeholder.", comment: ""), patchApp.name))
}
}
refreshGroup.beginInstallationHandler = { (installedApp) in
do
{
// Replace patch app name with correct name.
installedApp.name = patchApp.name
let ipaURL = installedApp.refreshedIPAURL
let resignedAppURL = try FileManager.default.unzipAppBundle(at: ipaURL, toDirectory: self.temporaryDirectory)
self.resignedApp = ALTApplication(fileURL: resignedAppURL)
}
catch
{
Logger.fugu14.error("Error unzipping app bundle: \(error.localizedDescription, privacy: .public)")
unzippingError = error
}
}
self.setProgress(refreshGroup.progress, description: nil)
self.cancellableProgress = refreshGroup.progress
}
func openApp()
{
guard let patchApp = self.patchApp else { return }
self.setProgress(nil, description: nil)
self.currentStep = .openApp
// This observation is willEnterForeground because patching starts immediately upon return.
self.didEnterBackgroundObservation = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main) { (notification) in
self.didEnterBackgroundObservation.map { NotificationCenter.default.removeObserver($0) }
self.patchApplication()
}
self.buttonHandler = { [weak self] in
guard let self = self else { return }
#if !targetEnvironment(simulator)
let openURL = InstalledApp.openAppURL(for: patchApp)
UIApplication.shared.open(openURL) { success in
guard !success else { return }
self.present(OperationError.openAppFailed(name: patchApp.name), title: String(format: NSLocalizedString("Could not open %@ placeholder.", comment: ""), patchApp.name))
}
#endif
}
}
func patchApplication()
{
guard let resignedApp = self.resignedApp else { return }
self.currentStep = .patchApp
self.buttonHandler = { [weak self] in
self?.patchApplication()
}
let patchAppOperation = AppManager.shared.patch(resignedApp: resignedApp, presentingViewController: self, context: self.context) { result in
switch result
{
case .failure(let error): self.present(error, title: String(format: NSLocalizedString("Could not patch %@ placeholder.", comment: ""), resignedApp.name))
case .success: self.rebootDevice()
}
}
patchAppOperation.progressHandler = { (progress, description) in
self.setProgress(progress, description: description)
}
self.cancellableProgress = patchAppOperation.progress
}
func rebootDevice()
{
guard let patchApp = self.patchApp else { return }
self.setProgress(nil, description: nil)
self.currentStep = .reboot
self.didEnterBackgroundObservation = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { (notification) in
self.didEnterBackgroundObservation.map { NotificationCenter.default.removeObserver($0) }
var patchedApps = UserDefaults.standard.patchedApps ?? []
if !patchedApps.contains(patchApp.bundleIdentifier)
{
patchedApps.append(patchApp.bundleIdentifier)
UserDefaults.standard.patchedApps = patchedApps
}
self.finish(with: .success(()))
}
self.buttonHandler = { [weak self] in
guard let self = self else { return }
#if !targetEnvironment(simulator)
let openURL = InstalledApp.openAppURL(for: patchApp)
UIApplication.shared.open(openURL) { success in
guard !success else { return }
self.present(OperationError.openAppFailed(name: patchApp.name), title: String(format: NSLocalizedString("Could not open %@ placeholder.", comment: ""), patchApp.name))
}
#endif
}
}
func refreshApp()
{
guard let installedApp = self.installedApp else { return }
self.currentStep = .refresh
self.buttonHandler = { [weak self] in
guard let self = self else { return }
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
let tempApp = context.object(with: installedApp.objectID) as! InstalledApp
tempApp.needsResign = true
let errorTitle = String(format: NSLocalizedString("Could not install %@.", comment: ""), tempApp.name)
do
{
try context.save()
installedApp.managedObjectContext?.perform {
// Refreshing ensures we don't attempt to patch the app again,
// since that is only checked when installing a new app.
let refreshGroup = AppManager.shared.refresh([installedApp], presentingViewController: self, group: nil)
refreshGroup.completionHandler = { [weak refreshGroup, weak self] (results) in
guard let self = self else { return }
do
{
guard let (bundleIdentifier, result) = results.first else { throw refreshGroup?.context.error ?? OperationError.unknown() }
_ = try result.get()
if var patchedApps = UserDefaults.standard.patchedApps, let index = patchedApps.firstIndex(of: bundleIdentifier)
{
patchedApps.remove(at: index)
UserDefaults.standard.patchedApps = patchedApps
}
self.finish()
}
catch
{
self.present(error, title: errorTitle)
}
}
self.setProgress(refreshGroup.progress, description: String(format: NSLocalizedString("Installing %@...", comment: ""), installedApp.name))
}
}
catch
{
self.present(error, title: errorTitle)
}
}
}
}
func finish()
{
guard let installedApp = self.installedApp else { return }
self.setProgress(nil, description: nil)
self.currentStep = .finish
self.didEnterBackgroundObservation = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { (notification) in
self.didEnterBackgroundObservation.map { NotificationCenter.default.removeObserver($0) }
self.finish(with: .success(()))
}
installedApp.managedObjectContext?.perform {
let appName = installedApp.name
let openURL = installedApp.openAppURL
self.buttonHandler = { [weak self] in
guard let self = self else { return }
#if !targetEnvironment(simulator)
UIApplication.shared.open(openURL) { success in
guard !success else { return }
self.present(OperationError.openAppFailed(name: appName), title: String(format: NSLocalizedString("Could not open %@.", comment: ""), appName))
}
#endif
}
}
}
}

View File

@@ -1,18 +0,0 @@
//
// fragmentzip.h
// AltStore
//
// Created by Riley Testut on 10/25/21.
// Copyright © 2021 Riley Testut. All rights reserved.
//
#ifndef fragmentzip_h
#define fragmentzip_h
typedef void fragmentzip_t;
typedef void (*fragmentzip_process_callback_t)(unsigned int progress);
fragmentzip_t *fragmentzip_open(const char *url);
int fragmentzip_download_file(fragmentzip_t *info, const char *remotepath, const char *savepath, fragmentzip_process_callback_t callback);
void fragmentzip_close(fragmentzip_t *info);
#endif /* fragmentzip_h */

View File

@@ -1,78 +0,0 @@
//
// RefreshAppOperation.swift
// AltStore
//
// Created by Riley Testut on 2/27/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import AltStoreCore
import AltSign
import Roxas
import minimuxer
@objc(RefreshAppOperation)
final class RefreshAppOperation: ResultOperation<InstalledApp>
{
let context: AppOperationContext
// Strong reference to managedObjectContext to keep it alive until we're finished.
let managedObjectContext: NSManagedObjectContext
init(context: AppOperationContext)
{
self.context = context
self.managedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext()
super.init()
}
override func main()
{
super.main()
do
{
if let error = self.context.error {
print("RefreshAppOperation.main: ERROR: self.context.app = \(self.context.app!); self.context.error is \(error)")
return self.finish(.failure(error))
}
guard let profiles = self.context.provisioningProfiles else {
return self.finish(.failure(OperationError.invalidParameters("RefreshAppOperation.main: self.context.provisioningProfiles is nil")))
}
guard let app = self.context.app else { return self.finish(.failure(OperationError(.appNotFound(name: nil)))) }
for p in profiles {
do {
let bytes = p.value.data.toRustByteSlice()
try install_provisioning_profile(bytes.forRust())
} catch {
self.finish(.failure(MinimuxerError.ProfileInstall))
}
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
self.progress.completedUnitCount += 1
let predicate = NSPredicate(format: "%K == %@", #keyPath(InstalledApp.bundleIdentifier), app.bundleIdentifier)
self.managedObjectContext.perform {
guard let installedApp = InstalledApp.first(satisfying: predicate, in: self.managedObjectContext) else {
self.finish(.failure(OperationError(.appNotFound(name: app.name))))
return
}
installedApp.update(provisioningProfile: p.value)
for installedExtension in installedApp.appExtensions {
guard let provisioningProfile = profiles[installedExtension.bundleIdentifier] else { continue }
installedExtension.update(provisioningProfile: provisioningProfile)
}
self.finish(.success(installedApp))
}
}
}
}
}
}

View File

@@ -1,82 +0,0 @@
//
// RemoveAppBackupOperation.swift
// AltStore
//
// Created by Riley Testut on 5/13/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import AltStoreCore
@objc(RemoveAppBackupOperation)
final class RemoveAppBackupOperation: ResultOperation<Void>
{
let context: InstallAppOperationContext
private let coordinator = NSFileCoordinator()
private let coordinatorQueue = OperationQueue()
init(context: InstallAppOperationContext)
{
self.context = context
super.init()
self.coordinatorQueue.name = "AltStore - RemoveAppBackupOperation Queue"
}
override func main()
{
super.main()
if let error = self.context.error
{
self.finish(.failure(error))
return
}
guard let installedApp = self.context.installedApp else {
return self.finish(.failure(OperationError.invalidParameters("RemoveAppBackupOperation.main: self.context.installedApp is nil")))
}
installedApp.managedObjectContext?.perform {
guard let backupDirectoryURL = FileManager.default.backupDirectoryURL(for: installedApp) else { return self.finish(.failure(OperationError.missingAppGroup)) }
let intent = NSFileAccessIntent.writingIntent(with: backupDirectoryURL, options: [.forDeleting])
self.coordinator.coordinate(with: [intent], queue: self.coordinatorQueue) { (error) in
do
{
if let error = error
{
throw error
}
try FileManager.default.removeItem(at: intent.url)
self.finish(.success(()))
}
catch let error as CocoaError where error.code == CocoaError.Code.fileNoSuchFile
{
// TODO: @mahee96: Find out why should in debug builds the app-groups is not expected to match
// #if DEBUG
//
// // When debugging, it's expected that app groups don't match, so ignore.
// self.finish(.success(()))
//
// #else
Logger.sideload.error("Failed to remove app backup directory \(backupDirectoryURL.lastPathComponent, privacy: .public). \(error.localizedDescription, privacy: .public)")
self.finish(.failure(error))
// #endif
}
catch
{
Logger.sideload.error("Failed to remove app backup directory \(backupDirectoryURL.lastPathComponent, privacy: .public). \(error.localizedDescription, privacy: .public)")
self.finish(.failure(error))
}
}
}
}
}

View File

@@ -1,239 +0,0 @@
//
// RefreshAppOperation.swift
// AltStore
//
// Created by Riley Testut on 2/27/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import AltStoreCore
import Roxas
import AltSign
@objc(RemoveAppExtensionsOperation)
final class RemoveAppExtensionsOperation: ResultOperation<Void>
{
let context: AppOperationContext
let localAppExtensions: Set<ALTApplication>?
init(context: AppOperationContext, localAppExtensions: Set<ALTApplication>?)
{
self.context = context
self.localAppExtensions = localAppExtensions
super.init()
}
override func main()
{
super.main()
if let error = self.context.error
{
self.finish(.failure(error))
return
}
guard let targetAppBundle = context.app else {
return self.finish(.failure(
OperationError.invalidParameters("RemoveAppExtensionsOperation: context.app is nil")
))
}
self.removeAppExtensions(from: targetAppBundle,
localAppExtensions: localAppExtensions,
extensions: targetAppBundle.appExtensions,
context.authenticatedContext.presentingViewController)
}
private static func removeExtensions(from extensions: Set<ALTApplication>) throws {
for appExtension in extensions {
print("Deleting extension \(appExtension.bundleIdentifier)")
try FileManager.default.removeItem(at: appExtension.fileURL)
}
}
private func updateManifest() throws {
guard let app = context.app else {
return
}
let scInfoURL = app.fileURL.appendingPathComponent("SC_Info")
let manifestPlistURL = scInfoURL.appendingPathComponent("Manifest.plist")
if let manifestPlist = NSMutableDictionary(contentsOf: manifestPlistURL),
let sinfReplicationPaths = manifestPlist["SinfReplicationPaths"] as? [String]
{
let replacementPaths = sinfReplicationPaths.filter { !$0.starts(with: "PlugIns/") } // Filter out app extension paths.
manifestPlist["SinfReplicationPaths"] = replacementPaths
try manifestPlist.write(to: manifestPlistURL)
}
}
private func removeAppExtensions(from targetAppBundle: ALTApplication,
localAppExtensions: Set<ALTApplication>?,
extensions: Set<ALTApplication>,
_ presentingViewController: UIViewController?)
{
// target App Bundle doesn't contain extensions so don't bother
guard !targetAppBundle.appExtensions.isEmpty else {
return self.finish(.success(()))
}
// process extensionsInfo
let excessExtensions = processExtensionsInfo(from: targetAppBundle, localAppExtensions: localAppExtensions)
DispatchQueue.main.async {
guard let presentingViewController: UIViewController = presentingViewController,
presentingViewController.viewIfLoaded?.window != nil else {
// background mode: remove only the excess extensions automatically for re-installs
// keep all extensions for fresh install (localAppBundle = nil)
return self.backgroundModeExtensionsCleanup(excessExtensions: excessExtensions)
}
// present prompt to the user if we have a view context
let alertController = self.createAlertDialog(from: targetAppBundle, extensions: extensions, presentingViewController)
presentingViewController.present(alertController, animated: true){
// if for any reason the view wasn't presented, then just signal that as error
if presentingViewController.presentedViewController == nil && !alertController.isViewLoaded {
let errMsg = "RemoveAppExtensionsOperation: unable to present dialog, view context not available." +
"\nDid you move to different screen or background after starting the operation?"
self.finish(.failure(
OperationError.invalidOperationContext(errMsg)
))
}
}
}
}
private func createAlertDialog(from targetAppBundle: ALTApplication,
extensions: Set<ALTApplication>,
_ presentingViewController: UIViewController) -> UIAlertController
{
/// Foreground prompt:
let firstSentence: String
if UserDefaults.standard.activeAppLimitIncludesExtensions
{
firstSentence = NSLocalizedString("Non-developer Apple IDs are limited to 3 active apps and app extensions.", comment: "")
}
else
{
firstSentence = NSLocalizedString("Non-developer Apple IDs are limited to creating 10 App IDs per week.", comment: "")
}
let message = firstSentence + " " + NSLocalizedString("Would you like to remove this app's extensions so they don't count towards your limit? There are \(extensions.count) Extensions", comment: "")
let alertController = UIAlertController(title: NSLocalizedString("App Contains Extensions", comment: ""), message: message, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style, handler: { (action) in
self.finish(.failure(OperationError.cancelled))
}))
alertController.addAction(UIAlertAction(title: NSLocalizedString("Keep App Extensions (Use Main Profile)", comment: ""), style: .default) { (action) in
self.context.useMainProfile = true
self.finish(.success(()))
})
alertController.addAction(UIAlertAction(title: NSLocalizedString("Keep App Extensions (Register App ID for Each Extension)", comment: ""), style: .default) { (action) in
self.finish(.success(()))
})
alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove App Extensions", comment: ""), style: .destructive) { (action) in
do {
try Self.removeExtensions(from: targetAppBundle.appExtensions)
try self.updateManifest()
return self.finish(.success(()))
} catch {
return self.finish(.failure(error))
}
})
alertController.addAction(UIAlertAction(title: NSLocalizedString("Choose App Extensions", comment: ""), style: .default) { (action) in
let popoverContentController = AppExtensionViewHostingController(extensions: extensions) { (selection) in
do {
try Self.removeExtensions(from: Set(selection))
return self.finish(.success(()))
} catch {
return self.finish(.failure(error))
}
}
let suiview = popoverContentController.view!
suiview.translatesAutoresizingMaskIntoConstraints = false
popoverContentController.modalPresentationStyle = .popover
if let popoverPresentationController = popoverContentController.popoverPresentationController {
popoverPresentationController.sourceView = presentingViewController.view
popoverPresentationController.sourceRect = CGRect(x: 50, y: 50, width: 4, height: 4)
popoverPresentationController.delegate = popoverContentController
DispatchQueue.main.async {
presentingViewController.present(popoverContentController, animated: true)
}
}else{
self.finish(.failure(
OperationError.invalidParameters("RemoveAppExtensionsOperation: popoverContentController.popoverPresentationController is nil"))
)
}
})
return alertController
}
struct ExtensionsInfo{
let excessInTarget: Set<ALTApplication>
let necessaryInExisting: Set<ALTApplication>
}
private func processExtensionsInfo(from targetAppBundle: ALTApplication,
localAppExtensions: Set<ALTApplication>?) -> Set<ALTApplication>
{
//App-Extensions: Ensure existing app's extensions in DB and currently installing app bundle's extensions must match
let targetAppEx: Set<ALTApplication> = targetAppBundle.appExtensions
let targetAppExNames = targetAppEx.map{ appEx in appEx.bundleIdentifier}
guard let extensionsInExistingApp = localAppExtensions else {
let diagnosticsMsg = "RemoveAppExtensionsOperation: ExistingApp is nil, Hence keeping all app extensions from targetAppBundle"
+ "RemoveAppExtensionsOperation: ExistingAppEx: nil; targetAppBundleEx: \(targetAppExNames)"
print(diagnosticsMsg)
return Set() // nothing is excess since we are keeping all, so returning empty
}
let existingAppEx: Set<ALTApplication> = extensionsInExistingApp
let existingAppExNames = existingAppEx.map{ appEx in appEx.bundleIdentifier}
let excessExtensionsInTargetApp = targetAppEx.filter{
!(existingAppExNames.contains($0.bundleIdentifier))
}
let isMatching = (targetAppEx.count == existingAppEx.count) && excessExtensionsInTargetApp.isEmpty
let diagnosticsMsg = "RemoveAppExtensionsOperation: App Extensions in localAppBundle and targetAppBundle are matching: \(isMatching)\n"
+ "RemoveAppExtensionsOperation: \nlocalAppBundleEx: \(existingAppExNames); \ntargetAppBundleEx: \(String(describing: targetAppExNames))\n"
print(diagnosticsMsg)
return excessExtensionsInTargetApp
}
private func backgroundModeExtensionsCleanup(excessExtensions: Set<ALTApplication>) {
// perform silent extensions cleanup for those that aren't already present in existing app
print("\n Performing background mode Extensions removal \n")
print("RemoveAppExtensionsOperation: Excess Extensions In TargetAppBundle: \(excessExtensions.map{$0.bundleIdentifier})")
do {
try Self.removeExtensions(from: excessExtensions)
return self.finish(.success(()))
} catch {
return self.finish(.failure(error))
}
}
}

View File

@@ -1,65 +0,0 @@
//
// SendAppOperation.swift
// AltStore
//
// Created by Riley Testut on 6/7/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import Network
import AltStoreCore
import minimuxer
@objc(SendAppOperation)
final class SendAppOperation: ResultOperation<()>
{
let context: InstallAppOperationContext
private let dispatchQueue = DispatchQueue(label: "com.sidestore.SendAppOperation")
init(context: InstallAppOperationContext)
{
self.context = context
super.init()
self.progress.totalUnitCount = 1
}
override func main()
{
super.main()
if let error = self.context.error
{
return self.finish(.failure(error))
}
guard let resignedApp = self.context.resignedApp else {
return self.finish(.failure(OperationError.invalidParameters("SendAppOperation.main: self.resignedApp is nil")))
}
// self.context.resignedApp.fileURL points to the app bundle, but we want the .ipa.
let app = AnyApp(name: resignedApp.name, bundleIdentifier: self.context.bundleIdentifier, url: resignedApp.fileURL, storeApp: nil)
let fileURL = InstalledApp.refreshedIPAURL(for: app)
print("AFC App `fileURL`: \(fileURL.absoluteString)")
if let data = NSData(contentsOf: fileURL) {
do {
let bytes = Data(data).toRustByteSlice()
try yeet_app_afc(app.bundleIdentifier, bytes.forRust())
self.progress.completedUnitCount += 1
self.finish(.success(()))
} catch {
self.finish(.failure(MinimuxerError.RwAfc))
self.progress.completedUnitCount += 1
self.finish(.success(()))
}
} else {
print("IPA doesn't exist????")
self.finish(.failure(OperationError(.appNotFound(name: resignedApp.name))))
}
}
}

View File

@@ -1,89 +0,0 @@
//
// UpdateKnownSourcesOperation.swift
// AltStore
//
// Created by Riley Testut on 4/13/22.
// Copyright © 2022 Riley Testut. All rights reserved.
//
import Foundation
import AltStoreCore
private extension URL
{
// TODO: @mahee96: update this to a non-branch specific (prod-ready) location like github.io repo similar to anisette servers URL list
#if STAGING
static let sources = URL(string: "https://raw.githubusercontent.com/SideStore/SideStore/develop/trustedapps.json")!
#else
static let sources = URL(string: "https://raw.githubusercontent.com/SideStore/SideStore/develop/trustedapps.json")!
#endif
}
extension UpdateKnownSourcesOperation
{
private struct Response: Decodable
{
var version: Int
var trusted: [KnownSource]?
var blocked: [KnownSource]?
}
}
class UpdateKnownSourcesOperation: ResultOperation<([KnownSource], [KnownSource])>
{
private let session: URLSession
override init()
{
let configuration = URLSessionConfiguration.default
if UserDefaults.standard.responseCachingDisabled
{
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.urlCache = nil
}
self.session = URLSession(configuration: configuration)
}
override func main()
{
super.main()
let dataTask = self.session.dataTask(with: .sources) { (data, response, error) in
do
{
if let response = response as? HTTPURLResponse
{
guard response.statusCode != 404 else {
self.finish(.failure(URLError(.fileDoesNotExist, userInfo: [NSURLErrorKey: URL.sources])))
return
}
}
guard let data = data else { throw error! }
let response = try Foundation.JSONDecoder().decode(Response.self, from: data)
let sources = (trusted: response.trusted ?? [], blocked: response.blocked ?? [])
// Cache sources
UserDefaults.shared.recommendedSources = sources.trusted
UserDefaults.shared.blockedSources = sources.blocked
// Cache trusted source IDs.
UserDefaults.shared.trustedSourceIDs = sources.trusted.map { $0.identifier }
self.finish(.success(sources))
}
catch
{
self.finish(.failure(error))
}
}
dataTask.resume()
}
}

View File

@@ -1,306 +0,0 @@
//
// VerifyAppOperation.swift
// AltStore
//
// Created by Riley Testut on 5/2/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import CryptoKit
import AltStoreCore
import AltSign
import Roxas
import RegexBuilder
private extension ALTEntitlement
{
static var ignoredEntitlements: Set<ALTEntitlement> = [
.applicationIdentifier,
.teamIdentifier
]
}
extension VerifyAppOperation
{
enum PermissionReviewMode
{
case none
case all
case added
}
}
@objc(VerifyAppOperation)
final class VerifyAppOperation: ResultOperation<Void>
{
let permissionsMode: PermissionReviewMode
let context: InstallAppOperationContext
init(permissionsMode: PermissionReviewMode, context: InstallAppOperationContext)
{
self.permissionsMode = permissionsMode
self.context = context
super.init()
}
override func main()
{
super.main()
do
{
if let error = self.context.error
{
throw error
}
let appName = self.context.app?.name ?? NSLocalizedString("The app", comment: "")
self.localizedFailure = String(format: NSLocalizedString("%@ could not be installed.", comment: ""), appName)
guard let app = self.context.app else {
throw OperationError.invalidParameters("VerifyAppOperation.main: self.context.app is nil")
}
if !["ny.litritt.ignited", "com.litritt.ignited"].contains(where: { $0 == app.bundleIdentifier }) {
guard app.bundleIdentifier == self.context.bundleIdentifier else {
throw VerificationError.mismatchedBundleIdentifiers(sourceBundleID: self.context.bundleIdentifier, app: app)
}
}
guard ProcessInfo.processInfo.isOperatingSystemAtLeast(app.minimumiOSVersion) else {
throw VerificationError.iOSVersionNotSupported(app: app, requiredOSVersion: app.minimumiOSVersion)
}
guard let appVersion = self.context.appVersion else {
return self.finish(.success(()))
}
Task<Void, Never> {
do
{
guard let ipaURL = self.context.ipaURL else { throw OperationError.appNotFound(name: app.name) }
try await self.verifyHash(of: app, at: ipaURL, matches: appVersion)
try await self.verifyDownloadedVersion(of: app, matches: appVersion)
// process missing permissions check only if the source is V2 or later
if let source = appVersion.app?.source,
source.isSourceAtLeastV2
{
try await self.verifyPermissions(of: app, match: appVersion)
}
self.finish(.success(()))
}
catch
{
self.finish(.failure(error))
}
}
}
catch
{
self.finish(.failure(error))
}
}
}
private extension VerifyAppOperation
{
func verifyHash(of app: ALTApplication, at ipaURL: URL, @AsyncManaged matches appVersion: AppVersion) async throws
{
// Do nothing if source doesn't provide hash.
guard let expectedHash = await $appVersion.sha256 else { return }
let data = try Data(contentsOf: ipaURL)
let sha256Hash = SHA256.hash(data: data)
let hashString = sha256Hash.compactMap { String(format: "%02x", $0) }.joined()
Logger.sideload.debug("Comparing app hash (\(hashString, privacy: .public)) against expected hash (\(expectedHash, privacy: .public))...")
guard hashString == expectedHash else { throw VerificationError.mismatchedHash(hashString, expectedHash: expectedHash, app: app) }
}
func verifyDownloadedVersion(of app: ALTApplication, @AsyncManaged matches appVersion: AppVersion) async throws
{
let (version, buildVersion) = await $appVersion.perform { ($0.version, $0.buildVersion) }
// marketplace buildVersion validation
if let buildVersion
{
guard buildVersion == app.buildVersion else {
throw VerificationError.mismatchedBuildVersion(app.buildVersion, expectedVersion: buildVersion, app: app)
}
}
if version != app.version {
throw VerificationError.mismatchedVersion(version: app.version, expectedVersion: version, app: app)
}
}
func verifyPermissions(of app: ALTApplication, @AsyncManaged match appVersion: AppVersion) async throws
{
guard self.permissionsMode != .none else { return }
guard let storeApp = await $appVersion.app else { throw OperationError.invalidParameters("verifyPermissions requires storeApp to be non-nil") }
// Verify source permissions match first.
let allPermissions = try await self.verifyPermissions(of: app, match: storeApp)
guard #available(iOS 15, *) else {
// Only review downloaded app permissions on iOS 15 and above.
return
}
switch self.permissionsMode
{
case .none: break
case .all:
guard let presentingViewController = self.context.presentingViewController else { break } // Don't fail just because we can't show permissions.
let allEntitlements = allPermissions.compactMap { $0 as? ALTEntitlement }
if !allEntitlements.isEmpty
{
try await self.review(allEntitlements, for: app, mode: .all, presentingViewController: presentingViewController)
}
case .added:
let installedAppURL = InstalledApp.fileURL(for: app)
guard let previousApp = ALTApplication(fileURL: installedAppURL) else { throw OperationError.appNotFound(name: app.name) }
var previousEntitlements = Set(previousApp.entitlements.keys)
for appExtension in previousApp.appExtensions
{
previousEntitlements.formUnion(appExtension.entitlements.keys)
}
// Make sure all entitlements already exist in previousApp.
let addedEntitlements = Array(allPermissions.lazy.compactMap { $0 as? ALTEntitlement }.filter { !previousEntitlements.contains($0) })
if !addedEntitlements.isEmpty
{
// _DO_ throw error if there isn't a presentingViewController.
guard let presentingViewController = self.context.presentingViewController else { throw VerificationError.addedPermissions(addedEntitlements, appVersion: appVersion) }
try await self.review(addedEntitlements, for: app, mode: .added, presentingViewController: presentingViewController)
}
}
}
@discardableResult
func verifyPermissions(of app: ALTApplication, @AsyncManaged match storeApp: StoreApp) async throws -> [any ALTAppPermission]
{
// Entitlements
var allEntitlements = Set(app.entitlements.keys)
for appExtension in app.appExtensions
{
allEntitlements.formUnion(appExtension.entitlements.keys)
}
// Filter out ignored entitlements.
allEntitlements = allEntitlements.filter { !ALTEntitlement.ignoredEntitlements.contains($0) }
if let isDebuggable = app.entitlements[.getTaskAllow] as? Bool, !isDebuggable
{
// App has `get-task-allow` entitlement but the value is false, so remove from allEntitlements.
allEntitlements.remove(.getTaskAllow)
}
// Privacy
let allPrivacyPermissions = ([app] + app.appExtensions).flatMap { (app) in
let permissions = app.bundle.infoDictionary?.keys.compactMap { key -> ALTAppPrivacyPermission? in
if #available(iOS 16, *)
{
guard key.wholeMatch(of: Regex.privacyPermission) != nil else { return nil }
}
else
{
guard key.contains("UsageDescription") else { return nil }
}
let permission = ALTAppPrivacyPermission(rawValue: key)
return permission
} ?? []
return permissions
}
// Verify permissions.
let sourcePermissions: Set<AnyHashable> = Set(await $storeApp.perform { $0.permissions.map { AnyHashable($0.permission) } })
let localPermissions: [any ALTAppPermission] = Array(allEntitlements) + Array(allPrivacyPermissions)
// To pass: EVERY permission in localPermissions must also appear in sourcePermissions.
// If there is a single missing permission, throw error.
let missingPermissions: [any ALTAppPermission] = localPermissions.filter { permission in
if sourcePermissions.contains(AnyHashable(permission))
{
// `permission` exists in source, so return false.
return false
}
else if permission.type == .privacy
{
guard #available(iOS 16, *) else {
// Assume all privacy permissions _are_ included in source on pre-iOS 16 devices.
return false
}
// Special-handling for legacy privacy permissions.
if let match = permission.rawValue.firstMatch(of: Regex.privacyPermission),
case let legacyPermission = ALTAppPrivacyPermission(rawValue: String(match.1)),
sourcePermissions.contains(AnyHashable(legacyPermission))
{
// The legacy name of this permission exists in the source, so return false.
return false
}
}
// Source doesn't contain permission or its legacy name, so assume it is missing.
return true
}
do
{
guard missingPermissions.isEmpty else {
// There is at least one undeclared permission, so throw error.
throw VerificationError.undeclaredPermissions(missingPermissions, app: app)
}
}
catch let error as VerificationError where error.code == .undeclaredPermissions
{
if let recommendedSources = UserDefaults.shared.recommendedSources, let (sourceID, sourceURL) = await $storeApp.perform({ $0.source.map { ($0.identifier, $0.sourceURL) } })
{
let normalizedSourceURL = try? sourceURL.normalized()
let isRecommended = recommendedSources.contains { $0.identifier == sourceID || (try? $0.sourceURL?.normalized()) == normalizedSourceURL }
guard !isRecommended else {
// Don't enforce permission checking for Recommended Sources for now.
return localPermissions
}
}
throw error
}
return localPermissions
}
@MainActor @available(iOS 15, *)
func review(_ permissions: [ALTEntitlement], for app: AppProtocol, mode: PermissionReviewMode, presentingViewController: UIViewController) async throws
{
let reviewPermissionsViewController = ReviewPermissionsViewController(app: app, permissions: permissions, mode: mode)
let navigationController = UINavigationController(rootViewController: reviewPermissionsViewController)
defer {
navigationController.dismiss(animated: true)
}
try await withCheckedThrowingContinuation { continuation in
reviewPermissionsViewController.completionHandler = { result in
continuation.resume(with: result)
}
presentingViewController.present(navigationController, animated: true)
}
}
}

View File

@@ -1,281 +0,0 @@
//
// VerifyAppPledgeOperation.swift
// AltStore
//
// Created by Riley Testut on 12/6/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import Combine
import AltStoreCore
class VerifyAppPledgeOperation: ResultOperation<Void>
{
@AsyncManaged
private(set) var storeApp: StoreApp
private let presentingViewController: UIViewController?
private var openPatreonPageContinuation: CheckedContinuation<Void, Never>?
private var cancellable: AnyCancellable?
init(storeApp: StoreApp, presentingViewController: UIViewController?)
{
self.storeApp = storeApp
self.presentingViewController = presentingViewController
}
override func main()
{
super.main()
// _Don't_ rethrow earlier errors, or else user will only be taken to Patreon post if connected to same Wi-Fi as AltServer.
// if let error = self.context.error
// {
// self.finish(.failure(error))
// return
// }
Task<Void, Never>.detached(priority: .medium) {
do
{
guard await self.$storeApp.isPledgeRequired else { return self.finish(.success(())) }
if let presentingViewController = self.presentingViewController
{
// Ask user to connect Patreon account if they are signed-in to Patreon inside WebViewController, but haven't yet signed in through AltStore settings.
// This is most likely because the user joined a Patreon campaign directly through WebViewController before connecting Patreon account in settings.
try await self.connectPatreonAccountIfNeeded(presentingViewController: presentingViewController)
}
do
{
try await self.verifyPledge()
}
catch let error as OperationError where error.code == .pledgeRequired || error.code == .pledgeInactive
{
guard
let presentingViewController = self.presentingViewController,
let source = await self.$storeApp.source,
let patreonURL = await self.$storeApp.perform({ _ in source.patreonURL })
else { throw error }
let components = URLComponents(url: patreonURL, resolvingAgainstBaseURL: false)
let lastPathComponent = components?.path.components(separatedBy: "/").last
let username = lastPathComponent ?? patreonURL.lastPathComponent
let checkoutURL: URL
if await self.$storeApp.prefersCustomPledge, let customPledgeURL = URL(string: "https://www.patreon.com/checkout/" + username + "?rid=0&custom=1")
{
checkoutURL = customPledgeURL
let action = await UIAlertAction(title: NSLocalizedString("Continue", comment: ""), style: .default)
try await presentingViewController.presentConfirmationAlert(title: NSLocalizedString("Custom Pledge", comment: ""),
message: NSLocalizedString("This app supports custom pledges. Pledge any amount on Patreon to receive access.", comment: ""),
primaryAction: action)
}
else if !username.isEmpty, let url = URL(string: "https://www.patreon.com/join/" + username)
{
// Prefer /join URL over campaign homepage.
// URL format from https://support.patreon.com/hc/en-us/articles/360044376211-Managing-members-with-custom-pledges
checkoutURL = url
}
else
{
checkoutURL = patreonURL
}
// Direct user to Patreon page if they're not already pledged.
await self.openPatreonPage(checkoutURL, presentingViewController: presentingViewController)
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
if let patreonAccount = await context.performAsync({ DatabaseManager.shared.patreonAccount(in: context) })
{
// Patreon account is connected, so we'll update it via API to see if pledges changed.
// If so, we'll re-fetch the source to update pledge statuses.
try await self.updatePledges(for: source, account: patreonAccount)
}
else
{
// Patreon account is not connected, so prompt user to connect it.
try await self.connectPatreonAccountIfNeeded(presentingViewController: presentingViewController)
}
do
{
try await self.verifyPledge()
}
catch
{
// Ignore error, but cancel remainder of operation.
throw CancellationError()
}
}
self.finish(.success(()))
}
catch
{
self.finish(.failure(error))
}
}
}
}
private extension VerifyAppPledgeOperation
{
func verifyPledge() async throws
{
let (appName, isPledged) = await self.$storeApp.perform { ($0.name, $0.isPledged) }
if !PatreonAPI.shared.isAuthenticated || !isPledged
{
let isInstalled = await self.$storeApp.installedApp != nil
if isInstalled
{
// Assume if there is an InstalledApp, the user had previously pledged to this app.
throw OperationError.pledgeInactive(appName: appName)
}
else
{
throw OperationError.pledgeRequired(appName: appName)
}
}
}
func connectPatreonAccountIfNeeded(presentingViewController: UIViewController) async throws
{
guard !PatreonAPI.shared.isAuthenticated, let authCookie = PatreonAPI.shared.authCookies.first(where: { $0.name.lowercased() == "session_id" }) else { return }
Logger.sideload.debug("Patreon Auth cookie: \(authCookie.name)=\(authCookie.value)")
let message = NSLocalizedString("You're signed into Patreon but haven't connected your account with SideStore.\n\nPlease connect your account to download Patreon-exclusive apps.", comment: "")
let action = await UIAlertAction(title: NSLocalizedString("Connect Patreon Account", comment: ""), style: .default)
do
{
_ = try await presentingViewController.presentConfirmationAlert(title: NSLocalizedString("Patreon Account Detected", comment: ""),
message: message, actions: [action])
}
catch
{
// Ignore and continue
return
}
try await withCheckedThrowingContinuation { continuation in
PatreonAPI.shared.authenticate(presentingViewController: presentingViewController) { result in
do
{
let account = try result.get()
try account.managedObjectContext?.save()
continuation.resume()
}
catch
{
continuation.resume(throwing: error)
}
}
}
if let source = await self.$storeApp.source
{
// Fetch source to update pledge status now that account is connected.
try await self.update(source)
}
}
func updatePledges(@AsyncManaged for source: Source, @AsyncManaged account: PatreonAccount) async throws
{
guard PatreonAPI.shared.isAuthenticated else { return }
let previousPledgeIDs = Set(await $account.perform { $0.pledges.map(\.identifier) })
let updatedPledgeIDs = try await withCheckedThrowingContinuation { continuation in
PatreonAPI.shared.fetchAccount { (result: Result<PatreonAccount, Swift.Error>) in
do
{
let account = try result.get()
let pledgeIDs = Set(account.pledges.map(\.identifier))
try account.managedObjectContext?.save()
continuation.resume(returning: pledgeIDs)
}
catch
{
Logger.sideload.error("Failed to update Patreon account. \(error.localizedDescription, privacy: .public)")
continuation.resume(throwing: error)
}
}
}
if updatedPledgeIDs != previousPledgeIDs
{
// Active pledges changed, so fetch source to update pledge status.
try await self.update(source)
}
}
func update(@AsyncManaged _ source: Source) async throws
{
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
_ = try await AppManager.shared.fetchSource(sourceURL: $source.sourceURL, managedObjectContext: context)
try await context.performAsync {
try context.save()
}
}
@MainActor
func openPatreonPage(_ patreonURL: URL, presentingViewController: UIViewController) async
{
let webViewController = WebViewController(url: patreonURL)
webViewController.delegate = self
let navigationController = UINavigationController(rootViewController: webViewController)
presentingViewController.present(navigationController, animated: true)
// Automatically dismiss if user completes checkout flow.
self.cancellable = webViewController.webView.publisher(for: \.url, options: [.new])
.compactMap { $0 }
.compactMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) }
.compactMap { components in
let lastPathComponent = components.path.components(separatedBy: "/").last
return lastPathComponent?.lowercased()
}
.filter { $0 == "membership" }
.receive(on: RunLoop.main)
.sink { [weak self] url in
guard let continuation = self?.openPatreonPageContinuation else { return }
self?.openPatreonPageContinuation = nil
continuation.resume()
}
await withCheckedContinuation { continuation in
self.openPatreonPageContinuation = continuation
}
// Cache auth cookies just in case user signed in.
await PatreonAPI.shared.saveAuthCookies()
navigationController.dismiss(animated: true)
self.cancellable = nil
}
}
extension VerifyAppPledgeOperation: WebViewControllerDelegate
{
func webViewControllerDidFinish(_ webViewController: WebViewController)
{
guard let continuation = self.openPatreonPageContinuation else { return }
self.openPatreonPageContinuation = nil
continuation.resume()
}
}

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