Compare commits

..

2 Commits

Author SHA1 Message Date
Riley Testut
c325b994d9 Updates app version to 1.1.3 2020-01-29 13:15:33 -08:00
Riley Testut
719cea97e8 Replaces frameworks with static libraries
As of iOS 13.3.1, apps installed with free developer accounts that contain embedded frameworks fail to launch. To work around this, we now link all dependencies via Cocoapods as static libraries.
2020-01-29 12:06:26 -08:00
1264 changed files with 27099 additions and 66041 deletions

View File

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

File diff suppressed because it is too large Load Diff

1
.github/CODEOWNERS vendored
View File

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

View File

@@ -1,40 +0,0 @@
name: Bug Report
description: Report a bug
title: "[BUG] "
labels: ["bug"]
assignees: []
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.**
- type: textarea
id: description
attributes:
label: Describe the bug
description: What is the bug and how did you discover it?
placeholder: Please be clear and concise with your description.
validations:
required: true
- type: textarea
id: how-to-reproduce
attributes:
label: Instructions to reproduce
description: Please include clear and consistent instructions for reproducing the bug to make it easier for us to fix it.
validations:
required: true
- type: input
id: app-version
attributes:
label: What version of SideStore are you using?
description: To retrieve this, go to `Settings` in the SideStore app and scroll down to the bottom.
validations:
required: true
- type: textarea
id: other-info
attributes:
label: Other info
description: If you have any other comments, other info that might be useful, or if you found a workaround, please put it here.

View File

@@ -1,10 +0,0 @@
# force issue template usage
blank_issues_enabled: false
contact_links:
- name: Discord
url: https://discord.gg/sidestore-949183273383395328
about: If you need support, please go here first instead of making an issue!
- name: GitHub Discussions
url: https://github.com/SideStore/SideStore/discussions
about: As an alternative to Discord, you can also make a new GitHub discussion.

View File

@@ -1,32 +0,0 @@
name: Feature Request
description: Suggest a feature
title: "[FEATURE REQUEST] "
labels: ["enhancement"]
assignees: []
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.**
- type: textarea
id: description
attributes:
label: Describe the feature
description: What is the feature? How would it work?
placeholder: Please be clear and concise with your description.
validations:
required: true
- type: textarea
id: use-cases
attributes:
label: Use cases
description: Please include multiple use cases where this feature would be useful.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives
description: If you have alternative ideas of how this feature could work, you can put them here.

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

@@ -1,12 +0,0 @@
### Changes
<!-- Fill this list with what your PR changes. Example: -->
- Fix bug
- Change UI for QOL
<!-- If your PR is ready to be merged, you can remove this section. -->
### Todo before merge
<!-- Example: -->
- [x] Finish UI changes
- [ ] Test

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

@@ -1,77 +0,0 @@
name: Add artifact links to pull request and related issues
on:
workflow_run:
workflows: [Pull Request SideStore build]
types: [completed]
jobs:
artifacts-url-comments:
name: add artifact links to pull request and related issues job
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: add artifact links to pull request and related issues step
uses: tonyhallett/artifacts-url-comments@v1.1.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
prefix: Builds for this Pull Request are available at
suffix: Have a nice day.
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

@@ -1,103 +0,0 @@
name: Beta SideStore build
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+-beta.[0-9]+' # example: 1.0.0-beta.1
jobs:
build:
name: Build and upload SideStore Beta
strategy:
fail-fast: false
matrix:
include:
- os: 'macos-14'
version: '15.4'
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- 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: Get version
id: version
run: echo "version=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_OUTPUT
- name: Echo version
run: echo "${{ steps.version.outputs.version }}"
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: ${{ matrix.version }}
- name: Cache Build
uses: irgaly/xcode-cache@v1
with:
key: xcode-cache-deriveddata-${{ github.sha }}
restore-keys: xcode-cache-deriveddata
- name: Build SideStore
run: make build | xcpretty && exit ${PIPESTATUS[0]}
- 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: Upload SideStore.ipa Artifact
uses: actions/upload-artifact@v4
with:
name: SideStore-${{ steps.version.outputs.version }}.ipa
path: SideStore-${{ steps.version.outputs.version }}.ipa
- name: Upload *.dSYM Artifact
uses: actions/upload-artifact@v4
with:
name: SideStore-${{ steps.version.outputs.version }}-dSYM
path: ./*.dSYM/

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

@@ -1,82 +0,0 @@
name: Nightly SideStore Build
on:
push:
branches:
- develop
schedule:
- cron: '0 0 * * *' # Runs every night at midnight UTC
workflow_dispatch: # Allows manual trigger
# cancel duplicate run if from same branch
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
check-changes:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
outputs:
has_changes: ${{ steps.check.outputs.has_changes }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Ensure full history
- 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: 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 }}
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 }}

View File

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

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,401 +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-15'
version: '16.2'
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) Restore Pods from Cache (Exact match)
id: pods-restore
uses: actions/cache/restore@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-build-${{ github.ref_name }}-${{ hashFiles('Podfile') }}
# restore-keys: | # commented out to strictly check cache for this particular podfile
# pods-cache-
- name: (Build) Restore Pods from Cache (Last Available)
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
id: pods-restore-recent
uses: actions/cache/restore@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-build-${{ github.ref_name }}-
- name: (Build) Install CocoaPods
run: pod install
shell: bash
- name: (Build) Save Pods to Cache
id: save-pods
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-build-${{ github.ref_name }}-${{ hashFiles('Podfile') }}
- 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 ">>>>>>>>> Pods <<<<<<<<<<"
find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> SideStore <<<<<<<<<<"
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
echo ""
- name: 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,235 +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: 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: SideStore.ipa SideStore.dSYMs.zip encrypted-build-logs.zip encrypted-tests-build-logs.zip encrypted-tests-run-logs.zip test-results.zip tests-recording.mp4
body: |
This is an ⚠️ **EXPERIMENTAL** ⚠️ ${{ inputs.release_name }} build for commit [${{ github.sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}).
${{ 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,204 +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-15'
version: '16.2'
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: '16.2'
# - 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: (Tests-Build) Restore Pods from Cache (Exact match)
id: pods-restore
uses: actions/cache/restore@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-test-${{ github.ref_name }}-${{ hashFiles('Podfile') }}
- name: (Tests-Build) Restore Pods from Cache (Last Available)
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
id: pods-restore-recent
uses: actions/cache/restore@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-test-${{ github.ref_name }}-
- name: (Tests-Build) Install CocoaPods
run: pod install
shell: bash
- name: (Tests-Build) Save Pods to Cache
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-test-${{ github.ref_name }}-${{ hashFiles('Podfile') }}
- 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 ">>>>>>>>> Pods <<<<<<<<<<"
find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> SideStore <<<<<<<<<<"
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
echo ""
- name: Build SideStore Tests
# using 'tee' to intercept stdout and log for detailed build-log
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,235 +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-15'
version: '16.2'
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: '16.2'
# - 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) Restore Pods from Cache (Exact match)
id: pods-restore
uses: actions/cache/restore@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-test-${{ github.ref_name }}-${{ hashFiles('Podfile') }}
- name: (Tests-Run) Restore Pods from Cache (Last Available)
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
id: pods-restore-recent
uses: actions/cache/restore@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-test-${{ github.ref_name }}-
- name: (Tests-Run) Install CocoaPods
run: pod install
shell: bash
- name: (Tests-Run) Save Pods to Cache
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-test-${{ github.ref_name }}-${{ hashFiles('Podfile') }}
- 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 ">>>>>>>>> Pods <<<<<<<<<<"
find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> SideStore <<<<<<<<<<"
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
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

@@ -1,283 +0,0 @@
name: Stable SideStore build
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+' # example: 1.0.0
workflow_dispatch:
jobs:
build:
name: Build SideStore - stable (on tag push)
strategy:
fail-fast: false
matrix:
include:
- os: 'macos-15'
version: '26.0'
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Echo Build.xcconfig
run: |
echo "cat Build.xcconfig"
cat Build.xcconfig
shell: bash
# - name: Change MARKETING_VERSION to the pushed tag that triggered this build
# run: sed -e '/MARKETING_VERSION = .*/s/= .*/= ${{ github.ref_name }}/' -i '' Build.xcconfig
- name: Echo Updated Build.xcconfig
run: |
cat Build.xcconfig
shell: bash
- name: Extract MARKETING_VERSION from Build.xcconfig
id: version
run: |
version=$(grep MARKETING_VERSION Build.xcconfig | sed -e 's/MARKETING_VERSION = //g')
echo "version=$version" >> $GITHUB_OUTPUT
echo "version=$version"
echo "MARKETING_VERSION=$version" >> $GITHUB_ENV
echo "MARKETING_VERSION=$version" >> $GITHUB_OUTPUT
echo "MARKETING_VERSION=$version"
shell: bash
- name: Fail the build if pushed tag and embedded MARKETING_VERSION in Build.xcconfig are mismatching
run: |
if [ "$MARKETING_VERSION" != "${{ github.ref_name }}" ]; then
echo 'Version mismatch: $tag != $marketing_version ... '
echo " expected-tag : $MARKETING_VERSION"
echo " pushed-tag : ${{ github.ref_name }}"
exit 1
fi
echo 'Version matches: $tag == $marketing_version ... '
echo " expected-tag : $MARKETING_VERSION"
echo " pushed-tag : ${{ github.ref_name }}"
shell: bash
- name: Install dependencies - ldid & xcbeautify
run: |
brew install ldid xcbeautify
- name: 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) Restore Pods from Cache (Exact match)
id: pods-restore
uses: actions/cache/restore@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-build-stable-${{ hashFiles('Podfile') }}
- name: (Build) Restore Pods from Cache (Last Available)
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
id: pods-restore-recent
uses: actions/cache/restore@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-build-stable-
- name: (Build) Install CocoaPods
run: pod install
shell: bash
- name: (Build) Save Pods to Cache
id: save-pods
if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v3
with:
path: |
./Podfile.lock
./Pods/
./AltStore.xcworkspace/
key: pods-cache-build-stable-${{ hashFiles('Podfile') }}
- 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 ">>>>>>>>> Pods <<<<<<<<<<"
find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> SideStore <<<<<<<<<<"
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
echo ""
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
echo ""
- name: Build SideStore.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: 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: Get current date
id: date
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
shell: bash
- name: Get current date in AltStore date form
id: date_altstore
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
shell: bash
- name: Upload to releases
uses: IsaacShelton/update-existing-release@v1.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
draft: true
release: ${{ github.ref_name }} # name
tag: ${{ github.ref_name }}
# stick with what the user pushed, do not use latest commit or anything,
# ex: if we want to go back to previous release due to hot issue, dev can create a new tag pointing to that older working tag/commit so as to keep it as an update (to revert major issue)
# in this case we do not want the tag to be auto-updated to latest
updateTag: false
prerelease: false
files: >
SideStore.ipa
SideStore.dSYMs.zip
encrypted-build-logs.zip
body: |
<!-- NOTE: to reset SideSource cache, go to `https://apps.sidestore.io/reset-cache/nightly/<sidesource key>`. This is not included in the GitHub Action since it makes draft releases so they can be edited and have a changelog. -->
## Changelog
- TODO
## Build Info
Built at (UTC): `${{ steps.date.outputs.date }}`
Built at (UTC date): `${{ steps.date_altstore.outputs.date }}`
Commit SHA: `${{ github.sha }}`
Version: `${{ steps.version.outputs.version }}`

46
.gitignore vendored
View File

@@ -1,18 +1,14 @@
# macOS
#
**/*.DS_Store
*.DS_Store
# Xcode
#
## CocoaPods
Pods/
## Build generated
build/
DerivedData
SideStore.xcarchive
## Various settings
*.pbxuser
!default.pbxuser
@@ -31,42 +27,4 @@ xcuserdata
*.xcscmblueprint
## Obj-C/Swift specific
*.hmap
/newrelic_agent.log
/CodeSigning.xcconfig
/.vscode
## AppCode specific
.idea/
Payload/
**/SideStore.ipa
**/AltBackup.ipa
**/*.dSYM
Dependencies/.*-prebuilt-fetch-*
SideStore/minimuxer/*
SideStore/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
*.hmap

71
.gitmodules vendored
View File

@@ -1,64 +1,15 @@
#-------------------------------
# When changing url/branch in this .gitmodules file,
# Always ensure you run:
# 1. `git rm --cached <submodule_relative_path>` # this removes the submodule entry from general git tracking
# 2. `rm -rf .git/modules/<submodule_relative_path>` # this removes the stale name entries in submodule tracker
# 3. `rm -rf <submodule_relative_path>` # removes the submodule completely
# 4. `git submodule --deinit <submodule_relative_path>` # make sure that the submodule is de-inited too (ignore errors at this point)
# 5. `git submodule add [-b <branch_name>] <repo_url> <submodule_relative_path>` # This adds the submodule back into general git tracking and also adds to the submodule tracker
# 6. Step 5 creates an entry in the .gitmodules when a submodule is added,
# So if you already had one entry, try to remove duplicates at this point
# 7. `git submodule sync --recursive` # this now sets/updates the submodule repo url tracker into git config
# 8. `git submodule update --init --recursive` # this now clones the updated repo set by .gitmodules
# But this will always fetch the latest commit sepecified by the custom(if set)/default branch
# 9. If you do want to have a specific commit in that submodule branch and not latest, you need to perform normal detached head checkout and check-in as follows:
# `pushd <submodule_relative_path>` # switch to the submodule repo
# `git checkout <commit-id>` # this creates a detached head state
# `popd` # get back to parent repo
# `git add <submodule_relative_path>` # check-in the changes in parent for this submodule link (tracker)
# `git commit -m <commit-message>` # commit it to parent repo
# `git push` # push to parent repo to preserve this entire change in the submodule repo/link file
#
# NOTES:
# 1. updating just this .gitmodules file is NOT ENOUGH when changing repo url and performing a simple `git submodule update --init --recursive`, need to do all the above listed steps for proper tracking
# 2. updating the branch in this .gitmodules for same repo is okay as long as `git submodule update --init --recursive` is also performed followed by it
# 3. Ensure there is no stale entries or duplicate entries in this .gitmodules file coz, `git submodule add ...` creates an entry here.
#-------------------------------
[submodule "Dependencies/Roxas"]
path = Dependencies/Roxas
url = https://github.com/rileytestut/Roxas.git
path = Dependencies/Roxas
url = https://github.com/rileytestut/Roxas.git
[submodule "Dependencies/AltSign"]
path = Dependencies/AltSign
url = https://github.com/rileytestut/AltSign.git
[submodule "Dependencies/libimobiledevice"]
path = Dependencies/libimobiledevice
url = https://github.com/SideStore/libimobiledevice
path = Dependencies/libimobiledevice
url = https://github.com/rileytestut/libimobiledevice.git
[submodule "Dependencies/libusbmuxd"]
path = Dependencies/libusbmuxd
url = https://github.com/libimobiledevice/libusbmuxd.git
path = Dependencies/libusbmuxd
url = https://github.com/libimobiledevice/libusbmuxd.git
[submodule "Dependencies/libplist"]
path = Dependencies/libplist
url = https://github.com/SideStore/libplist.git
[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/libfragmentzip"]
path = SideStore/libfragmentzip
url = https://github.com/SideStore/libfragmentzip
branch = master
[submodule "SideStore/apps-v2.json"]
path = SideStore/apps-v2.json
url = https://github.com/SideStore/apps-v2.json
branch = main
[submodule "SideStore/AltSign"]
path = SideStore/AltSign
url = https://github.com/SideStore/AltSign
branch = master
path = Dependencies/libplist
url = https://github.com/libimobiledevice/libplist.git

View File

@@ -1,133 +0,0 @@
//
// AppDelegate.swift
// AltBackup
//
// Created by Riley Testut on 5/11/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import UIKit
extension AppDelegate
{
static let startBackupNotification = Notification.Name("io.sidestore.StartBackup")
static let startRestoreNotification = Notification.Name("io.sidestore.StartRestore")
static let operationDidFinishNotification = Notification.Name("io.sidestore.BackupOperationFinished")
static let operationResultKey = "result"
}
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private var currentBackupReturnURL: URL?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool
{
// Override point for customization after application launch.
NotificationCenter.default.addObserver(self, selector: #selector(AppDelegate.operationDidFinish(_:)), name: AppDelegate.operationDidFinishNotification, object: nil)
let viewController = ViewController()
self.window = UIWindow(frame: UIScreen.main.bounds)
self.window?.rootViewController = viewController
self.window?.makeKeyAndVisible()
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}
func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) -> Bool
{
return self.open(url)
}
}
private extension AppDelegate
{
func open(_ url: URL) -> Bool
{
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false }
guard let command = components.host?.lowercased() else { return false }
switch command
{
case "backup":
guard let returnString = components.queryItems?.first(where: { $0.name == "returnURL" })?.value, let returnURL = URL(string: returnString) else { return false }
self.currentBackupReturnURL = returnURL
NotificationCenter.default.post(name: AppDelegate.startBackupNotification, object: nil)
return true
case "restore":
guard let returnString = components.queryItems?.first(where: { $0.name == "returnURL" })?.value, let returnURL = URL(string: returnString) else { return false }
self.currentBackupReturnURL = returnURL
NotificationCenter.default.post(name: AppDelegate.startRestoreNotification, object: nil)
return true
default: return false
}
}
@objc func operationDidFinish(_ notification: Notification)
{
defer {
self.currentBackupReturnURL = nil
}
// TODO: @mahee96: This doesn't account cases where backup is too long and user switched to other apps
// The check for self.currentBackupReturnURL when backup/restore was still in progress but app switched
// between FG/BG is improper, since it will ignore(eat up) the response(success/failure) to parent
//
// This leaves the backup/restore to show dummy animation forever
guard
let returnURL = self.currentBackupReturnURL,
let result = notification.userInfo?[AppDelegate.operationResultKey] as? Result<Void, Error>
else {
return // This is bad (Needs fixing - never eat up response like this unless there is no context to post response to!)
}
guard var components = URLComponents(url: returnURL, resolvingAgainstBaseURL: false) else {
return // This is ASSERTION Failure, ie RETURN URL needs to be valid. So ignoring (eating up) response is not the solution
}
switch result
{
case .success:
components.path = "/success"
case .failure(let error as NSError):
components.path = "/failure"
components.queryItems = ["errorDomain": error.domain,
"errorCode": String(error.code),
"errorDescription": error.localizedDescription].map { URLQueryItem(name: $0, value: $1) }
}
guard let responseURL = components.url else { return }
DispatchQueue.main.async {
// Response to the caller/parent app is posted here (url is provided by caller in incoming query params)
UIApplication.shared.open(responseURL, options: [:]) { (success) in
print("Sent response to app with success:", success)
}
}
}
}

View File

@@ -1,38 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.518",
"green" : "0.502",
"red" : "0.004"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.404",
"green" : "0.322",
"red" : "0.008"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "0.750",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,342 +0,0 @@
//
// BackupController.swift
// AltBackup
//
// Created by Riley Testut on 5/12/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
extension ErrorUserInfoKey
{
static let sourceFile: String = "alt_sourceFile"
static let sourceFileLine: String = "alt_sourceFileLine"
}
extension Error
{
var sourceDescription: String? {
guard let sourceFile = (self as NSError).userInfo[ErrorUserInfoKey.sourceFile] as? String, let sourceFileLine = (self as NSError).userInfo[ErrorUserInfoKey.sourceFileLine] else {
return nil
}
return "(\((sourceFile as NSString).lastPathComponent), Line \(sourceFileLine))"
}
}
struct BackupError: ALTLocalizedError
{
enum Code: ALTErrorEnum, RawRepresentable
{
case invalidBundleID
case appGroupNotFound(String?)
case randomError // Used for debugging.
// Provide failure reason for each error code
var errorFailureReason: String {
switch self {
case .invalidBundleID:
return NSLocalizedString("The bundle identifier is invalid.", comment: "")
case .appGroupNotFound(let appGroup):
if let appGroup = appGroup {
return String(format: NSLocalizedString("The app group “%@” could not be found.", comment: ""), appGroup)
} else {
return NSLocalizedString("The AltStore app group could not be found.", comment: "")
}
case .randomError:
return NSLocalizedString("A random error occurred.", comment: "")
}
}
static var errorDomain: String {
return "com.sidestore.BackupError"
}
// Add a raw value for RawRepresentable conformance
var rawValue: Int {
switch self {
case .invalidBundleID: return 0
case .appGroupNotFound: return 1
case .randomError: return 2
}
}
// Initializer for RawRepresentable
init?(rawValue: Int) {
switch rawValue {
case 0: self = .invalidBundleID
case 1: self = .appGroupNotFound(nil)
case 2: self = .randomError
default: return nil
}
}
}
let code: Code
let sourceFile: String
let sourceFileLine: Int
var failure: String?
var errorTitle: String?
var errorFailure: String?
var failureReason: String? {
switch self.code
{
case .invalidBundleID: return NSLocalizedString("The bundle identifier is invalid.", comment: "")
case .appGroupNotFound(let appGroup):
if let appGroup = appGroup
{
return String(format: NSLocalizedString("The app group “%@” could not be found.", comment: ""), appGroup)
}
else
{
return NSLocalizedString("The AltStore app group could not be found.", comment: "")
}
case .randomError: return NSLocalizedString("A random error occured.", comment: "")
}
}
var errorUserInfo: [String : Any] {
let userInfo: [String: Any?] = [NSLocalizedDescriptionKey: self.errorDescription,
NSLocalizedFailureReasonErrorKey: self.failureReason,
NSLocalizedFailureErrorKey: self.failure,
ErrorUserInfoKey.sourceFile: self.sourceFile,
ErrorUserInfoKey.sourceFileLine: self.sourceFileLine]
return userInfo.compactMapValues { $0 }
}
// Implement description for CustomStringConvertible
var description: String {
return "\(errorTitle ?? "Unknown Error"): \(failureReason ?? "No reason available")"
}
init(_ code: Code, description: String? = nil, file: String = #file, line: Int = #line)
{
self.code = code
self.failure = description
self.sourceFile = file
self.sourceFileLine = line
self.errorTitle = NSLocalizedString("Backup Error", comment: "")
self.errorFailure = description
}
}
class BackupController: NSObject
{
private let fileCoordinator = NSFileCoordinator(filePresenter: nil)
private let operationQueue = OperationQueue()
override init()
{
self.operationQueue.name = "AltBackup-BackupQueue"
}
func performBackup(completionHandler: @escaping (Result<Void, Error>) -> Void)
{
do
{
guard let bundleIdentifier = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.altBundleID) as? String else {
throw BackupError(.invalidBundleID, description: NSLocalizedString("Unable to create backup directory.", comment: ""))
}
guard
let altstoreAppGroup = Bundle.main.altstoreAppGroup,
let sharedDirectoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: altstoreAppGroup)
else {
throw BackupError(.appGroupNotFound(nil), description: NSLocalizedString("Unable to create backup directory.", comment: ""))
}
let backupsDirectory = sharedDirectoryURL.appendingPathComponent("Backups")
// Use temporary directory to prevent messing up successful backup with incomplete one.
let temporaryAppBackupDirectory = backupsDirectory.appendingPathComponent("Temp", isDirectory: true).appendingPathComponent(UUID().uuidString)
let appBackupDirectory = backupsDirectory.appendingPathComponent(bundleIdentifier)
let writingIntent = NSFileAccessIntent.writingIntent(with: temporaryAppBackupDirectory, options: [])
let replacementIntent = NSFileAccessIntent.writingIntent(with: appBackupDirectory, options: [.forReplacing])
self.fileCoordinator.coordinate(with: [writingIntent, replacementIntent], queue: self.operationQueue) { (error) in
do
{
if let error = error
{
throw error
}
do
{
let mainGroupBackupDirectory = temporaryAppBackupDirectory.appendingPathComponent("App")
try FileManager.default.createDirectory(at: mainGroupBackupDirectory, withIntermediateDirectories: true, attributes: nil)
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let backupDocumentsDirectory = mainGroupBackupDirectory.appendingPathComponent(documentsDirectory.lastPathComponent)
if FileManager.default.fileExists(atPath: backupDocumentsDirectory.path)
{
try FileManager.default.removeItem(at: backupDocumentsDirectory)
}
if FileManager.default.fileExists(atPath: documentsDirectory.path)
{
try FileManager.default.copyItem(at: documentsDirectory, to: backupDocumentsDirectory)
}
print("Copied Documents directory from \(documentsDirectory) to \(backupDocumentsDirectory)")
let libraryDirectory = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0]
let backupLibraryDirectory = mainGroupBackupDirectory.appendingPathComponent(libraryDirectory.lastPathComponent)
if FileManager.default.fileExists(atPath: backupLibraryDirectory.path)
{
try FileManager.default.removeItem(at: backupLibraryDirectory)
}
if FileManager.default.fileExists(atPath: libraryDirectory.path)
{
try FileManager.default.copyItem(at: libraryDirectory, to: backupLibraryDirectory)
}
print("Copied Library directory from \(libraryDirectory) to \(backupLibraryDirectory)")
}
for appGroup in Bundle.main.appGroups where appGroup != altstoreAppGroup
{
guard let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
throw BackupError(.appGroupNotFound(appGroup), description: NSLocalizedString("Unable to create app group backup directory.", comment: ""))
}
let backupAppGroupURL = temporaryAppBackupDirectory.appendingPathComponent(appGroup)
// There are several system hidden files that we don't have permission to read, so we just skip all hidden files in app group directories.
try self.copyDirectoryContents(at: appGroupURL, to: backupAppGroupURL, options: [.skipsHiddenFiles])
}
// Replace previous backup with new backup.
_ = try FileManager.default.replaceItemAt(appBackupDirectory, withItemAt: temporaryAppBackupDirectory)
print("Replaced previous backup with new backup:", temporaryAppBackupDirectory)
completionHandler(.success(()))
}
catch
{
do { try FileManager.default.removeItem(at: temporaryAppBackupDirectory) }
catch { print("Failed to remove temporary directory.", error) }
completionHandler(.failure(error))
}
}
}
catch
{
completionHandler(.failure(error))
}
}
func restoreBackup(completionHandler: @escaping (Result<Void, Error>) -> Void)
{
do
{
guard let bundleIdentifier = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.altBundleID) as? String else {
throw BackupError(.invalidBundleID, description: NSLocalizedString("Unable to access backup.", comment: ""))
}
guard
let altstoreAppGroup = Bundle.main.altstoreAppGroup,
let sharedDirectoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: altstoreAppGroup)
else { throw BackupError(.appGroupNotFound(nil), description: NSLocalizedString("Unable to access backup.", comment: "")) }
let backupsDirectory = sharedDirectoryURL.appendingPathComponent("Backups")
let appBackupDirectory = backupsDirectory.appendingPathComponent(bundleIdentifier)
let readingIntent = NSFileAccessIntent.readingIntent(with: appBackupDirectory, options: [])
self.fileCoordinator.coordinate(with: [readingIntent], queue: self.operationQueue) { (error) in
do
{
if let error = error
{
throw error
}
let mainGroupBackupDirectory = appBackupDirectory.appendingPathComponent("App")
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let backupDocumentsDirectory = mainGroupBackupDirectory.appendingPathComponent(documentsDirectory.lastPathComponent)
let libraryDirectory = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0]
let backupLibraryDirectory = mainGroupBackupDirectory.appendingPathComponent(libraryDirectory.lastPathComponent)
try self.copyDirectoryContents(at: backupDocumentsDirectory, to: documentsDirectory)
try self.copyDirectoryContents(at: backupLibraryDirectory, to: libraryDirectory)
for appGroup in Bundle.main.appGroups where appGroup != altstoreAppGroup
{
guard let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
throw BackupError(.appGroupNotFound(appGroup), description: NSLocalizedString("Unable to read app group backup.", comment: ""))
}
let backupAppGroupURL = appBackupDirectory.appendingPathComponent(appGroup)
try self.copyDirectoryContents(at: backupAppGroupURL, to: appGroupURL)
}
completionHandler(.success(()))
}
catch
{
completionHandler(.failure(error))
}
}
}
catch
{
completionHandler(.failure(error))
}
}
}
private extension BackupController
{
func copyDirectoryContents(at sourceDirectoryURL: URL, to destinationDirectoryURL: URL, options: FileManager.DirectoryEnumerationOptions = []) throws
{
guard FileManager.default.fileExists(atPath: sourceDirectoryURL.path) else { return }
if !FileManager.default.fileExists(atPath: destinationDirectoryURL.path)
{
try FileManager.default.createDirectory(at: destinationDirectoryURL, withIntermediateDirectories: true, attributes: nil)
}
for fileURL in try FileManager.default.contentsOfDirectory(at: sourceDirectoryURL, includingPropertiesForKeys: [.isDirectoryKey], options: options)
{
let isDirectory = try fileURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false
let destinationURL = destinationDirectoryURL.appendingPathComponent(fileURL.lastPathComponent)
if FileManager.default.fileExists(atPath: destinationURL.path)
{
do {
try FileManager.default.removeItem(at: destinationURL)
}
catch CocoaError.fileWriteNoPermission where isDirectory {
try self.copyDirectoryContents(at: fileURL, to: destinationURL, options: options)
continue
}
catch {
print(error)
throw error
}
}
do {
try FileManager.default.copyItem(at: fileURL, to: destinationURL)
print("Copied item from \(fileURL) to \(destinationURL)")
}
catch let error where fileURL.lastPathComponent == "Inbox" && fileURL.deletingLastPathComponent().lastPathComponent == "Documents" {
// Ignore errors for /Documents/Inbox
print("Failed to copy Inbox directory:", error)
}
catch {
print(error)
throw error
}
}
}
}

View File

@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" name="Background"/>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<namedColor name="Background">
<color red="0.0040000001899898052" green="0.50199997425079346" blue="0.51800000667572021" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
</document>

View File

@@ -1,66 +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>ALTAppGroups</key>
<array>
<string>group.$(APP_GROUP_IDENTIFIER)</string>
</array>
<key>ALTBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>SideBackup General</string>
<key>CFBundleURLSchemes</key>
<array>
<string>sidebackup</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleLightContent</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportsDocumentBrowser</key>
<true/>
</dict>
</plist>

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,15 +0,0 @@
//
// UIColor+AltBackup.swift
// AltBackup
//
// Created by Riley Testut on 5/11/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import UIKit
extension UIColor
{
static let altstoreBackground = UIColor(named: "Background")!
static let altstoreText = UIColor(named: "Text")!
}

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

View File

@@ -1,11 +1,9 @@
//
// ALTConstants.h
// AltKit.h
// AltKit
//
// Created by Riley Testut on 5/30/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
#import <Foundation/Foundation.h>
extern uint16_t ALTDeviceListeningSocket;
#import "NSError+ALTServerError.h"

View File

@@ -0,0 +1,35 @@
//
// Bundle+AltStore.swift
// AltStore
//
// Created by Riley Testut on 5/30/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
public extension Bundle
{
struct Info
{
public static let deviceID = "ALTDeviceID"
public static let serverID = "ALTServerID"
public static let certificateID = "ALTCertificateID"
public static let appGroups = "ALTAppGroups"
public static let urlTypes = "CFBundleURLTypes"
}
}
public extension Bundle
{
var infoPlistURL: URL {
let infoPlistURL = self.bundleURL.appendingPathComponent("Info.plist")
return infoPlistURL
}
var certificateURL: URL {
let infoPlistURL = self.bundleURL.appendingPathComponent("ALTCertificate.p12")
return infoPlistURL
}
}

View File

@@ -0,0 +1,43 @@
//
// NSError+ALTServerError.h
// AltStore
//
// Created by Riley Testut on 5/30/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
#import <Foundation/Foundation.h>
extern NSErrorDomain const AltServerErrorDomain;
extern NSErrorDomain const AltServerInstallationErrorDomain;
typedef NS_ERROR_ENUM(AltServerErrorDomain, ALTServerError)
{
ALTServerErrorUnknown = 0,
ALTServerErrorConnectionFailed = 1,
ALTServerErrorLostConnection = 2,
ALTServerErrorDeviceNotFound = 3,
ALTServerErrorDeviceWriteFailed = 4,
ALTServerErrorInvalidRequest = 5,
ALTServerErrorInvalidResponse = 6,
ALTServerErrorInvalidApp = 7,
ALTServerErrorInstallationFailed = 8,
ALTServerErrorMaximumFreeAppLimitReached = 9,
ALTServerErrorUnsupportediOSVersion = 10,
ALTServerErrorUnknownRequest = 11,
ALTServerErrorUnknownResponse = 12,
ALTServerErrorInvalidAnisetteData = 13,
ALTServerErrorPluginNotFound = 14
};
NS_ASSUME_NONNULL_BEGIN
@interface NSError (ALTServerError)
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,79 @@
//
// NSError+ALTServerError.m
// AltStore
//
// Created by Riley Testut on 5/30/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
#import "NSError+ALTServerError.h"
NSErrorDomain const AltServerErrorDomain = @"com.rileytestut.AltServer";
NSErrorDomain const AltServerInstallationErrorDomain = @"com.rileytestut.AltServer.Installation";
@implementation NSError (ALTServerError)
+ (void)load
{
[NSError setUserInfoValueProviderForDomain:AltServerErrorDomain provider:^id _Nullable(NSError * _Nonnull error, NSErrorUserInfoKey _Nonnull userInfoKey) {
if ([userInfoKey isEqualToString:NSLocalizedDescriptionKey])
{
return [error alt_localizedDescription];
}
return nil;
}];
}
- (nullable NSString *)alt_localizedDescription
{
switch ((ALTServerError)self.code)
{
case ALTServerErrorUnknown:
return NSLocalizedString(@"An unknown error occured.", @"");
case ALTServerErrorConnectionFailed:
return NSLocalizedString(@"Could not connect to AltServer.", @"");
case ALTServerErrorLostConnection:
return NSLocalizedString(@"Lost connection to AltServer.", @"");
case ALTServerErrorDeviceNotFound:
return NSLocalizedString(@"AltServer could not find this device.", @"");
case ALTServerErrorDeviceWriteFailed:
return NSLocalizedString(@"Failed to write app data to device.", @"");
case ALTServerErrorInvalidRequest:
return NSLocalizedString(@"AltServer received an invalid request.", @"");
case ALTServerErrorInvalidResponse:
return NSLocalizedString(@"AltServer sent an invalid response.", @"");
case ALTServerErrorInvalidApp:
return NSLocalizedString(@"The app is invalid.", @"");
case ALTServerErrorInstallationFailed:
return NSLocalizedString(@"An error occured while installing the app.", @"");
case ALTServerErrorMaximumFreeAppLimitReached:
return NSLocalizedString(@"You have reached the limit of 3 apps per device.", @"");
case ALTServerErrorUnsupportediOSVersion:
return NSLocalizedString(@"Your device must be running iOS 12.2 or later to install AltStore.", @"");
case ALTServerErrorUnknownRequest:
return NSLocalizedString(@"AltServer does not support this request.", @"");
case ALTServerErrorUnknownResponse:
return NSLocalizedString(@"Received an unknown response from AltServer.", @"");
case ALTServerErrorInvalidAnisetteData:
return NSLocalizedString(@"Invalid anisette data.", @"");
case ALTServerErrorPluginNotFound:
return NSLocalizedString(@"Could not connect to Mail plug-in. Please make sure the plug-in is installed and Mail is running, then try again.", @"");
}
}
@end

253
AltKit/ServerProtocol.swift Normal file
View File

@@ -0,0 +1,253 @@
//
// ServerProtocol.swift
// AltServer
//
// Created by Riley Testut on 5/24/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import AltSign
public let ALTServerServiceType = "_altserver._tcp"
// Can only automatically conform ALTServerError.Code to Codable, not ALTServerError itself
extension ALTServerError.Code: Codable {}
protocol ServerMessageProtocol: Codable
{
var version: Int { get }
var identifier: String { get }
}
public enum ServerRequest: Decodable
{
case anisetteData(AnisetteDataRequest)
case prepareApp(PrepareAppRequest)
case beginInstallation(BeginInstallationRequest)
case unknown(identifier: String, version: Int)
var identifier: String {
switch self
{
case .anisetteData(let request): return request.identifier
case .prepareApp(let request): return request.identifier
case .beginInstallation(let request): return request.identifier
case .unknown(let identifier, _): return identifier
}
}
var version: Int {
switch self
{
case .anisetteData(let request): return request.version
case .prepareApp(let request): return request.version
case .beginInstallation(let request): return request.version
case .unknown(_, let version): return version
}
}
private enum CodingKeys: String, CodingKey
{
case identifier
case version
}
public init(from decoder: Decoder) throws
{
let container = try decoder.container(keyedBy: CodingKeys.self)
let version = try container.decode(Int.self, forKey: .version)
let identifier = try container.decode(String.self, forKey: .identifier)
switch identifier
{
case "AnisetteDataRequest":
let request = try AnisetteDataRequest(from: decoder)
self = .anisetteData(request)
case "PrepareAppRequest":
let request = try PrepareAppRequest(from: decoder)
self = .prepareApp(request)
case "BeginInstallationRequest":
let request = try BeginInstallationRequest(from: decoder)
self = .beginInstallation(request)
default:
self = .unknown(identifier: identifier, version: version)
}
}
}
public enum ServerResponse: Decodable
{
case anisetteData(AnisetteDataResponse)
case installationProgress(InstallationProgressResponse)
case error(ErrorResponse)
case unknown(identifier: String, version: Int)
var identifier: String {
switch self
{
case .anisetteData(let response): return response.identifier
case .installationProgress(let response): return response.identifier
case .error(let response): return response.identifier
case .unknown(let identifier, _): return identifier
}
}
var version: Int {
switch self
{
case .anisetteData(let response): return response.version
case .installationProgress(let response): return response.version
case .error(let response): return response.version
case .unknown(_, let version): return version
}
}
private enum CodingKeys: String, CodingKey
{
case identifier
case version
}
public init(from decoder: Decoder) throws
{
let container = try decoder.container(keyedBy: CodingKeys.self)
let version = try container.decode(Int.self, forKey: .version)
let identifier = try container.decode(String.self, forKey: .identifier)
switch identifier
{
case "AnisetteDataResponse":
let response = try AnisetteDataResponse(from: decoder)
self = .anisetteData(response)
case "InstallationProgressResponse":
let response = try InstallationProgressResponse(from: decoder)
self = .installationProgress(response)
case "ErrorResponse":
let response = try ErrorResponse(from: decoder)
self = .error(response)
default:
self = .unknown(identifier: identifier, version: version)
}
}
}
public struct AnisetteDataRequest: ServerMessageProtocol
{
public var version = 1
public var identifier = "AnisetteDataRequest"
public init()
{
}
}
public struct AnisetteDataResponse: ServerMessageProtocol
{
public var version = 1
public var identifier = "AnisetteDataResponse"
public var anisetteData: ALTAnisetteData
private enum CodingKeys: String, CodingKey
{
case identifier
case version
case anisetteData
}
public init(anisetteData: ALTAnisetteData)
{
self.anisetteData = anisetteData
}
public init(from decoder: Decoder) throws
{
let container = try decoder.container(keyedBy: CodingKeys.self)
self.version = try container.decode(Int.self, forKey: .version)
self.identifier = try container.decode(String.self, forKey: .identifier)
let json = try container.decode([String: String].self, forKey: .anisetteData)
if let anisetteData = ALTAnisetteData(json: json)
{
self.anisetteData = anisetteData
}
else
{
throw DecodingError.dataCorruptedError(forKey: CodingKeys.anisetteData, in: container, debugDescription: "Couuld not parse anisette data from JSON")
}
}
public func encode(to encoder: Encoder) throws
{
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.version, forKey: .version)
try container.encode(self.identifier, forKey: .identifier)
let json = self.anisetteData.json()
try container.encode(json, forKey: .anisetteData)
}
}
public struct PrepareAppRequest: ServerMessageProtocol
{
public var version = 1
public var identifier = "PrepareAppRequest"
public var udid: String
public var contentSize: Int
public init(udid: String, contentSize: Int)
{
self.udid = udid
self.contentSize = contentSize
}
}
public struct BeginInstallationRequest: ServerMessageProtocol
{
public var version = 1
public var identifier = "BeginInstallationRequest"
public init()
{
}
}
public struct ErrorResponse: ServerMessageProtocol
{
public var version = 1
public var identifier = "ErrorResponse"
public var error: ALTServerError {
return ALTServerError(self.errorCode)
}
private var errorCode: ALTServerError.Code
public init(error: ALTServerError)
{
self.errorCode = error.code
}
}
public struct InstallationProgressResponse: ServerMessageProtocol
{
public var version = 1
public var identifier = "InstallationProgressResponse"
public var progress: Double
public init(progress: Double)
{
self.progress = progress
}
}

View File

@@ -0,0 +1,19 @@
//
// ALTPluginService.h
// AltPlugin
//
// Created by Riley Testut on 11/14/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface ALTPluginService : NSObject
+ (instancetype)sharedService;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,99 @@
//
// ALTPluginService.m
// AltPlugin
//
// Created by Riley Testut on 11/14/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
#import "ALTPluginService.h"
#import <dlfcn.h>
#import "ALTAnisetteData.h"
@import AppKit;
@interface AKAppleIDSession : NSObject
- (id)appleIDHeadersForRequest:(id)arg1;
@end
@interface AKDevice
+ (AKDevice *)currentDevice;
- (NSString *)uniqueDeviceIdentifier;
- (NSString *)serialNumber;
- (NSString *)serverFriendlyDescription;
@end
@interface ALTPluginService ()
@property (nonatomic, readonly) NSISO8601DateFormatter *dateFormatter;
@end
@implementation ALTPluginService
+ (instancetype)sharedService
{
static ALTPluginService *_service = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_service = [[self alloc] init];
});
return _service;
}
- (instancetype)init
{
self = [super init];
if (self)
{
_dateFormatter = [[NSISO8601DateFormatter alloc] init];
}
return self;
}
+ (void)initialize
{
[[ALTPluginService sharedService] start];
}
- (void)start
{
dlopen("/System/Library/PrivateFrameworks/AuthKit.framework/AuthKit", RTLD_NOW);
[[NSDistributedNotificationCenter defaultCenter] addObserver:self selector:@selector(receiveNotification:) name:@"com.rileytestut.AltServer.FetchAnisetteData" object:nil];
}
- (void)receiveNotification:(NSNotification *)notification
{
NSString *requestUUID = notification.userInfo[@"requestUUID"];
NSMutableURLRequest* req = [[NSMutableURLRequest alloc] initWithURL:[[NSURL alloc] initWithString:@"https://developerservices2.apple.com/services/QH65B2/listTeams.action?clientId=XABBG36SBA"]];
[req setHTTPMethod:@"POST"];
AKAppleIDSession *session = [[NSClassFromString(@"AKAppleIDSession") alloc] initWithIdentifier:@"com.apple.gs.xcode.auth"];
NSDictionary *headers = [session appleIDHeadersForRequest:req];
AKDevice *device = [NSClassFromString(@"AKDevice") currentDevice];
NSDate *date = [self.dateFormatter dateFromString:headers[@"X-Apple-I-Client-Time"]];
ALTAnisetteData *anisetteData = [[NSClassFromString(@"ALTAnisetteData") alloc] initWithMachineID:headers[@"X-Apple-I-MD-M"]
oneTimePassword:headers[@"X-Apple-I-MD"]
localUserID:headers[@"X-Apple-I-MD-LU"]
routingInfo:[headers[@"X-Apple-I-MD-RINFO"] longLongValue]
deviceUniqueIdentifier:device.uniqueDeviceIdentifier
deviceSerialNumber:device.serialNumber
deviceDescription:device.serverFriendlyDescription
date:date
locale:[NSLocale currentLocale]
timeZone:[NSTimeZone localTimeZone]];
NSData *data = [NSKeyedArchiver archivedDataWithRootObject:anisetteData requiringSecureCoding:YES error:nil];
[[NSDistributedNotificationCenter defaultCenter] postNotificationName:@"com.rileytestut.AltServer.AnisetteDataResponse" object:nil userInfo:@{@"requestUUID": requestUUID, @"anisetteData": data} deliverImmediately:YES];
}
@end

62
AltPlugin/Info.plist Normal file
View File

@@ -0,0 +1,62 @@
<?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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2019 Riley Testut. All rights reserved.</string>
<key>NSPrincipalClass</key>
<string>ALTPluginService</string>
<key>Supported10.14PluginCompatibilityUUIDs</key>
<array>
<string># UUIDs for versions from 10.12 to 99.99.99</string>
<string># For mail version 10.0 (3226) on OS X Version 10.12 (build 16A319)</string>
<string>36CCB8BB-2207-455E-89BC-B9D6E47ABB5B</string>
<string># For mail version 10.1 (3251) on OS X Version 10.12.1 (build 16B2553a)</string>
<string>9054AFD9-2607-489E-8E63-8B09A749BC61</string>
<string># For mail version 10.2 (3259) on OS X Version 10.12.2 (build 16D12b)</string>
<string>1CD3B36A-0E3B-4A26-8F7E-5BDF96AAC97E</string>
<string># For mail version 10.3 (3273) on OS X Version 10.12.4 (build 16G1036)</string>
<string>21560BD9-A3CC-482E-9B99-95B7BF61EDC1</string>
<string># For mail version 11.0 (3441.0.1) on OS X Version 10.13 (build 17A315i)</string>
<string>C86CD990-4660-4E36-8CDA-7454DEB2E199</string>
<string># For mail version 12.0 (3445.100.39) on OS X Version 10.14.1 (build 18B45d)</string>
<string>A4343FAF-AE18-40D0-8A16-DFAE481AF9C1</string>
<string># For mail version 13.0 (3594.4.2) on OS X Version 10.15 (build 19A558d)</string>
<string>6EEA38FB-1A0B-469B-BB35-4C2E0EEA9053</string>
</array>
<key>Supported10.15PluginCompatibilityUUIDs</key>
<array>
<string># UUIDs for versions from 10.12 to 99.99.99</string>
<string># For mail version 10.0 (3226) on OS X Version 10.12 (build 16A319)</string>
<string>36CCB8BB-2207-455E-89BC-B9D6E47ABB5B</string>
<string># For mail version 10.1 (3251) on OS X Version 10.12.1 (build 16B2553a)</string>
<string>9054AFD9-2607-489E-8E63-8B09A749BC61</string>
<string># For mail version 10.2 (3259) on OS X Version 10.12.2 (build 16D12b)</string>
<string>1CD3B36A-0E3B-4A26-8F7E-5BDF96AAC97E</string>
<string># For mail version 10.3 (3273) on OS X Version 10.12.4 (build 16G1036)</string>
<string>21560BD9-A3CC-482E-9B99-95B7BF61EDC1</string>
<string># For mail version 11.0 (3441.0.1) on OS X Version 10.13 (build 17A315i)</string>
<string>C86CD990-4660-4E36-8CDA-7454DEB2E199</string>
<string># For mail version 12.0 (3445.100.39) on OS X Version 10.14.1 (build 18B45d)</string>
<string>A4343FAF-AE18-40D0-8A16-DFAE481AF9C1</string>
<string># For mail version 13.0 (3594.4.2) on OS X Version 10.15 (build 19A558d)</string>
<string>6EEA38FB-1A0B-469B-BB35-4C2E0EEA9053</string>
</array>
</dict>
</plist>

Binary file not shown.

View File

@@ -0,0 +1,5 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
#import "ALTDeviceManager.h"

View File

@@ -0,0 +1,5 @@
<?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/>
</plist>

View File

@@ -0,0 +1,79 @@
//
// AnisetteDataManager.swift
// AltServer
//
// Created by Riley Testut on 11/16/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import AltKit
class AnisetteDataManager: NSObject
{
static let shared = AnisetteDataManager()
private var anisetteDataCompletionHandlers: [String: (Result<ALTAnisetteData, Error>) -> Void] = [:]
private var anisetteDataTimers: [String: Timer] = [:]
private override init()
{
super.init()
DistributedNotificationCenter.default().addObserver(self, selector: #selector(AnisetteDataManager.handleAnisetteDataResponse(_:)), name: Notification.Name("com.rileytestut.AltServer.AnisetteDataResponse"), object: nil)
}
func requestAnisetteData(_ completion: @escaping (Result<ALTAnisetteData, Error>) -> Void)
{
let requestUUID = UUID().uuidString
self.anisetteDataCompletionHandlers[requestUUID] = completion
let timer = Timer(timeInterval: 1.0, repeats: false) { (timer) in
self.finishRequest(forUUID: requestUUID, result: .failure(ALTServerError(.pluginNotFound)))
}
self.anisetteDataTimers[requestUUID] = timer
RunLoop.main.add(timer, forMode: .default)
DistributedNotificationCenter.default().postNotificationName(Notification.Name("com.rileytestut.AltServer.FetchAnisetteData"), object: nil, userInfo: ["requestUUID": requestUUID], options: .deliverImmediately)
}
}
private extension AnisetteDataManager
{
@objc func handleAnisetteDataResponse(_ notification: Notification)
{
guard let userInfo = notification.userInfo, let requestUUID = userInfo["requestUUID"] as? String else { return }
if
let archivedAnisetteData = userInfo["anisetteData"] as? Data,
let anisetteData = try? NSKeyedUnarchiver.unarchivedObject(ofClass: ALTAnisetteData.self, from: archivedAnisetteData)
{
if let range = anisetteData.deviceDescription.lowercased().range(of: "(com.apple.mail")
{
var adjustedDescription = anisetteData.deviceDescription[..<range.lowerBound]
adjustedDescription += "(com.apple.dt.Xcode/3594.4.19)>"
anisetteData.deviceDescription = String(adjustedDescription)
}
self.finishRequest(forUUID: requestUUID, result: .success(anisetteData))
}
else
{
self.finishRequest(forUUID: requestUUID, result: .failure(ALTServerError(.invalidAnisetteData)))
}
}
func finishRequest(forUUID requestUUID: String, result: Result<ALTAnisetteData, Error>)
{
let completionHandler = self.anisetteDataCompletionHandlers[requestUUID]
self.anisetteDataCompletionHandlers[requestUUID] = nil
let timer = self.anisetteDataTimers[requestUUID]
self.anisetteDataTimers[requestUUID] = nil
timer?.invalidate()
completionHandler?(result)
}
}

377
AltServer/AppDelegate.swift Normal file
View File

@@ -0,0 +1,377 @@
//
// AppDelegate.swift
// AltServer
//
// Created by Riley Testut on 5/24/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Cocoa
import UserNotifications
import AltSign
import LaunchAtLogin
import STPrivilegedTask
enum PluginError: LocalizedError
{
case installationScriptNotFound
case failedToRun(Int)
case scriptError(String)
var errorDescription: String? {
switch self
{
case .installationScriptNotFound: return NSLocalizedString("The installation script could not be found.", comment: "")
case .failedToRun(let errorCode): return String(format: NSLocalizedString("The installation script could not be run. (%@)", comment: ""), NSNumber(value: errorCode))
case .scriptError(let output): return output
}
}
}
private let pluginURL = URL(fileURLWithPath: "/Library/Mail/Bundles/AltPlugin.mailbundle")
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem?
private var connectedDevices = [ALTDevice]()
private weak var authenticationAlert: NSAlert?
@IBOutlet private var appMenu: NSMenu!
@IBOutlet private var connectedDevicesMenu: NSMenu!
@IBOutlet private var launchAtLoginMenuItem: NSMenuItem!
@IBOutlet private var installMailPluginMenuItem: NSMenuItem!
private weak var authenticationAppleIDTextField: NSTextField?
private weak var authenticationPasswordTextField: NSSecureTextField?
private var isMailPluginInstalled: Bool {
let isMailPluginInstalled = FileManager.default.fileExists(atPath: pluginURL.path)
return isMailPluginInstalled
}
func applicationDidFinishLaunching(_ aNotification: Notification)
{
UserDefaults.standard.registerDefaults()
UNUserNotificationCenter.current().delegate = self
ConnectionManager.shared.start()
let item = NSStatusBar.system.statusItem(withLength: -1)
guard let button = item.button else { return }
button.image = NSImage(named: "MenuBarIcon")
button.target = self
button.action = #selector(AppDelegate.presentMenu)
self.statusItem = item
self.connectedDevicesMenu.delegate = self
UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { (success, error) in
guard success else { return }
if !UserDefaults.standard.didPresentInitialNotification
{
let content = UNMutableNotificationContent()
content.title = NSLocalizedString("AltServer Running", comment: "")
content.body = NSLocalizedString("AltServer runs in the background as a menu bar app listening for AltStore.", comment: "")
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
UserDefaults.standard.didPresentInitialNotification = true
}
}
}
func applicationWillTerminate(_ aNotification: Notification)
{
// Insert code here to tear down your application
}
}
private extension AppDelegate
{
@objc func presentMenu()
{
guard let button = self.statusItem?.button, let superview = button.superview, let window = button.window else { return }
self.connectedDevices = ALTDeviceManager.shared.connectedDevices
self.launchAtLoginMenuItem.state = LaunchAtLogin.isEnabled ? .on : .off
self.launchAtLoginMenuItem.action = #selector(AppDelegate.toggleLaunchAtLogin(_:))
if FileManager.default.fileExists(atPath: pluginURL.path)
{
self.installMailPluginMenuItem.title = NSLocalizedString("Uninstall Mail Plug-in", comment: "")
}
else
{
self.installMailPluginMenuItem.title = NSLocalizedString("Install Mail Plug-in", comment: "")
}
self.installMailPluginMenuItem.target = self
self.installMailPluginMenuItem.action = #selector(AppDelegate.handleInstallMailPluginMenuItem(_:))
let x = button.frame.origin.x
let y = button.frame.origin.y - 5
let location = superview.convert(NSMakePoint(x, y), to: nil)
guard let event = NSEvent.mouseEvent(with: .leftMouseUp, location: location,
modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil,
eventNumber: 0, clickCount: 1, pressure: 0)
else { return }
NSMenu.popUpContextMenu(self.appMenu, with: event, for: button)
}
@objc func installAltStore(_ item: NSMenuItem)
{
guard case let index = self.connectedDevicesMenu.index(of: item), index != -1 else { return }
let alert = NSAlert()
alert.messageText = NSLocalizedString("Please enter your Apple ID and password.", comment: "")
alert.informativeText = NSLocalizedString("Your Apple ID and password are not saved and are only sent to Apple for authentication.", comment: "")
let textFieldSize = NSSize(width: 300, height: 22)
let appleIDTextField = NSTextField(frame: NSRect(x: 0, y: 0, width: textFieldSize.width, height: textFieldSize.height))
appleIDTextField.delegate = self
appleIDTextField.translatesAutoresizingMaskIntoConstraints = false
appleIDTextField.placeholderString = NSLocalizedString("Apple ID", comment: "")
alert.window.initialFirstResponder = appleIDTextField
self.authenticationAppleIDTextField = appleIDTextField
let passwordTextField = NSSecureTextField(frame: NSRect(x: 0, y: 0, width: textFieldSize.width, height: textFieldSize.height))
passwordTextField.delegate = self
passwordTextField.translatesAutoresizingMaskIntoConstraints = false
passwordTextField.placeholderString = NSLocalizedString("Password", comment: "")
self.authenticationPasswordTextField = passwordTextField
appleIDTextField.nextKeyView = passwordTextField
let stackView = NSStackView(frame: NSRect(x: 0, y: 0, width: textFieldSize.width, height: textFieldSize.height * 2))
stackView.orientation = .vertical
stackView.distribution = .equalSpacing
stackView.spacing = 0
stackView.addArrangedSubview(appleIDTextField)
stackView.addArrangedSubview(passwordTextField)
alert.accessoryView = stackView
alert.addButton(withTitle: NSLocalizedString("Install", comment: ""))
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: ""))
self.authenticationAlert = alert
self.validate()
NSRunningApplication.current.activate(options: .activateIgnoringOtherApps)
let response = alert.runModal()
guard response == .alertFirstButtonReturn else { return }
let username = appleIDTextField.stringValue
let password = passwordTextField.stringValue
let device = self.connectedDevices[index]
if !self.isMailPluginInstalled
{
let result = self.installMailPlugin()
guard result else { return }
}
ALTDeviceManager.shared.installAltStore(to: device, appleID: username, password: password) { (result) in
switch result
{
case .success:
let content = UNMutableNotificationContent()
content.title = NSLocalizedString("Installation Succeeded", comment: "")
content.body = String(format: NSLocalizedString("AltStore was successfully installed on %@.", comment: ""), device.name)
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
case .failure(InstallError.cancelled), .failure(ALTAppleAPIError.requiresTwoFactorAuthentication):
// Ignore
break
case .failure(let error as NSError):
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = NSLocalizedString("Installation Failed", comment: "")
if let underlyingError = error.userInfo[NSUnderlyingErrorKey] as? Error
{
alert.informativeText = underlyingError.localizedDescription
}
else
{
alert.informativeText = error.localizedDescription
}
NSRunningApplication.current.activate(options: .activateIgnoringOtherApps)
alert.runModal()
}
}
}
@objc func toggleLaunchAtLogin(_ item: NSMenuItem)
{
if item.state == .on
{
item.state = .off
}
else
{
item.state = .on
}
LaunchAtLogin.isEnabled.toggle()
}
@objc func handleInstallMailPluginMenuItem(_ item: NSMenuItem)
{
installMailPlugin()
}
@discardableResult
func installMailPlugin() -> Bool
{
do
{
let previouslyInstalled = self.isMailPluginInstalled
if !previouslyInstalled
{
let alert = NSAlert()
alert.messageText = NSLocalizedString("Install Mail Plug-in", comment: "")
alert.informativeText = NSLocalizedString("AltServer requires a Mail plug-in in order to retrieve necessary information about your Apple ID. Would you like to install it now?", comment: "")
alert.addButton(withTitle: NSLocalizedString("Install Plug-in", comment: ""))
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: ""))
NSRunningApplication.current.activate(options: .activateIgnoringOtherApps)
let response = alert.runModal()
guard response == .alertFirstButtonReturn else { return false }
}
guard let scriptURL = Bundle.main.url(forResource: self.isMailPluginInstalled ? "UninstallPlugin" : "InstallPlugin", withExtension: "sh") else { throw PluginError.installationScriptNotFound }
try FileManager.default.setAttributes([.posixPermissions: 0o777], ofItemAtPath: scriptURL.path)
let task = STPrivilegedTask()
task.setLaunchPath(scriptURL.path)
task.setCurrentDirectoryPath(scriptURL.deletingLastPathComponent().path)
let errorCode = task.launch()
guard errorCode == 0 else { throw PluginError.failedToRun(Int(errorCode)) }
task.waitUntilExit()
if
let outputData = task.outputFileHandle()?.readDataToEndOfFile(),
let outputString = String(data: outputData, encoding: .utf8), !outputString.isEmpty
{
throw PluginError.scriptError(outputString)
}
if !previouslyInstalled && self.isMailPluginInstalled
{
let alert = NSAlert()
alert.messageText = NSLocalizedString("Mail Plug-in Installed", comment: "")
alert.informativeText = NSLocalizedString("Please restart Mail and enable AltPlugin in Mail's Preferences. Mail must be running when installing or refreshing apps with AltServer.", comment: "")
alert.runModal()
}
return true
}
catch
{
let alert = NSAlert()
alert.messageText = self.isMailPluginInstalled ? NSLocalizedString("Failed to Uninstall Mail Plug-in", comment: "") : NSLocalizedString("Failed to Install Mail Plug-in", comment: "")
alert.informativeText = error.localizedDescription
alert.runModal()
return false
}
}
}
extension AppDelegate: NSMenuDelegate
{
func numberOfItems(in menu: NSMenu) -> Int
{
return self.connectedDevices.isEmpty ? 1 : self.connectedDevices.count
}
func menu(_ menu: NSMenu, update item: NSMenuItem, at index: Int, shouldCancel: Bool) -> Bool
{
if self.connectedDevices.isEmpty
{
item.title = NSLocalizedString("No Connected Devices", comment: "")
item.isEnabled = false
item.target = nil
item.action = nil
}
else
{
let device = self.connectedDevices[index]
item.title = device.name
item.isEnabled = true
item.target = self
item.action = #selector(AppDelegate.installAltStore)
item.tag = index
}
return true
}
}
extension AppDelegate: NSTextFieldDelegate
{
func controlTextDidChange(_ obj: Notification)
{
self.validate()
}
func controlTextDidEndEditing(_ obj: Notification)
{
self.validate()
}
private func validate()
{
guard
let appleID = self.authenticationAppleIDTextField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
let password = self.authenticationPasswordTextField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
else { return }
if appleID.isEmpty || password.isEmpty
{
self.authenticationAlert?.buttons.first?.isEnabled = false
}
else
{
self.authenticationAlert?.buttons.first?.isEnabled = true
}
self.authenticationAlert?.layout()
}
}
extension AppDelegate: UNUserNotificationCenterDelegate
{
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
{
completionHandler([.alert, .sound, .badge])
}
}

View File

@@ -0,0 +1,68 @@
{
"images" : [
{
"size" : "16x16",
"idiom" : "mac",
"filename" : "Icon@16.png",
"scale" : "1x"
},
{
"size" : "16x16",
"idiom" : "mac",
"filename" : "Icon@32-1.png",
"scale" : "2x"
},
{
"size" : "32x32",
"idiom" : "mac",
"filename" : "Icon@32.png",
"scale" : "1x"
},
{
"size" : "32x32",
"idiom" : "mac",
"filename" : "Icon@64.png",
"scale" : "2x"
},
{
"size" : "128x128",
"idiom" : "mac",
"filename" : "Icon@128.png",
"scale" : "1x"
},
{
"size" : "128x128",
"idiom" : "mac",
"filename" : "Icon@256-1.png",
"scale" : "2x"
},
{
"size" : "256x256",
"idiom" : "mac",
"filename" : "Icon@256.png",
"scale" : "1x"
},
{
"size" : "256x256",
"idiom" : "mac",
"filename" : "Icon@512-1.png",
"scale" : "2x"
},
{
"size" : "512x512",
"idiom" : "mac",
"filename" : "Icon@512.png",
"scale" : "1x"
},
{
"size" : "512x512",
"idiom" : "mac",
"filename" : "Icon@1024.png",
"scale" : "2x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}

View File

@@ -1,23 +1,25 @@
{
"images" : [
{
"filename" : "1024.png",
"idiom" : "universal",
"filename" : "MenuBar@19.png",
"scale" : "1x"
},
{
"filename" : "1024.png",
"idiom" : "universal",
"filename" : "MenuBar@38.png",
"scale" : "2x"
},
{
"filename" : "1024.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
"version" : 1,
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1,364 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="15504" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="15504"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Application-->
<scene sceneID="JPo-4y-FX3">
<objects>
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
<stackView distribution="fill" orientation="vertical" alignment="leading" spacing="4" horizontalStackHuggingPriority="249.99998474121094" verticalStackHuggingPriority="249.99998474121094" detachesHiddenViews="YES" id="urc-xw-Dhc">
<rect key="frame" x="0.0" y="0.0" width="300" height="46"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<subviews>
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="zLd-d8-ghZ">
<rect key="frame" x="0.0" y="25" width="300" height="21"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Apple ID" drawsBackground="YES" id="BXa-Re-rs3">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
<connections>
<outlet property="delegate" destination="Voe-Tx-rLC" id="QtW-r2-Vuh"/>
<outlet property="nextKeyView" destination="9rp-Vx-rvB" id="bQY-qj-Sej"/>
</connections>
</textField>
<secureTextField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="9rp-Vx-rvB">
<rect key="frame" x="0.0" y="0.0" width="300" height="21"/>
<secureTextFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" placeholderString="Password" drawsBackground="YES" usesSingleLineMode="YES" id="xqJ-wt-DlP">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
<allowedInputSourceLocales>
<string>NSAllRomanInputSourcesLocaleIdentifier</string>
</allowedInputSourceLocales>
</secureTextFieldCell>
<connections>
<outlet property="delegate" destination="Voe-Tx-rLC" id="qav-xj-izy"/>
</connections>
</secureTextField>
</subviews>
<constraints>
<constraint firstItem="9rp-Vx-rvB" firstAttribute="width" secondItem="urc-xw-Dhc" secondAttribute="width" id="Eht-pU-Gyh"/>
<constraint firstItem="zLd-d8-ghZ" firstAttribute="width" secondItem="urc-xw-Dhc" secondAttribute="width" id="mg7-Kq-abL"/>
<constraint firstAttribute="width" constant="300" id="zqf-x6-BET"/>
</constraints>
<visibilityPriorities>
<integer value="1000"/>
<integer value="1000"/>
</visibilityPriorities>
<customSpacing>
<real value="3.4028234663852886e+38"/>
<real value="3.4028234663852886e+38"/>
</customSpacing>
</stackView>
<customObject id="Ady-hI-5gd" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="AltServer" customModuleProvider="target">
<connections>
<outlet property="appMenu" destination="uQy-DD-JDr" id="7cY-Ov-AOW"/>
<outlet property="authenticationAppleIDTextField" destination="zLd-d8-ghZ" id="wW5-0J-zdq"/>
<outlet property="authenticationPasswordTextField" destination="9rp-Vx-rvB" id="ZoC-DI-jzQ"/>
<outlet property="connectedDevicesMenu" destination="KJ9-WY-pW1" id="Mcv-64-iFU"/>
<outlet property="installMailPluginMenuItem" destination="3CM-gV-X2G" id="lio-ha-z0S"/>
<outlet property="launchAtLoginMenuItem" destination="IyR-FQ-upe" id="Fxn-EP-hwH"/>
</connections>
</customObject>
<customObject id="Arf-IC-5eb" customClass="SUUpdater"/>
<application id="hnw-xV-0zn" sceneMemberID="viewController">
<menu key="mainMenu" title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
<items>
<menuItem title="AltServer" id="1Xt-HY-uBw">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="AltServer" systemMenu="apple" id="uQy-DD-JDr">
<items>
<menuItem title="About AltServer" id="5kV-Vb-QxS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="orderFrontStandardAboutPanel:" target="Ady-hI-5gd" id="Exp-CZ-Vem"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="wFC-TO-SCJ"/>
<menuItem title="Install AltStore" id="MJ8-Lt-SSV">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Install AltStore" systemMenu="recentDocuments" id="KJ9-WY-pW1">
<items>
<menuItem title="No Connected Devices" id="N5N-3K-XuR">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="clearRecentDocuments:" target="Ady-hI-5gd" id="DKG-yI-Ujv"/>
</connections>
</menuItem>
</items>
<connections>
<outlet property="delegate" destination="Voe-Tx-rLC" id="VYb-BL-Zri"/>
</connections>
</menu>
</menuItem>
<menuItem isSeparatorItem="YES" id="1ZZ-BB-xHy"/>
<menuItem title="Launch at Login" id="IyR-FQ-upe" userLabel="Launch At Login">
<modifierMask key="keyEquivalentModifierMask"/>
</menuItem>
<menuItem title="Install Mail Plug-in" id="3CM-gV-X2G" userLabel="Mail Plug-in">
<modifierMask key="keyEquivalentModifierMask"/>
</menuItem>
<menuItem isSeparatorItem="YES" id="mVM-Nm-Zi9"/>
<menuItem title="Check for Updates..." id="Tnq-gD-Eic" userLabel="Check for Updates">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="checkForUpdates:" target="Arf-IC-5eb" id="7JG-du-nr4"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="hmG-xg-qgm"/>
<menuItem title="Quit AltServer" keyEquivalent="q" id="4sb-4s-VLi">
<connections>
<action selector="terminate:" target="Ady-hI-5gd" id="Te7-pn-YzF"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Edit" id="5QF-Oa-p0T">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Edit" id="W48-6f-4Dl">
<items>
<menuItem title="Undo" keyEquivalent="z" id="dRJ-4n-Yzg">
<connections>
<action selector="undo:" target="Ady-hI-5gd" id="M6e-cu-g7V"/>
</connections>
</menuItem>
<menuItem title="Redo" keyEquivalent="Z" id="6dh-zS-Vam">
<connections>
<action selector="redo:" target="Ady-hI-5gd" id="oIA-Rs-6OD"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="WRV-NI-Exz"/>
<menuItem title="Cut" keyEquivalent="x" id="uRl-iY-unG">
<connections>
<action selector="cut:" target="Ady-hI-5gd" id="YJe-68-I9s"/>
</connections>
</menuItem>
<menuItem title="Copy" keyEquivalent="c" id="x3v-GG-iWU">
<connections>
<action selector="copy:" target="Ady-hI-5gd" id="G1f-GL-Joy"/>
</connections>
</menuItem>
<menuItem title="Paste" keyEquivalent="v" id="gVA-U4-sdL">
<connections>
<action selector="paste:" target="Ady-hI-5gd" id="UvS-8e-Qdg"/>
</connections>
</menuItem>
<menuItem title="Paste and Match Style" keyEquivalent="V" id="WeT-3V-zwk">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="pasteAsPlainText:" target="Ady-hI-5gd" id="cEh-KX-wJQ"/>
</connections>
</menuItem>
<menuItem title="Delete" id="pa3-QI-u2k">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="delete:" target="Ady-hI-5gd" id="0Mk-Ml-PaM"/>
</connections>
</menuItem>
<menuItem title="Select All" keyEquivalent="a" id="Ruw-6m-B2m">
<connections>
<action selector="selectAll:" target="Ady-hI-5gd" id="VNm-Mi-diN"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="uyl-h8-XO2"/>
<menuItem title="Find" id="4EN-yA-p0u">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Find" id="1b7-l0-nxx">
<items>
<menuItem title="Find…" tag="1" keyEquivalent="f" id="Xz5-n4-O0W">
<connections>
<action selector="performFindPanelAction:" target="Ady-hI-5gd" id="cD7-Qs-BN4"/>
</connections>
</menuItem>
<menuItem title="Find and Replace…" tag="12" keyEquivalent="f" id="YEy-JH-Tfz">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="performFindPanelAction:" target="Ady-hI-5gd" id="WD3-Gg-5AJ"/>
</connections>
</menuItem>
<menuItem title="Find Next" tag="2" keyEquivalent="g" id="q09-fT-Sye">
<connections>
<action selector="performFindPanelAction:" target="Ady-hI-5gd" id="NDo-RZ-v9R"/>
</connections>
</menuItem>
<menuItem title="Find Previous" tag="3" keyEquivalent="G" id="OwM-mh-QMV">
<connections>
<action selector="performFindPanelAction:" target="Ady-hI-5gd" id="HOh-sY-3ay"/>
</connections>
</menuItem>
<menuItem title="Use Selection for Find" tag="7" keyEquivalent="e" id="buJ-ug-pKt">
<connections>
<action selector="performFindPanelAction:" target="Ady-hI-5gd" id="U76-nv-p5D"/>
</connections>
</menuItem>
<menuItem title="Jump to Selection" keyEquivalent="j" id="S0p-oC-mLd">
<connections>
<action selector="centerSelectionInVisibleArea:" target="Ady-hI-5gd" id="IOG-6D-g5B"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Spelling and Grammar" id="Dv1-io-Yv7">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Spelling" id="3IN-sU-3Bg">
<items>
<menuItem title="Show Spelling and Grammar" keyEquivalent=":" id="HFo-cy-zxI">
<connections>
<action selector="showGuessPanel:" target="Ady-hI-5gd" id="vFj-Ks-hy3"/>
</connections>
</menuItem>
<menuItem title="Check Document Now" keyEquivalent=";" id="hz2-CU-CR7">
<connections>
<action selector="checkSpelling:" target="Ady-hI-5gd" id="fz7-VC-reM"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="bNw-od-mp5"/>
<menuItem title="Check Spelling While Typing" id="rbD-Rh-wIN">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleContinuousSpellChecking:" target="Ady-hI-5gd" id="7w6-Qz-0kB"/>
</connections>
</menuItem>
<menuItem title="Check Grammar With Spelling" id="mK6-2p-4JG">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleGrammarChecking:" target="Ady-hI-5gd" id="muD-Qn-j4w"/>
</connections>
</menuItem>
<menuItem title="Correct Spelling Automatically" id="78Y-hA-62v">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticSpellingCorrection:" target="Ady-hI-5gd" id="2lM-Qi-WAP"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Substitutions" id="9ic-FL-obx">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Substitutions" id="FeM-D8-WVr">
<items>
<menuItem title="Show Substitutions" id="z6F-FW-3nz">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="orderFrontSubstitutionsPanel:" target="Ady-hI-5gd" id="oku-mr-iSq"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="gPx-C9-uUO"/>
<menuItem title="Smart Copy/Paste" id="9yt-4B-nSM">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleSmartInsertDelete:" target="Ady-hI-5gd" id="3IJ-Se-DZD"/>
</connections>
</menuItem>
<menuItem title="Smart Quotes" id="hQb-2v-fYv">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticQuoteSubstitution:" target="Ady-hI-5gd" id="ptq-xd-QOA"/>
</connections>
</menuItem>
<menuItem title="Smart Dashes" id="rgM-f4-ycn">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticDashSubstitution:" target="Ady-hI-5gd" id="oCt-pO-9gS"/>
</connections>
</menuItem>
<menuItem title="Smart Links" id="cwL-P1-jid">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticLinkDetection:" target="Ady-hI-5gd" id="Gip-E3-Fov"/>
</connections>
</menuItem>
<menuItem title="Data Detectors" id="tRr-pd-1PS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticDataDetection:" target="Ady-hI-5gd" id="R1I-Nq-Kbl"/>
</connections>
</menuItem>
<menuItem title="Text Replacement" id="HFQ-gK-NFA">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticTextReplacement:" target="Ady-hI-5gd" id="DvP-Fe-Py6"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Transformations" id="2oI-Rn-ZJC">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Transformations" id="c8a-y6-VQd">
<items>
<menuItem title="Make Upper Case" id="vmV-6d-7jI">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="uppercaseWord:" target="Ady-hI-5gd" id="sPh-Tk-edu"/>
</connections>
</menuItem>
<menuItem title="Make Lower Case" id="d9M-CD-aMd">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="lowercaseWord:" target="Ady-hI-5gd" id="iUZ-b5-hil"/>
</connections>
</menuItem>
<menuItem title="Capitalize" id="UEZ-Bs-lqG">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="capitalizeWord:" target="Ady-hI-5gd" id="26H-TL-nsh"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Speech" id="xrE-MZ-jX0">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Speech" id="3rS-ZA-NoH">
<items>
<menuItem title="Start Speaking" id="Ynk-f8-cLZ">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="startSpeaking:" target="Ady-hI-5gd" id="654-Ng-kyl"/>
</connections>
</menuItem>
<menuItem title="Stop Speaking" id="Oyz-dy-DGm">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="stopSpeaking:" target="Ady-hI-5gd" id="dX8-6p-jy9"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Help" id="wpr-3q-Mcd">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Help" systemMenu="help" id="F2S-fz-NVQ">
<items>
<menuItem title="AltServer Help" keyEquivalent="?" id="FKE-Sm-Kum">
<connections>
<action selector="showHelp:" target="Ady-hI-5gd" id="y7X-2Q-9no"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
</items>
</menu>
<connections>
<outlet property="delegate" destination="Voe-Tx-rLC" id="PrD-fu-P6m"/>
</connections>
</application>
</objects>
<point key="canvasLocation" x="75" y="0.0"/>
</scene>
</scenes>
</document>

View File

@@ -0,0 +1,496 @@
//
// ConnectionManager.swift
// AltServer
//
// Created by Riley Testut on 5/23/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
import Network
import AppKit
import AltKit
extension ALTServerError
{
init<E: Error>(_ error: E)
{
switch error
{
case let error as ALTServerError: self = error
case is DecodingError: self = ALTServerError(.invalidRequest)
case is EncodingError: self = ALTServerError(.invalidResponse)
default:
assertionFailure("Caught unknown error type")
self = ALTServerError(.unknown)
}
}
}
extension ConnectionManager
{
enum State
{
case notRunning
case connecting
case running(NWListener.Service)
case failed(Swift.Error)
}
}
class ConnectionManager
{
static let shared = ConnectionManager()
var stateUpdateHandler: ((State) -> Void)?
private(set) var state: State = .notRunning {
didSet {
self.stateUpdateHandler?(self.state)
}
}
private lazy var listener = self.makeListener()
private let dispatchQueue = DispatchQueue(label: "com.rileytestut.AltServer.connections", qos: .utility)
private var connections = [NWConnection]()
private init()
{
}
func start()
{
switch self.state
{
case .notRunning, .failed: self.listener.start(queue: self.dispatchQueue)
default: break
}
}
func stop()
{
switch self.state
{
case .running: self.listener.cancel()
default: break
}
}
}
private extension ConnectionManager
{
func makeListener() -> NWListener
{
let listener = try! NWListener(using: .tcp)
let service: NWListener.Service
if let serverID = UserDefaults.standard.serverID?.data(using: .utf8)
{
let txtDictionary = ["serverID": serverID]
let txtData = NetService.data(fromTXTRecord: txtDictionary)
service = NWListener.Service(name: nil, type: ALTServerServiceType, domain: nil, txtRecord: txtData)
}
else
{
service = NWListener.Service(type: ALTServerServiceType)
}
listener.service = service
listener.serviceRegistrationUpdateHandler = { (serviceChange) in
switch serviceChange
{
case .add(.service(let name, let type, let domain, _)):
let service = NWListener.Service(name: name, type: type, domain: domain, txtRecord: nil)
self.state = .running(service)
default: break
}
}
listener.stateUpdateHandler = { (state) in
switch state
{
case .ready: break
case .waiting, .setup: self.state = .connecting
case .cancelled: self.state = .notRunning
case .failed(let error):
self.state = .failed(error)
self.start()
@unknown default: break
}
}
listener.newConnectionHandler = { [weak self] (connection) in
self?.awaitRequest(from: connection)
}
return listener
}
func disconnect(_ connection: NWConnection)
{
switch connection.state
{
case .cancelled, .failed:
print("Disconnecting from \(connection.endpoint)...")
if let index = self.connections.firstIndex(where: { $0 === connection })
{
self.connections.remove(at: index)
}
default:
// State update handler will call this method again.
connection.cancel()
}
}
func process(data: Data?, error: NWError?, from connection: NWConnection) throws -> Data
{
do
{
do
{
guard let data = data else { throw error ?? ALTServerError(.unknown) }
return data
}
catch let error as NWError
{
print("Error receiving data from connection \(connection)", error)
throw ALTServerError(.lostConnection)
}
catch
{
throw error
}
}
catch let error as ALTServerError
{
throw error
}
catch
{
preconditionFailure("A non-ALTServerError should never be thrown from this method.")
}
}
}
private extension ConnectionManager
{
func awaitRequest(from connection: NWConnection)
{
guard !self.connections.contains(where: { $0 === connection }) else { return }
self.connections.append(connection)
connection.stateUpdateHandler = { [weak self] (state) in
switch state
{
case .setup, .preparing: break
case .ready:
print("Connected to client:", connection.endpoint)
self?.handleRequest(for: connection)
case .waiting:
print("Waiting for connection...")
case .failed(let error):
print("Failed to connect to service \(connection.endpoint).", error)
self?.disconnect(connection)
case .cancelled:
self?.disconnect(connection)
@unknown default: break
}
}
connection.start(queue: self.dispatchQueue)
}
func handleRequest(for connection: NWConnection)
{
self.receiveRequest(from: connection) { (result) in
print("Received initial request with result:", result)
switch result
{
case .failure(let error):
let response = ErrorResponse(error: ALTServerError(error))
self.send(response, to: connection, shouldDisconnect: true) { (result) in
print("Sent error response with result:", result)
}
case .success(.anisetteData(let request)):
self.handleAnisetteDataRequest(request, for: connection)
case .success(.prepareApp(let request)):
self.handlePrepareAppRequest(request, for: connection)
case .success:
let response = ErrorResponse(error: ALTServerError(.unknownRequest))
self.send(response, to: connection, shouldDisconnect: true) { (result) in
print("Sent unknown request response with result:", result)
}
}
}
}
func handleAnisetteDataRequest(_ request: AnisetteDataRequest, for connection: NWConnection)
{
AnisetteDataManager.shared.requestAnisetteData { (result) in
switch result
{
case .failure(let error):
let errorResponse = ErrorResponse(error: ALTServerError(error))
self.send(errorResponse, to: connection, shouldDisconnect: true) { (result) in
print("Sent anisette data error response with result:", result)
}
case .success(let anisetteData):
let response = AnisetteDataResponse(anisetteData: anisetteData)
self.send(response, to: connection, shouldDisconnect: true) { (result) in
print("Sent anisette data response with result:", result)
}
}
}
}
func handlePrepareAppRequest(_ request: PrepareAppRequest, for connection: NWConnection)
{
var temporaryURL: URL?
func finish(_ result: Result<Void, ALTServerError>)
{
if let temporaryURL = temporaryURL
{
do { try FileManager.default.removeItem(at: temporaryURL) }
catch { print("Failed to remove .ipa.", error) }
}
switch result
{
case .failure(let error):
print("Failed to process request from \(connection.endpoint).", error)
let response = ErrorResponse(error: ALTServerError(error))
self.send(response, to: connection, shouldDisconnect: true) { (result) in
print("Sent install app error response to \(connection.endpoint) with result:", result)
}
case .success:
print("Processed request from \(connection.endpoint).")
let response = InstallationProgressResponse(progress: 1.0)
self.send(response, to: connection, shouldDisconnect: true) { (result) in
print("Sent install app response to \(connection.endpoint) with result:", result)
}
}
}
self.receiveApp(for: request, from: connection) { (result) in
print("Received app with result:", result)
switch result
{
case .failure(let error): finish(.failure(error))
case .success(let fileURL):
temporaryURL = fileURL
print("Awaiting begin installation request...")
self.receiveRequest(from: connection) { (result) in
print("Received begin installation request with result:", result)
switch result
{
case .failure(let error): finish(.failure(error))
case .success(.beginInstallation):
print("Installing to device \(request.udid)...")
self.installApp(at: fileURL, toDeviceWithUDID: request.udid, connection: connection) { (result) in
print("Installed to device with result:", result)
switch result
{
case .failure(let error): finish(.failure(error))
case .success: finish(.success(()))
}
}
case .success:
let response = ErrorResponse(error: ALTServerError(.unknownRequest))
self.send(response, to: connection, shouldDisconnect: true) { (result) in
print("Sent unknown request error response to \(connection.endpoint) with result:", result)
}
}
}
}
}
}
func receiveApp(for request: PrepareAppRequest, from connection: NWConnection, completionHandler: @escaping (Result<URL, ALTServerError>) -> Void)
{
connection.receive(minimumIncompleteLength: request.contentSize, maximumLength: request.contentSize) { (data, _, _, error) in
do
{
print("Received app data!")
let data = try self.process(data: data, error: error, from: connection)
print("Processed app data!")
guard ALTDeviceManager.shared.availableDevices.contains(where: { $0.identifier == request.udid }) else { throw ALTServerError(.deviceNotFound) }
print("Writing app data...")
let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".ipa")
try data.write(to: temporaryURL, options: .atomic)
print("Wrote app to URL:", temporaryURL)
completionHandler(.success(temporaryURL))
}
catch
{
print("Error processing app data:", error)
completionHandler(.failure(ALTServerError(error)))
}
}
}
func installApp(at fileURL: URL, toDeviceWithUDID udid: String, connection: NWConnection, completionHandler: @escaping (Result<Void, ALTServerError>) -> Void)
{
let serialQueue = DispatchQueue(label: "com.altstore.ConnectionManager.installQueue", qos: .default)
var isSending = false
var observation: NSKeyValueObservation?
let progress = ALTDeviceManager.shared.installApp(at: fileURL, toDeviceWithUDID: udid) { (success, error) in
print("Installed app with result:", error == nil ? "Success" : error!.localizedDescription)
if let error = error.map({ $0 as? ALTServerError ?? ALTServerError(.unknown) })
{
completionHandler(.failure(error))
}
else
{
completionHandler(.success(()))
}
observation?.invalidate()
observation = nil
}
observation = progress.observe(\.fractionCompleted, changeHandler: { (progress, change) in
serialQueue.async {
guard !isSending else { return }
isSending = true
print("Progress:", progress.fractionCompleted)
let response = InstallationProgressResponse(progress: progress.fractionCompleted)
self.send(response, to: connection) { (result) in
serialQueue.async {
isSending = false
}
}
}
})
}
func send<T: Encodable>(_ response: T, to connection: NWConnection, shouldDisconnect: Bool = false, completionHandler: @escaping (Result<Void, ALTServerError>) -> Void)
{
func finish(_ result: Result<Void, ALTServerError>)
{
completionHandler(result)
if shouldDisconnect
{
// Add short delay to prevent us from dropping connection too quickly.
DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
self.disconnect(connection)
}
}
}
do
{
let data = try JSONEncoder().encode(response)
let responseSize = withUnsafeBytes(of: Int32(data.count)) { Data($0) }
connection.send(content: responseSize, completion: .contentProcessed { (error) in
do
{
if let error = error
{
throw error
}
connection.send(content: data, completion: .contentProcessed { (error) in
if error != nil
{
finish(.failure(.init(.lostConnection)))
}
else
{
finish(.success(()))
}
})
}
catch
{
finish(.failure(.init(.lostConnection)))
}
})
}
catch
{
finish(.failure(.init(.invalidResponse)))
}
}
func receiveRequest(from connection: NWConnection, completionHandler: @escaping (Result<ServerRequest, ALTServerError>) -> Void)
{
let size = MemoryLayout<Int32>.size
print("Receiving request size")
connection.receive(minimumIncompleteLength: size, maximumLength: size) { (data, _, _, error) in
do
{
let data = try self.process(data: data, error: error, from: connection)
print("Receiving request...")
let expectedBytes = Int(data.withUnsafeBytes { $0.load(as: Int32.self) })
connection.receive(minimumIncompleteLength: expectedBytes, maximumLength: expectedBytes) { (data, _, _, error) in
do
{
let data = try self.process(data: data, error: error, from: connection)
let request = try JSONDecoder().decode(ServerRequest.self, from: data)
print("Received installation request:", request)
completionHandler(.success(request))
}
catch
{
completionHandler(.failure(ALTServerError(error)))
}
}
}
catch
{
completionHandler(.failure(ALTServerError(error)))
}
}
}
}

View File

@@ -0,0 +1,587 @@
//
// ALTDeviceManager+Installation.swift
// AltServer
//
// Created by Riley Testut on 7/1/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Cocoa
import UserNotifications
import ObjectiveC
#if STAGING
private let appURL = URL(string: "https://f000.backblazeb2.com/file/altstore-staging/altstore.ipa")!
#else
private let appURL = URL(string: "https://f000.backblazeb2.com/file/altstore/altstore.ipa")!
#endif
enum InstallError: LocalizedError
{
case cancelled
case noTeam
case missingPrivateKey
case missingCertificate
var errorDescription: String? {
switch self
{
case .cancelled: return NSLocalizedString("The operation was cancelled.", comment: "")
case .noTeam: return "You are not a member of any developer teams."
case .missingPrivateKey: return "The developer certificate's private key could not be found."
case .missingCertificate: return "The developer certificate could not be found."
}
}
}
extension ALTDeviceManager
{
func installAltStore(to device: ALTDevice, appleID: String, password: String, completion: @escaping (Result<Void, Error>) -> Void)
{
let destinationDirectoryURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
func finish(_ error: Error?, title: String = "")
{
DispatchQueue.main.async {
if let error = error
{
completion(.failure(error))
}
else
{
completion(.success(()))
}
}
try? FileManager.default.removeItem(at: destinationDirectoryURL)
}
AnisetteDataManager.shared.requestAnisetteData { (result) in
do
{
let anisetteData = try result.get()
self.authenticate(appleID: appleID, password: password, anisetteData: anisetteData) { (result) in
do
{
let (account, session) = try result.get()
self.fetchTeam(for: account, session: session) { (result) in
do
{
let team = try result.get()
self.register(device, team: team, session: session) { (result) in
do
{
let device = try result.get()
self.fetchCertificate(for: team, session: session) { (result) in
do
{
let certificate = try result.get()
let content = UNMutableNotificationContent()
content.title = String(format: NSLocalizedString("Installing AltStore to %@...", comment: ""), device.name)
content.body = NSLocalizedString("This may take a few seconds.", comment: "")
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
self.downloadApp { (result) in
do
{
let fileURL = try result.get()
try FileManager.default.createDirectory(at: destinationDirectoryURL, withIntermediateDirectories: true, attributes: nil)
let appBundleURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: destinationDirectoryURL)
do
{
try FileManager.default.removeItem(at: fileURL)
}
catch
{
print("Failed to remove downloaded .ipa.", error)
}
guard let application = ALTApplication(fileURL: appBundleURL) else { throw ALTError(.invalidApp) }
self.registerAppID(name: "AltStore", identifier: "com.rileytestut.AltStore", team: team, session: session) { (result) in
do
{
let appID = try result.get()
self.updateFeatures(for: appID, app: application, team: team, session: session) { (result) in
do
{
let appID = try result.get()
self.fetchProvisioningProfile(for: appID, team: team, session: session) { (result) in
do
{
let provisioningProfile = try result.get()
self.install(application, to: device, team: team, appID: appID, certificate: certificate, profile: provisioningProfile) { (result) in
finish(result.error, title: "Failed to Install AltStore")
}
}
catch
{
finish(error, title: "Failed to Fetch Provisioning Profile")
}
}
}
catch
{
finish(error, title: "Failed to Update App ID")
}
}
}
catch
{
finish(error, title: "Failed to Register App")
}
}
}
catch
{
finish(error, title: "Failed to Download AltStore")
return
}
}
}
catch
{
finish(error, title: "Failed to Fetch Certificate")
}
}
}
catch
{
finish(error, title: "Failed to Register Device")
}
}
}
catch
{
finish(error, title: "Failed to Fetch Team")
}
}
}
catch
{
finish(error, title: "Failed to Authenticate")
}
}
}
catch
{
finish(error, title: "Failed to Fetch Anisette Data")
}
}
}
func downloadApp(completionHandler: @escaping (Result<URL, Error>) -> Void)
{
let downloadTask = URLSession.shared.downloadTask(with: appURL) { (fileURL, response, error) in
do
{
let (fileURL, _) = try Result((fileURL, response), error).get()
completionHandler(.success(fileURL))
}
catch
{
completionHandler(.failure(error))
}
}
downloadTask.resume()
}
func authenticate(appleID: String, password: String, anisetteData: ALTAnisetteData, completionHandler: @escaping (Result<(ALTAccount, ALTAppleAPISession), Error>) -> Void)
{
func handleVerificationCode(_ completionHandler: @escaping (String?) -> Void)
{
DispatchQueue.main.async {
let alert = NSAlert()
alert.messageText = NSLocalizedString("Two-Factor Authentication Enabled", comment: "")
alert.informativeText = NSLocalizedString("Please enter the 6-digit verification code that was sent to your Apple devices.", comment: "")
let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 22))
textField.delegate = self
textField.translatesAutoresizingMaskIntoConstraints = false
textField.placeholderString = NSLocalizedString("123456", comment: "")
alert.accessoryView = textField
alert.window.initialFirstResponder = textField
alert.addButton(withTitle: NSLocalizedString("Continue", comment: ""))
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: ""))
self.securityCodeAlert = alert
self.securityCodeTextField = textField
self.validate()
NSRunningApplication.current.activate(options: .activateIgnoringOtherApps)
let response = alert.runModal()
if response == .alertFirstButtonReturn
{
let code = textField.stringValue
completionHandler(code)
}
else
{
completionHandler(nil)
}
}
}
ALTAppleAPI.shared.authenticate(appleID: appleID, password: password, anisetteData: anisetteData, verificationHandler: handleVerificationCode) { (account, session, error) in
if let account = account, let session = session
{
completionHandler(.success((account, session)))
}
else
{
completionHandler(.failure(error ?? ALTAppleAPIError(.unknown)))
}
}
}
func fetchTeam(for account: ALTAccount, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTTeam, Error>) -> Void)
{
func finish(_ result: Result<ALTTeam, Error>)
{
switch result
{
case .failure(let error):
completionHandler(.failure(error))
case .success(let team):
var isCancelled = false
if team.type != .free
{
DispatchQueue.main.sync {
let alert = NSAlert()
alert.messageText = NSLocalizedString("Installing AltStore will revoke your iOS development certificate.", comment: "")
alert.informativeText = NSLocalizedString("""
This will not affect apps you've submitted to the App Store, but may cause apps you've installed to your devices with Xcode to stop working until you reinstall them.
To prevent this from happening, feel free to try again with another Apple ID to install AltStore.
""", comment: "")
alert.addButton(withTitle: NSLocalizedString("Continue", comment: ""))
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: ""))
NSRunningApplication.current.activate(options: .activateIgnoringOtherApps)
let buttonIndex = alert.runModal()
if buttonIndex == NSApplication.ModalResponse.alertSecondButtonReturn
{
isCancelled = true
}
}
if isCancelled
{
return completionHandler(.failure(InstallError.cancelled))
}
}
completionHandler(.success(team))
}
}
ALTAppleAPI.shared.fetchTeams(for: account, session: session) { (teams, error) in
do
{
let teams = try Result(teams, error).get()
if let team = teams.first(where: { $0.type == .free })
{
return finish(.success(team))
}
else if let team = teams.first(where: { $0.type == .individual })
{
return finish(.success(team))
}
else if let team = teams.first
{
return finish(.success(team))
}
else
{
throw InstallError.noTeam
}
}
catch
{
finish(.failure(error))
}
}
}
func fetchCertificate(for team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTCertificate, Error>) -> Void)
{
ALTAppleAPI.shared.fetchCertificates(for: team, session: session) { (certificates, error) in
do
{
let certificates = try Result(certificates, error).get()
// Check if there is another AltStore certificate, which means AltStore has been installed with this Apple ID before.
if certificates.contains(where: { $0.machineName?.starts(with: "AltStore") == true })
{
var isCancelled = false
DispatchQueue.main.sync {
let alert = NSAlert()
alert.messageText = NSLocalizedString("AltStore already installed on another device.", comment: "")
alert.informativeText = NSLocalizedString("Apps installed with AltStore on your other devices will stop working. Are you sure you want to continue?", comment: "")
alert.addButton(withTitle: NSLocalizedString("Continue", comment: ""))
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: ""))
NSRunningApplication.current.activate(options: .activateIgnoringOtherApps)
let buttonIndex = alert.runModal()
if buttonIndex == NSApplication.ModalResponse.alertSecondButtonReturn
{
isCancelled = true
}
}
if isCancelled
{
return completionHandler(.failure(InstallError.cancelled))
}
}
if let certificate = certificates.first
{
ALTAppleAPI.shared.revoke(certificate, for: team, session: session) { (success, error) in
do
{
try Result(success, error).get()
self.fetchCertificate(for: team, session: session, completionHandler: completionHandler)
}
catch
{
completionHandler(.failure(error))
}
}
}
else
{
ALTAppleAPI.shared.addCertificate(machineName: "AltStore", to: team, session: session) { (certificate, error) in
do
{
let certificate = try Result(certificate, error).get()
guard let privateKey = certificate.privateKey else { throw InstallError.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 InstallError.missingCertificate
}
certificate.privateKey = privateKey
completionHandler(.success(certificate))
}
catch
{
completionHandler(.failure(error))
}
}
}
catch
{
completionHandler(.failure(error))
}
}
}
}
catch
{
completionHandler(.failure(error))
}
}
}
func registerAppID(name appName: String, identifier: String, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void)
{
let bundleID = "com.\(team.identifier).\(identifier)"
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 == bundleID })
{
completionHandler(.success(appID))
}
else
{
ALTAppleAPI.shared.addAppID(withName: appName, bundleIdentifier: bundleID, team: team, session: session) { (appID, error) in
completionHandler(Result(appID, error))
}
}
}
catch
{
completionHandler(.failure(error))
}
}
}
func updateFeatures(for appID: ALTAppID, app: ALTApplication, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTAppID, Error>) -> Void)
{
let requiredFeatures = app.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 = app.entitlements[.appGroups] as? [String], !applicationGroups.isEmpty
{
features[.appGroups] = true
}
let appID = appID.copy() as! ALTAppID
appID.features = features
ALTAppleAPI.shared.update(appID, team: team, session: session) { (appID, error) in
completionHandler(Result(appID, error))
}
}
func register(_ device: ALTDevice, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTDevice, Error>) -> Void)
{
ALTAppleAPI.shared.fetchDevices(for: team, session: session) { (devices, error) in
do
{
let devices = try Result(devices, error).get()
if let device = devices.first(where: { $0.identifier == device.identifier })
{
completionHandler(.success(device))
}
else
{
ALTAppleAPI.shared.registerDevice(name: device.name, identifier: device.identifier, team: team, session: session) { (device, error) in
completionHandler(Result(device, error))
}
}
}
catch
{
completionHandler(.failure(error))
}
}
}
func fetchProvisioningProfile(for appID: ALTAppID, team: ALTTeam, session: ALTAppleAPISession, completionHandler: @escaping (Result<ALTProvisioningProfile, Error>) -> Void)
{
ALTAppleAPI.shared.fetchProvisioningProfile(for: appID, team: team, session: session) { (profile, error) in
completionHandler(Result(profile, error))
}
}
func install(_ application: ALTApplication, to device: ALTDevice, team: ALTTeam, appID: ALTAppID, certificate: ALTCertificate, profile: ALTProvisioningProfile, completionHandler: @escaping (Result<Void, Error>) -> Void)
{
DispatchQueue.global().async {
do
{
let infoPlistURL = application.fileURL.appendingPathComponent("Info.plist")
guard var infoDictionary = NSDictionary(contentsOf: infoPlistURL) as? [String: Any] else { throw ALTError(.missingInfoPlist) }
infoDictionary[kCFBundleIdentifierKey as String] = profile.bundleIdentifier
infoDictionary[Bundle.Info.deviceID] = device.identifier
infoDictionary[Bundle.Info.serverID] = UserDefaults.standard.serverID
infoDictionary[Bundle.Info.certificateID] = certificate.serialNumber
try (infoDictionary as NSDictionary).write(to: infoPlistURL)
if
let machineIdentifier = certificate.machineIdentifier,
let encryptedData = certificate.encryptedP12Data(withPassword: machineIdentifier)
{
let certificateURL = application.fileURL.appendingPathComponent("ALTCertificate.p12")
try encryptedData.write(to: certificateURL, options: .atomic)
}
let resigner = ALTSigner(team: team, certificate: certificate)
resigner.signApp(at: application.fileURL, provisioningProfiles: [profile]) { (success, error) in
do
{
try Result(success, error).get()
ALTDeviceManager.shared.installApp(at: application.fileURL, toDeviceWithUDID: device.identifier) { (success, error) in
completionHandler(Result(success, error))
}
}
catch
{
print("Failed to install app", error)
completionHandler(.failure(error))
}
}
}
catch
{
print("Failed to install AltStore", error)
completionHandler(.failure(error))
}
}
}
}
private var securityCodeAlertKey = 0
private var securityCodeTextFieldKey = 0
extension ALTDeviceManager: NSTextFieldDelegate
{
var securityCodeAlert: NSAlert? {
get { return objc_getAssociatedObject(self, &securityCodeAlertKey) as? NSAlert }
set { objc_setAssociatedObject(self, &securityCodeAlertKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}
var securityCodeTextField: NSTextField? {
get { return objc_getAssociatedObject(self, &securityCodeTextFieldKey) as? NSTextField }
set { objc_setAssociatedObject(self, &securityCodeTextFieldKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}
public func controlTextDidChange(_ obj: Notification)
{
self.validate()
}
public func controlTextDidEndEditing(_ obj: Notification)
{
self.validate()
}
private func validate()
{
guard let code = self.securityCodeTextField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) else { return }
if code.count == 6
{
self.securityCodeAlert?.buttons.first?.isEnabled = true
}
else
{
self.securityCodeAlert?.buttons.first?.isEnabled = false
}
self.securityCodeAlert?.layout()
}
}

View File

@@ -0,0 +1,25 @@
//
// ALTDeviceManager.h
// AltServer
//
// Created by Riley Testut on 5/24/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
#import <Foundation/Foundation.h>
#import <AltSign/AltSign.h>
NS_ASSUME_NONNULL_BEGIN
@interface ALTDeviceManager : NSObject
@property (class, nonatomic, readonly) ALTDeviceManager *sharedManager;
@property (nonatomic, readonly) NSArray<ALTDevice *> *connectedDevices;
@property (nonatomic, readonly) NSArray<ALTDevice *> *availableDevices;
- (NSProgress *)installAppAtURL:(NSURL *)fileURL toDeviceWithUDID:(NSString *)udid completionHandler:(void (^)(BOOL success, NSError *_Nullable error))completionHandler;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,685 @@
//
// ALTDeviceManager.m
// AltServer
//
// Created by Riley Testut on 5/24/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
#import "ALTDeviceManager.h"
#import "NSError+ALTServerError.h"
#include <libimobiledevice/libimobiledevice.h>
#include <libimobiledevice/lockdown.h>
#include <libimobiledevice/installation_proxy.h>
#include <libimobiledevice/notification_proxy.h>
#include <libimobiledevice/afc.h>
#include <libimobiledevice/misagent.h>
void ALTDeviceManagerUpdateStatus(plist_t command, plist_t status, void *udid);
NSErrorDomain const ALTDeviceErrorDomain = @"com.rileytestut.ALTDeviceError";
@interface ALTDeviceManager ()
@property (nonatomic, readonly) NSMutableDictionary<NSUUID *, void (^)(NSError *)> *installationCompletionHandlers;
@property (nonatomic, readonly) NSMutableDictionary<NSUUID *, NSProgress *> *installationProgress;
@property (nonatomic, readonly) dispatch_queue_t installationQueue;
@end
@implementation ALTDeviceManager
+ (ALTDeviceManager *)sharedManager
{
static ALTDeviceManager *_manager = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_manager = [[self alloc] init];
});
return _manager;
}
- (instancetype)init
{
self = [super init];
if (self)
{
_installationCompletionHandlers = [NSMutableDictionary dictionary];
_installationProgress = [NSMutableDictionary dictionary];
_installationQueue = dispatch_queue_create("com.rileytestut.AltServer.InstallationQueue", DISPATCH_QUEUE_SERIAL);
}
return self;
}
- (NSProgress *)installAppAtURL:(NSURL *)fileURL toDeviceWithUDID:(NSString *)udid completionHandler:(void (^)(BOOL, NSError * _Nullable))completionHandler
{
NSProgress *progress = [NSProgress discreteProgressWithTotalUnitCount:4];
dispatch_async(self.installationQueue, ^{
NSUUID *UUID = [NSUUID UUID];
__block char *uuidString = (char *)malloc(UUID.UUIDString.length + 1);
strncpy(uuidString, (const char *)UUID.UUIDString.UTF8String, UUID.UUIDString.length);
uuidString[UUID.UUIDString.length] = '\0';
__block idevice_t device = NULL;
__block lockdownd_client_t client = NULL;
__block instproxy_client_t ipc = NULL;
__block afc_client_t afc = NULL;
__block misagent_client_t mis = NULL;
__block lockdownd_service_descriptor_t service = NULL;
NSURL *removedProfilesDirectoryURL = [[[NSFileManager defaultManager] temporaryDirectory] URLByAppendingPathComponent:[[NSUUID UUID] UUIDString]];
NSMutableDictionary<NSString *, ALTProvisioningProfile *> *preferredProfiles = [NSMutableDictionary dictionary];
void (^finish)(NSError *error) = ^(NSError *error) {
if ([[NSFileManager defaultManager] fileExistsAtPath:removedProfilesDirectoryURL.path isDirectory:nil])
{
// Reinstall all provisioning profiles we removed before installation.
NSArray *contents = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:removedProfilesDirectoryURL.path error:nil];
for (NSString *filename in contents)
{
NSURL *fileURL = [removedProfilesDirectoryURL URLByAppendingPathComponent:filename];
ALTProvisioningProfile *provisioningProfile = [[ALTProvisioningProfile alloc] initWithURL:fileURL];
if (provisioningProfile == nil)
{
continue;
}
ALTProvisioningProfile *preferredProfile = preferredProfiles[provisioningProfile.bundleIdentifier];
if (![preferredProfile isEqual:provisioningProfile])
{
continue;
}
plist_t pdata = plist_new_data((const char *)provisioningProfile.data.bytes, provisioningProfile.data.length);
if (misagent_install(mis, pdata) == MISAGENT_E_SUCCESS)
{
NSLog(@"Reinstalled profile: %@", provisioningProfile.UUID);
}
else
{
int code = misagent_get_status_code(mis);
NSLog(@"Failed to reinstall provisioning profile %@. (%@)", provisioningProfile.UUID, @(code));
}
}
[[NSFileManager defaultManager] removeItemAtURL:removedProfilesDirectoryURL error:nil];
}
instproxy_client_free(ipc);
afc_client_free(afc);
lockdownd_client_free(client);
misagent_client_free(mis);
idevice_free(device);
lockdownd_service_descriptor_free(service);
free(uuidString);
uuidString = NULL;
if (error != nil)
{
completionHandler(NO, error);
}
else
{
completionHandler(YES, nil);
}
};
NSURL *appBundleURL = nil;
NSURL *temporaryDirectoryURL = nil;
if ([fileURL.pathExtension.lowercaseString isEqualToString:@"app"])
{
appBundleURL = fileURL;
temporaryDirectoryURL = nil;
}
else if ([fileURL.pathExtension.lowercaseString isEqualToString:@"ipa"])
{
NSLog(@"Unzipping .ipa...");
temporaryDirectoryURL = [NSFileManager.defaultManager.temporaryDirectory URLByAppendingPathComponent:[[NSUUID UUID] UUIDString] isDirectory:YES];
NSError *error = nil;
if (![[NSFileManager defaultManager] createDirectoryAtURL:temporaryDirectoryURL withIntermediateDirectories:YES attributes:nil error:&error])
{
return finish(error);
}
appBundleURL = [[NSFileManager defaultManager] unzipAppBundleAtURL:fileURL toDirectory:temporaryDirectoryURL error:&error];
if (appBundleURL == nil)
{
return finish(error);
}
}
else
{
return finish([NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadCorruptFileError userInfo:@{NSURLErrorKey: fileURL}]);
}
/* Find Device */
if (idevice_new(&device, udid.UTF8String) != IDEVICE_E_SUCCESS)
{
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorDeviceNotFound userInfo:nil]);
}
/* Connect to Device */
if (lockdownd_client_new_with_handshake(device, &client, "altserver") != LOCKDOWN_E_SUCCESS)
{
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]);
}
/* Connect to Installation Proxy */
if ((lockdownd_start_service(client, "com.apple.mobile.installation_proxy", &service) != LOCKDOWN_E_SUCCESS) || service == NULL)
{
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]);
}
if (instproxy_client_new(device, service, &ipc) != INSTPROXY_E_SUCCESS)
{
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]);
}
if (service)
{
lockdownd_service_descriptor_free(service);
service = NULL;
}
/* Connect to Misagent */
// Must connect now, since if we take too long writing files to device, connecting may fail later when managing profiles.
if (lockdownd_start_service(client, "com.apple.misagent", &service) != LOCKDOWN_E_SUCCESS || service == NULL)
{
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]);
}
if (misagent_client_new(device, service, &mis) != MISAGENT_E_SUCCESS)
{
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]);
}
/* Connect to AFC service */
if ((lockdownd_start_service(client, "com.apple.afc", &service) != LOCKDOWN_E_SUCCESS) || service == NULL)
{
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]);
}
if (afc_client_new(device, service, &afc) != AFC_E_SUCCESS)
{
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]);
}
NSURL *stagingURL = [NSURL fileURLWithPath:@"PublicStaging" isDirectory:YES];
/* Prepare for installation */
char **files = NULL;
if (afc_get_file_info(afc, stagingURL.relativePath.fileSystemRepresentation, &files) != AFC_E_SUCCESS)
{
if (afc_make_directory(afc, stagingURL.relativePath.fileSystemRepresentation) != AFC_E_SUCCESS)
{
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorDeviceWriteFailed userInfo:nil]);
}
}
if (files)
{
int i = 0;
while (files[i])
{
free(files[i]);
i++;
}
free(files);
}
NSLog(@"Writing to device...");
plist_t options = instproxy_client_options_new();
instproxy_client_options_add(options, "PackageType", "Developer", NULL);
NSURL *destinationURL = [stagingURL URLByAppendingPathComponent:appBundleURL.lastPathComponent];
// Writing files to device should be worth 3/4 of total work.
[progress becomeCurrentWithPendingUnitCount:3];
NSError *writeError = nil;
if (![self writeDirectory:appBundleURL toDestinationURL:destinationURL client:afc progress:nil error:&writeError])
{
return finish(writeError);
}
NSLog(@"Finished writing to device.");
if (service)
{
lockdownd_service_descriptor_free(service);
service = NULL;
}
/* Provisioning Profiles */
NSURL *provisioningProfileURL = [appBundleURL URLByAppendingPathComponent:@"embedded.mobileprovision"];
ALTProvisioningProfile *installationProvisioningProfile = [[ALTProvisioningProfile alloc] initWithURL:provisioningProfileURL];
if (installationProvisioningProfile != nil)
{
NSError *error = nil;
if (![[NSFileManager defaultManager] createDirectoryAtURL:removedProfilesDirectoryURL withIntermediateDirectories:YES attributes:nil error:&error])
{
return finish(error);
}
plist_t rawProfiles = NULL;
if (misagent_copy_all(mis, &rawProfiles) != MISAGENT_E_SUCCESS)
{
return finish([NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorConnectionFailed userInfo:nil]);
}
// For some reason, libplist now fails to parse `rawProfiles` correctly.
// Specifically, it no longer recognizes the nodes in the plist array as "data" nodes.
// However, if we encode it as XML then decode it again, it'll work ¯\_(ツ)_/¯
char *plistXML = nullptr;
uint32_t plistLength = 0;
plist_to_xml(rawProfiles, &plistXML, &plistLength);
plist_t profiles = NULL;
plist_from_xml(plistXML, plistLength, &profiles);
uint32_t profileCount = plist_array_get_size(profiles);
for (int i = 0; i < profileCount; i++)
{
plist_t profile = plist_array_get_item(profiles, i);
if (plist_get_node_type(profile) != PLIST_DATA)
{
continue;
}
char *bytes = NULL;
uint64_t length = 0;
plist_get_data_val(profile, &bytes, &length);
if (bytes == NULL)
{
continue;
}
NSData *data = [NSData dataWithBytes:(const void *)bytes length:length];
ALTProvisioningProfile *provisioningProfile = [[ALTProvisioningProfile alloc] initWithData:data];
if (![provisioningProfile isFreeProvisioningProfile])
{
NSLog(@"Ignoring: %@ (Team: %@)", provisioningProfile.bundleIdentifier, provisioningProfile.teamIdentifier);
continue;
}
ALTProvisioningProfile *preferredProfile = preferredProfiles[provisioningProfile.bundleIdentifier];
if (preferredProfile != nil)
{
if ([provisioningProfile.expirationDate compare:preferredProfile.expirationDate] == NSOrderedDescending)
{
preferredProfiles[provisioningProfile.bundleIdentifier] = provisioningProfile;
}
}
else
{
preferredProfiles[provisioningProfile.bundleIdentifier] = provisioningProfile;
}
NSString *filename = [NSString stringWithFormat:@"%@.mobileprovision", [[NSUUID UUID] UUIDString]];
NSURL *fileURL = [removedProfilesDirectoryURL URLByAppendingPathComponent:filename];
NSError *copyError = nil;
if (![provisioningProfile.data writeToURL:fileURL options:NSDataWritingAtomic error:&copyError])
{
NSLog(@"Failed to copy profile to temporary URL. %@", copyError);
continue;
}
if (misagent_remove(mis, provisioningProfile.UUID.UUIDString.lowercaseString.UTF8String) == MISAGENT_E_SUCCESS)
{
NSLog(@"Removed provisioning profile: %@ (Team: %@)", provisioningProfile.bundleIdentifier, provisioningProfile.teamIdentifier);
}
else
{
int code = misagent_get_status_code(mis);
NSLog(@"Failed to remove provisioning profile %@ (Team: %@). Error Code: %@", provisioningProfile.bundleIdentifier, provisioningProfile.teamIdentifier, @(code));
}
}
plist_free(rawProfiles);
plist_free(profiles);
lockdownd_client_free(client);
client = NULL;
}
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
NSProgress *installationProgress = [NSProgress progressWithTotalUnitCount:100 parent:progress pendingUnitCount:1];
self.installationProgress[UUID] = installationProgress;
self.installationCompletionHandlers[UUID] = ^(NSError *error) {
finish(error);
if (temporaryDirectoryURL != nil)
{
NSError *error = nil;
if (![[NSFileManager defaultManager] removeItemAtURL:temporaryDirectoryURL error:&error])
{
NSLog(@"Error removing temporary directory. %@", error);
}
}
dispatch_semaphore_signal(semaphore);
};
NSLog(@"Installing to device %@...", udid);
instproxy_install(ipc, destinationURL.relativePath.fileSystemRepresentation, options, ALTDeviceManagerUpdateStatus, uuidString);
instproxy_client_options_free(options);
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
});
return progress;
}
- (BOOL)writeDirectory:(NSURL *)directoryURL toDestinationURL:(NSURL *)destinationURL client:(afc_client_t)afc progress:(NSProgress *)progress error:(NSError **)error
{
afc_make_directory(afc, destinationURL.relativePath.fileSystemRepresentation);
if (progress == nil)
{
NSDirectoryEnumerator *countEnumerator = [[NSFileManager defaultManager] enumeratorAtURL:directoryURL
includingPropertiesForKeys:@[]
options:0
errorHandler:^BOOL(NSURL * _Nonnull url, NSError * _Nonnull error) {
if (error) {
NSLog(@"[Error] %@ (%@)", error, url);
return NO;
}
return YES;
}];
NSInteger totalCount = 0;
for (NSURL *__unused fileURL in countEnumerator)
{
totalCount++;
}
progress = [NSProgress progressWithTotalUnitCount:totalCount];
}
NSDirectoryEnumerator *enumerator = [[NSFileManager defaultManager] enumeratorAtURL:directoryURL
includingPropertiesForKeys:@[NSURLIsDirectoryKey]
options:NSDirectoryEnumerationSkipsSubdirectoryDescendants
errorHandler:^BOOL(NSURL * _Nonnull url, NSError * _Nonnull error) {
if (error) {
NSLog(@"[Error] %@ (%@)", error, url);
return NO;
}
return YES;
}];
for (NSURL *fileURL in enumerator)
{
NSNumber *isDirectory = nil;
if (![fileURL getResourceValue:&isDirectory forKey:NSURLIsDirectoryKey error:error])
{
return NO;
}
if ([isDirectory boolValue])
{
NSURL *destinationDirectoryURL = [destinationURL URLByAppendingPathComponent:fileURL.lastPathComponent isDirectory:YES];
if (![self writeDirectory:fileURL toDestinationURL:destinationDirectoryURL client:afc progress:progress error:error])
{
return NO;
}
}
else
{
NSURL *destinationFileURL = [destinationURL URLByAppendingPathComponent:fileURL.lastPathComponent isDirectory:NO];
if (![self writeFile:fileURL toDestinationURL:destinationFileURL client:afc error:error])
{
return NO;
}
}
progress.completedUnitCount += 1;
}
return YES;
}
- (BOOL)writeFile:(NSURL *)fileURL toDestinationURL:(NSURL *)destinationURL client:(afc_client_t)afc error:(NSError **)error
{
NSFileHandle *fileHandle = [NSFileHandle fileHandleForReadingAtPath:fileURL.path];
if (fileHandle == nil)
{
if (error)
{
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileNoSuchFileError userInfo:@{NSURLErrorKey: fileURL}];
}
return NO;
}
NSData *data = [fileHandle readDataToEndOfFile];
uint64_t af = 0;
if ((afc_file_open(afc, destinationURL.relativePath.fileSystemRepresentation, AFC_FOPEN_WRONLY, &af) != AFC_E_SUCCESS) || af == 0)
{
if (error)
{
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileWriteUnknownError userInfo:@{NSURLErrorKey: destinationURL}];
}
return NO;
}
BOOL success = YES;
uint32_t bytesWritten = 0;
while (bytesWritten < data.length)
{
uint32_t count = 0;
if (afc_file_write(afc, af, (const char *)data.bytes + bytesWritten, (uint32_t)data.length - bytesWritten, &count) != AFC_E_SUCCESS)
{
if (error)
{
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileWriteUnknownError userInfo:@{NSURLErrorKey: destinationURL}];
}
success = NO;
break;
}
bytesWritten += count;
}
if (bytesWritten != data.length)
{
if (error)
{
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileWriteUnknownError userInfo:@{NSURLErrorKey: destinationURL}];
}
success = NO;
}
afc_file_close(afc, af);
return success;
}
#pragma mark - Getters -
- (NSArray<ALTDevice *> *)connectedDevices
{
return [self availableDevicesIncludingNetworkDevices:NO];
}
- (NSArray<ALTDevice *> *)availableDevices
{
return [self availableDevicesIncludingNetworkDevices:YES];
}
- (NSArray<ALTDevice *> *)availableDevicesIncludingNetworkDevices:(BOOL)includingNetworkDevices
{
NSMutableSet *connectedDevices = [NSMutableSet set];
int count = 0;
char **udids = NULL;
if (idevice_get_device_list(&udids, &count) < 0)
{
fprintf(stderr, "ERROR: Unable to retrieve device list!\n");
return @[];
}
for (int i = 0; i < count; i++)
{
char *udid = udids[i];
idevice_t device = NULL;
if (includingNetworkDevices)
{
idevice_new(&device, udid);
}
else
{
idevice_new_ignore_network(&device, udid);
}
if (!device)
{
continue;
}
lockdownd_client_t client = NULL;
int result = lockdownd_client_new(device, &client, "altserver");
if (result != LOCKDOWN_E_SUCCESS)
{
fprintf(stderr, "ERROR: Connecting to device %s failed! (%d)\n", udid, result);
idevice_free(device);
continue;
}
char *device_name = NULL;
if (lockdownd_get_device_name(client, &device_name) != LOCKDOWN_E_SUCCESS || device_name == NULL)
{
fprintf(stderr, "ERROR: Could not get device name!\n");
lockdownd_client_free(client);
idevice_free(device);
continue;
}
lockdownd_client_free(client);
idevice_free(device);
NSString *name = [NSString stringWithCString:device_name encoding:NSUTF8StringEncoding];
NSString *identifier = [NSString stringWithCString:udid encoding:NSUTF8StringEncoding];
ALTDevice *altDevice = [[ALTDevice alloc] initWithName:name identifier:identifier];
[connectedDevices addObject:altDevice];
if (device_name != NULL)
{
free(device_name);
}
}
idevice_device_list_free(udids);
return connectedDevices.allObjects;
}
@end
#pragma mark - Callbacks -
void ALTDeviceManagerUpdateStatus(plist_t command, plist_t status, void *uuid)
{
NSUUID *UUID = [[NSUUID alloc] initWithUUIDString:[NSString stringWithUTF8String:(const char *)uuid]];
NSProgress *progress = ALTDeviceManager.sharedManager.installationProgress[UUID];
if (progress == nil)
{
return;
}
int percent = -1;
instproxy_status_get_percent_complete(status, &percent);
char *name = NULL;
char *description = NULL;
uint64_t code = 0;
instproxy_status_get_error(status, &name, &description, &code);
if ((percent == -1 && progress.completedUnitCount > 0) || code != 0 || name != NULL)
{
void (^completionHandler)(NSError *) = ALTDeviceManager.sharedManager.installationCompletionHandlers[UUID];
if (completionHandler != nil)
{
if (code != 0 || name != NULL)
{
NSLog(@"Error installing app. %@ (%@). %@", @(code), @(name), @(description));
NSError *error = nil;
if (code == 3892346913)
{
error = [NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorMaximumFreeAppLimitReached userInfo:nil];
}
else
{
NSString *errorName = [NSString stringWithCString:name encoding:NSUTF8StringEncoding];
if ([errorName isEqualToString:@"DeviceOSVersionTooLow"])
{
error = [NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorUnsupportediOSVersion userInfo:nil];
}
else
{
NSError *underlyingError = [NSError errorWithDomain:AltServerInstallationErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey: @(description)}];
error = [NSError errorWithDomain:AltServerErrorDomain code:ALTServerErrorInstallationFailed userInfo:@{NSUnderlyingErrorKey: underlyingError}];
}
}
completionHandler(error);
}
else
{
NSLog(@"Finished installing app!");
completionHandler(nil);
}
ALTDeviceManager.sharedManager.installationCompletionHandlers[UUID] = nil;
ALTDeviceManager.sharedManager.installationProgress[UUID] = nil;
}
}
else if (progress.completedUnitCount < percent)
{
progress.completedUnitCount = percent;
NSLog(@"Installation Progress: %@", @(percent));
}
}

View File

@@ -0,0 +1,38 @@
//
// UserDefaults+AltServer.swift
// AltServer
//
// Created by Riley Testut on 7/31/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Foundation
extension UserDefaults
{
var serverID: String? {
get {
return self.string(forKey: "serverID")
}
set {
self.set(newValue, forKey: "serverID")
}
}
var didPresentInitialNotification: Bool {
get {
return self.bool(forKey: "didPresentInitialNotification")
}
set {
self.set(newValue, forKey: "didPresentInitialNotification")
}
}
func registerDefaults()
{
if self.serverID == nil
{
self.serverID = UUID().uuidString
}
}
}

View File

@@ -2,16 +2,12 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ALTAppGroups</key>
<array>
<string>group.$(APP_GROUP_IDENTIFIER)</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>AltWidget</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
@@ -19,15 +15,22 @@
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2019 Riley Testut. All rights reserved.</string>
<key>NSMainStoryboardFile</key>
<string>Main</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>SUFeedURL</key>
<string>https://altstore.io/altserver/sparkle-macos.xml</string>
</dict>
</plist>

View File

@@ -0,0 +1,13 @@
#!/bin/sh
# InstallAltPlugin.sh
# AltStore
#
# Created by Riley Testut on 11/16/19.
# Copyright © 2019 Riley Testut. All rights reserved.
rm -f AltPlugin.mailbundle
unzip AltPlugin.mailbundle.zip 1>/dev/null
mkdir -p /Library/Mail/Bundles
cp -r AltPlugin.mailbundle /Library/Mail/Bundles
defaults write "/Library/Preferences/com.apple.mail" EnableBundles 1

View File

@@ -0,0 +1,9 @@
#!/bin/sh
# UninstallPlugin.sh
# AltStore
#
# Created by Riley Testut on 11/16/19.
# Copyright © 2019 Riley Testut. All rights reserved.
rm -rf /Library/Mail/Bundles/AltPlugin.mailbundle

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,6 @@
<Workspace
version = "1.0">
<FileRef
location = "self:">
location = "self:AltStore.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -1,42 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1620"
version = "1.7">
LastUpgradeVersion = "1120"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF5C5FC4237DF5AE00EDD0C6"
BuildableName = "AltPlugin.mailbundle"
BlueprintName = "AltPlugin"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<TestPlans>
<TestPlanReference
reference = "container:SideStore/Tests/DataStructureTests.xctestplan"
default = "YES">
</TestPlanReference>
</TestPlans>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A81A8CC42D68BA610086C96F"
BuildableName = "DataStructureTests.xctest"
BlueprintName = "DataStructureTests"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
launchStyle = "1"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
@@ -49,6 +47,15 @@
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF5C5FC4237DF5AE00EDD0C6"
BuildableName = "AltPlugin.mailbundle"
BlueprintName = "AltPlugin"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">

View File

@@ -1,11 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1610"
version = "1.7">
LastUpgradeVersion = "1020"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -15,9 +14,9 @@
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF58047A246A28F7008AE704"
BuildableName = "AltBackup.app"
BlueprintName = "AltBackup"
BlueprintIdentifier = "BF45868C229872EA00BD7491"
BuildableName = "AltServer.app"
BlueprintName = "AltServer"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
@@ -27,8 +26,20 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF45868C229872EA00BD7491"
BuildableName = "AltServer.app"
BlueprintName = "AltServer"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</MacroExpansion>
<AdditionalOptions>
</AdditionalOptions>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -44,42 +55,14 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF58047A246A28F7008AE704"
BuildableName = "AltBackup.app"
BlueprintName = "AltBackup"
BlueprintIdentifier = "BF45868C229872EA00BD7491"
BuildableName = "AltServer.app"
BlueprintName = "AltServer"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.MigrationDebug 1"
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.SQLiteIntegrityCheck 1"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.SQLDebug 1"
isEnabled = "NO">
</CommandLineArgument>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "OS_ACTIVITY_MODE"
value = "$(DEBUG_ACTIVITY_MODE)"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "OBJC_DEBUG_DUPLICATE_CLASSES"
value = "$(DEBUG_DUPLICATE_CLASSES)"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
<AdditionalOptions>
</AdditionalOptions>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -91,9 +74,9 @@
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF58047A246A28F7008AE704"
BuildableName = "AltBackup.app"
BlueprintName = "AltBackup"
BlueprintIdentifier = "BF45868C229872EA00BD7491"
BuildableName = "AltServer.app"
BlueprintName = "AltServer"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1020"
version = "1.7">
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
@@ -15,22 +15,34 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BFD247692284B9A500981D42"
BuildableName = "SideStore.app"
BlueprintName = "SideStore"
BuildableName = "AltStore.app"
BlueprintName = "AltStore"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Release"
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BFD247692284B9A500981D42"
BuildableName = "AltStore.app"
BlueprintName = "AltStore"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</MacroExpansion>
<AdditionalOptions>
</AdditionalOptions>
</TestAction>
<LaunchAction
buildConfiguration = "Release"
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
@@ -44,41 +56,19 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BFD247692284B9A500981D42"
BuildableName = "SideStore.app"
BlueprintName = "SideStore"
BuildableName = "AltStore.app"
BlueprintName = "AltStore"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.MigrationDebug 1"
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.SQLiteIntegrityCheck 1"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.SQLDebug 1"
isEnabled = "NO">
</CommandLineArgument>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "OS_ACTIVITY_MODE"
value = "$(DEBUG_ACTIVITY_MODE)"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "OBJC_DEBUG_DUPLICATE_CLASSES"
value = "$(DEBUG_DUPLICATE_CLASSES)"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
<AdditionalOptions>
</AdditionalOptions>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -91,14 +81,14 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BFD247692284B9A500981D42"
BuildableName = "SideStore.app"
BlueprintName = "SideStore"
BuildableName = "AltStore.app"
BlueprintName = "AltStore"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Release">
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"

View File

@@ -1,130 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1610"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BFD247692284B9A500981D42"
BuildableName = "SideStore.app"
BlueprintName = "SideStore"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<TestPlans>
<TestPlanReference
reference = "container:SideStore/Tests/SideStoreTests.xctestplan"
default = "YES">
</TestPlanReference>
</TestPlans>
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A8E2DB202D684CBD009E5D31"
BuildableName = "UITests.xctest"
BlueprintName = "UITests"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
<SelectedTests>
<Test
Identifier = "UITests/testExample()">
</Test>
</SelectedTests>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BFD247692284B9A500981D42"
BuildableName = "SideStore.app"
BlueprintName = "SideStore"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.MigrationDebug 1"
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.SQLiteIntegrityCheck 1"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.SQLDebug 1"
isEnabled = "NO">
</CommandLineArgument>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "OS_ACTIVITY_MODE"
value = "$(DEBUG_ACTIVITY_MODE)"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "OBJC_DEBUG_DUPLICATE_CLASSES"
value = "$(DEBUG_DUPLICATE_CLASSES)"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BFD247692284B9A500981D42"
BuildableName = "SideStore.app"
BlueprintName = "SideStore"
ReferencedContainer = "container:AltStore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -2,10 +2,10 @@
<Workspace
version = "1.0">
<FileRef
location = "group:AltStore.xcodeproj">
location = "container:AltStore.xcodeproj">
</FileRef>
<FileRef
location = "group:SideStore/AltSign">
location = "group:Dependencies/AltSign/AltSign.xcodeproj">
</FileRef>
<FileRef
location = "group:Dependencies/Roxas/Roxas.xcodeproj">

View File

@@ -2,9 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.$(APP_GROUP_IDENTIFIER)</string>
</array>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -2,7 +2,7 @@
// 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"
#import "NSError+ALTServerError.h"
#import "ALTAppPermission.h"
#import "ALTPatreonBenefitType.h"
#import "ALTSourceUserInfoKey.h"

View File

@@ -2,27 +2,7 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- <key>com.apple.security.files.user-selected.read-write</key>
<array>
<string></string>
</array>
<key>com.apple.developer.applesignin</key>
<array>
<string></string>
</array> -->
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
<true/>
<key>com.apple.developer.kernel.increased-debugging-memory-limit</key>
<true/>
<key>com.apple.developer.kernel.increased-memory-limit</key>
<true/>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.siri</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.$(APP_GROUP_IDENTIFIER)</string>
</array>
</dict>
</plist>

View File

@@ -1,115 +0,0 @@
//
// AnalyticsManager.swift
// AltStore
//
// Created by Riley Testut on 3/31/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import Foundation
import AltStoreCore
import AppCenter
import AppCenterAnalytics
import AppCenterCrashes
#if DEBUG
private let appCenterAppSecret = "73532d3e-e573-4693-99a4-9f85840bbb44"
#elseif RELEASE
private let appCenterAppSecret = "73532d3e-e573-4693-99a4-9f85840bbb44"
#else
private let appCenterAppSecret = "73532d3e-e573-4693-99a4-9f85840bbb44"
#endif
extension AnalyticsManager
{
enum EventProperty: String
{
case name
case bundleIdentifier
case developerName
case version
case buildVersion
case size
case tintColor
case sourceIdentifier
case sourceURL
case patreonURL
case pledgeAmount
case pledgeCurrency
}
enum Event
{
case installedApp(InstalledApp)
case updatedApp(InstalledApp)
case refreshedApp(InstalledApp)
var name: String {
switch self
{
case .installedApp: return "installed_app"
case .updatedApp: return "updated_app"
case .refreshedApp: return "refreshed_app"
}
}
var properties: [EventProperty: String] {
let properties: [EventProperty: String?]
switch self
{
case .installedApp(let app), .updatedApp(let app), .refreshedApp(let app):
let appBundleURL = InstalledApp.fileURL(for: app)
let appBundleSize = FileManager.default.directorySize(at: appBundleURL)
properties = [
.name: app.name,
.bundleIdentifier: app.bundleIdentifier,
.developerName: app.storeApp?.developerName,
.version: app.version,
.buildVersion: app.buildVersion,
.size: appBundleSize?.description,
.tintColor: app.storeApp?.tintColor?.hexString,
.sourceIdentifier: app.storeApp?.sourceIdentifier,
.sourceURL: app.storeApp?.source?.sourceURL.absoluteString,
.patreonURL: app.storeApp?.source?.patreonURL?.absoluteString,
.pledgeAmount: app.storeApp?.pledgeAmount?.description,
.pledgeCurrency: app.storeApp?.pledgeCurrency
]
}
return properties.compactMapValues { $0 }
}
}
}
final class AnalyticsManager
{
static let shared = AnalyticsManager()
private init()
{
}
}
extension AnalyticsManager
{
func start()
{
AppCenter.start(withAppSecret: appCenterAppSecret, services: [
Analytics.self,
Crashes.self
])
}
func trackEvent(_ event: Event)
{
let properties = event.properties.reduce(into: [:]) { (properties, item) in
properties[item.key.rawValue] = item.value
}
Analytics.trackEvent(event.name, withProperties: properties)
}
}

View File

@@ -8,7 +8,6 @@
import UIKit
import AltStoreCore
import Roxas
import Nuke
@@ -25,11 +24,13 @@ extension AppContentViewController
}
}
final class AppContentViewController: UITableViewController
class AppContentViewController: UITableViewController
{
var app: StoreApp!
// private lazy var screenshotsDataSource = self.makeScreenshotsDataSource()
private lazy var screenshotsDataSource = self.makeScreenshotsDataSource()
private lazy var permissionsDataSource = self.makePermissionsDataSource()
private lazy var dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
@@ -43,113 +44,137 @@ final class AppContentViewController: UITableViewController
}()
@IBOutlet private var subtitleLabel: UILabel!
// @IBOutlet private var descriptionTextView: CollapsingTextView!
@IBOutlet private var descriptionTextView: CollapsingMarkdownView!
// @IBOutlet private var versionDescriptionTextView: CollapsingTextView!
@IBOutlet private var versionDescriptionTextView: CollapsingMarkdownView!
@IBOutlet private var descriptionTextView: CollapsingTextView!
@IBOutlet private var versionDescriptionTextView: CollapsingTextView!
@IBOutlet private var versionLabel: UILabel!
@IBOutlet private var versionDateLabel: UILabel!
@IBOutlet private var sizeLabel: UILabel!
@IBOutlet private(set) var appScreenshotsViewController: AppScreenshotsViewController!
@IBOutlet private var appScreenshotsHeightConstraint: NSLayoutConstraint!
@IBOutlet private var screenshotsCollectionView: UICollectionView!
@IBOutlet private var permissionsCollectionView: UICollectionView!
@IBOutlet private(set) var appDetailCollectionViewController: AppDetailCollectionViewController!
@IBOutlet private var appDetailCollectionViewHeightConstraint: NSLayoutConstraint!
var preferredScreenshotSize: CGSize? {
let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
let aspectRatio: CGFloat = 16.0 / 9.0 // Hardcoded for now.
let width = self.screenshotsCollectionView.bounds.width - (layout.minimumInteritemSpacing * 2)
let itemWidth = width / 1.5
let itemHeight = itemWidth * aspectRatio
return CGSize(width: itemWidth, height: itemHeight)
}
override func viewDidLoad() {
override func viewDidLoad()
{
super.viewDidLoad()
self.tableView.contentInset.bottom = 20
self.screenshotsCollectionView.dataSource = self.screenshotsDataSource
self.screenshotsCollectionView.prefetchDataSource = self.screenshotsDataSource
self.permissionsCollectionView.dataSource = self.permissionsDataSource
self.subtitleLabel.text = self.app.subtitle
let desc = self.app.localizedDescription
self.descriptionTextView.text = desc
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.text = self.app.localizedDescription
self.versionDescriptionTextView.text = self.app.versionDescription
self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), self.app.version)
self.versionDateLabel.text = Date().relativeDateString(since: self.app.versionDate, dateFormatter: self.dateFormatter)
self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: Int64(self.app.size))
self.descriptionTextView.maximumNumberOfLines = 5
self.versionDescriptionTextView.maximumNumberOfLines = 5
self.descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
self.descriptionTextView.toggleButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
self.versionDescriptionTextView.toggleButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
self.versionDescriptionTextView.maximumNumberOfLines = 3
self.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
}
override func viewDidLayoutSubviews()
{
super.viewDidLayoutSubviews()
var needsTableViewUpdate = false
guard var size = self.preferredScreenshotSize else { return }
size.height = min(size.height, self.screenshotsCollectionView.bounds.height) // Silence temporary "item too tall" warning.
let screenshotsHeight = self.appScreenshotsViewController.collectionView.contentSize.height
if self.appScreenshotsHeightConstraint.constant != screenshotsHeight && screenshotsHeight > 0
{
self.appScreenshotsHeightConstraint.constant = screenshotsHeight
needsTableViewUpdate = true
}
let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
layout.itemSize = size
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?)
{
guard segue.identifier == "showPermission" else { return }
let permissionsHeight = self.appDetailCollectionViewController.collectionView.contentSize.height
if self.appDetailCollectionViewHeightConstraint.constant != permissionsHeight && permissionsHeight > 0
{
self.appDetailCollectionViewHeightConstraint.constant = permissionsHeight
needsTableViewUpdate = true
}
guard let cell = sender as? UICollectionViewCell, let indexPath = self.permissionsCollectionView.indexPath(for: cell) else { return }
if needsTableViewUpdate
{
UIView.performWithoutAnimation {
// Update row height without animation.
self.tableView.beginUpdates()
self.tableView.endUpdates()
}
}
let permission = self.permissionsDataSource.item(at: indexPath)
let maximumWidth = self.view.bounds.width - 20
let permissionPopoverViewController = segue.destination as! PermissionPopoverViewController
permissionPopoverViewController.permission = permission
permissionPopoverViewController.view.widthAnchor.constraint(lessThanOrEqualToConstant: maximumWidth).isActive = true
let size = permissionPopoverViewController.view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
permissionPopoverViewController.preferredContentSize = size
permissionPopoverViewController.popoverPresentationController?.delegate = self
permissionPopoverViewController.popoverPresentationController?.sourceRect = cell.frame
permissionPopoverViewController.popoverPresentationController?.sourceView = self.permissionsCollectionView
}
}
private extension AppContentViewController
{
@IBSegueAction
func makeAppScreenshotsViewController(_ coder: NSCoder, sender: Any?) -> UIViewController?
func makeScreenshotsDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>
{
let appScreenshotsViewController = AppScreenshotsViewController(app: self.app, coder: coder)
self.appScreenshotsViewController = appScreenshotsViewController
return appScreenshotsViewController
}
func makePermissionsDataSource() -> RSTArrayCollectionViewDataSource<AppPermission>
{
let dataSource = RSTArrayCollectionViewDataSource(items: Array(self.app.permissions))
dataSource.cellConfigurationHandler = { (cell, permission, indexPath) in
let cell = cell as! PermissionCollectionViewCell
// cell.button.setImage(permission.type.icon, for: .normal)
// cell.button.tintColor = .label
// cell.textLabel.text = permission.type.localizedShortName ?? permission.type.localizedName
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: self.app.screenshotURLs as [NSURL])
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in
let cell = cell as! ScreenshotCollectionViewCell
cell.imageView.image = nil
cell.imageView.isIndicatingActivity = true
}
dataSource.prefetchHandler = { (imageURL, indexPath, completionHandler) in
return RSTAsyncBlockOperation() { (operation) in
ImagePipeline.shared.loadImage(with: imageURL as URL, progress: nil, completion: { (response, error) in
guard !operation.isCancelled else { return operation.finish() }
if let image = response?.image
{
completionHandler(image, nil)
}
else
{
completionHandler(nil, error)
}
})
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! ScreenshotCollectionViewCell
cell.imageView.isIndicatingActivity = false
cell.imageView.image = image
let icon = UIImage(systemName: permission.symbolName ?? "lock")
cell.button.setImage(icon, for: .normal)
cell.textLabel.text = permission.localizedDisplayName
if let error = error
{
print("Error loading image:", error)
}
}
return dataSource
}
@IBSegueAction
func makeAppDetailCollectionViewController(_ coder: NSCoder, sender: Any?) -> UIViewController?
{
let appDetailViewController = AppDetailCollectionViewController(app: self.app, coder: coder)
self.appDetailCollectionViewController = appDetailViewController
return appDetailViewController
func makePermissionsDataSource() -> RSTArrayCollectionViewDataSource<AppPermission>
{
let dataSource = RSTArrayCollectionViewDataSource(items: self.app.permissions)
dataSource.cellConfigurationHandler = { (cell, permission, indexPath) in
let cell = cell as! PermissionCollectionViewCell
cell.button.setImage(permission.type.icon, for: .normal)
cell.textLabel.text = permission.type.localizedShortName
}
return dataSource
}
}
@@ -161,12 +186,8 @@ private extension AppContentViewController
switch sender
{
case self.descriptionTextView.toggleButton:
indexPath = IndexPath(row: Row.description.rawValue, section: 0)
case self.versionDescriptionTextView.toggleButton:
indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0)
case self.descriptionTextView.moreButton: indexPath = IndexPath(row: Row.description.rawValue, section: 0)
case self.versionDescriptionTextView.moreButton: indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0)
default: return
}
@@ -186,18 +207,17 @@ extension AppContentViewController
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)
}
guard indexPath.row == Row.screenshots.rawValue else { return super.tableView(tableView, heightForRowAt: indexPath) }
guard let size = self.preferredScreenshotSize else { return 0.0 }
return size.height
}
}
extension AppContentViewController: UIPopoverPresentationControllerDelegate
{
func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle
{
return .none
}
}

View File

@@ -8,7 +8,7 @@
import UIKit
final class PermissionCollectionViewCell: UICollectionViewCell
class PermissionCollectionViewCell: UICollectionViewCell
{
@IBOutlet var button: UIButton!
@IBOutlet var textLabel: UILabel!
@@ -29,7 +29,7 @@ final class PermissionCollectionViewCell: UICollectionViewCell
}
}
final class AppContentTableViewCell: UITableViewCell
class AppContentTableViewCell: UITableViewCell
{
override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize
{

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

@@ -8,12 +8,11 @@
import UIKit
import AltStoreCore
import Roxas
import Nuke
final class AppViewController: UIViewController
class AppViewController: UIViewController
{
var app: StoreApp!
@@ -42,22 +41,13 @@ final class AppViewController: UIViewController
@IBOutlet private var navigationBarAppNameLabel: UILabel!
private var _shouldResetLayout = false
private var _viewDidAppear = false
private var _backgroundBlurEffect: UIBlurEffect?
private var _backgroundBlurTintColor: UIColor?
private var _preferredStatusBarStyle: UIStatusBarStyle = .default
override var preferredStatusBarStyle: UIStatusBarStyle {
if #available(iOS 17, *)
{
// On iOS 17+, .default will update the status bar automatically.
return .default
}
else
{
return _preferredStatusBarStyle
}
return _preferredStatusBarStyle
}
override func viewDidLoad()
@@ -67,11 +57,6 @@ final class AppViewController: UIViewController
self.navigationBarTitleView.sizeToFit()
self.navigationItem.titleView = self.navigationBarTitleView
// spacing in storyboard wasn't working, so had to do programatically
if let stackView = self.navigationBarTitleView as? UIStackView {
stackView.spacing = 8
}
self.contentViewControllerShadowView = UIView()
self.contentViewControllerShadowView.backgroundColor = .white
self.contentViewControllerShadowView.layer.cornerRadius = 38
@@ -87,7 +72,6 @@ final class AppViewController: UIViewController
self.contentViewController.view.layer.masksToBounds = true
self.contentViewController.tableView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer)
self.contentViewController.appDetailCollectionViewController.collectionView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer)
self.contentViewController.tableView.showsVerticalScrollIndicator = false
// Bring to front so the scroll indicators are visible.
@@ -97,16 +81,19 @@ final class AppViewController: UIViewController
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.titleLabel.text = self.app.name
self.bannerView.subtitleLabel.text = self.app.developerName
self.bannerView.iconImageView.image = nil
self.bannerView.iconImageView.tintColor = self.app.tintColor
self.bannerView.button.tintColor = self.app.tintColor
self.bannerView.betaBadgeView.isHidden = !self.app.isBeta
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.navigationController?.navigationBar.tintColor = self.app.tintColor
self.navigationBarDownloadButton.tintColor = self.app.tintColor
self.navigationBarAppNameLabel.text = self.app.name
self.navigationBarAppIconImageView.tintColor = self.app.tintColor
@@ -130,17 +117,13 @@ final class AppViewController: UIViewController
{
imageView.isIndicatingActivity = true
Nuke.loadImage(with: self.app.iconURL, options: .shared, into: imageView, progress: nil) { [weak imageView] (result) in
switch result
Nuke.loadImage(with: self.app.iconURL, options: .shared, into: imageView, progress: nil) { [weak imageView] (response, error) in
if response?.image != nil
{
case .success: imageView?.isIndicatingActivity = false
case .failure(let error): print("[ALTLog] Failed to load app icons.", error)
imageView?.isIndicatingActivity = false
}
}
}
// Start with navigation bar hidden.
self.hideNavigationBar()
}
override func viewWillAppear(_ animated: Bool)
@@ -152,26 +135,42 @@ final class AppViewController: UIViewController
// Update blur immediately.
self.view.setNeedsLayout()
self.view.layoutIfNeeded()
}
override func viewIsAppearing(_ animated: Bool)
{
super.viewIsAppearing(animated)
// Prevent banner temporarily flashing a color due to being added back to self.view.
self.bannerView.backgroundEffectView.backgroundColor = .clear
self.transitionCoordinator?.animate(alongsideTransition: { (context) in
self.hideNavigationBar()
}, completion: nil)
}
override func viewDidAppear(_ animated: Bool)
{
super.viewDidAppear(animated)
self._viewDidAppear = true
self._shouldResetLayout = true
self.view.setNeedsLayout()
self.view.layoutIfNeeded()
}
override func viewWillDisappear(_ animated: Bool)
{
super.viewWillDisappear(animated)
// Guard against "dismissing" when presenting via 3D Touch pop.
guard self.navigationController != nil else { return }
// Store reference since self.navigationController will be nil after disappearing.
let navigationController = self.navigationController
navigationController?.navigationBar.barStyle = .default // Don't animate, or else status bar might appear messed-up.
self.transitionCoordinator?.animate(alongsideTransition: { (context) in
self.showNavigationBar(for: navigationController)
}, completion: { (context) in
if !context.isCancelled
{
self.showNavigationBar(for: navigationController)
}
})
}
override func viewDidDisappear(_ animated: Bool)
{
super.viewDidDisappear(animated)
@@ -188,12 +187,6 @@ final class AppViewController: UIViewController
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()
@@ -204,6 +197,11 @@ final class AppViewController: UIViewController
{
// Various events can cause UI to mess up, so reset affected components now.
if self.navigationController?.topViewController == self
{
self.hideNavigationBar()
}
self.prepareBlur()
// Reset navigation bar animation, and create a new one later in this method if necessary.
@@ -211,22 +209,8 @@ final class AppViewController: UIViewController
self._shouldResetLayout = false
}
let statusBarHeight: Double
if let navigationController, navigationController.presentingViewController != nil, navigationController.modalPresentationStyle != .fullScreen
{
statusBarHeight = 20
}
else if let statusBarManager = (self.view.window ?? self.presentedViewController?.view.window)?.windowScene?.statusBarManager
{
statusBarHeight = statusBarManager.statusBarFrame.height
}
else
{
statusBarHeight = 0
}
let statusBarHeight = UIApplication.shared.statusBarFrame.height
let cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius
let inset = 12 as CGFloat
@@ -285,25 +269,13 @@ final class AppViewController: UIViewController
}
let difference = self.scrollView.contentOffset.y - showNavigationBarThreshold
let range: Double
if self.presentingViewController == nil && self.parent?.presentingViewController == nil
{
// Not presented modally, so rely on safe area + navigation bar height.
range = (headerFrame.height + padding) - (self.navigationController?.navigationBar.bounds.height ?? self.view.safeAreaInsets.top)
}
else
{
// Presented modally, so rely on maximumContentY.
range = maximumContentY - (maximumContentY - padding - headerFrame.height) - inset
}
let range = (headerFrame.height + padding) - (self.navigationController?.navigationBar.bounds.height ?? self.view.safeAreaInsets.top)
let fractionComplete = min(difference, range) / range
self.navigationBarAnimator?.fractionComplete = fractionComplete
}
else
{
self.navigationBarAnimator?.fractionComplete = 0.0
self.resetNavigationBarAnimation()
}
@@ -343,7 +315,7 @@ final class AppViewController: UIViewController
self.backButtonContainerView.layer.cornerRadius = self.backButtonContainerView.bounds.midY
self.scrollView.verticalScrollIndicatorInsets.top = statusBarHeight
self.scrollView.scrollIndicatorInsets.top = statusBarHeight
// Adjust content offset + size.
let contentOffset = self.scrollView.contentOffset
@@ -360,11 +332,7 @@ final class AppViewController: UIViewController
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?)
{
super.traitCollectionDidChange(previousTraitCollection)
if self._viewDidAppear
{
self._shouldResetLayout = true
}
self._shouldResetLayout = true
}
deinit
@@ -376,7 +344,7 @@ final class AppViewController: UIViewController
extension AppViewController
{
final class func makeAppViewController(app: StoreApp) -> AppViewController
class func makeAppViewController(app: StoreApp) -> AppViewController
{
let storyboard = UIStoryboard(name: "Main", bundle: nil)
@@ -390,40 +358,46 @@ private extension AppViewController
{
func update()
{
var buttonAction: AppBannerView.AppAction?
// if let installedApp = self.app.installedApp, let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion), !self.app.isPledgeRequired || self.app.isPledged
if let installedApp = self.app.installedApp, installedApp.hasUpdate
{
// Explicitly set button action to .update if there is an update available, even if it's not supported.
buttonAction = .update
}
for button in [self.bannerView.button!, self.navigationBarDownloadButton!]
{
button.tintColor = self.app.tintColor
button.isIndicatingActivity = false
if self.app.installedApp == nil
{
button.setTitle(NSLocalizedString("FREE", comment: ""), for: .normal)
}
else
{
button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
}
let progress = AppManager.shared.installationProgress(for: self.app)
button.progress = progress
}
self.bannerView.configure(for: self.app, action: buttonAction)
let title = self.bannerView.button.title(for: .normal)
self.navigationBarDownloadButton.setTitle(title, for: .normal)
self.navigationBarDownloadButton.progress = self.bannerView.button.progress
self.navigationBarDownloadButton.countdownDate = self.bannerView.button.countdownDate
if Date() < self.app.versionDate
{
self.bannerView.button.countdownDate = self.app.versionDate
self.navigationBarDownloadButton.countdownDate = self.app.versionDate
}
else
{
self.bannerView.button.countdownDate = nil
self.navigationBarDownloadButton.countdownDate = nil
}
let barButtonItem = self.navigationItem.rightBarButtonItem
self.navigationItem.rightBarButtonItem = nil
self.navigationItem.rightBarButtonItem = barButtonItem
}
func showNavigationBar()
func showNavigationBar(for navigationController: UINavigationController? = nil)
{
self.navigationBarAppIconImageView.alpha = 1.0
self.navigationBarAppNameLabel.alpha = 1.0
self.navigationBarDownloadButton.alpha = 1.0
self.updateNavigationBarAppearance(isHidden: false)
let navigationController = navigationController ?? self.navigationController
navigationController?.navigationBar.alpha = 1.0
navigationController?.navigationBar.tintColor = .altPrimary
navigationController?.navigationBar.setNeedsLayout()
if self.traitCollection.userInterfaceStyle == .dark
{
@@ -434,51 +408,16 @@ private extension AppViewController
self._preferredStatusBarStyle = .default
}
if #unavailable(iOS 17)
{
self.navigationController?.setNeedsStatusBarAppearanceUpdate()
}
navigationController?.setNeedsStatusBarAppearanceUpdate()
}
func hideNavigationBar()
func hideNavigationBar(for navigationController: UINavigationController? = nil)
{
self.navigationBarAppIconImageView.alpha = 0.0
self.navigationBarAppNameLabel.alpha = 0.0
self.navigationBarDownloadButton.alpha = 0.0
self.updateNavigationBarAppearance(isHidden: true)
let navigationController = navigationController ?? self.navigationController
navigationController?.navigationBar.alpha = 0.0
self._preferredStatusBarStyle = .lightContent
if #unavailable(iOS 17)
{
self.navigationController?.setNeedsStatusBarAppearanceUpdate()
}
}
// Copied from HeaderContentViewController
func updateNavigationBarAppearance(isHidden: Bool)
{
let barAppearance = self.navigationItem.standardAppearance as? NavigationBarAppearance ?? NavigationBarAppearance()
if isHidden
{
barAppearance.configureWithTransparentBackground()
barAppearance.ignoresUserInteraction = true
}
else
{
barAppearance.configureWithDefaultBackground()
barAppearance.ignoresUserInteraction = false
}
barAppearance.titleTextAttributes = [.foregroundColor: UIColor.clear]
let tintColor = isHidden ? UIColor.clear : self.app.tintColor ?? .altPrimary
barAppearance.configureWithTintColor(tintColor)
self.navigationItem.standardAppearance = barAppearance
self.navigationItem.scrollEdgeAppearance = barAppearance
navigationController?.setNeedsStatusBarAppearanceUpdate()
}
func prepareBlur()
@@ -506,10 +445,8 @@ private extension AppViewController
self.navigationBarAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in
self?.showNavigationBar()
// Must call layoutIfNeeded() to animate appearance change.
self?.navigationController?.navigationBar.layoutIfNeeded()
self?.navigationController?.navigationBar.tintColor = self?.app.tintColor
self?.navigationController?.navigationBar.barTintColor = nil
self?.contentViewController.view.layer.cornerRadius = 0
}
@@ -521,8 +458,6 @@ private extension AppViewController
func resetNavigationBarAnimation()
{
guard self.navigationBarAnimator != nil else { return }
self.navigationBarAnimator?.stopAnimation(true)
self.navigationBarAnimator = nil
@@ -543,15 +478,7 @@ extension AppViewController
{
if let installedApp = self.app.installedApp
{
// if let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion), !self.app.isPledgeRequired || self.app.isPledged
if let latestVersion = self.app.latestAvailableVersion, installedApp.hasUpdate
{
self.updateApp(installedApp, to: latestVersion)
}
else
{
self.open(installedApp)
}
self.open(installedApp)
}
else
{
@@ -563,72 +490,38 @@ extension AppViewController
{
guard self.app.installedApp == nil else { return }
Task<Void, Never>(priority: .userInitiated) {
let group = await AppManager.shared.installAsync(self.app, presentingViewController: self) { (result) in
do
{
_ = try result.get()
}
catch OperationError.cancelled
{
// Ignore
}
catch
{
DispatchQueue.main.async {
let toastView = ToastView(error: error)
toastView.opensErrorLog = true
toastView.show(in: self)
}
}
let progress = AppManager.shared.install(self.app, presentingViewController: self) { (result) in
do
{
_ = try result.get()
}
catch OperationError.cancelled
{
// Ignore
}
catch
{
DispatchQueue.main.async {
self.bannerView.button.progress = nil
self.navigationBarDownloadButton.progress = nil
self.update()
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2)
}
}
if !group.progress.isCancelled
{
self.bannerView.button.progress = group.progress
self.navigationBarDownloadButton.progress = group.progress
DispatchQueue.main.async {
self.bannerView.button.progress = nil
self.navigationBarDownloadButton.progress = nil
self.update()
}
}
self.bannerView.button.progress = progress
self.navigationBarDownloadButton.progress = progress
}
func open(_ installedApp: InstalledApp)
{
UIApplication.shared.open(installedApp.openAppURL)
}
func updateApp(_ installedApp: InstalledApp, to version: AppVersion)
{
let previousProgress = AppManager.shared.installationProgress(for: installedApp)
guard previousProgress == nil else {
//TODO: Handle cancellation
//previousProgress?.cancel()
return
}
AppManager.shared.update(installedApp, to: version, presentingViewController: self) { (result) in
DispatchQueue.main.async {
switch result
{
case .success: print("Updated app from AppViewController:", installedApp.bundleIdentifier)
case .failure(OperationError.cancelled): break
case .failure(let error):
let toastView = ToastView(error: error)
toastView.opensErrorLog = true
toastView.show(in: self)
}
self.update()
}
}
self.update()
}
}
private extension AppViewController

View File

@@ -8,9 +8,7 @@
import UIKit
import AltStoreCore
final class PermissionPopoverViewController: UIViewController
class PermissionPopoverViewController: UIViewController
{
var permission: AppPermission!
@@ -21,7 +19,7 @@ final class PermissionPopoverViewController: UIViewController
{
super.viewDidLoad()
self.nameLabel.text = self.permission.localizedName ?? self.permission.permission.rawValue
self.nameLabel.text = self.permission.type.localizedName
self.descriptionLabel.text = self.permission.usageDescription
}
}

View File

@@ -1,154 +0,0 @@
//
// AppScreenshotCollectionViewCell.swift
// AltStore
//
// Created by Riley Testut on 10/11/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import UIKit
import AltStoreCore
extension AppScreenshotCollectionViewCell
{
private class ImageView: UIImageView
{
override func layoutSubviews()
{
super.layoutSubviews()
// Explicitly layout cell to ensure rounded corners are accurate.
self.superview?.superview?.setNeedsLayout()
}
}
}
class AppScreenshotCollectionViewCell: UICollectionViewCell
{
let imageView: UIImageView
var aspectRatio: CGSize = AppScreenshot.defaultAspectRatio {
didSet {
self.updateAspectRatio()
}
}
private var isRounded: Bool = false {
didSet {
self.setNeedsLayout()
self.layoutIfNeeded()
}
}
private var aspectRatioConstraint: NSLayoutConstraint?
override init(frame: CGRect)
{
self.imageView = ImageView(frame: .zero)
self.imageView.clipsToBounds = true
self.imageView.layer.cornerCurve = .continuous
self.imageView.layer.borderColor = UIColor.tertiaryLabel.cgColor
super.init(frame: frame)
self.imageView.translatesAutoresizingMaskIntoConstraints = false
self.contentView.addSubview(self.imageView)
let widthConstraint = self.imageView.widthAnchor.constraint(equalTo: self.contentView.widthAnchor)
widthConstraint.priority = .defaultHigh
let heightConstraint = self.imageView.heightAnchor.constraint(equalTo: self.contentView.heightAnchor)
heightConstraint.priority = .defaultHigh
NSLayoutConstraint.activate([
widthConstraint,
heightConstraint,
self.imageView.widthAnchor.constraint(lessThanOrEqualTo: self.contentView.widthAnchor),
self.imageView.heightAnchor.constraint(lessThanOrEqualTo: self.contentView.heightAnchor),
self.imageView.centerXAnchor.constraint(equalTo: self.contentView.centerXAnchor),
self.imageView.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor)
])
self.updateAspectRatio()
self.updateTraits()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?)
{
super.traitCollectionDidChange(previousTraitCollection)
self.updateTraits()
}
override func layoutSubviews()
{
super.layoutSubviews()
if self.isRounded
{
let cornerRadius = self.imageView.bounds.width / 9.0 // Based on iPhone 15
self.imageView.layer.cornerRadius = cornerRadius
}
else
{
self.imageView.layer.cornerRadius = 5
}
}
}
extension AppScreenshotCollectionViewCell
{
func setImage(_ image: UIImage?)
{
guard var image, let cgImage = image.cgImage else {
self.imageView.image = image
return
}
if image.size.width > image.size.height && self.aspectRatio.width < self.aspectRatio.height
{
// Image is landscape, but cell has portrait aspect ratio, so rotate image to match.
image = UIImage(cgImage: cgImage, scale: image.scale, orientation: .right)
}
self.imageView.image = image
}
}
private extension AppScreenshotCollectionViewCell
{
func updateAspectRatio()
{
self.aspectRatioConstraint?.isActive = false
self.aspectRatioConstraint = self.imageView.widthAnchor.constraint(equalTo: self.imageView.heightAnchor, multiplier: self.aspectRatio.width / self.aspectRatio.height)
self.aspectRatioConstraint?.isActive = true
let aspectRatio: Double
if self.aspectRatio.width > self.aspectRatio.height
{
aspectRatio = self.aspectRatio.height / self.aspectRatio.width
}
else
{
aspectRatio = self.aspectRatio.width / self.aspectRatio.height
}
let tolerance = 0.001 as Double
let modernAspectRatio = AppScreenshot.defaultAspectRatio.width / AppScreenshot.defaultAspectRatio.height
let isRounded = (aspectRatio >= modernAspectRatio - tolerance) && (aspectRatio <= modernAspectRatio + tolerance)
self.isRounded = isRounded
}
func updateTraits()
{
let displayScale = (self.traitCollection.displayScale == 0.0) ? 1.0 : self.traitCollection.displayScale
self.imageView.layer.borderWidth = 1.0 / displayScale
}
}

View File

@@ -1,186 +0,0 @@
//
// AppScreenshotsViewController.swift
// AltStore
//
// Created by Riley Testut on 9/18/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import UIKit
import AltStoreCore
import Roxas
import Nuke
class AppScreenshotsViewController: UICollectionViewController
{
let app: StoreApp
private lazy var dataSource = self.makeDataSource()
init?(app: StoreApp, coder: NSCoder)
{
self.app = app
super.init(coder: coder)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad()
{
super.viewDidLoad()
self.collectionView.showsHorizontalScrollIndicator = false
// Allow parent background color to show through.
self.collectionView.backgroundColor = nil
// Match the parent table view margins.
self.collectionView.directionalLayoutMargins.top = 0
self.collectionView.directionalLayoutMargins.bottom = 0
self.collectionView.directionalLayoutMargins.leading = 20
self.collectionView.directionalLayoutMargins.trailing = 20
let collectionViewLayout = self.makeLayout()
self.collectionView.collectionViewLayout = collectionViewLayout
self.collectionView.register(AppScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
self.collectionView.dataSource = self.dataSource
self.collectionView.prefetchDataSource = self.dataSource
}
}
private extension AppScreenshotsViewController
{
func makeLayout() -> UICollectionViewCompositionalLayout
{
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
layoutConfig.contentInsetsReference = .layoutMargins
let preferredHeight = 400.0
let estimatedWidth = preferredHeight * (AppScreenshot.defaultAspectRatio.width / AppScreenshot.defaultAspectRatio.height)
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [dataSource] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
let screenshotWidths = dataSource.items.map { screenshot in
var aspectRatio = screenshot.size ?? AppScreenshot.defaultAspectRatio
if aspectRatio.width > aspectRatio.height
{
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
}
let screenshotWidth = (preferredHeight * (aspectRatio.width / aspectRatio.height)).rounded()
return screenshotWidth
}
let smallestWidth = screenshotWidths.sorted().first
let itemWidth = smallestWidth ?? estimatedWidth // Use smallestWidth to ensure we never overshoot an item when paging.
let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(itemWidth), heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .estimated(itemWidth), heightDimension: .absolute(preferredHeight))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let layoutSection = NSCollectionLayoutSection(group: group)
layoutSection.interGroupSpacing = 10
layoutSection.orthogonalScrollingBehavior = .groupPaging
return layoutSection
}, configuration: layoutConfig)
return layout
}
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>
{
let screenshots = self.app.preferredScreenshots()
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>(items: screenshots)
dataSource.cellConfigurationHandler = { [weak self] (cell, screenshot, indexPath) in
let cell = cell as! AppScreenshotCollectionViewCell
cell.imageView.isIndicatingActivity = true
cell.setImage(nil)
var aspectRatio = screenshot.size ?? AppScreenshot.defaultAspectRatio
if aspectRatio.width > aspectRatio.height
{
switch screenshot.deviceType
{
case .iphone:
// Always rotate landscape iPhone screenshots regardless of horizontal size class.
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
case .ipad where self?.traitCollection.horizontalSizeClass == .compact:
// Only rotate landscape iPad screenshots if we're in horizontally compact environment.
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
default: break
}
}
cell.aspectRatio = aspectRatio
}
dataSource.prefetchHandler = { (screenshot, indexPath, completionHandler) in
let imageURL = screenshot.imageURL
return RSTAsyncBlockOperation() { (operation) in
let request = ImageRequest(url: imageURL)
ImagePipeline.shared.loadImage(with: request, progress: nil) { result in
guard !operation.isCancelled else { return operation.finish() }
switch result
{
case .success(let response): completionHandler(response.image, nil)
case .failure(let error): completionHandler(nil, error)
}
}
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! AppScreenshotCollectionViewCell
cell.imageView.isIndicatingActivity = false
cell.setImage(image)
if let error = error
{
print("Error loading image:", error)
}
}
return dataSource
}
}
extension AppScreenshotsViewController
{
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
{
let screenshot = self.dataSource.item(at: indexPath)
let previewViewController = PreviewAppScreenshotsViewController(app: self.app)
previewViewController.currentScreenshot = screenshot
let navigationController = UINavigationController(rootViewController: previewViewController)
navigationController.modalPresentationStyle = .fullScreen
self.present(navigationController, animated: true)
}
}
@available(iOS 17, *)
#Preview(traits: .portrait) {
DatabaseManager.shared.startForPreview()
let fetchRequest = StoreApp.fetchRequest()
let storeApp = try! DatabaseManager.shared.viewContext.fetch(fetchRequest).first!
let storyboard = UIStoryboard(name: "Main", bundle: .main)
let appViewConttroller = storyboard.instantiateViewController(withIdentifier: "appViewController") as! AppViewController
appViewConttroller.app = storeApp
let navigationController = UINavigationController(rootViewController: appViewConttroller)
return navigationController
}

View File

@@ -1,189 +0,0 @@
//
// PreviewAppScreenshotsViewController.swift
// AltStore
//
// Created by Riley Testut on 9/19/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import UIKit
import AltStoreCore
import Roxas
import Nuke
class PreviewAppScreenshotsViewController: UICollectionViewController
{
let app: StoreApp
var currentScreenshot: AppScreenshot?
private lazy var dataSource = self.makeDataSource()
init(app: StoreApp)
{
self.app = app
super.init(collectionViewLayout: UICollectionViewFlowLayout())
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad()
{
super.viewDidLoad()
let tintColor = self.app.tintColor ?? .altPrimary
self.navigationController?.view.tintColor = tintColor
self.view.backgroundColor = .systemBackground
self.collectionView.backgroundColor = nil
let collectionViewLayout = self.makeLayout()
self.collectionView.collectionViewLayout = collectionViewLayout
self.collectionView.directionalLayoutMargins.leading = 20
self.collectionView.directionalLayoutMargins.trailing = 20
self.collectionView.preservesSuperviewLayoutMargins = true
self.collectionView.insetsLayoutMarginsFromSafeArea = true
self.collectionView.alwaysBounceVertical = false
self.collectionView.register(AppScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
self.collectionView.dataSource = self.dataSource
self.collectionView.prefetchDataSource = self.dataSource
let doneButton = UIBarButtonItem(systemItem: .done, primaryAction: UIAction { [weak self] _ in
self?.dismissPreview()
})
self.navigationItem.rightBarButtonItem = doneButton
let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(PreviewAppScreenshotsViewController.dismissPreview))
swipeGestureRecognizer.direction = .down
self.view.addGestureRecognizer(swipeGestureRecognizer)
}
override func viewIsAppearing(_ animated: Bool)
{
super.viewIsAppearing(animated)
if let screenshot = self.currentScreenshot, let index = self.dataSource.items.firstIndex(of: screenshot)
{
let indexPath = IndexPath(item: index, section: 0)
self.collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
}
}
}
private extension PreviewAppScreenshotsViewController
{
func makeLayout() -> UICollectionViewCompositionalLayout
{
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
layoutConfig.contentInsetsReference = .none
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
guard let self else { return nil }
let contentInsets = self.collectionView.directionalLayoutMargins
let groupWidth = layoutEnvironment.container.contentSize.width - (contentInsets.leading + contentInsets.trailing)
let groupHeight = layoutEnvironment.container.contentSize.height - (contentInsets.top + contentInsets.bottom)
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(groupWidth), heightDimension: .absolute(groupHeight))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let layoutSection = NSCollectionLayoutSection(group: group)
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
layoutSection.interGroupSpacing = 10
return layoutSection
}, configuration: layoutConfig)
return layout
}
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>
{
let screenshots = self.app.preferredScreenshots()
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>(items: screenshots)
dataSource.cellConfigurationHandler = { [weak self] (cell, screenshot, indexPath) in
let cell = cell as! AppScreenshotCollectionViewCell
cell.imageView.isIndicatingActivity = true
cell.setImage(nil)
var aspectRatio = screenshot.size ?? AppScreenshot.defaultAspectRatio
if aspectRatio.width > aspectRatio.height
{
switch screenshot.deviceType
{
case .iphone:
// Always rotate landscape iPhone screenshots regardless of horizontal size class.
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
case .ipad where self?.traitCollection.horizontalSizeClass == .compact:
// Only rotate landscape iPad screenshots if we're in horizontally compact environment.
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
default: break
}
}
cell.aspectRatio = aspectRatio
}
dataSource.prefetchHandler = { (screenshot, indexPath, completionHandler) in
let imageURL = screenshot.imageURL
return RSTAsyncBlockOperation() { (operation) in
let request = ImageRequest(url: imageURL)
ImagePipeline.shared.loadImage(with: request, progress: nil) { result in
guard !operation.isCancelled else { return operation.finish() }
switch result
{
case .success(let response): completionHandler(response.image, nil)
case .failure(let error): completionHandler(nil, error)
}
}
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! AppScreenshotCollectionViewCell
cell.imageView.isIndicatingActivity = false
cell.setImage(image)
if let error = error
{
print("Error loading image:", error)
}
}
return dataSource
}
}
private extension PreviewAppScreenshotsViewController
{
@objc func dismissPreview()
{
self.dismiss(animated: true)
}
}
@available(iOS 17, *)
#Preview(traits: .portrait) {
DatabaseManager.shared.startForPreview()
let fetchRequest = StoreApp.fetchRequest()
let storeApp = try! DatabaseManager.shared.viewContext.fetch(fetchRequest).first!
let previewViewController = PreviewAppScreenshotsViewController(app: storeApp)
let navigationController = UINavigationController(rootViewController: previewViewController)
return navigationController
}

View File

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

@@ -9,98 +9,69 @@
import UIKit
import UserNotifications
import AVFoundation
import Intents
import AltStoreCore
import AltSign
import AltKit
import Roxas
import Nuke
private enum RefreshError: LocalizedError
{
case noInstalledApps
var errorDescription: String? {
switch self
{
case .noInstalledApps: return NSLocalizedString("No installed apps to refresh.", comment: "")
}
}
}
extension UIApplication: LegacyBackgroundFetching {}
private extension CFNotificationName
{
static let requestAppState = CFNotificationName("com.altstore.RequestAppState" as CFString)
static let appIsRunning = CFNotificationName("com.altstore.AppState.Running" as CFString)
static func requestAppState(for appID: String) -> CFNotificationName
{
let name = String(CFNotificationName.requestAppState.rawValue) + "." + appID
return CFNotificationName(name as CFString)
}
static func appIsRunning(for appID: String) -> CFNotificationName
{
let name = String(CFNotificationName.appIsRunning.rawValue) + "." + appID
return CFNotificationName(name as CFString)
}
}
private let ReceivedApplicationState: @convention(c) (CFNotificationCenter?, UnsafeMutableRawPointer?, CFNotificationName?, UnsafeRawPointer?, CFDictionary?) -> Void =
{ (center, observer, name, object, userInfo) in
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate, let name = name else { return }
appDelegate.receivedApplicationState(notification: name)
}
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 openPatreonSettingsDeepLinkNotification = Notification.Name("com.rileytestut.AltStore.OpenPatreonSettingsDeepLinkNotification")
static let importAppDeepLinkNotification = Notification.Name("com.rileytestut.AltStore.ImportAppDeepLinkNotification")
static let importAppDeepLinkURLKey = "fileURL"
static let appBackupResultKey = "result"
static let addSourceDeepLinkURLKey = "sourceURL"
static let exportCertificateCallbackTemplateKey = "callback"
}
@UIApplicationMain
final class AppDelegate: UIResponder, UIApplicationDelegate {
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private let intentHandler = IntentHandler()
private let viewAppIntentHandler = ViewAppIntentHandler()
public let consoleLog = ConsoleLog()
private var runningApplications: Set<String>?
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?
// start_em_proxy(bind_addr: Consts.Proxy.serverURL)
SecureValueTransformer.register()
ServerManager.shared.startDiscovering()
UserDefaults.standard.registerDefaults()
if UserDefaults.standard.firstLaunch == nil
{
@@ -110,7 +81,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
UserDefaults.standard.preferredServerID = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.serverID) as? String
#if DEBUG && targetEnvironment(simulator)
#if DEBUG || BETA
UserDefaults.standard.isDebugModeEnabled = true
#endif
@@ -121,26 +92,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidEnterBackground(_ application: UIApplication)
{
// Make sure to update SceneDelegate.sceneDidEnterBackground() as well.
// TODO: @mahee96: find if we need to stop em_proxy as in altstore?
// stop_em_proxy()
guard let oneMonthAgo = Calendar.current.date(byAdding: .month, value: -1, to: Date()) else { return }
let midnightOneMonthAgo = Calendar.current.startOfDay(for: oneMonthAgo)
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)
}
}
ServerManager.shared.stopDiscovering()
}
func applicationWillEnterForeground(_ application: UIApplication)
{
AppManager.shared.update()
ServerManager.shared.startDiscovering()
PatreonAPI.shared.refreshPatreonAccount()
}
@@ -148,44 +107,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
{
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
@@ -195,33 +116,6 @@ private extension AppDelegate
self.window?.tintColor = .altPrimary
}
func prepareImageCache()
{
// Avoid caching responses twice.
DataLoader.sharedUrlCache.diskCapacity = 0
let pipeline = ImagePipeline { configuration in
do
{
let dataCache = try DataCache(name: "io.sidestore.Nuke")
dataCache.sizeLimit = 512 * 1024 * 1024 // 512MB
configuration.dataCache = dataCache
}
catch
{
Logger.main.error("Failed to create image disk cache. Falling back to URL cache. \(error.localizedDescription, privacy: .public)")
}
}
ImagePipeline.shared = pipeline
if let dataCache = ImagePipeline.shared.configuration.dataCache as? DataCache, #available(iOS 15, *)
{
Logger.main.info("Current image cache size: \(dataCache.totalSize.formatted(.byteCount(style: .file)), privacy: .public)")
}
}
func open(_ url: URL) -> Bool
{
if url.isFileURL
@@ -237,83 +131,13 @@ private extension AppDelegate
else
{
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false }
guard let host = components.host?.lowercased() else { return false }
guard let host = components.host, host.lowercased() == "patreon" 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
DispatchQueue.main.async {
NotificationCenter.default.post(name: AppDelegate.openPatreonSettingsDeepLinkNotification, object: nil)
}
return true
}
}
}
@@ -323,12 +147,12 @@ extension AppDelegate
private func prepareForBackgroundFetch()
{
// "Fetch" every hour, but then refresh only those that need to be refreshed (so we don't drain the battery).
(UIApplication.shared as LegacyBackgroundFetching).setMinimumBackgroundFetchInterval(1 * 60 * 60)
UIApplication.shared.setMinimumBackgroundFetchInterval(1 * 60 * 60)
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { (success, error) in
}
#if DEBUG && targetEnvironment(simulator)
#if DEBUG
UIApplication.shared.registerForRemoteNotifications()
#endif
}
@@ -350,99 +174,98 @@ extension AppDelegate
func application(_ application: UIApplication, performFetchWithCompletionHandler backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void)
{
if UserDefaults.standard.isBackgroundRefreshEnabled && !UserDefaults.standard.presentedLaunchReminderNotification
if UserDefaults.standard.isBackgroundRefreshEnabled
{
let threeHours: TimeInterval = 3 * 60 * 60
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: threeHours, repeats: false)
ServerManager.shared.startDiscovering()
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
if !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 AltStore, 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
}
}
let refreshIdentifier = UUID().uuidString
BackgroundTaskManager.shared.performExtendedBackgroundTask { (taskResult, taskCompletionHandler) in
func finish(_ result: Result<[String: Result<InstalledApp, Error>], Error>)
{
// If finish is actually called, that means an error occured during installation.
if UserDefaults.standard.isBackgroundRefreshEnabled
{
ServerManager.shared.stopDiscovering()
self.scheduleFinishedRefreshingNotification(for: result, identifier: refreshIdentifier, delay: 0)
}
taskCompletionHandler()
}
if let error = taskResult.error
{
print("Error starting extended background task. Aborting.", error)
backgroundFetchCompletionHandler(.failed)
taskCompletionHandler()
finish(.failure(error))
return
}
if !DatabaseManager.shared.isStarted
{
DatabaseManager.shared.start() { (error) in
if error != nil
if let error = error
{
backgroundFetchCompletionHandler(.failed)
taskCompletionHandler()
finish(.failure(error))
}
else
{
self.performBackgroundFetch { (backgroundFetchResult) in
backgroundFetchCompletionHandler(backgroundFetchResult)
} refreshAppsCompletionHandler: { (refreshAppsResult) in
taskCompletionHandler()
}
self.refreshApps(identifier: refreshIdentifier, backgroundFetchCompletionHandler: backgroundFetchCompletionHandler, completionHandler: finish(_:))
}
}
}
else
{
self.performBackgroundFetch { (backgroundFetchResult) in
backgroundFetchCompletionHandler(backgroundFetchResult)
} refreshAppsCompletionHandler: { (refreshAppsResult) in
taskCompletionHandler()
}
self.refreshApps(identifier: refreshIdentifier, backgroundFetchCompletionHandler: backgroundFetchCompletionHandler, completionHandler: finish(_:))
}
}
}
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)
func refreshApps(identifier: String,
backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void,
completionHandler: @escaping (Result<[String: Result<InstalledApp, Error>], Error>) -> Void)
{
AppManager.shared.fetchSources() { (result) in
var fetchSourceResult: Result<Source, Error>?
var serversResult: Result<Void, Error>?
let dispatchGroup = DispatchGroup()
dispatchGroup.enter()
AppManager.shared.fetchSource() { (result) in
fetchSourceResult = result
do
{
let (sources, context) = try result.get()
let source = try result.get()
let previousUpdatesFetchRequest = InstalledApp.supportedUpdatesFetchRequest() as! NSFetchRequest<NSFetchRequestResult>
guard let context = source.managedObjectContext else { return }
let previousUpdatesFetchRequest = InstalledApp.updatesFetchRequest() as! NSFetchRequest<NSFetchRequestResult>
previousUpdatesFetchRequest.includesPendingChanges = false
previousUpdatesFetchRequest.resultType = .dictionaryResultType
previousUpdatesFetchRequest.propertiesToFetch = [#keyPath(InstalledApp.bundleIdentifier),
#keyPath(InstalledApp.storeApp.latestSupportedVersion.version),
#keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion)]
previousUpdatesFetchRequest.propertiesToFetch = [#keyPath(InstalledApp.bundleIdentifier)]
let previousNewsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NSFetchRequestResult>
previousNewsItemsFetchRequest.includesPendingChanges = false
@@ -454,9 +277,7 @@ private extension AppDelegate
try context.save()
let updatesFetchRequest = InstalledApp.supportedUpdatesFetchRequest()
let updatesFetchRequest = InstalledApp.updatesFetchRequest()
let newsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NewsItem>
let updates = try context.fetch(updatesFetchRequest)
@@ -464,23 +285,12 @@ private extension AppDelegate
for update in updates
{
guard let storeApp = update.storeApp, let latestSupportedVersion = storeApp.latestSupportedVersion, latestSupportedVersion.isSupported else { continue }
if let previousUpdate = previousUpdates.first(where: { $0[#keyPath(InstalledApp.bundleIdentifier)] == update.bundleIdentifier })
{
// An update for this app was already available, so check whether the version or build version is different.
guard let previousVersion = previousUpdate[#keyPath(InstalledApp.storeApp.latestSupportedVersion.version)] else { continue }
// previousUpdate might not contain buildVersion, but if it does then map empty string to nil to match AppVersion.
let previousBuildVersion = previousUpdate[#keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion)].map { $0.isEmpty ? nil : "" }
// Only show notification if previous latestSupportedVersion does not _exactly_ match current latestSupportedVersion.
guard previousVersion != latestSupportedVersion.version || previousBuildVersion != latestSupportedVersion.buildVersion else { continue }
}
guard !previousUpdates.contains(where: { $0[#keyPath(InstalledApp.bundleIdentifier)] == update.bundleIdentifier }) else { continue }
guard let storeApp = update.storeApp else { continue }
let content = UNMutableNotificationContent()
content.title = NSLocalizedString("New Update Available", comment: "")
content.body = String(format: NSLocalizedString("%@ %@ is now available for download.", comment: ""), update.name, latestSupportedVersion.localizedVersion)
content.body = String(format: NSLocalizedString("%@ %@ is now available for download.", comment: ""), update.name, storeApp.version)
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
@@ -500,7 +310,7 @@ private extension AppDelegate
}
else
{
content.title = NSLocalizedString("SideStore News", comment: "")
content.title = NSLocalizedString("AltStore News", comment: "")
}
content.body = newsItem.title
@@ -513,14 +323,223 @@ private extension AppDelegate
DispatchQueue.main.async {
UIApplication.shared.applicationIconBadgeNumber = updates.count
}
completionHandler(.success(sources))
}
catch
{
print("Error fetching apps:", error)
completionHandler(.failure(error))
fetchSourceResult = .failure(error)
}
dispatchGroup.leave()
}
if UserDefaults.standard.isBackgroundRefreshEnabled
{
dispatchGroup.enter()
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
let installedApps = InstalledApp.fetchAppsForBackgroundRefresh(in: context)
guard !installedApps.isEmpty else {
serversResult = .success(())
dispatchGroup.leave()
completionHandler(.failure(RefreshError.noInstalledApps))
return
}
self.runningApplications = []
let identifiers = installedApps.compactMap { $0.bundleIdentifier }
print("Apps to refresh:", identifiers)
DispatchQueue.global().async {
let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter()
for identifier in identifiers
{
let appIsRunningNotification = CFNotificationName.appIsRunning(for: identifier)
CFNotificationCenterAddObserver(notificationCenter, nil, ReceivedApplicationState, appIsRunningNotification.rawValue, nil, .deliverImmediately)
let requestAppStateNotification = CFNotificationName.requestAppState(for: identifier)
CFNotificationCenterPostNotification(notificationCenter, requestAppStateNotification, nil, nil, true)
}
}
// Wait for three seconds to:
// a) give us time to discover AltServers
// b) give other processes a chance to respond to requestAppState notification
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
context.perform {
if ServerManager.shared.discoveredServers.isEmpty
{
serversResult = .failure(ConnectionError.serverNotFound)
}
else
{
serversResult = .success(())
}
dispatchGroup.leave()
let filteredApps = installedApps.filter { !(self.runningApplications?.contains($0.bundleIdentifier) ?? false) }
print("Filtered Apps to Refresh:", filteredApps.map { $0.bundleIdentifier })
let group = AppManager.shared.refresh(filteredApps, presentingViewController: nil)
group.beginInstallationHandler = { (installedApp) in
guard installedApp.bundleIdentifier == StoreApp.altstoreAppID else { return }
// We're starting to install AltStore, which means the app is about to quit.
// So, we schedule a "refresh successful" local notification to be displayed after a delay,
// but if the app is still running, we cancel the notification.
// Then, we schedule another notification and repeat the process.
// Also since AltServer has already received the app, it can finish installing even if we're no longer running in background.
if let error = group.error
{
self.scheduleFinishedRefreshingNotification(for: .failure(error), identifier: identifier)
}
else
{
var results = group.results
results[installedApp.bundleIdentifier] = .success(installedApp)
self.scheduleFinishedRefreshingNotification(for: .success(results), identifier: identifier)
}
}
group.completionHandler = { (result) in
completionHandler(result)
}
}
}
}
}
dispatchGroup.notify(queue: .main) {
if !UserDefaults.standard.isBackgroundRefreshEnabled
{
guard let fetchSourceResult = fetchSourceResult else {
backgroundFetchCompletionHandler(.failed)
return
}
switch fetchSourceResult
{
case .failure: backgroundFetchCompletionHandler(.failed)
case .success: backgroundFetchCompletionHandler(.newData)
}
completionHandler(.success([:]))
}
else
{
guard let fetchSourceResult = fetchSourceResult, let serversResult = serversResult else {
backgroundFetchCompletionHandler(.failed)
return
}
// Call completionHandler early to improve chances of refreshing in the background again.
switch (fetchSourceResult, serversResult)
{
case (.success, .success): backgroundFetchCompletionHandler(.newData)
case (.success, .failure(ConnectionError.serverNotFound)): backgroundFetchCompletionHandler(.newData)
case (.failure, _), (_, .failure): backgroundFetchCompletionHandler(.failed)
}
}
}
}
func receivedApplicationState(notification: CFNotificationName)
{
let baseName = String(CFNotificationName.appIsRunning.rawValue)
let appID = String(notification.rawValue).replacingOccurrences(of: baseName + ".", with: "")
self.runningApplications?.insert(appID)
}
func scheduleFinishedRefreshingNotification(for result: Result<[String: Result<InstalledApp, Error>], Error>, identifier: String, delay: TimeInterval = 5)
{
func scheduleFinishedRefreshingNotification()
{
self.cancelFinishedRefreshingNotification(identifier: identifier)
let content = UNMutableNotificationContent()
var shouldPresentAlert = true
do
{
let results = try result.get()
shouldPresentAlert = !results.isEmpty
for (_, result) in results
{
guard case let .failure(error) = result else { continue }
throw error
}
content.title = NSLocalizedString("Refreshed Apps", comment: "")
content.body = NSLocalizedString("All apps have been refreshed.", comment: "")
}
catch ConnectionError.serverNotFound
{
shouldPresentAlert = false
}
catch RefreshError.noInstalledApps
{
shouldPresentAlert = false
}
catch
{
print("Failed to refresh apps in background.", error)
content.title = NSLocalizedString("Failed to Refresh Apps", comment: "")
content.body = error.localizedDescription
shouldPresentAlert = true
}
if shouldPresentAlert
{
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay + 1, repeats: false)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)
if delay > 0
{
DispatchQueue.global().asyncAfter(deadline: .now() + delay) {
UNUserNotificationCenter.current().getPendingNotificationRequests() { (requests) in
// If app is still running at this point, we schedule another notification with same identifier.
// This prevents the currently scheduled notification from displaying, and starts another countdown timer.
// First though, make sure there _is_ still a pending request, otherwise it's been cancelled
// and we should stop polling.
guard requests.contains(where: { $0.identifier == identifier }) else { return }
scheduleFinishedRefreshingNotification()
}
}
}
}
}
scheduleFinishedRefreshingNotification()
// Perform synchronously to ensure app doesn't quit before we've finishing saving to disk.
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
context.performAndWait {
_ = RefreshAttempt(identifier: identifier, result: result, context: context)
do { try context.save() }
catch { print("Failed to save refresh attempt.", error) }
}
}
func cancelFinishedRefreshingNotification(identifier: String)
{
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier])
}
}

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15703"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@@ -13,8 +13,8 @@
<scene sceneID="lNR-II-WoW">
<objects>
<navigationController storyboardIdentifier="navigationController" id="ZTo-53-dSL" sceneMemberID="viewController">
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" barStyle="black" largeTitles="YES" id="Aej-RF-PfV" customClass="NavigationBar" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="20" width="375" height="96"/>
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" barStyle="black" largeTitles="YES" id="Aej-RF-PfV" customClass="NavigationBar" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="96"/>
<autoresizingMask key="autoresizingMask"/>
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="barTintColor" name="SettingsBackground"/>
@@ -36,19 +36,19 @@
<!--Authentication View Controller-->
<scene sceneID="OCd-xc-Ms7">
<objects>
<viewController storyboardIdentifier="authenticationViewController" id="yO1-iT-7NP" customClass="AuthenticationViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<viewController storyboardIdentifier="authenticationViewController" id="yO1-iT-7NP" customClass="AuthenticationViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="mjy-4S-hyH">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<view hidden="YES" userInteractionEnabled="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="oyW-Fd-ojD" userLabel="Sizing View">
<rect key="frame" x="0.0" y="64" width="375" height="603"/>
<rect key="frame" x="0.0" y="44" width="375" height="623"/>
</view>
<scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" alwaysBounceVertical="YES" indicatorStyle="white" keyboardDismissMode="onDrag" translatesAutoresizingMaskIntoConstraints="NO" id="WXx-hX-AXv">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<subviews>
<view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" translatesAutoresizingMaskIntoConstraints="NO" id="2wp-qG-f0Z">
<rect key="frame" x="0.0" y="0.0" width="375" height="603"/>
<rect key="frame" x="0.0" y="0.0" width="375" height="623"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" axis="vertical" spacing="50" translatesAutoresizingMaskIntoConstraints="NO" id="YmX-7v-pxh">
<rect key="frame" x="16" y="6" width="343" height="359.5"/>
@@ -56,14 +56,14 @@
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="Yfu-hI-0B7" userLabel="Welcome">
<rect key="frame" x="0.0" y="0.0" width="343" height="67.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Welcome to SideStore." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="EI2-V3-zQZ">
<rect key="frame" x="0.0" y="0.0" width="343" height="41"/>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Welcome to AltStore." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="EI2-V3-zQZ">
<rect key="frame" x="0.0" y="0.0" width="333.5" height="41"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="34"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Sign in with your Apple ID to get started." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="SNU-tv-8Au">
<rect key="frame" x="0.0" y="47" width="306.5" height="20.5"/>
<rect key="frame" x="0.0" y="47" width="308.5" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
@@ -160,7 +160,7 @@
</stackView>
</subviews>
</stackView>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="2N5-zd-fUj">
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="2N5-zd-fUj">
<rect key="frame" x="0.0" y="191" width="343" height="51"/>
<color key="backgroundColor" name="SettingsHighlighted"/>
<constraints>
@@ -179,7 +179,7 @@
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="250" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="DBk-rT-ZE8">
<rect key="frame" x="16" y="498.5" width="343" height="96.5"/>
<rect key="frame" x="16" y="518.5" width="343" height="96.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Why do we need this?" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="p9U-0q-Kn8">
<rect key="frame" x="0.0" y="0.0" width="343" height="20.5"/>
@@ -198,10 +198,6 @@
</stackView>
</subviews>
<constraints>
<constraint firstItem="DBk-rT-ZE8" firstAttribute="leading" secondItem="2wp-qG-f0Z" secondAttribute="leadingMargin" id="5AT-nV-ZP9"/>
<constraint firstAttribute="bottomMargin" secondItem="DBk-rT-ZE8" secondAttribute="bottom" id="HgY-oY-8KM"/>
<constraint firstAttribute="trailingMargin" secondItem="DBk-rT-ZE8" secondAttribute="trailing" id="VCf-bW-2K4"/>
<constraint firstItem="YmX-7v-pxh" firstAttribute="top" secondItem="2wp-qG-f0Z" secondAttribute="top" constant="6" id="iUr-Nd-tkt"/>
<constraint firstItem="DBk-rT-ZE8" firstAttribute="top" relation="greaterThanOrEqual" secondItem="YmX-7v-pxh" secondAttribute="bottom" constant="8" symbolic="YES" id="zTU-eY-DWd"/>
</constraints>
</view>
@@ -214,22 +210,26 @@
</constraints>
</scrollView>
</subviews>
<viewLayoutGuide key="safeArea" id="zMn-DV-fpy"/>
<color key="backgroundColor" name="SettingsBackground"/>
<constraints>
<constraint firstAttribute="bottom" secondItem="WXx-hX-AXv" secondAttribute="bottom" id="0jL-Ky-ju6"/>
<constraint firstAttribute="leadingMargin" secondItem="YmX-7v-pxh" secondAttribute="leading" id="2PO-lG-dmB"/>
<constraint firstItem="DBk-rT-ZE8" firstAttribute="leading" secondItem="2wp-qG-f0Z" secondAttribute="leadingMargin" id="5AT-nV-ZP9"/>
<constraint firstItem="oyW-Fd-ojD" firstAttribute="top" secondItem="zMn-DV-fpy" secondAttribute="top" id="730-db-ukB"/>
<constraint firstItem="2wp-qG-f0Z" firstAttribute="bottomMargin" secondItem="DBk-rT-ZE8" secondAttribute="bottom" id="HgY-oY-8KM"/>
<constraint firstItem="zMn-DV-fpy" firstAttribute="trailing" secondItem="oyW-Fd-ojD" secondAttribute="trailing" id="KGE-CN-SWf"/>
<constraint firstItem="WXx-hX-AXv" firstAttribute="top" secondItem="mjy-4S-hyH" secondAttribute="top" id="LPQ-bF-ic0"/>
<constraint firstItem="zMn-DV-fpy" firstAttribute="trailing" secondItem="WXx-hX-AXv" secondAttribute="trailing" id="MG7-A6-pKp"/>
<constraint firstAttribute="trailingMargin" secondItem="YmX-7v-pxh" secondAttribute="trailing" id="O4T-nu-o3e"/>
<constraint firstItem="zMn-DV-fpy" firstAttribute="bottom" secondItem="oyW-Fd-ojD" secondAttribute="bottom" id="PuX-ab-cEq"/>
<constraint firstItem="oyW-Fd-ojD" firstAttribute="leading" secondItem="zMn-DV-fpy" secondAttribute="leading" id="SzC-gC-Nvi"/>
<constraint firstItem="2wp-qG-f0Z" firstAttribute="trailingMargin" secondItem="DBk-rT-ZE8" secondAttribute="trailing" id="VCf-bW-2K4"/>
<constraint firstItem="WXx-hX-AXv" firstAttribute="leading" secondItem="zMn-DV-fpy" secondAttribute="leading" id="d08-zF-5X6"/>
<constraint firstItem="2wp-qG-f0Z" firstAttribute="height" secondItem="oyW-Fd-ojD" secondAttribute="height" id="dFN-pw-TWt"/>
<constraint firstItem="YmX-7v-pxh" firstAttribute="top" secondItem="2wp-qG-f0Z" secondAttribute="top" constant="6" id="iUr-Nd-tkt"/>
<constraint firstItem="2wp-qG-f0Z" firstAttribute="width" secondItem="oyW-Fd-ojD" secondAttribute="width" id="rYO-GN-0Lk"/>
</constraints>
<viewLayoutGuide key="safeArea" id="zMn-DV-fpy"/>
</view>
<toolbarItems/>
<navigationItem key="navigationItem" largeTitleDisplayMode="never" id="jCf-N4-xVD">
@@ -258,13 +258,13 @@
<!--How it works-->
<scene sceneID="dMt-EA-SGy">
<objects>
<viewController storyboardIdentifier="instructionsViewController" hidesBottomBarWhenPushed="YES" id="aFi-fb-W0B" customClass="InstructionsViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<viewController storyboardIdentifier="instructionsViewController" hidesBottomBarWhenPushed="YES" id="aFi-fb-W0B" customClass="InstructionsViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" id="Otz-hn-WGS">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" axis="vertical" distribution="equalSpacing" translatesAutoresizingMaskIntoConstraints="NO" id="bp6-55-IG2">
<rect key="frame" x="0.0" y="64" width="375" height="544"/>
<rect key="frame" x="0.0" y="44" width="375" height="564"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="FjP-tm-w7K">
<rect key="frame" x="16" y="35" width="343" height="95.5"/>
@@ -281,13 +281,13 @@
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="Q20-ml-9D0">
<rect key="frame" x="79" y="16" width="264" height="64"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Launch SideStore" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="XKD-XH-eB0">
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Launch AltServer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="XKD-XH-eB0">
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Leave SideStore running in the background on your idevice." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="6HP-Xh-sAH">
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Leave AltServer running in the background on your computer." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="6HP-Xh-sAH">
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
<fontDescription key="fontDescription" type="system" pointSize="16"/>
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
@@ -298,7 +298,7 @@
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="LpI-Jt-SzX">
<rect key="frame" x="16" y="161" width="343" height="95.5"/>
<rect key="frame" x="16" y="168" width="343" height="95.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="2" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="0LW-eE-qHa">
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
@@ -310,15 +310,15 @@
<nil key="highlightedColor"/>
</label>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="dMu-eg-gIO">
<rect key="frame" x="79" y="16" width="264" height="64"/>
<rect key="frame" x="79" y="15.5" width="264" height="64"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Connect to Wi-Fi and VPN" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="esj-pD-D4A">
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Connect to WiFi" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="esj-pD-D4A">
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Enable StosVPN and be able to use Sidestore on the go." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="4rk-ge-FSj">
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Enable iTunes WiFi Sync and connect to the same WiFi as AltServer." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="4rk-ge-FSj">
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
<fontDescription key="fontDescription" type="system" pointSize="16"/>
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
@@ -329,7 +329,7 @@
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="tfb-ja-9UC">
<rect key="frame" x="16" y="287.5" width="343" height="95.5"/>
<rect key="frame" x="16" y="300.5" width="343" height="95.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="3" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="nVr-El-Csi">
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
@@ -341,7 +341,7 @@
<nil key="highlightedColor"/>
</label>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="z6Y-zi-teL">
<rect key="frame" x="79" y="15.5" width="264" height="64"/>
<rect key="frame" x="79" y="16" width="264" height="64"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Download Apps" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="JeJ-bk-UCA">
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
@@ -349,7 +349,7 @@
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Browse and download apps directly from SideStore." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="M7T-9j-uyt">
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Browse and download apps directly from AltStore." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="M7T-9j-uyt">
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
<fontDescription key="fontDescription" type="system" pointSize="16"/>
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
@@ -360,7 +360,7 @@
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="X3r-G1-vf2">
<rect key="frame" x="16" y="413.5" width="343" height="95.5"/>
<rect key="frame" x="16" y="433.5" width="343" height="95.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="4" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="i2U-NL-plG">
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
@@ -372,7 +372,7 @@
<nil key="highlightedColor"/>
</label>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="Xs6-pJ-PUz">
<rect key="frame" x="79" y="17" width="264" height="62"/>
<rect key="frame" x="79" y="16" width="264" height="64"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Apps Refresh Automatically" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="nvb-Aq-sYa">
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
@@ -380,8 +380,8 @@
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Apps are refreshed in the background while you are on SideStore VPN!" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="HU5-Hv-E3d">
<rect key="frame" x="0.0" y="25.5" width="264" height="36.5"/>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Apps are refreshed in the background when on same WiFi as AltServer." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="HU5-Hv-E3d">
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
<fontDescription key="fontDescription" type="system" pointSize="16"/>
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
@@ -408,7 +408,6 @@
</connections>
</button>
</subviews>
<viewLayoutGuide key="safeArea" id="Zek-aC-HOO"/>
<color key="backgroundColor" name="SettingsBackground"/>
<constraints>
<constraint firstItem="qZ9-AR-2zK" firstAttribute="top" secondItem="bp6-55-IG2" secondAttribute="bottom" id="3yt-cr-swd"/>
@@ -419,6 +418,7 @@
<constraint firstAttribute="bottomMargin" secondItem="qZ9-AR-2zK" secondAttribute="bottom" id="e8e-9l-Mkt"/>
<constraint firstItem="qZ9-AR-2zK" firstAttribute="leading" secondItem="Otz-hn-WGS" secondAttribute="leadingMargin" id="t2b-3e-6ld"/>
</constraints>
<viewLayoutGuide key="safeArea" id="Zek-aC-HOO"/>
</view>
<navigationItem key="navigationItem" title="How it works" largeTitleDisplayMode="always" id="bCq-Jq-gf1"/>
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
@@ -431,10 +431,10 @@
</objects>
<point key="canvasLocation" x="1353" y="736"/>
</scene>
<!--Refresh SideStore-->
<!--Refresh AltStore-->
<scene sceneID="9Vh-dM-OqX">
<objects>
<viewController storyboardIdentifier="refreshAltStoreViewController" id="aoK-yE-UVT" customClass="RefreshAltStoreViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<viewController storyboardIdentifier="refreshAltStoreViewController" id="aoK-yE-UVT" customClass="RefreshAltStoreViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" id="R83-kV-365">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
@@ -445,7 +445,7 @@
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="tDQ-ao-1Jg">
<rect key="frame" x="16" y="570" width="343" height="89"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="xcg-hT-tDe" customClass="PillButton" customModule="SideStore" customModuleProvider="target">
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="xcg-hT-tDe" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="343" height="51"/>
<color key="backgroundColor" name="SettingsHighlighted"/>
<constraints>
@@ -473,7 +473,6 @@
</subviews>
</stackView>
</subviews>
<viewLayoutGuide key="safeArea" id="iwE-xE-ziz"/>
<color key="backgroundColor" name="SettingsBackground"/>
<constraints>
<constraint firstItem="fpO-Bf-gFY" firstAttribute="leading" secondItem="iwE-xE-ziz" secondAttribute="leading" id="A77-nX-Wg2"/>
@@ -484,8 +483,9 @@
<constraint firstItem="fpO-Bf-gFY" firstAttribute="top" secondItem="R83-kV-365" secondAttribute="top" id="oKo-10-7kD"/>
<constraint firstItem="tDQ-ao-1Jg" firstAttribute="leading" secondItem="R83-kV-365" secondAttribute="leadingMargin" id="zEt-Xr-kJx"/>
</constraints>
<viewLayoutGuide key="safeArea" id="iwE-xE-ziz"/>
</view>
<navigationItem key="navigationItem" title="Refresh SideStore" largeTitleDisplayMode="always" id="5nk-NR-jtV"/>
<navigationItem key="navigationItem" title="Refresh AltStore" largeTitleDisplayMode="always" id="5nk-NR-jtV"/>
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
<connections>
<outlet property="placeholderView" destination="fpO-Bf-gFY" id="q7d-au-d94"/>
@@ -493,76 +493,16 @@
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Chr-7g-qEw" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="3025" y="734"/>
</scene>
<!--Select a Team-->
<scene sceneID="ioQ-WB-CLJ">
<objects>
<viewController storyboardIdentifier="selectTeamViewController" hidesBottomBarWhenPushed="YES" id="kOD-4P-a6L" customClass="SelectTeamViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" indicatorStyle="white" dataMode="prototypes" style="grouped" separatorStyle="none" rowHeight="60" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="fWW-kX-ifH">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" name="SettingsBackground"/>
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<color key="separatorColor" white="1" alpha="0.25" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" reuseIdentifier="TeamCell" textLabel="6ip-34-gmM" detailTextLabel="knk-Wf-PKf" style="IBUITableViewCellStyleSubtitle" id="qeQ-eb-2SC" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="55.5" width="375" height="60"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="qeQ-eb-2SC" id="bT4-Fc-u6I">
<rect key="frame" x="0.0" y="0.0" width="334.5" height="60"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Team 1" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="6ip-34-gmM">
<rect key="frame" x="30" y="10" width="56.5" height="20.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Description" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="knk-Wf-PKf">
<rect key="frame" x="30" y="33.5" width="70" height="14.5"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="12"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<color key="backgroundColor" white="1" alpha="0.14999999999999999" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<edgeInsets key="layoutMargins" top="8" left="30" bottom="8" right="30"/>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="style">
<integer key="value" value="0"/>
</userDefinedRuntimeAttribute>
<userDefinedRuntimeAttribute type="boolean" keyPath="isSelectable" value="YES"/>
</userDefinedRuntimeAttributes>
</tableViewCell>
</prototypes>
<sections/>
<connections>
<outlet property="dataSource" destination="kOD-4P-a6L" id="OLE-fk-1MD"/>
<outlet property="delegate" destination="kOD-4P-a6L" id="t9T-jO-TrR"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Select a Team" largeTitleDisplayMode="always" id="qxJ-Go-OPq"/>
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="yH5-jU-aez" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2114" y="734"/>
<point key="canvasLocation" x="2101.5999999999999" y="733.5832083958021"/>
</scene>
</scenes>
<color key="tintColor" name="Primary"/>
<resources>
<namedColor name="Primary">
<color red="0.64313725490196083" green="0.019607843137254902" blue="0.98039215686274506" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<namedColor name="SettingsBackground">
<color red="0.45098039215686275" green="0.015686274509803921" blue="0.68627450980392157" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color red="0.0039215686274509803" green="0.50196078431372548" blue="0.51764705882352946" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<namedColor name="SettingsHighlighted">
<color red="0.38823529411764707" green="0.011764705882352941" blue="0.58823529411764708" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color red="0.0080000003799796104" green="0.32199999690055847" blue="0.40400001406669617" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
</resources>
<color key="tintColor" name="Primary"/>
</document>

View File

@@ -10,7 +10,7 @@ import UIKit
import AltSign
final class AuthenticationViewController: UIViewController
class AuthenticationViewController: UIViewController
{
var authenticationHandler: ((String, String, @escaping (Result<(ALTAccount, ALTAppleAPISession), Error>) -> Void) -> Void)?
var completionHandler: (((ALTAccount, ALTAppleAPISession, String)?) -> Void)?
@@ -31,21 +31,7 @@ final class AuthenticationViewController: UIViewController
{
super.viewDidLoad()
// fetch anisette servers asap when loading Auth Screen (if list is empty
if(UserDefaults.standard.menuAnisetteServersList.isEmpty){
Task{
let sourceURL = UserDefaults.standard.menuAnisetteList
do{
_ = try await AnisetteViewModel.getListOfServers(serverSource: sourceURL)
print("AuthenticationViewController: Server list refresh request completed for sourceURL: \(sourceURL)")
}catch{
print("AuthenticationViewController: Server list refresh request Failed for sourceURL: \(sourceURL) Error: \(error)")
}
}
}
self.signInButton.activityIndicatorView.style = .medium
self.signInButton.activityIndicatorView.color = .white
self.signInButton.activityIndicatorView.style = .white
for view in [self.appleIDBackgroundView!, self.passwordBackgroundView!, self.signInButton!]
{
@@ -120,20 +106,18 @@ private extension AuthenticationViewController
self.signInButton.isIndicatingActivity = false
}
case .failure(let error as NSError):
case .failure(let error):
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
let toastView = ToastView(text: NSLocalizedString("Failed to Log In", comment: ""), detailText: error.localizedDescription)
toastView.textLabel.textColor = .altPink
toastView.detailTextLabel.textColor = .altPink
toastView.show(in: self.navigationController?.view ?? self.view)
self.toastView = toastView
self.signInButton.isIndicatingActivity = false
}
case .success((let account, let session)):
case .success(let account, let session):
self.completionHandler?((account, session, password))
}

View File

@@ -8,7 +8,7 @@
import UIKit
final class InstructionsViewController: UIViewController
class InstructionsViewController: UIViewController
{
var completionHandler: (() -> Void)?

View File

@@ -7,14 +7,14 @@
//
import UIKit
import AltStoreCore
import AltSign
import Roxas
final class RefreshAltStoreViewController: UIViewController
class RefreshAltStoreViewController: UIViewController
{
var context: AuthenticatedOperationContext!
var signer: ALTSigner!
var session: ALTAppleAPISession!
var completionHandler: ((Result<Void, Error>) -> Void)?
@@ -28,7 +28,7 @@ final class RefreshAltStoreViewController: UIViewController
self.placeholderView.detailTextLabel.textAlignment = .left
self.placeholderView.detailTextLabel.textColor = UIColor.white.withAlphaComponent(0.6)
self.placeholderView.detailTextLabel.text = NSLocalizedString("SideStore was unable to use an existing signing certificate, so it had to create a new one. This will cause any apps installed with an existing certificate to expire — including SideStore.\n\nTo prevent SideStore from expiring early, please refresh the app now. SideStore will quit once refreshing is complete.", comment: "")
self.placeholderView.detailTextLabel.text = NSLocalizedString("AltStore was unable to use an existing signing certificate, so it had to create a new one. This will cause any apps installed with an existing certificate to expire — including AltStore.\n\nTo prevent AltStore from expiring early, please refresh the app now. AltStore will quit once refreshing is complete.", comment: "")
}
}
@@ -42,23 +42,23 @@ private extension RefreshAltStoreViewController
{
sender.isIndicatingActivity = true
if let progress = AppManager.shared.installationProgress(for: altStore)
if let progress = AppManager.shared.refreshProgress(for: altStore) ?? AppManager.shared.installationProgress(for: altStore)
{
// Cancel pending AltStore installation so we can start a new one.
// Cancel pending AltStore refresh so we can start a new one.
progress.cancel()
}
// Install, _not_ refresh, to ensure we are installing with a non-revoked certificate.
let group = AppManager.shared.install(altStore, presentingViewController: self, context: self.context) { (result) in
switch result
let group = OperationGroup()
group.signer = self.signer // Prevent us from trying to authenticate a second time.
group.session = self.session // ^
group.completionHandler = { (result) in
if let error = result.error ?? result.value?.values.compactMap({ $0.error }).first
{
case .success: self.completionHandler?(.success(()))
case .failure(let error as NSError):
DispatchQueue.main.async {
sender.progress = nil
sender.isIndicatingActivity = false
let alertController = UIAlertController(title: NSLocalizedString("Failed to Refresh SideStore", comment: ""), message: error.localizedFailureReason ?? error.localizedDescription, preferredStyle: .alert)
let alertController = UIAlertController(title: NSLocalizedString("Failed to Refresh AltStore", comment: ""), message: error.localizedDescription, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: NSLocalizedString("Try Again", comment: ""), style: .default, handler: { (action) in
refresh()
}))
@@ -69,8 +69,13 @@ private extension RefreshAltStoreViewController
self.present(alertController, animated: true, completion: nil)
}
}
else
{
self.completionHandler?(.success(()))
}
}
_ = AppManager.shared.refresh([altStore], presentingViewController: self, group: group)
sender.progress = group.progress
}

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,25 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="wKh-xq-NuP">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15400" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="wKh-xq-NuP">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15404"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="collection view cell content view" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Launch View Controller-->
<scene sceneID="q24-yd-v7v">
<objects>
<viewController id="wKh-xq-NuP" customClass="LaunchViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<viewController id="wKh-xq-NuP" customClass="LaunchViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="G9E-Qs-gFM">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<viewLayoutGuide key="safeArea" id="sZd-sc-Bvn"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<viewLayoutGuide key="safeArea" id="sZd-sc-Bvn"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="vOq-mm-rY5" userLabel="First Responder" sceneMemberID="firstResponder"/>
@@ -29,7 +27,7 @@
<!--Tab Bar Controller-->
<scene sceneID="yl2-sM-qoP">
<objects>
<tabBarController storyboardIdentifier="tabBarController" modalPresentationStyle="fullScreen" id="49e-Tb-3d3" customClass="TabBarController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<tabBarController storyboardIdentifier="tabBarController" modalPresentationStyle="fullScreen" id="49e-Tb-3d3" customClass="TabBarController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<tabBar key="tabBar" contentMode="scaleToFill" id="W28-zg-YXA">
<rect key="frame" x="0.0" y="975" width="768" height="49"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
@@ -37,11 +35,9 @@
</tabBar>
<connections>
<segue destination="kjR-gi-fgT" kind="relationship" relationship="viewControllers" id="eWy-uk-nwG"/>
<segue destination="bTL-bY-9Yq" kind="presentation" identifier="finishJailbreak" id="cIc-Ta-uNk"/>
<segue destination="HCK-G6-KdY" kind="relationship" relationship="viewControllers" id="X0t-T6-JeA"/>
<segue destination="faz-B4-Sub" kind="relationship" relationship="viewControllers" id="OLu-kM-z1J"/>
<segue destination="3Ew-ox-i4n" kind="relationship" relationship="viewControllers" id="phQ-Pc-pqw"/>
<segue destination="p3d-dP-Swg" kind="relationship" relationship="viewControllers" id="cQE-Az-fdo"/>
<segue destination="faz-B4-Sub" kind="relationship" relationship="viewControllers" id="dXz-Tu-hW8"/>
<segue destination="3Ew-ox-i4n" kind="relationship" relationship="viewControllers" id="zii-dF-qEt"/>
<segue destination="p3d-dP-Swg" kind="relationship" relationship="viewControllers" id="4Nf-rL-P4c"/>
</connections>
</tabBarController>
<placeholder placeholderIdentifier="IBFirstResponder" id="HuB-VB-40B" sceneMemberID="firstResponder"/>
@@ -51,7 +47,7 @@
<!--Browse-->
<scene sceneID="rXq-UR-qQp">
<objects>
<collectionViewController storyboardIdentifier="browseViewController" id="e3L-BF-iXp" customClass="BrowseViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionViewController id="e3L-BF-iXp" customClass="BrowseViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" dataMode="prototypes" id="CaT-1q-qnx">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
@@ -72,12 +68,12 @@
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="cMN-i4-Bxk" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2730" y="-373"/>
<point key="canvasLocation" x="1730" y="-17"/>
</scene>
<!--App View Controller-->
<scene sceneID="TgT-LO-3Er">
<objects>
<viewController storyboardIdentifier="appViewController" id="0V6-N4-hTO" customClass="AppViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<viewController storyboardIdentifier="appViewController" id="0V6-N4-hTO" customClass="AppViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="0cR-li-tCB">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
@@ -85,7 +81,7 @@
<stackView opaque="NO" contentMode="scaleToFill" fixedFrame="YES" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="Bql-t3-Ndi">
<rect key="frame" x="47" y="238" width="85" height="35"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="j1W-Jn-HFI" customClass="AppIconImageView" customModule="SideStore" customModuleProvider="target">
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="j1W-Jn-HFI" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="35" height="35"/>
<constraints>
<constraint firstAttribute="width" constant="35" id="aMS-N7-WYn"/>
@@ -107,7 +103,7 @@
<visualEffectView opaque="NO" contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="8Tg-wk-r0u">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="oNk-OQ-r4M">
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" ambiguous="YES" insetsLayoutMarginsFromSafeArea="NO" id="oNk-OQ-r4M">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
</view>
@@ -123,7 +119,7 @@
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Qlg-m3-lXg">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<subviews>
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="NEy-yr-cLS" customClass="AppBannerView" customModule="SideStore" customModuleProvider="target">
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="NEy-yr-cLS" customClass="AppBannerView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="37" y="287" width="300" height="93"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
</view>
@@ -137,18 +133,18 @@
<visualEffectView opaque="NO" clipsSubviews="YES" contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="tUK-0J-07U">
<rect key="frame" x="58" y="117" width="18" height="18"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="yyn-wP-xk4">
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" ambiguous="YES" insetsLayoutMarginsFromSafeArea="NO" id="yyn-wP-xk4">
<rect key="frame" x="0.0" y="0.0" width="18" height="18"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<visualEffectView opaque="NO" contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="JP7-6F-CoG">
<rect key="frame" x="0.0" y="0.0" width="18" height="18"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="UJ5-ia-PVA">
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" ambiguous="YES" insetsLayoutMarginsFromSafeArea="NO" id="UJ5-ia-PVA">
<rect key="frame" x="0.0" y="0.0" width="18" height="18"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" fixedFrame="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="mkD-3C-WMV">
<button opaque="NO" contentMode="scaleToFill" fixedFrame="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="mkD-3C-WMV">
<rect key="frame" x="0.0" y="0.0" width="18" height="18"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<state key="normal" image="Back"/>
@@ -169,7 +165,6 @@
</subviews>
</view>
</subviews>
<viewLayoutGuide key="safeArea" id="wiR-52-nwg"/>
<color key="backgroundColor" name="Background"/>
<constraints>
<constraint firstItem="Ci9-Iw-aR2" firstAttribute="top" secondItem="0cR-li-tCB" secondAttribute="top" id="015-fz-v3B"/>
@@ -181,10 +176,11 @@
<constraint firstAttribute="trailing" secondItem="Qlg-m3-lXg" secondAttribute="trailing" id="UrQ-oK-TKQ"/>
<constraint firstAttribute="bottom" secondItem="Qlg-m3-lXg" secondAttribute="bottom" id="Vf7-Fg-88c"/>
</constraints>
<viewLayoutGuide key="safeArea" id="wiR-52-nwg"/>
</view>
<navigationItem key="navigationItem" largeTitleDisplayMode="never" id="maq-gT-QcS">
<barButtonItem key="rightBarButtonItem" style="plain" id="FLf-DS-F77">
<button key="customView" opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" id="grk-xM-YWA" customClass="PillButton" customModule="SideStore" customModuleProvider="target">
<button key="customView" opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" id="grk-xM-YWA" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="287" y="6.5" width="72" height="31"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
@@ -216,12 +212,12 @@
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="C9o-C3-sMK" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2730" y="439"/>
<point key="canvasLocation" x="2525.5999999999999" y="-17.541229385307346"/>
</scene>
<!--App-->
<scene sceneID="CgX-7h-sRI">
<objects>
<tableViewController id="kBq-V8-3XC" customClass="AppContentViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<tableViewController id="kBq-V8-3XC" customClass="AppContentViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" contentInsetAdjustmentBehavior="never" dataMode="static" style="plain" separatorStyle="none" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" contentViewInsetsToSafeArea="NO" id="w5c-Q3-FcU">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
@@ -230,7 +226,7 @@
<tableViewSection id="rfR-32-T0h">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" rowHeight="57" id="xef-ko-Qp1">
<rect key="frame" x="0.0" y="50" width="375" height="57"/>
<rect key="frame" x="0.0" y="28" width="375" height="57"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="xef-ko-Qp1" id="8PX-jQ-nHd">
<rect key="frame" x="0.0" y="0.0" width="375" height="57"/>
@@ -254,40 +250,51 @@
<inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="nI6-wC-H2d">
<rect key="frame" x="0.0" y="107" width="375" height="300"/>
<rect key="frame" x="0.0" y="85" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="nI6-wC-H2d" id="Z4y-vb-Z4Q">
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<containerView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5yj-Nb-f5H">
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
<constraints>
<constraint firstAttribute="height" priority="999" constant="300" id="dpf-ba-NNr"/>
</constraints>
<connections>
<segue destination="nX2-hQ-qjX" kind="embed" destinationCreationSelector="makeAppScreenshotsViewController:sender:" id="VxG-Pu-Kf1"/>
</connections>
</containerView>
<collectionView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" dataMode="prototypes" translatesAutoresizingMaskIntoConstraints="NO" id="ppk-lL-at8">
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
<color key="backgroundColor" name="Background"/>
<collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" minimumLineSpacing="10" minimumInteritemSpacing="15" id="ace-Ns-Jd2">
<size key="itemSize" width="189" height="406"/>
<size key="headerReferenceSize" width="0.0" height="0.0"/>
<size key="footerReferenceSize" width="0.0" height="0.0"/>
<inset key="sectionInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
</collectionViewFlowLayout>
<cells>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="Cell" id="2U6-d3-e4r" customClass="ScreenshotCollectionViewCell">
<rect key="frame" x="15" y="-181" width="189" height="406"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
<rect key="frame" x="0.0" y="0.0" width="189" height="406"/>
<autoresizingMask key="autoresizingMask"/>
</view>
</collectionViewCell>
</cells>
</collectionView>
</subviews>
<constraints>
<constraint firstAttribute="trailing" secondItem="5yj-Nb-f5H" secondAttribute="trailing" id="2DI-44-pC1"/>
<constraint firstItem="5yj-Nb-f5H" firstAttribute="top" secondItem="Z4y-vb-Z4Q" secondAttribute="top" id="URh-5T-73x"/>
<constraint firstAttribute="bottom" secondItem="5yj-Nb-f5H" secondAttribute="bottom" id="Yb6-aZ-qNF"/>
<constraint firstItem="5yj-Nb-f5H" firstAttribute="leading" secondItem="Z4y-vb-Z4Q" secondAttribute="leading" id="rpG-Ip-qZU"/>
<constraint firstAttribute="trailing" secondItem="ppk-lL-at8" secondAttribute="trailing" id="3QR-Y2-v26"/>
<constraint firstAttribute="bottom" secondItem="ppk-lL-at8" secondAttribute="bottom" id="EgJ-Uw-5ta"/>
<constraint firstItem="ppk-lL-at8" firstAttribute="leading" secondItem="Z4y-vb-Z4Q" secondAttribute="leading" id="wHf-S9-gMV"/>
<constraint firstItem="ppk-lL-at8" firstAttribute="top" secondItem="Z4y-vb-Z4Q" secondAttribute="top" id="xY5-w8-roA"/>
</constraints>
</tableViewCellContentView>
<color key="backgroundColor" name="Background"/>
<inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="EL5-UC-RIw" customClass="AppContentTableViewCell" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="407" width="375" height="98"/>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="EL5-UC-RIw" customClass="AppContentTableViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="129" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="EL5-UC-RIw" id="D1G-nK-G0Z">
<rect key="frame" x="0.0" y="0.0" width="375" height="98"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" ambiguous="YES" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="EL5-UC-RIw" id="D1G-nK-G0Z">
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" text="Hello Me" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="Pyt-8D-BZA" customClass="CollapsingMarkdownView" customModule="SideStore" customModuleProvider="target">
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" text="Hello Me" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="Pyt-8D-BZA" customClass="CollapsingTextView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="20" y="20" width="335" height="34"/>
<color key="backgroundColor" name="Background"/>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
@@ -295,8 +302,8 @@
</textView>
</subviews>
<constraints>
<constraint firstItem="Pyt-8D-BZA" firstAttribute="top" secondItem="D1G-nK-G0Z" secondAttribute="top" priority="999" constant="20" id="Lm9-lx-kJ8"/>
<constraint firstAttribute="bottom" secondItem="Pyt-8D-BZA" secondAttribute="bottom" priority="999" constant="44" id="TSS-Au-gYx"/>
<constraint firstItem="Pyt-8D-BZA" firstAttribute="top" secondItem="D1G-nK-G0Z" secondAttribute="top" constant="20" id="Lm9-lx-kJ8"/>
<constraint firstAttribute="bottom" secondItem="Pyt-8D-BZA" secondAttribute="bottom" constant="44" id="TSS-Au-gYx"/>
<constraint firstItem="Pyt-8D-BZA" firstAttribute="leading" secondItem="D1G-nK-G0Z" secondAttribute="leading" constant="20" id="UlS-ct-L9Y"/>
<constraint firstAttribute="trailing" secondItem="Pyt-8D-BZA" secondAttribute="trailing" constant="20" id="Wq4-Ql-wvN"/>
</constraints>
@@ -304,30 +311,30 @@
<color key="backgroundColor" name="Background"/>
<inset key="separatorInset" minX="1000" minY="0.0" maxX="0.0" maxY="0.0"/>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="47M-El-a4G" customClass="AppContentTableViewCell" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="505" width="375" height="137.5"/>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="47M-El-a4G" customClass="AppContentTableViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="173" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="47M-El-a4G" id="f9D-OR-oGE">
<rect key="frame" x="0.0" y="0.0" width="375" height="137.5"/>
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" distribution="equalSpacing" spacing="12" translatesAutoresizingMaskIntoConstraints="NO" id="n9R-39-Glq">
<rect key="frame" x="0.0" y="0.0" width="375" height="137.5"/>
<rect key="frame" x="0.0" y="0.0" width="375" height="60"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="y3w-4S-e64">
<rect key="frame" x="20" y="0.0" width="335" height="47.5"/>
<rect key="frame" x="20" y="0.0" width="335" height="4"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" distribution="equalSpacing" spacing="11" translatesAutoresizingMaskIntoConstraints="NO" id="PFD-gZ-77F">
<rect key="frame" x="0.0" y="0.0" width="335" height="26.5"/>
<rect key="frame" x="0.0" y="0.0" width="335" height="0.0"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="What's New" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="obM-TM-y2E">
<rect key="frame" x="0.0" y="0.0" width="124" height="26.5"/>
<rect key="frame" x="0.0" y="0.0" width="123.5" height="0.0"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="22"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="2w ago" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="wGD-mS-8fO">
<rect key="frame" x="285" y="0.0" width="50" height="26.5"/>
<rect key="frame" x="285" y="0.0" width="50" height="0.0"/>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
<color key="textColor" white="0.5" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
@@ -335,16 +342,16 @@
</subviews>
</stackView>
<stackView opaque="NO" contentMode="scaleToFill" distribution="equalSpacing" translatesAutoresizingMaskIntoConstraints="NO" id="ewH-gi-pyW">
<rect key="frame" x="0.0" y="30.5" width="335" height="17"/>
<rect key="frame" x="0.0" y="4" width="335" height="0.0"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="Version 4.4.2" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="7E0-TV-G4l">
<rect key="frame" x="0.0" y="0.0" width="84.5" height="17"/>
<rect key="frame" x="0.0" y="0.0" width="84.5" height="0.0"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="50.4 MB" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="DgM-bD-bBY">
<rect key="frame" x="280.5" y="0.0" width="54.5" height="17"/>
<rect key="frame" x="280.5" y="0.0" width="54.5" height="0.0"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
@@ -353,8 +360,8 @@
</stackView>
</subviews>
</stackView>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="wQF-WY-Gdz" customClass="CollapsingMarkdownView" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="20" y="59.5" width="335" height="34"/>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="wQF-WY-Gdz" customClass="CollapsingTextView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="20" y="16" width="335" height="0.0"/>
<color key="backgroundColor" name="Background"/>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
@@ -373,35 +380,83 @@
<color key="backgroundColor" name="Background"/>
<inset key="separatorInset" minX="1000" minY="0.0" maxX="0.0" maxY="0.0"/>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" rowHeight="300" id="nM7-vJ-W8b">
<rect key="frame" x="0.0" y="642.5" width="375" height="300"/>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" rowHeight="149" id="nM7-vJ-W8b">
<rect key="frame" x="0.0" y="217" width="375" height="149"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="nM7-vJ-W8b" id="cQ2-Jd-pRK">
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
<rect key="frame" x="0.0" y="0.0" width="375" height="149"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" distribution="equalSpacing" alignment="center" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="Jvb-r8-XrY">
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
<rect key="frame" x="0.0" y="0.0" width="375" height="149"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="Permissions" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="dj7-G8-GFv">
<rect key="frame" x="20" y="0.0" width="335" height="26.5"/>
<rect key="frame" x="20" y="0.0" width="335" height="26"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="22"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<containerView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wus-dU-ZqZ">
<rect key="frame" x="0.0" y="80" width="375" height="200"/>
<collectionView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" dataMode="prototypes" translatesAutoresizingMaskIntoConstraints="NO" id="r8T-dj-wQX">
<rect key="frame" x="0.0" y="41" width="375" height="88"/>
<color key="backgroundColor" name="Background"/>
<constraints>
<constraint firstAttribute="height" constant="200" id="HFx-PP-dAt"/>
<constraint firstAttribute="height" constant="88" id="6Lk-OO-MsA"/>
</constraints>
<connections>
<segue destination="OYP-I1-A3i" kind="embed" destinationCreationSelector="makeAppDetailCollectionViewController:sender:" id="Uxh-GM-nzb"/>
</connections>
</containerView>
<collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" minimumLineSpacing="10" minimumInteritemSpacing="10" id="2HF-4d-3Im">
<size key="itemSize" width="60" height="88"/>
<size key="headerReferenceSize" width="0.0" height="0.0"/>
<size key="footerReferenceSize" width="0.0" height="0.0"/>
<inset key="sectionInset" minX="20" minY="0.0" maxX="20" maxY="0.0"/>
</collectionViewFlowLayout>
<cells>
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="Cell" id="WYy-bZ-h3T" customClass="PermissionCollectionViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="20" y="0.0" width="60" height="88"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
<rect key="frame" x="0.0" y="0.0" width="60" height="88"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView verifyAmbiguity="off" opaque="NO" contentMode="scaleToFill" ambiguous="YES" axis="vertical" alignment="center" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="fSx-We-L4W">
<rect key="frame" x="0.0" y="0.0" width="56" height="56"/>
<subviews>
<button opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="79g-9q-mE2">
<rect key="frame" x="5" y="0.0" width="50" height="50"/>
<constraints>
<constraint firstAttribute="width" constant="50" id="0LZ-4n-COH"/>
<constraint firstAttribute="height" constant="50" id="keD-mf-Rga"/>
</constraints>
</button>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" textAlignment="center" lineBreakMode="wordWrap" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="pQi-FD-18P">
<rect key="frame" x="12.5" y="56" width="35.5" height="31.5"/>
<string key="text">Hello
World</string>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
</subviews>
</view>
<constraints>
<constraint firstAttribute="trailing" secondItem="fSx-We-L4W" secondAttribute="trailing" id="IyD-vD-tA4"/>
<constraint firstItem="fSx-We-L4W" firstAttribute="leading" secondItem="WYy-bZ-h3T" secondAttribute="leading" id="bTq-op-ivD"/>
<constraint firstItem="fSx-We-L4W" firstAttribute="top" secondItem="WYy-bZ-h3T" secondAttribute="top" id="sMw-NS-jtY"/>
</constraints>
<connections>
<outlet property="button" destination="79g-9q-mE2" id="G5V-SS-vaA"/>
<outlet property="textLabel" destination="pQi-FD-18P" id="D5d-20-cm3"/>
<segue destination="Ojq-DN-xcF" kind="popoverPresentation" identifier="showPermission" popoverAnchorView="r8T-dj-wQX" id="ftM-H7-Q7G">
<popoverArrowDirection key="popoverArrowDirection" down="YES"/>
</segue>
</connections>
</collectionViewCell>
</cells>
</collectionView>
</subviews>
<constraints>
<constraint firstItem="dj7-G8-GFv" firstAttribute="leading" secondItem="Jvb-r8-XrY" secondAttribute="leading" constant="20" id="9pB-Md-91A"/>
<constraint firstItem="wus-dU-ZqZ" firstAttribute="width" secondItem="Jvb-r8-XrY" secondAttribute="width" id="coR-wZ-TkD"/>
<constraint firstItem="r8T-dj-wQX" firstAttribute="width" secondItem="Jvb-r8-XrY" secondAttribute="width" id="QJH-2y-DSh"/>
</constraints>
<edgeInsets key="layoutMargins" top="0.0" left="0.0" bottom="20" right="0.0"/>
</stackView>
@@ -427,9 +482,9 @@
<navigationItem key="navigationItem" title="App" largeTitleDisplayMode="never" id="sWo-Y8-aF6"/>
<size key="freeformSize" width="375" height="667"/>
<connections>
<outlet property="appDetailCollectionViewHeightConstraint" destination="HFx-PP-dAt" id="ti3-q6-ku1"/>
<outlet property="appScreenshotsHeightConstraint" destination="dpf-ba-NNr" id="shO-Kq-Y90"/>
<outlet property="descriptionTextView" destination="Pyt-8D-BZA" id="cgV-Hg-LrH"/>
<outlet property="permissionsCollectionView" destination="r8T-dj-wQX" id="Xud-5X-w2E"/>
<outlet property="screenshotsCollectionView" destination="ppk-lL-at8" id="YoQ-Z6-WTP"/>
<outlet property="sizeLabel" destination="DgM-bD-bBY" id="Oky-ax-u20"/>
<outlet property="subtitleLabel" destination="BsL-O2-UjD" id="cfe-cf-4a9"/>
<outlet property="versionDateLabel" destination="wGD-mS-8fO" id="icB-lC-g9x"/>
@@ -439,52 +494,52 @@
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dhh-ZN-LoG" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="3506" y="437"/>
<point key="canvasLocation" x="3302" y="-18"/>
</scene>
<!--App Screenshots View Controller-->
<scene sceneID="E6k-TI-c4N">
<!--Permission Popover View Controller-->
<scene sceneID="24j-EJ-G4e">
<objects>
<collectionViewController storyboardIdentifier="appScreenshotsViewController" id="nX2-hQ-qjX" customClass="AppScreenshotsViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" contentInsetAdjustmentBehavior="never" dataMode="prototypes" id="zXl-if-KtH">
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
<viewController id="Ojq-DN-xcF" customClass="PermissionPopoverViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="IgU-aM-YrX">
<rect key="frame" x="0.0" y="0.0" width="375" height="217"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" automaticEstimatedItemSize="YES" minimumLineSpacing="10" minimumInteritemSpacing="10" id="MGS-YY-5g9">
<size key="itemSize" width="150" height="300"/>
<size key="headerReferenceSize" width="0.0" height="0.0"/>
<size key="footerReferenceSize" width="0.0" height="0.0"/>
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
</collectionViewFlowLayout>
<cells/>
<connections>
<outlet property="dataSource" destination="nX2-hQ-qjX" id="QRj-01-ddR"/>
<outlet property="delegate" destination="nX2-hQ-qjX" id="Ha5-Xa-Q6e"/>
</connections>
</collectionView>
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="np0-Hj-vy7" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="xnC-tS-ZdV">
<rect key="frame" x="20" y="10" width="335" height="197"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="500" insetsLayoutMarginsFromSafeArea="NO" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="4fh-lO-rAn">
<rect key="frame" x="0.0" y="0.0" width="335" height="17"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="TopLeft" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="500" insetsLayoutMarginsFromSafeArea="NO" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" preferredMaxLayoutWidth="300" translatesAutoresizingMaskIntoConstraints="NO" id="ErG-8A-uqY">
<rect key="frame" x="0.0" y="21" width="335" height="176"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<edgeInsets key="layoutMargins" top="0.0" left="0.0" bottom="0.0" right="0.0"/>
</stackView>
</subviews>
<color key="backgroundColor" systemColor="tertiarySystemBackgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="xnC-tS-ZdV" firstAttribute="leading" secondItem="c7x-ee-3HH" secondAttribute="leading" constant="20" id="LO8-Au-SYF"/>
<constraint firstItem="c7x-ee-3HH" firstAttribute="bottom" secondItem="xnC-tS-ZdV" secondAttribute="bottom" constant="10" id="NZ9-iG-E10"/>
<constraint firstItem="c7x-ee-3HH" firstAttribute="trailing" secondItem="xnC-tS-ZdV" secondAttribute="trailing" constant="20" id="ZkD-tb-mBf"/>
<constraint firstItem="xnC-tS-ZdV" firstAttribute="top" secondItem="c7x-ee-3HH" secondAttribute="top" constant="10" id="oKq-9e-DtW"/>
</constraints>
<viewLayoutGuide key="safeArea" id="c7x-ee-3HH"/>
</view>
<connections>
<outlet property="descriptionLabel" destination="ErG-8A-uqY" id="iuN-kE-IEm"/>
<outlet property="nameLabel" destination="4fh-lO-rAn" id="GWh-7k-yWw"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="7Tu-x9-xBb" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="4302" y="20"/>
</scene>
<!--App Detail Collection View Controller-->
<scene sceneID="Pcn-h5-5fk">
<objects>
<collectionViewController id="OYP-I1-A3i" customClass="AppDetailCollectionViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" dataMode="prototypes" id="y1V-56-IqS" customClass="SafeAreaIgnoringCollectionView">
<rect key="frame" x="0.0" y="0.0" width="375" height="200"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<collectionViewLayout key="collectionViewLayout" id="KQE-PB-FbG"/>
<cells/>
<connections>
<outlet property="dataSource" destination="OYP-I1-A3i" id="YDU-V6-g0R"/>
<outlet property="delegate" destination="OYP-I1-A3i" id="faX-I5-qJ2"/>
</connections>
</collectionView>
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="fxm-bB-W29" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="4298" y="434"/>
<point key="canvasLocation" x="4257" y="-412"/>
</scene>
<!--Settings-->
<scene sceneID="KlD-j0-ROn">
@@ -494,12 +549,12 @@
</viewControllerPlaceholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="HgE-PD-dC2" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="233" y="550"/>
<point key="canvasLocation" x="962" y="1197"/>
</scene>
<!--News-->
<scene sceneID="bqw-wB-hyB">
<objects>
<collectionViewController id="3sa-FZ-PTg" customClass="NewsViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionViewController id="3sa-FZ-PTg" customClass="NewsViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" id="736-lq-Aef">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
@@ -525,41 +580,33 @@
<!--Browse-->
<scene sceneID="VHa-uP-bFU">
<objects>
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="faz-B4-Sub" customClass="ForwardingNavigationController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="faz-B4-Sub" customClass="ForwardingNavigationController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<tabBarItem key="tabBarItem" title="Browse" image="Browse" id="Uwh-Bg-Ymq"/>
<toolbarItems/>
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="dIv-qd-9L5" customClass="NavigationBar" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="20" width="375" height="96"/>
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="dIv-qd-9L5" customClass="NavigationBar" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="96"/>
<autoresizingMask key="autoresizingMask"/>
<color key="tintColor" name="Primary"/>
</navigationBar>
<nil name="viewControllers"/>
<connections>
<segue destination="KKu-kI-2kg" kind="relationship" relationship="rootViewController" id="2Dm-Oy-wu0"/>
<segue destination="e3L-BF-iXp" kind="relationship" relationship="rootViewController" id="EVp-fA-PvU"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="OkH-49-O0J" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="962" y="-17"/>
</scene>
<!--PatchApp-->
<scene sceneID="vdC-wt-hvX">
<objects>
<viewControllerPlaceholder storyboardName="PatchApp" id="bTL-bY-9Yq" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="NyZ-z6-R2q" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-228" y="551"/>
</scene>
<!--My Apps-->
<scene sceneID="nhh-BJ-XiT">
<objects>
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="3Ew-ox-i4n" customClass="ForwardingNavigationController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="3Ew-ox-i4n" customClass="ForwardingNavigationController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<tabBarItem key="tabBarItem" title="My Apps" image="MyApps" id="4gT-9u-k7y">
<color key="badgeColor" name="Primary"/>
</tabBarItem>
<toolbarItems/>
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="CzO-Kt-BlZ" customClass="NavigationBar" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="20" width="375" height="96"/>
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="CzO-Kt-BlZ" customClass="NavigationBar" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="96"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<nil name="viewControllers"/>
@@ -574,27 +621,30 @@
<!--My Apps-->
<scene sceneID="EC8-Sf-AF9">
<objects>
<collectionViewController id="hv7-Ar-voT" customClass="MyAppsViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionViewController id="hv7-Ar-voT" customClass="MyAppsViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" id="Jrp-gi-4Df">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" name="Background"/>
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="15" minimumInteritemSpacing="10" id="SB5-U0-jyy">
<size key="itemSize" width="375" height="60"/>
<size key="headerReferenceSize" width="0.0" height="0.0"/>
<size key="footerReferenceSize" width="50" height="60.5"/>
<size key="headerReferenceSize" width="50" height="50"/>
<size key="footerReferenceSize" width="0.0" height="0.0"/>
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
</collectionViewFlowLayout>
<cells>
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" reuseIdentifier="AppCell" id="kMp-ym-2yu" customClass="InstalledAppCollectionViewCell" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="60"/>
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" reuseIdentifier="AppCell" id="kMp-ym-2yu" customClass="InstalledAppCollectionViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="50" width="375" height="60"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
<rect key="frame" x="0.0" y="0.0" width="375" height="60"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="mos-e4-dQ7" customClass="AppBannerView" customModule="SideStore" customModuleProvider="target">
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="mos-e4-dQ7" customClass="AppBannerView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="8" y="0.0" width="359" height="60"/>
<accessibility key="accessibilityConfiguration">
<bool key="isElement" value="YES"/>
</accessibility>
</view>
</subviews>
</view>
@@ -612,8 +662,8 @@
</segue>
</connections>
</collectionViewCell>
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="NoUpdatesCell" id="h0f-XI-UA5" customClass="NoUpdatesCollectionViewCell" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="75" width="375" height="60"/>
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="NoUpdatesCell" id="h0f-XI-UA5" customClass="NoUpdatesCollectionViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="125" width="375" height="60"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
<rect key="frame" x="0.0" y="0.0" width="375" height="60"/>
@@ -632,24 +682,17 @@
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="No Updates Available" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="z04-yg-x1t">
<rect key="frame" x="96.5" y="20" width="166" height="20.5"/>
<rect key="frame" x="96" y="20" width="167" height="20.5"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="17"/>
<color key="textColor" name="Primary"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Who-nd-jyt">
<rect key="frame" x="313" y="13" width="38" height="34.5"/>
<state key="normal" title="Button"/>
<buttonConfiguration key="configuration" style="plain" title="..."/>
</button>
</subviews>
<constraints>
<constraint firstItem="Who-nd-jyt" firstAttribute="trailing" secondItem="F8U-ab-fOM" secondAttribute="trailingMargin" id="0Fe-FJ-P3p"/>
<constraint firstItem="z04-yg-x1t" firstAttribute="centerY" secondItem="F8U-ab-fOM" secondAttribute="centerY" id="9w9-Z0-jZl"/>
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="z04-yg-x1t" secondAttribute="bottom" constant="10" id="IWL-Ei-QC2"/>
<constraint firstItem="z04-yg-x1t" firstAttribute="top" relation="greaterThanOrEqual" secondItem="F8U-ab-fOM" secondAttribute="top" constant="10" id="fLp-au-PLf"/>
<constraint firstItem="z04-yg-x1t" firstAttribute="centerX" secondItem="F8U-ab-fOM" secondAttribute="centerX" id="fiy-Zt-GmB"/>
<constraint firstItem="Who-nd-jyt" firstAttribute="centerY" secondItem="F8U-ab-fOM" secondAttribute="centerY" id="tV3-4W-6Ha"/>
</constraints>
</view>
<vibrancyEffect style="secondaryLabel">
@@ -677,43 +720,34 @@
</constraints>
<connections>
<outlet property="blurView" destination="7iO-O4-Mr9" id="kQ4-9N-nnv"/>
<outlet property="button" destination="Who-nd-jyt" id="EA8-Jn-NJs"/>
<outlet property="textLabel" destination="z04-yg-x1t" id="njE-fn-vxd"/>
</connections>
</collectionViewCell>
</cells>
<collectionReusableView key="sectionFooterView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="InstalledAppsFooter" id="HYs-co-nJZ" customClass="InstalledAppsCollectionFooterView" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="135" width="375" height="60.5"/>
<collectionReusableView key="sectionHeaderView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="InstalledAppsHeader" id="Crb-NU-1Ye" customClass="InstalledAppsCollectionHeaderView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="50"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" horizontalCompressionResistancePriority="900" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="GFQ-Wy-Qhy">
<rect key="frame" x="139" y="0.0" width="97" height="52.5"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="5/10 App IDs" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="LLv-8I-6Of">
<rect key="frame" x="0.0" y="0.0" width="97" height="20.5"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" white="0.5" alpha="1" colorSpace="calibratedWhite"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="NHb-0F-cHZ">
<rect key="frame" x="0.0" y="20.5" width="97" height="32"/>
<fontDescription key="fontDescription" type="system" pointSize="16"/>
<state key="normal" title="View App IDs"/>
<connections>
<segue destination="IXk-qg-mFJ" kind="presentation" identifier="showAppIDs" id="yZB-Fh-cTL"/>
</connections>
</button>
</subviews>
</stackView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Installed" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="BDU-hM-rro">
<rect key="frame" x="20" y="21" width="96.5" height="29"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="24"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="nxk-e8-ARx">
<rect key="frame" x="274" y="23" width="81" height="32"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="16"/>
<state key="normal" title="Refresh All"/>
</button>
</subviews>
<constraints>
<constraint firstAttribute="bottom" secondItem="GFQ-Wy-Qhy" secondAttribute="bottom" priority="999" constant="8" id="HGl-P6-G2v"/>
<constraint firstItem="GFQ-Wy-Qhy" firstAttribute="top" secondItem="HYs-co-nJZ" secondAttribute="top" id="gg9-XU-2ej"/>
<constraint firstItem="GFQ-Wy-Qhy" firstAttribute="centerX" secondItem="HYs-co-nJZ" secondAttribute="centerX" id="vyo-h4-yD9"/>
<constraint firstAttribute="bottom" secondItem="BDU-hM-rro" secondAttribute="bottom" id="9iT-ur-A4W"/>
<constraint firstItem="BDU-hM-rro" firstAttribute="leading" secondItem="Crb-NU-1Ye" secondAttribute="leading" constant="20" id="F8e-9W-MC2"/>
<constraint firstAttribute="trailing" secondItem="nxk-e8-ARx" secondAttribute="trailing" constant="20" id="WxV-85-RcK"/>
<constraint firstItem="nxk-e8-ARx" firstAttribute="firstBaseline" secondItem="BDU-hM-rro" secondAttribute="firstBaseline" id="lIO-3C-ZPH"/>
</constraints>
<connections>
<outlet property="button" destination="NHb-0F-cHZ" id="wOh-Ee-jhN"/>
<outlet property="textLabel" destination="LLv-8I-6Of" id="t2D-f1-5pC"/>
<outlet property="button" destination="nxk-e8-ARx" id="gwj-97-LVi"/>
<outlet property="textLabel" destination="BDU-hM-rro" id="CQM-8K-bcH"/>
</connections>
</collectionReusableView>
<connections>
@@ -734,165 +768,16 @@
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="kiO-UO-esV" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1729" y="716"/>
</scene>
<!--Featured View Controller-->
<scene sceneID="1eF-L7-aZz">
<objects>
<collectionViewController storyboardIdentifier="featuredViewController" id="KKu-kI-2kg" customClass="FeaturedViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" dataMode="prototypes" id="2HL-eH-weG">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<collectionViewFlowLayout key="collectionViewLayout" automaticEstimatedItemSize="YES" minimumLineSpacing="10" minimumInteritemSpacing="10" id="PI1-YC-d4l">
<size key="itemSize" width="128" height="128"/>
<size key="headerReferenceSize" width="0.0" height="0.0"/>
<size key="footerReferenceSize" width="0.0" height="0.0"/>
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
</collectionViewFlowLayout>
<cells>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="Cell" id="Eo1-84-9m0">
<rect key="frame" x="0.0" y="0.0" width="128" height="128"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<collectionViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="4ra-vw-qNw">
<rect key="frame" x="0.0" y="0.0" width="128" height="128"/>
<autoresizingMask key="autoresizingMask"/>
</collectionViewCellContentView>
</collectionViewCell>
</cells>
<connections>
<outlet property="dataSource" destination="KKu-kI-2kg" id="tXR-fi-SxU"/>
<outlet property="delegate" destination="KKu-kI-2kg" id="XC4-MP-Zdr"/>
</connections>
</collectionView>
<navigationItem key="navigationItem" id="zft-Mo-I7C"/>
<connections>
<segue destination="e3L-BF-iXp" kind="show" identifier="showBrowseViewController" destinationCreationSelector="makeBrowseViewController:sender:" id="qDq-A7-sdW"/>
<segue destination="177-gr-dJU" kind="show" identifier="showSourceDetails" destinationCreationSelector="makeSourceDetailViewController:sender:" id="dmC-aP-9Hg"/>
</connections>
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Hwb-Di-x8C" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1729" y="-19"/>
</scene>
<!--sourceDetailViewController-->
<scene sceneID="nDc-kS-RDF">
<objects>
<viewControllerPlaceholder storyboardName="Sources" referencedIdentifier="sourceDetailViewController" id="177-gr-dJU" sceneMemberID="viewController">
<navigationItem key="navigationItem" id="7hT-A6-bBi"/>
</viewControllerPlaceholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="bhw-oh-Eeq" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2730" y="-21"/>
</scene>
<!--App IDs-->
<scene sceneID="kvf-US-rRe">
<objects>
<collectionViewController title="App IDs" id="y1A-Nm-mw7" customClass="AppIDsViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" dataMode="prototypes" id="v1r-C8-h6h">
<rect key="frame" x="0.0" y="0.0" width="375" height="647"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="15" minimumInteritemSpacing="10" id="Wzt-qc-XG8">
<size key="itemSize" width="375" height="80"/>
<size key="headerReferenceSize" width="50" height="60"/>
<size key="footerReferenceSize" width="50" height="50"/>
<inset key="sectionInset" minX="0.0" minY="10" maxX="0.0" maxY="20"/>
</collectionViewFlowLayout>
<cells>
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" reuseIdentifier="Cell" id="XWu-DU-xbh" customClass="AppBannerCollectionViewCell" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="70" width="375" height="80"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
<rect key="frame" x="0.0" y="0.0" width="375" height="80"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="1w8-fI-98T" customClass="AppBannerView" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="8" y="0.0" width="359" height="80"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<accessibility key="accessibilityConfiguration">
<bool key="isElement" value="YES"/>
</accessibility>
</view>
</subviews>
</view>
</collectionViewCell>
</cells>
<collectionReusableView key="sectionHeaderView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="Header" id="th0-G6-bRt" customClass="TextCollectionReusableView" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="60"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="App IDs Description" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="83Z-Ih-nOW">
<rect key="frame" x="8" y="14" width="359" height="31"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleCallout"/>
<color key="textColor" white="0.5" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstAttribute="bottom" secondItem="83Z-Ih-nOW" secondAttribute="bottom" constant="15" id="CQA-og-LZ2"/>
<constraint firstItem="83Z-Ih-nOW" firstAttribute="top" secondItem="th0-G6-bRt" secondAttribute="top" constant="14" id="e0J-MA-eH5"/>
<constraint firstAttribute="leadingMargin" secondItem="83Z-Ih-nOW" secondAttribute="leading" id="nGf-Rh-mnk"/>
<constraint firstAttribute="trailingMargin" secondItem="83Z-Ih-nOW" secondAttribute="trailing" id="sYg-nT-ror"/>
</constraints>
<connections>
<outlet property="textLabel" destination="83Z-Ih-nOW" id="xxM-HD-iJS"/>
</connections>
</collectionReusableView>
<collectionReusableView key="sectionFooterView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="Footer" id="xMh-lD-r6C" customClass="TextCollectionReusableView" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="170" width="375" height="50"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="10 App IDs" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Zna-7n-kBz">
<rect key="frame" x="146.5" y="0.0" width="82" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" white="0.5" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="Zna-7n-kBz" firstAttribute="centerX" secondItem="xMh-lD-r6C" secondAttribute="centerX" id="7RS-ua-XzZ"/>
<constraint firstItem="Zna-7n-kBz" firstAttribute="top" secondItem="xMh-lD-r6C" secondAttribute="top" id="RvY-z8-XI6"/>
</constraints>
<connections>
<outlet property="textLabel" destination="Zna-7n-kBz" id="LK5-BR-skx"/>
</connections>
</collectionReusableView>
<connections>
<outlet property="dataSource" destination="y1A-Nm-mw7" id="U8O-CF-Jhv"/>
<outlet property="delegate" destination="y1A-Nm-mw7" id="a8i-FA-aUq"/>
</connections>
</collectionView>
<navigationItem key="navigationItem" title="App IDs" id="3Co-uv-Fhb">
<barButtonItem key="leftBarButtonItem" id="Aqs-QK-Ups">
<view key="customView" contentMode="scaleToFill" id="p0q-Fg-3Ba">
<rect key="frame" x="16" y="7" width="83" height="42"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
</view>
</barButtonItem>
<barButtonItem key="rightBarButtonItem" style="done" systemItem="done" id="Ekd-oC-gOr">
<connections>
<segue destination="eS1-sQ-VUA" kind="unwind" unwindAction="unwindToMyAppsViewController:" id="VHS-kt-woS"/>
</connections>
</barButtonItem>
</navigationItem>
<connections>
<outlet property="activityIndicatorBarButtonItem" destination="Aqs-QK-Ups" id="2I7-rT-muy"/>
</connections>
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Lvd-jC-AZO" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
<exit id="eS1-sQ-VUA" userLabel="Exit" sceneMemberID="exit"/>
</objects>
<point key="canvasLocation" x="3506" y="1121"/>
<point key="canvasLocation" x="1728.8" y="716.49175412293857"/>
</scene>
<!--News-->
<scene sceneID="BV8-6J-nIv">
<objects>
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="kjR-gi-fgT" customClass="ForwardingNavigationController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="kjR-gi-fgT" customClass="ForwardingNavigationController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<tabBarItem key="tabBarItem" title="News" image="News" id="fVN-ed-uO1"/>
<toolbarItems/>
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="525-jF-uDK" customClass="NavigationBar" customModule="SideStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="20" width="375" height="96"/>
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="525-jF-uDK" customClass="NavigationBar" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="96"/>
<autoresizingMask key="autoresizingMask"/>
<edgeInsets key="layoutMargins" top="8" left="20" bottom="8" right="8"/>
</navigationBar>
@@ -905,42 +790,10 @@
</objects>
<point key="canvasLocation" x="962" y="-752"/>
</scene>
<!--Navigation Controller-->
<scene sceneID="1Gj-mS-BaN">
<objects>
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="IXk-qg-mFJ" sceneMemberID="viewController">
<toolbarItems/>
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="9sB-f3-Fnk">
<rect key="frame" x="0.0" y="0.0" width="375" height="108"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<nil name="viewControllers"/>
<connections>
<segue destination="y1A-Nm-mw7" kind="relationship" relationship="rootViewController" id="ZYf-6x-9a0"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="3LN-mt-qWn" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2730" y="1120"/>
</scene>
<!--Sources-->
<scene sceneID="Vzf-tb-LIH">
<objects>
<viewControllerPlaceholder storyboardName="Sources" id="HCK-G6-KdY" sceneMemberID="viewController">
<tabBarItem key="tabBarItem" title="Item" id="Q7y-bi-ncT"/>
</viewControllerPlaceholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="VTd-he-VYb" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-2" y="550"/>
</scene>
</scenes>
<inferredMetricsTieBreakers>
<segue reference="cnd-KK-o60"/>
</inferredMetricsTieBreakers>
<color key="tintColor" name="Primary"/>
<resources>
<image name="Back" width="18" height="18"/>
<image name="Browse" width="128" height="128"/>
<image name="Browse" width="20" height="20"/>
<image name="MyApps" width="20" height="20"/>
<image name="News" width="19" height="20"/>
<image name="Settings" width="20" height="20"/>
@@ -951,10 +804,11 @@
<color red="1" green="1" blue="1" alpha="0.30000001192092896" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<namedColor name="Primary">
<color red="0.64313725490196083" green="0.019607843137254902" blue="0.98039215686274506" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color red="0.0039215686274509803" green="0.50196078431372548" blue="0.51764705882352946" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
<inferredMetricsTieBreakers>
<segue reference="dzt-2e-VM9"/>
</inferredMetricsTieBreakers>
<color key="tintColor" name="Primary"/>
</document>

View File

@@ -0,0 +1,98 @@
//
// BrowseCollectionViewCell.swift
// AltStore
//
// Created by Riley Testut on 7/15/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import Roxas
import Nuke
@objc class BrowseCollectionViewCell: UICollectionViewCell
{
var imageURLs: [URL] = [] {
didSet {
self.dataSource.items = self.imageURLs as [NSURL]
}
}
private lazy var dataSource = self.makeDataSource()
@IBOutlet var bannerView: AppBannerView!
@IBOutlet var subtitleLabel: UILabel!
@IBOutlet private(set) var screenshotsCollectionView: UICollectionView!
override func awakeFromNib()
{
super.awakeFromNib()
self.contentView.preservesSuperviewLayoutMargins = true
// Must be registered programmatically, not in BrowseCollectionViewCell.xib, or else it'll throw an exception 🤷.
self.screenshotsCollectionView.register(ScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
self.screenshotsCollectionView.delegate = self
self.screenshotsCollectionView.dataSource = self.dataSource
self.screenshotsCollectionView.prefetchDataSource = self.dataSource
}
}
private extension BrowseCollectionViewCell
{
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>
{
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: [])
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in
let cell = cell as! ScreenshotCollectionViewCell
cell.imageView.image = nil
cell.imageView.isIndicatingActivity = true
}
dataSource.prefetchHandler = { (imageURL, indexPath, completionHandler) in
return RSTAsyncBlockOperation() { (operation) in
ImagePipeline.shared.loadImage(with: imageURL as URL, progress: nil, completion: { (response, error) in
guard !operation.isCancelled else { return operation.finish() }
if let image = response?.image
{
completionHandler(image, nil)
}
else
{
completionHandler(nil, error)
}
})
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! ScreenshotCollectionViewCell
cell.imageView.isIndicatingActivity = false
cell.imageView.image = image
if let error = error
{
print("Error loading image:", error)
}
}
return dataSource
}
}
extension BrowseCollectionViewCell: UICollectionViewDelegateFlowLayout
{
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
{
// Assuming 9.0 / 16.0 ratio for now.
let aspectRatio: CGFloat = 9.0 / 16.0
let itemHeight = collectionView.bounds.height
let itemWidth = itemHeight * aspectRatio
let size = CGSize(width: itemWidth.rounded(.down), height: itemHeight.rounded(.down))
return size
}
}

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