mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-19 19:53:25 +01:00
Compare commits
7 Commits
1d642e2ffa
...
feature/Da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9d3060df7 | ||
|
|
c59043068e | ||
|
|
65c43d683c | ||
|
|
9e6147c860 | ||
|
|
b3074cadf9 | ||
|
|
9c5c597ce6 | ||
|
|
977a452605 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1 +1 @@
|
|||||||
* @JoeMatt @lonkelle @nythepegasus @Spidy123222 @SternXD
|
* @JoeMatt @lonkelle
|
||||||
|
|||||||
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -2,15 +2,15 @@ name: Bug Report
|
|||||||
description: Report a bug
|
description: Report a bug
|
||||||
title: "[BUG] "
|
title: "[BUG] "
|
||||||
labels: ["bug"]
|
labels: ["bug"]
|
||||||
assignees: []
|
assignees:
|
||||||
|
- naturecodevoid
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
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.
|
Thanks for taking the time to fill out this bug report! Before you continue filling out the report, please **[search in GitHub Issues](https://github.com/SideStore/SideStore/issues?q=is%3Aissue+is%3Aopen) for the bug you are experiencing** in case it has already been reported.
|
||||||
|
|
||||||
**Please use [Discord](https://discord.gg/sidestore-949183273383395328) or [GitHub Discussions](https://github.com/SideStore/SideStore/discussions) for support.**
|
**Please use [Discord](https://discord.gg/RgpFBX3Q3k) or [GitHub Discussions](https://github.com/SideStore/SideStore/discussions) for support.**
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -3,7 +3,7 @@ blank_issues_enabled: false
|
|||||||
|
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Discord
|
- name: Discord
|
||||||
url: https://discord.gg/sidestore-949183273383395328
|
url: https://discord.gg/RgpFBX3Q3k
|
||||||
about: If you need support, please go here first instead of making an issue!
|
about: If you need support, please go here first instead of making an issue!
|
||||||
- name: GitHub Discussions
|
- name: GitHub Discussions
|
||||||
url: https://github.com/SideStore/SideStore/discussions
|
url: https://github.com/SideStore/SideStore/discussions
|
||||||
|
|||||||
5
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
5
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -2,14 +2,15 @@ name: Feature Request
|
|||||||
description: Suggest a feature
|
description: Suggest a feature
|
||||||
title: "[FEATURE REQUEST] "
|
title: "[FEATURE REQUEST] "
|
||||||
labels: ["enhancement"]
|
labels: ["enhancement"]
|
||||||
assignees: []
|
assignees:
|
||||||
|
- naturecodevoid
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
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.
|
Thanks for taking the time to fill out this feature request! Before you continue filling out the form, please **[search in GitHub Issues](https://github.com/SideStore/SideStore/issues?q=is%3Aissue+is%3Aopen) for the feature you are suggestion** in case it has already been suggested.
|
||||||
|
|
||||||
**Please use [Discord](https://discord.gg/sidestore-949183273383395328) or [GitHub Discussions](https://github.com/SideStore/SideStore/discussions) for support.**
|
**Please use [Discord](https://discord.gg/RgpFBX3Q3k) or [GitHub Discussions](https://github.com/SideStore/SideStore/discussions) for support.**
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
63
.github/maintenance/cache.py
vendored
63
.github/maintenance/cache.py
vendored
@@ -1,63 +0,0 @@
|
|||||||
import requests
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Your GitHub Personal Access Token
|
|
||||||
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
|
|
||||||
|
|
||||||
# Repository details
|
|
||||||
REPO_OWNER = "SideStore"
|
|
||||||
REPO_NAME = "SideStore"
|
|
||||||
|
|
||||||
|
|
||||||
API_URL = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/actions/caches"
|
|
||||||
|
|
||||||
# Common headers for GitHub API calls
|
|
||||||
HEADERS = {
|
|
||||||
"Accept": "application/vnd.github+json",
|
|
||||||
"Authorization": f"Bearer {GITHUB_TOKEN}"
|
|
||||||
}
|
|
||||||
|
|
||||||
def list_caches():
|
|
||||||
response = requests.get(API_URL, headers=HEADERS)
|
|
||||||
if response.status_code != 200:
|
|
||||||
print(f"Failed to list caches. HTTP {response.status_code}")
|
|
||||||
print("Response:", response.text)
|
|
||||||
sys.exit(1)
|
|
||||||
data = response.json()
|
|
||||||
return data.get("actions_caches", [])
|
|
||||||
|
|
||||||
def delete_cache(cache_id):
|
|
||||||
delete_url = f"{API_URL}/{cache_id}"
|
|
||||||
response = requests.delete(delete_url, headers=HEADERS)
|
|
||||||
return response.status_code
|
|
||||||
|
|
||||||
def main():
|
|
||||||
caches = list_caches()
|
|
||||||
if not caches:
|
|
||||||
print("No caches found.")
|
|
||||||
return
|
|
||||||
|
|
||||||
print("Found caches:")
|
|
||||||
for cache in caches:
|
|
||||||
print(f"ID: {cache.get('id')}, Key: {cache.get('key')}")
|
|
||||||
|
|
||||||
print("\nDeleting caches...")
|
|
||||||
for cache in caches:
|
|
||||||
cache_id = cache.get("id")
|
|
||||||
status = delete_cache(cache_id)
|
|
||||||
if status == 204:
|
|
||||||
print(f"Successfully deleted cache with ID: {cache_id}")
|
|
||||||
else:
|
|
||||||
print(f"Failed to delete cache with ID: {cache_id}. HTTP status code: {status}")
|
|
||||||
|
|
||||||
print("All caches processed.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
||||||
|
|
||||||
### How to use
|
|
||||||
'''
|
|
||||||
just export the GITHUB_TOKEN and then run this script via `python3 cache.py' to delete the caches
|
|
||||||
'''
|
|
||||||
3
.github/pull_request_template.md
vendored
3
.github/pull_request_template.md
vendored
@@ -10,3 +10,6 @@
|
|||||||
<!-- Example: -->
|
<!-- Example: -->
|
||||||
- [x] Finish UI changes
|
- [x] Finish UI changes
|
||||||
- [ ] Test
|
- [ ] Test
|
||||||
|
|
||||||
|
<!-- If your PR doesn't close an issue, you can remove the next line. -->
|
||||||
|
Closes #1234
|
||||||
|
|||||||
28
.github/workflows/alpha.yml
vendored
28
.github/workflows/alpha.yml
vendored
@@ -1,28 +0,0 @@
|
|||||||
name: Alpha SideStore build
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop-alpha
|
|
||||||
|
|
||||||
# cancel duplicate run if from same branch
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
Reusable-build:
|
|
||||||
uses: ./.github/workflows/reusable-sidestore-build.yml
|
|
||||||
with:
|
|
||||||
# bundle_id: "com.SideStore.SideStore.Alpha"
|
|
||||||
bundle_id: "com.SideStore.SideStore"
|
|
||||||
# bundle_id_suffix: ".Alpha"
|
|
||||||
is_beta: true
|
|
||||||
publish: ${{ vars.PUBLISH_ALPHA_UPDATES == 'true' }}
|
|
||||||
is_shared_build_num: false
|
|
||||||
release_tag: "alpha"
|
|
||||||
release_name: "Alpha"
|
|
||||||
upstream_tag: "nightly"
|
|
||||||
upstream_name: "Nightly"
|
|
||||||
secrets:
|
|
||||||
CROSS_REPO_PUSH_KEY: ${{ secrets.CROSS_REPO_PUSH_KEY }}
|
|
||||||
BUILD_LOG_ZIP_PASSWORD: ${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
|
|
||||||
55
.github/workflows/attach_build_products.yml
vendored
55
.github/workflows/attach_build_products.yml
vendored
@@ -20,58 +20,3 @@ jobs:
|
|||||||
format: name
|
format: name
|
||||||
addTo: pull
|
addTo: pull
|
||||||
# addTo: pullandissues
|
# 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);
|
|
||||||
}
|
|
||||||
|
|||||||
47
.github/workflows/beta.yml
vendored
47
.github/workflows/beta.yml
vendored
@@ -11,13 +11,13 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: 'macos-14'
|
- os: 'macos-12'
|
||||||
version: '15.4'
|
version: '14.2'
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
@@ -27,25 +27,11 @@ jobs:
|
|||||||
- name: Change version to tag
|
- name: Change version to tag
|
||||||
run: sed -e '/MARKETING_VERSION = .*/s/= .*/= ${{ github.ref_name }}/' -i '' Build.xcconfig
|
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
|
- name: Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@v1
|
uses: maxim-lobanov/setup-xcode@v1.4.1
|
||||||
with:
|
with:
|
||||||
xcode-version: ${{ matrix.version }}
|
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
|
- name: Build SideStore
|
||||||
run: make build | xcpretty && exit ${PIPESTATUS[0]}
|
run: make build | xcpretty && exit ${PIPESTATUS[0]}
|
||||||
|
|
||||||
@@ -55,6 +41,16 @@ jobs:
|
|||||||
- name: Convert to IPA
|
- name: Convert to IPA
|
||||||
run: make ipa
|
run: make ipa
|
||||||
|
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@v3.1.0
|
||||||
|
with:
|
||||||
|
name: SideStore.ipa
|
||||||
|
path: SideStore.ipa
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: version
|
||||||
|
run: echo "version=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Get current date
|
- name: Get current date
|
||||||
id: date
|
id: date
|
||||||
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
|
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
|
||||||
@@ -86,18 +82,3 @@ jobs:
|
|||||||
Built at (UTC date): `${{ steps.date_altstore.outputs.date }}`
|
Built at (UTC date): `${{ steps.date_altstore.outputs.date }}`
|
||||||
Commit SHA: `${{ github.sha }}`
|
Commit SHA: `${{ github.sha }}`
|
||||||
Version: `${{ steps.version.outputs.version }}`
|
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/
|
|
||||||
|
|||||||
13
.github/workflows/danger.yml
vendored
Normal file
13
.github/workflows/danger.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
name: "Danger Swift"
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Danger JS
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v1
|
||||||
|
- name: Danger Swift
|
||||||
|
uses: danger/swift@2.0.3
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
34
.github/workflows/increase-beta-build-num.sh
vendored
34
.github/workflows/increase-beta-build-num.sh
vendored
@@ -1,34 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# Ensure we are in root directory
|
|
||||||
cd "$(dirname "$0")/../.."
|
|
||||||
|
|
||||||
DATE=`date -u +'%Y.%m.%d'`
|
|
||||||
BUILD_NUM=1
|
|
||||||
|
|
||||||
# Use RELEASE_CHANNEL from the environment variable or default to "beta"
|
|
||||||
RELEASE_CHANNEL=${RELEASE_CHANNEL:-"beta"}
|
|
||||||
|
|
||||||
write() {
|
|
||||||
sed -e "/MARKETING_VERSION = .*/s/$/-$RELEASE_CHANNEL.$DATE.$BUILD_NUM+$(git rev-parse --short HEAD)/" -i '' Build.xcconfig
|
|
||||||
echo "$DATE,$BUILD_NUM" > build_number.txt
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ ! -f "build_number.txt" ]; then
|
|
||||||
write
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
LAST_DATE=`cat build_number.txt | perl -n -e '/([^,]*),([^ ]*)$/ && print $1'`
|
|
||||||
LAST_BUILD_NUM=`cat build_number.txt | perl -n -e '/([^,]*),([^ ]*)$/ && print $2'`
|
|
||||||
|
|
||||||
# if [[ "$DATE" != "$LAST_DATE" ]]; then
|
|
||||||
# write
|
|
||||||
# else
|
|
||||||
# BUILD_NUM=`expr $LAST_BUILD_NUM + 1`
|
|
||||||
# write
|
|
||||||
# fi
|
|
||||||
|
|
||||||
# Build number is always incremental
|
|
||||||
BUILD_NUM=`expr $LAST_BUILD_NUM + 1`
|
|
||||||
write
|
|
||||||
28
.github/workflows/increase-nightly-build-num.sh
vendored
Normal file
28
.github/workflows/increase-nightly-build-num.sh
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Ensure we are in root directory
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
DATE=`date -u +'%Y.%m.%d'`
|
||||||
|
BUILD_NUM=1
|
||||||
|
|
||||||
|
write() {
|
||||||
|
sed -e "/MARKETING_VERSION = .*/s/$/-nightly.$DATE.$BUILD_NUM/" -i '' Build.xcconfig
|
||||||
|
echo "$DATE,$BUILD_NUM" > .nightly-build-num
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ ! -f ".nightly-build-num" ]; then
|
||||||
|
write
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
LAST_DATE=`cat .nightly-build-num | perl -n -e '/([^,]*),([^ ]*)$/ && print $1'`
|
||||||
|
LAST_BUILD_NUM=`cat .nightly-build-num | perl -n -e '/([^,]*),([^ ]*)$/ && print $2'`
|
||||||
|
|
||||||
|
if [[ "$DATE" != "$LAST_DATE" ]]; then
|
||||||
|
write
|
||||||
|
else
|
||||||
|
BUILD_NUM=`expr $LAST_BUILD_NUM + 1`
|
||||||
|
write
|
||||||
|
fi
|
||||||
|
|
||||||
152
.github/workflows/nightly.yml
vendored
152
.github/workflows/nightly.yml
vendored
@@ -1,82 +1,94 @@
|
|||||||
name: Nightly SideStore Build
|
name: Nightly SideStore build
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- develop
|
- 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:
|
jobs:
|
||||||
check-changes:
|
build:
|
||||||
if: github.event_name == 'schedule'
|
name: Build and upload SideStore Nightly
|
||||||
runs-on: ubuntu-latest
|
concurrency:
|
||||||
outputs:
|
group: ${{ github.ref }}
|
||||||
has_changes: ${{ steps.check.outputs.has_changes }}
|
cancel-in-progress: true
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: 'macos-12'
|
||||||
|
version: '14.2'
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Ensure full history
|
submodules: recursive
|
||||||
|
|
||||||
- name: Get last successful workflow run
|
- name: Install dependencies
|
||||||
id: get_last_success
|
run: brew install ldid
|
||||||
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
|
- name: Cache .nightly-build-num
|
||||||
id: check
|
uses: actions/cache@v3
|
||||||
run: |
|
with:
|
||||||
if [ -n "$LAST_SUCCESS" ]; then
|
path: .nightly-build-num
|
||||||
NEW_COMMITS=$(git rev-list --count --since="$LAST_SUCCESS" origin/develop)
|
key: nightly-build-num
|
||||||
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:
|
- name: Increase nightly build number and set as version
|
||||||
if: |
|
run: bash .github/workflows/increase-nightly-build-num.sh
|
||||||
always() &&
|
|
||||||
(github.event_name == 'push' ||
|
|
||||||
(github.event_name == 'schedule' && needs.check-changes.result == 'success' && needs.check-changes.outputs.has_changes == 'true'))
|
|
||||||
needs: check-changes
|
|
||||||
uses: ./.github/workflows/reusable-sidestore-build.yml
|
|
||||||
with:
|
|
||||||
# bundle_id: "com.SideStore.SideStore.Nightly"
|
|
||||||
bundle_id: "com.SideStore.SideStore"
|
|
||||||
# bundle_id_suffix: ".Nightly"
|
|
||||||
is_beta: true
|
|
||||||
publish: ${{ vars.PUBLISH_NIGHTLY_UPDATES == 'true' }}
|
|
||||||
is_shared_build_num: false
|
|
||||||
release_tag: "nightly"
|
|
||||||
release_name: "Nightly"
|
|
||||||
upstream_tag: "0.5.10"
|
|
||||||
upstream_name: "Stable"
|
|
||||||
secrets:
|
|
||||||
CROSS_REPO_PUSH_KEY: ${{ secrets.CROSS_REPO_PUSH_KEY }}
|
|
||||||
BUILD_LOG_ZIP_PASSWORD: ${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
|
|
||||||
|
|
||||||
|
- name: Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@v1.4.1
|
||||||
|
with:
|
||||||
|
xcode-version: ${{ matrix.version }}
|
||||||
|
|
||||||
|
- name: Build SideStore
|
||||||
|
run: make build | xcpretty && exit ${PIPESTATUS[0]}
|
||||||
|
|
||||||
|
- name: Fakesign app
|
||||||
|
run: make fakesign
|
||||||
|
|
||||||
|
- name: Convert to IPA
|
||||||
|
run: make ipa
|
||||||
|
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@v3.1.0
|
||||||
|
with:
|
||||||
|
name: SideStore.ipa
|
||||||
|
path: SideStore.ipa
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: version
|
||||||
|
run: echo "version=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Get current date
|
||||||
|
id: date
|
||||||
|
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Get current date in AltStore date form
|
||||||
|
id: date_altstore
|
||||||
|
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Upload to nightly release
|
||||||
|
uses: IsaacShelton/update-existing-release@v1.3.1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
release: "Nightly"
|
||||||
|
tag: "nightly"
|
||||||
|
prerelease: true
|
||||||
|
files: SideStore.ipa
|
||||||
|
body: |
|
||||||
|
This is an ⚠️ **EXPERIMENTAL** ⚠️ nightly build for commit [${{ github.sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}).
|
||||||
|
|
||||||
|
Nightly builds are **extremely experimental builds only meant to be used by developers and alpha testers. They often contain bugs and experimental features. Use at your own risk!**
|
||||||
|
|
||||||
|
If you want to try out new features early but want a lower chance of bugs, you can look at [SideStore Beta](https://github.com/${{ github.repository }}/releases?q=beta).
|
||||||
|
|
||||||
|
## Build Info
|
||||||
|
|
||||||
|
Built at (UTC): `${{ steps.date.outputs.date }}`
|
||||||
|
Built at (UTC date): `${{ steps.date_altstore.outputs.date }}`
|
||||||
|
Commit SHA: `${{ github.sha }}`
|
||||||
|
Version: `${{ steps.version.outputs.version }}`
|
||||||
|
|
||||||
|
- name: Reset cache for apps.sidestore.io/nightly
|
||||||
|
run: sleep 10 && curl https://apps.sidestore.io/reset-cache/nightly/${{ secrets.SIDESOURCE_KEY }}
|
||||||
|
|||||||
72
.github/workflows/pr.yml
vendored
72
.github/workflows/pr.yml
vendored
@@ -1,80 +1,37 @@
|
|||||||
name: Pull Request SideStore build
|
name: Pull Request SideStore build
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
# types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
|
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build and upload SideStore
|
name: Build and upload SideStore
|
||||||
if: ${{ github.event.pull_request.draft == false }}
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: 'macos-14'
|
- os: 'macos-12'
|
||||||
version: '16.1'
|
version: '14.2'
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: brew install ldid
|
run: brew install ldid
|
||||||
|
|
||||||
- name: Install xcbeautify
|
|
||||||
run: brew install xcbeautify
|
|
||||||
|
|
||||||
- name: Add PR suffix to version
|
- 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
|
run: sed -e '/MARKETING_VERSION = .*/s/$/-pr.${{ github.event.pull_request.number }}/' -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
|
- name: Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@v1.6.0
|
uses: maxim-lobanov/setup-xcode@v1.4.1
|
||||||
with:
|
with:
|
||||||
xcode-version: ${{ matrix.version }}
|
xcode-version: ${{ matrix.version }}
|
||||||
|
|
||||||
- name: Cache Build
|
|
||||||
uses: irgaly/xcode-cache@v1
|
|
||||||
with:
|
|
||||||
key: xcode-cache-deriveddata-${{ github.sha }}
|
|
||||||
restore-keys: xcode-cache-deriveddata-
|
|
||||||
swiftpm-cache-key: xcode-cache-sourcedata-${{ github.sha }}
|
|
||||||
swiftpm-cache-restore-keys: |
|
|
||||||
xcode-cache-sourcedata-
|
|
||||||
|
|
||||||
- name: List Files and derived data
|
|
||||||
run: |
|
|
||||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
|
||||||
ls -la .
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> SideStore <<<<<<<<<<"
|
|
||||||
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
|
|
||||||
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
|
||||||
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
|
|
||||||
- name: Build SideStore
|
- name: Build SideStore
|
||||||
run: NSUnbufferedIO=YES make build 2>&1 | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
|
run: make build | xcpretty && exit ${PIPESTATUS[0]}
|
||||||
|
|
||||||
- name: Fakesign app
|
- name: Fakesign app
|
||||||
run: make fakesign
|
run: make fakesign
|
||||||
@@ -82,17 +39,8 @@ jobs:
|
|||||||
- name: Convert to IPA
|
- name: Convert to IPA
|
||||||
run: make ipa
|
run: make ipa
|
||||||
|
|
||||||
- name: Add version to IPA file name
|
- name: Upload Artifact
|
||||||
run: mv SideStore.ipa SideStore-${{ steps.version.outputs.version }}.ipa
|
uses: actions/upload-artifact@v3.1.0
|
||||||
|
|
||||||
- name: Upload SideStore.ipa Artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: SideStore-${{ steps.version.outputs.version }}.ipa
|
name: SideStore.ipa
|
||||||
path: SideStore-${{ steps.version.outputs.version }}.ipa
|
path: SideStore.ipa
|
||||||
|
|
||||||
- name: Upload *.dSYM Artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: SideStore-${{ steps.version.outputs.version }}-dSYM
|
|
||||||
path: ./SideStore.xcarchive/dSYMs/*
|
|
||||||
|
|||||||
104
.github/workflows/reusable-sidestore-build.yml
vendored
104
.github/workflows/reusable-sidestore-build.yml
vendored
@@ -1,104 +0,0 @@
|
|||||||
name: Reusable SideStore Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
is_beta:
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
publish:
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
is_shared_build_num:
|
|
||||||
required: false
|
|
||||||
default: true
|
|
||||||
type: boolean
|
|
||||||
release_name:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
release_tag:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
upstream_tag:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
upstream_name:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
bundle_id:
|
|
||||||
default: com.SideStore.SideStore
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
bundle_id_suffix:
|
|
||||||
default: ''
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
|
|
||||||
secrets:
|
|
||||||
# GITHUB_TOKEN:
|
|
||||||
# required: true
|
|
||||||
CROSS_REPO_PUSH_KEY:
|
|
||||||
required: true
|
|
||||||
BUILD_LOG_ZIP_PASSWORD:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
|
|
||||||
# since build cache, test-build cache, test-run cache are involved, out of order exec if serialization is on individual jobs will wreak all sorts of havoc
|
|
||||||
# so we serialize on the entire workflow
|
|
||||||
concurrency:
|
|
||||||
group: serialize-workflow
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
shared:
|
|
||||||
uses: ./.github/workflows/sidestore-shared.yml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
build:
|
|
||||||
needs: shared
|
|
||||||
uses: ./.github/workflows/sidestore-build.yml
|
|
||||||
with:
|
|
||||||
is_beta: ${{ inputs.is_beta }}
|
|
||||||
is_shared_build_num: ${{ inputs.is_shared_build_num }}
|
|
||||||
release_tag: ${{ inputs.release_tag }}
|
|
||||||
short_commit: ${{ needs.shared.outputs.short-commit }}
|
|
||||||
bundle_id: ${{ inputs.bundle_id }}
|
|
||||||
bundle_id_suffix: ${{ inputs.bundle_id_suffix }}
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
tests-build:
|
|
||||||
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_BUILD == '1' }}
|
|
||||||
needs: shared
|
|
||||||
uses: ./.github/workflows/sidestore-tests-build.yml
|
|
||||||
with:
|
|
||||||
release_tag: ${{ inputs.release_tag }}
|
|
||||||
short_commit: ${{ needs.shared.outputs.short-commit }}
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
tests-run:
|
|
||||||
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
|
|
||||||
needs: [shared, tests-build]
|
|
||||||
uses: ./.github/workflows/sidestore-tests-run.yml
|
|
||||||
with:
|
|
||||||
release_tag: ${{ inputs.release_tag }}
|
|
||||||
short_commit: ${{ needs.shared.outputs.short-commit }}
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
needs: [shared, build, tests-build, tests-run] # Keep tests-run in needs
|
|
||||||
if: ${{ always() && (needs.tests-run.result == 'skipped' || needs.tests-run.result == 'success') }}
|
|
||||||
uses: ./.github/workflows/sidestore-deploy.yml
|
|
||||||
with:
|
|
||||||
is_beta: ${{ inputs.is_beta }}
|
|
||||||
publish: ${{ inputs.publish }}
|
|
||||||
release_name: ${{ inputs.release_name }}
|
|
||||||
release_tag: ${{ inputs.release_tag }}
|
|
||||||
upstream_tag: ${{ inputs.upstream_tag }}
|
|
||||||
upstream_name: ${{ inputs.upstream_name }}
|
|
||||||
version: ${{ needs.build.outputs.version }}
|
|
||||||
short_commit: ${{ needs.shared.outputs.short-commit }}
|
|
||||||
release_channel: ${{ needs.build.outputs.release-channel }}
|
|
||||||
marketing_version: ${{ needs.build.outputs.marketing-version }}
|
|
||||||
bundle_id: ${{ inputs.bundle_id }}
|
|
||||||
secrets: inherit
|
|
||||||
358
.github/workflows/sidestore-build.yml
vendored
358
.github/workflows/sidestore-build.yml
vendored
@@ -1,358 +0,0 @@
|
|||||||
name: SideStore Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
is_beta:
|
|
||||||
type: boolean
|
|
||||||
is_shared_build_num:
|
|
||||||
type: boolean
|
|
||||||
release_tag:
|
|
||||||
type: string
|
|
||||||
bundle_id:
|
|
||||||
type: string
|
|
||||||
bundle_id_suffix:
|
|
||||||
type: string
|
|
||||||
short_commit:
|
|
||||||
type: string
|
|
||||||
secrets:
|
|
||||||
CROSS_REPO_PUSH_KEY:
|
|
||||||
required: true
|
|
||||||
BUILD_LOG_ZIP_PASSWORD:
|
|
||||||
required: false
|
|
||||||
outputs:
|
|
||||||
version:
|
|
||||||
value: ${{ jobs.build.outputs.version }}
|
|
||||||
marketing-version:
|
|
||||||
value: ${{ jobs.build.outputs.marketing-version }}
|
|
||||||
release-channel:
|
|
||||||
value: ${{ jobs.build.outputs.release-channel }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build SideStore - ${{ inputs.release_tag }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- os: 'macos-26'
|
|
||||||
version: '26.0'
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
outputs:
|
|
||||||
version: ${{ steps.version.outputs.version }}
|
|
||||||
marketing-version: ${{ steps.marketing-version.outputs.MARKETING_VERSION }}
|
|
||||||
release-channel: ${{ steps.release-channel.outputs.RELEASE_CHANNEL }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Set beta status
|
|
||||||
run: echo "IS_BETA=${{ inputs.is_beta }}" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Install dependencies - ldid & xcbeautify
|
|
||||||
run: |
|
|
||||||
brew install ldid xcbeautify
|
|
||||||
|
|
||||||
- name: Set ref based on is_shared_build_num
|
|
||||||
if: ${{ inputs.is_beta }}
|
|
||||||
id: set_ref
|
|
||||||
run: |
|
|
||||||
if [ "${{ inputs.is_shared_build_num }}" == "true" ]; then
|
|
||||||
echo "ref=main" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "ref=${{ inputs.release_tag }}" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Checkout SideStore/beta-build-num repo
|
|
||||||
if: ${{ inputs.is_beta }}
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: 'SideStore/beta-build-num'
|
|
||||||
ref: ${{ env.ref }}
|
|
||||||
token: ${{ secrets.CROSS_REPO_PUSH_KEY }}
|
|
||||||
path: 'SideStore/beta-build-num'
|
|
||||||
|
|
||||||
- name: Copy build_number.txt to repo root
|
|
||||||
if: ${{ inputs.is_beta }}
|
|
||||||
run: |
|
|
||||||
cp SideStore/beta-build-num/build_number.txt .
|
|
||||||
echo "cat build_number.txt"
|
|
||||||
cat build_number.txt
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Echo Build.xcconfig
|
|
||||||
run: |
|
|
||||||
echo "cat Build.xcconfig"
|
|
||||||
cat Build.xcconfig
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Set Release Channel info for build number bumper
|
|
||||||
id: release-channel
|
|
||||||
run: |
|
|
||||||
RELEASE_CHANNEL="${{ inputs.release_tag }}"
|
|
||||||
echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}" >> $GITHUB_ENV
|
|
||||||
echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}" >> $GITHUB_OUTPUT
|
|
||||||
echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}"
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Increase build number for beta builds
|
|
||||||
if: ${{ inputs.is_beta }}
|
|
||||||
run: |
|
|
||||||
bash .github/workflows/increase-beta-build-num.sh
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Extract MARKETING_VERSION from Build.xcconfig
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
version=$(grep MARKETING_VERSION Build.xcconfig | sed -e 's/MARKETING_VERSION = //g')
|
|
||||||
echo "version=$version" >> $GITHUB_OUTPUT
|
|
||||||
echo "version=$version"
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Set MARKETING_VERSION
|
|
||||||
if: ${{ inputs.is_beta }}
|
|
||||||
id: marketing-version
|
|
||||||
run: |
|
|
||||||
# Extract version number (e.g., "0.6.0")
|
|
||||||
version=$(echo "${{ steps.version.outputs.version }}" | sed -E 's/^[^0-9]*([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
|
|
||||||
# Extract date (YYYYMMDD) (e.g., "20250205")
|
|
||||||
date=$(echo "${{ steps.version.outputs.version }}" | sed -E 's/.*\.([0-9]{4})\.([0-9]{2})\.([0-9]{2})\..*/\1\2\3/')
|
|
||||||
# Extract build number (e.g., "2")
|
|
||||||
build_num=$(echo "${{ steps.version.outputs.version }}" | sed -E 's/.*\.([0-9]+)\+.*/\1/')
|
|
||||||
|
|
||||||
# Combine them into the final output
|
|
||||||
MARKETING_VERSION="${version}-${date}.${build_num}+${{ inputs.short_commit }}"
|
|
||||||
|
|
||||||
echo "MARKETING_VERSION=$MARKETING_VERSION" >> $GITHUB_ENV
|
|
||||||
echo "MARKETING_VERSION=$MARKETING_VERSION" >> $GITHUB_OUTPUT
|
|
||||||
echo "MARKETING_VERSION=$MARKETING_VERSION"
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Echo Updated Build.xcconfig, build_number.txt
|
|
||||||
if: ${{ inputs.is_beta }}
|
|
||||||
run: |
|
|
||||||
cat Build.xcconfig
|
|
||||||
cat build_number.txt
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Setup Xcode
|
|
||||||
uses: maxim-lobanov/setup-xcode@v1.6.0
|
|
||||||
with:
|
|
||||||
xcode-version: ${{ matrix.version }}
|
|
||||||
|
|
||||||
- name: (Build) Restore Xcode & SwiftPM Cache (Exact match)
|
|
||||||
id: xcode-cache-restore
|
|
||||||
uses: actions/cache/restore@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/Library/Developer/Xcode/DerivedData
|
|
||||||
~/Library/Caches/org.swift.swiftpm
|
|
||||||
key: xcode-cache-build-${{ github.ref_name }}-${{ github.sha }}
|
|
||||||
|
|
||||||
- name: (Build) Restore Xcode & SwiftPM Cache (Last Available)
|
|
||||||
id: xcode-cache-restore-recent
|
|
||||||
uses: actions/cache/restore@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/Library/Developer/Xcode/DerivedData
|
|
||||||
~/Library/Caches/org.swift.swiftpm
|
|
||||||
key: xcode-cache-build-${{ github.ref_name }}-
|
|
||||||
|
|
||||||
# - name: (Build) Cache Build
|
|
||||||
# uses: irgaly/xcode-cache@v1.8.1
|
|
||||||
# with:
|
|
||||||
# key: xcode-cache-deriveddata-build-${{ github.ref_name }}-${{ github.sha }}
|
|
||||||
# restore-keys: xcode-cache-deriveddata-build-${{ github.ref_name }}-
|
|
||||||
# swiftpm-cache-key: xcode-cache-sourcedata-build-${{ github.ref_name }}-${{ github.sha }}
|
|
||||||
# swiftpm-cache-restore-keys: |
|
|
||||||
# xcode-cache-sourcedata-build-${{ github.ref_name }}-
|
|
||||||
|
|
||||||
- name: (Build) Clean previous build artifacts
|
|
||||||
# using 'tee' to intercept stdout and log for detailed build-log
|
|
||||||
run: |
|
|
||||||
make clean
|
|
||||||
mkdir -p build/logs
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: (Build) List Files and derived data
|
|
||||||
if: always()
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
|
||||||
ls -la .
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> SideStore <<<<<<<<<<"
|
|
||||||
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
|
|
||||||
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
|
||||||
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
- name: Set BundleID Suffix for Sidestore build
|
|
||||||
run: |
|
|
||||||
echo "BUNDLE_ID_SUFFIX=${{ inputs.bundle_id_suffix }}" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
|
|
||||||
- name: Build SideStore.xcarchive
|
|
||||||
# using 'tee' to intercept stdout and log for detailed build-log
|
|
||||||
run: |
|
|
||||||
NSUnbufferedIO=YES make -B build 2>&1 | tee -a build/logs/build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Fakesign app
|
|
||||||
run: make fakesign | tee -a build/logs/build.log
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Convert to IPA
|
|
||||||
run: make ipa | tee -a build/logs/build.log
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: (Build) Save Xcode & SwiftPM Cache
|
|
||||||
id: cache-save
|
|
||||||
if: ${{ steps.xcode-cache-restore.outputs.cache-hit != 'true' }}
|
|
||||||
uses: actions/cache/save@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/Library/Developer/Xcode/DerivedData
|
|
||||||
~/Library/Caches/org.swift.swiftpm
|
|
||||||
key: xcode-cache-build-${{ github.ref_name }}-${{ github.sha }}
|
|
||||||
|
|
||||||
- name: (Build) List Files and Build artifacts
|
|
||||||
run: |
|
|
||||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
|
||||||
ls -la .
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> Build <<<<<<<<<<"
|
|
||||||
find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> SideStore <<<<<<<<<<"
|
|
||||||
find SideStore -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> SideStore.xcarchive <<<<<<<<<<"
|
|
||||||
find SideStore.xcarchive -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
|
||||||
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Encrypt build-logs for upload
|
|
||||||
id: encrypt-build-log
|
|
||||||
run: |
|
|
||||||
DEFAULT_BUILD_LOG_PASSWORD=12345
|
|
||||||
|
|
||||||
BUILD_LOG_ZIP_PASSWORD=${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
|
|
||||||
BUILD_LOG_ZIP_PASSWORD=${BUILD_LOG_ZIP_PASSWORD:-$DEFAULT_BUILD_LOG_PASSWORD}
|
|
||||||
|
|
||||||
if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then
|
|
||||||
echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'."
|
|
||||||
fi
|
|
||||||
|
|
||||||
pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-build-logs.zip * || popd
|
|
||||||
echo "::set-output name=encrypted::true"
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Upload encrypted-build-logs.zip
|
|
||||||
id: attach-encrypted-build-log
|
|
||||||
if: ${{ always() && steps.encrypt-build-log.outputs.encrypted == 'true' }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: encrypted-build-logs-${{ steps.version.outputs.version }}.zip
|
|
||||||
path: encrypted-build-logs.zip
|
|
||||||
|
|
||||||
- name: Upload SideStore.ipa Artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: SideStore-${{ steps.version.outputs.version }}.ipa
|
|
||||||
path: SideStore.ipa
|
|
||||||
|
|
||||||
- name: Zip dSYMs
|
|
||||||
run: zip -r -9 ./SideStore.dSYMs.zip ./SideStore.xcarchive/dSYMs
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Upload *.dSYM Artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: SideStore-${{ steps.version.outputs.version }}-dSYMs.zip
|
|
||||||
path: SideStore.dSYMs.zip
|
|
||||||
|
|
||||||
- name: Keep rolling the build numbers for each successful build
|
|
||||||
if: ${{ inputs.is_beta }}
|
|
||||||
run: |
|
|
||||||
pushd SideStore/beta-build-num/
|
|
||||||
|
|
||||||
echo "Configure Git user (committer details)"
|
|
||||||
git config user.name "GitHub Actions"
|
|
||||||
git config user.email "github-actions@github.com"
|
|
||||||
|
|
||||||
echo "Adding files to commit"
|
|
||||||
git add --verbose build_number.txt
|
|
||||||
git commit -m " - updated for ${{ inputs.release_tag }} - ${{ inputs.short_commit }} deployment" || echo "No changes to commit"
|
|
||||||
|
|
||||||
echo "Pushing to remote repo"
|
|
||||||
git push --verbose
|
|
||||||
popd
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Get last successful commit
|
|
||||||
id: get_last_commit
|
|
||||||
run: |
|
|
||||||
# Try to get the last successful workflow run commit
|
|
||||||
LAST_SUCCESS_SHA=$(gh run list --branch "${{ github.ref_name }}" --status success --json headSha --jq '.[0].headSha')
|
|
||||||
echo "LAST_SUCCESS_SHA=$LAST_SUCCESS_SHA" >> $GITHUB_OUTPUT
|
|
||||||
echo "LAST_SUCCESS_SHA=$LAST_SUCCESS_SHA" >> $GITHUB_ENV
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Create release notes
|
|
||||||
run: |
|
|
||||||
LAST_SUCCESS_SHA=${{ steps.get_last_commit.outputs.LAST_SUCCESS_SHA}}
|
|
||||||
echo "Last successful commit SHA: $LAST_SUCCESS_SHA"
|
|
||||||
|
|
||||||
FROM_COMMIT=$LAST_SUCCESS_SHA
|
|
||||||
# Check if we got a valid SHA
|
|
||||||
if [ -z "$LAST_SUCCESS_SHA" ] || [ "$LAST_SUCCESS_SHA" = "null" ]; then
|
|
||||||
echo "No successful run found, using initial commit of branch"
|
|
||||||
# Get the first commit of the branch (initial commit)
|
|
||||||
FROM_COMMIT=$(git rev-list --max-parents=0 HEAD)
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 update_release_notes.py $FROM_COMMIT ${{ inputs.release_tag }} ${{ github.ref_name }}
|
|
||||||
# cat release-notes.md
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Upload release-notes.md
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: release-notes-${{ inputs.short_commit }}.md
|
|
||||||
path: release-notes.md
|
|
||||||
|
|
||||||
- name: Upload update_release_notes.py
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: update_release_notes-${{ inputs.short_commit }}.py
|
|
||||||
path: update_release_notes.py
|
|
||||||
|
|
||||||
- name: Upload update_apps.py
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: update_apps-${{ inputs.short_commit }}.py
|
|
||||||
path: update_apps.py
|
|
||||||
264
.github/workflows/sidestore-deploy.yml
vendored
264
.github/workflows/sidestore-deploy.yml
vendored
@@ -1,264 +0,0 @@
|
|||||||
name: SideStore Deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
is_beta:
|
|
||||||
type: boolean
|
|
||||||
publish:
|
|
||||||
type: boolean
|
|
||||||
release_name:
|
|
||||||
type: string
|
|
||||||
release_tag:
|
|
||||||
type: string
|
|
||||||
upstream_tag:
|
|
||||||
type: string
|
|
||||||
upstream_name:
|
|
||||||
type: string
|
|
||||||
version:
|
|
||||||
type: string
|
|
||||||
short_commit:
|
|
||||||
type: string
|
|
||||||
marketing_version:
|
|
||||||
type: string
|
|
||||||
release_channel:
|
|
||||||
type: string
|
|
||||||
bundle_id:
|
|
||||||
type: string
|
|
||||||
secrets:
|
|
||||||
CROSS_REPO_PUSH_KEY:
|
|
||||||
required: true
|
|
||||||
# GITHUB_TOKEN:
|
|
||||||
# required: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
name: Deploy SideStore - ${{ inputs.release_tag }}
|
|
||||||
runs-on: macos-15
|
|
||||||
steps:
|
|
||||||
- name: Download IPA artifact
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: SideStore-${{ inputs.version }}.ipa
|
|
||||||
|
|
||||||
- name: Download dSYM artifact
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: SideStore-${{ inputs.version }}-dSYMs.zip
|
|
||||||
|
|
||||||
- name: Download encrypted-build-logs artifact
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: encrypted-build-logs-${{ inputs.version }}.zip
|
|
||||||
|
|
||||||
- name: Download encrypted-tests-build-logs artifact
|
|
||||||
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_BUILD == '1' }}
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: encrypted-tests-build-logs-${{ inputs.short_commit }}.zip
|
|
||||||
|
|
||||||
- name: Download encrypted-tests-run-logs artifact
|
|
||||||
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: encrypted-tests-run-logs-${{ inputs.short_commit }}.zip
|
|
||||||
|
|
||||||
- name: Download tests-recording artifact
|
|
||||||
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: tests-recording-${{ inputs.short_commit }}.mp4
|
|
||||||
|
|
||||||
- name: Download test-results artifact
|
|
||||||
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: test-results-${{ inputs.short_commit }}.zip
|
|
||||||
|
|
||||||
- name: Download release-notes.md
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: release-notes-${{ inputs.short_commit }}.md
|
|
||||||
|
|
||||||
- name: Download update_release_notes.py
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: update_release_notes-${{ inputs.short_commit }}.py
|
|
||||||
|
|
||||||
- name: Download update_apps.py
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: update_apps-${{ inputs.short_commit }}.py
|
|
||||||
|
|
||||||
- name: Read release notes
|
|
||||||
id: release_notes
|
|
||||||
run: |
|
|
||||||
CONTENT=$(python3 update_release_notes.py --retrieve ${{ inputs.release_tag }})
|
|
||||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
|
||||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
|
||||||
echo "EOF" >> $GITHUB_OUTPUT
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: List files before upload
|
|
||||||
run: |
|
|
||||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
|
||||||
find . -maxdepth 4 -exec ls -ld {} + || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Get current date
|
|
||||||
id: date
|
|
||||||
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Get current date in AltStore date form
|
|
||||||
id: date_altstore
|
|
||||||
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
|
|
||||||
- name: List files to upload
|
|
||||||
id: list_uploads
|
|
||||||
run: |
|
|
||||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
|
||||||
find . -maxdepth 4 -exec ls -ld {} + || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
FILES="SideStore.ipa SideStore.dSYMs.zip encrypted-build-logs.zip"
|
|
||||||
|
|
||||||
if [[ "${{ vars.ENABLE_TESTS }}" == "1" && "${{ vars.ENABLE_TESTS_BUILD }}" == "1" ]]; then
|
|
||||||
FILES="$FILES encrypted-tests-build-logs.zip"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${{ vars.ENABLE_TESTS }}" == "1" && "${{ vars.ENABLE_TESTS_RUN }}" == "1" ]]; then
|
|
||||||
FILES="$FILES encrypted-tests-run-logs.zip test-results.zip tests-recording.mp4"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Final upload list:"
|
|
||||||
for f in $FILES; do
|
|
||||||
if [[ -f "$f" ]]; then
|
|
||||||
echo " ✓ $f"
|
|
||||||
else
|
|
||||||
echo " - $f (missing)"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "files=$FILES" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Upload to releases
|
|
||||||
uses: IsaacShelton/update-existing-release@v1.3.1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
release: ${{ inputs.release_name }}
|
|
||||||
tag: ${{ inputs.release_tag }}
|
|
||||||
prerelease: ${{ inputs.is_beta }}
|
|
||||||
files: ${{ steps.list_uploads.outputs.files }}
|
|
||||||
body: |
|
|
||||||
This is an ⚠️ **EXPERIMENTAL** ⚠️ ${{ inputs.release_name }} build for commit [${{ github.sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}).
|
|
||||||
|
|
||||||
${{ inputs.release_name }} builds are **extremely experimental builds only meant to be used by developers and beta testers. They often contain bugs and experimental features. Use at your own risk!**
|
|
||||||
|
|
||||||
If you want to try out new features early but want a lower chance of bugs, you can look at [SideStore ${{ inputs.upstream_name }}](https://github.com/${{ github.repository }}/releases?q=${{ inputs.upstream_tag }}).
|
|
||||||
|
|
||||||
## Build Info
|
|
||||||
|
|
||||||
Built at (UTC): `${{ steps.date.outputs.date }}`
|
|
||||||
Built at (UTC date): `${{ steps.date_altstore.outputs.date }}`
|
|
||||||
Commit SHA: `${{ github.sha }}`
|
|
||||||
Version: `${{ inputs.version }}`
|
|
||||||
|
|
||||||
${{ steps.release_notes.outputs.content }}
|
|
||||||
|
|
||||||
- name: Get formatted date
|
|
||||||
run: |
|
|
||||||
FORMATTED_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
||||||
echo "Formatted date: $FORMATTED_DATE"
|
|
||||||
echo "FORMATTED_DATE=$FORMATTED_DATE" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Get size of IPA in bytes (macOS/Linux)
|
|
||||||
run: |
|
|
||||||
if [[ "$(uname)" == "Darwin" ]]; then
|
|
||||||
# macOS
|
|
||||||
IPA_SIZE=$(stat -f %z SideStore.ipa)
|
|
||||||
else
|
|
||||||
# Linux
|
|
||||||
IPA_SIZE=$(stat -c %s SideStore.ipa)
|
|
||||||
fi
|
|
||||||
echo "IPA size in bytes: $IPA_SIZE"
|
|
||||||
echo "IPA_SIZE=$IPA_SIZE" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Compute SHA-256 of IPA
|
|
||||||
run: |
|
|
||||||
SHA256_HASH=$(shasum -a 256 SideStore.ipa | awk '{ print $1 }')
|
|
||||||
echo "SHA-256 Hash: $SHA256_HASH"
|
|
||||||
echo "SHA256_HASH=$SHA256_HASH" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Set Release Info variables
|
|
||||||
run: |
|
|
||||||
# Format localized description
|
|
||||||
LOCALIZED_DESCRIPTION=$(cat <<EOF
|
|
||||||
This is release for:
|
|
||||||
- version: "${{ inputs.version }}"
|
|
||||||
- revision: "${{ inputs.short_commit }}"
|
|
||||||
- timestamp: "${{ steps.date.outputs.date }}"
|
|
||||||
|
|
||||||
Release Notes:
|
|
||||||
${{ steps.release_notes.outputs.content }}
|
|
||||||
EOF
|
|
||||||
)
|
|
||||||
|
|
||||||
echo "IS_BETA=${{ inputs.is_beta }}" >> $GITHUB_ENV
|
|
||||||
echo "BUNDLE_IDENTIFIER=${{ inputs.bundle_id }}" >> $GITHUB_ENV
|
|
||||||
echo "VERSION_IPA=${{ inputs.marketing_version }}" >> $GITHUB_ENV
|
|
||||||
echo "VERSION_DATE=$FORMATTED_DATE" >> $GITHUB_ENV
|
|
||||||
echo "RELEASE_CHANNEL=${{ inputs.release_channel }}" >> $GITHUB_ENV
|
|
||||||
echo "SIZE=$IPA_SIZE" >> $GITHUB_ENV
|
|
||||||
echo "SHA256=$SHA256_HASH" >> $GITHUB_ENV
|
|
||||||
echo "DOWNLOAD_URL=https://github.com/SideStore/SideStore/releases/download/${{ inputs.release_tag }}/SideStore.ipa" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
# multiline strings
|
|
||||||
echo "LOCALIZED_DESCRIPTION<<EOF" >> $GITHUB_ENV
|
|
||||||
echo "$LOCALIZED_DESCRIPTION" >> $GITHUB_ENV
|
|
||||||
echo "EOF" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Check if Publish updates is set
|
|
||||||
id: check_publish
|
|
||||||
run: |
|
|
||||||
echo "Publish updates to source.json = ${{ inputs.publish }}"
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Checkout SideStore/apps-v2.json
|
|
||||||
if: ${{ inputs.is_beta && inputs.publish }}
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: 'SideStore/apps-v2.json'
|
|
||||||
ref: 'main' # this branch is shared by all beta builds, so beta build workflows are serialized
|
|
||||||
token: ${{ secrets.CROSS_REPO_PUSH_KEY }}
|
|
||||||
path: 'SideStore/apps-v2.json'
|
|
||||||
|
|
||||||
# for stable builds, let the user manually edit the source.json
|
|
||||||
- name: Publish to SideStore/apps-v2.json
|
|
||||||
if: ${{ inputs.is_beta && inputs.publish }}
|
|
||||||
id: publish-release
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
# Copy and execute the update script
|
|
||||||
pushd SideStore/apps-v2.json/
|
|
||||||
|
|
||||||
# Configure Git user (committer details)
|
|
||||||
git config user.name "GitHub Actions"
|
|
||||||
git config user.email "github-actions@github.com"
|
|
||||||
|
|
||||||
# update the source.json
|
|
||||||
python3 ../../update_apps.py "./_includes/source.json"
|
|
||||||
|
|
||||||
# Commit changes and push using SSH
|
|
||||||
git add --verbose ./_includes/source.json
|
|
||||||
git commit -m " - updated for ${{ inputs.short_commit }} deployment" || echo "No changes to commit"
|
|
||||||
|
|
||||||
git push --verbose
|
|
||||||
popd
|
|
||||||
24
.github/workflows/sidestore-shared.yml
vendored
24
.github/workflows/sidestore-shared.yml
vendored
@@ -1,24 +0,0 @@
|
|||||||
name: SideStore Shared
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
outputs:
|
|
||||||
short-commit:
|
|
||||||
value: ${{ jobs.shared.outputs.short-commit }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
shared:
|
|
||||||
name: Shared Steps
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
runs-on: 'macos-15'
|
|
||||||
steps:
|
|
||||||
- name: Set short commit hash
|
|
||||||
id: commit-id
|
|
||||||
run: |
|
|
||||||
# SHORT_COMMIT="${{ github.sha }}"
|
|
||||||
SHORT_COMMIT=${GITHUB_SHA:0:7}
|
|
||||||
echo "Short commit hash: $SHORT_COMMIT"
|
|
||||||
echo "SHORT_COMMIT=$SHORT_COMMIT" >> $GITHUB_OUTPUT
|
|
||||||
outputs:
|
|
||||||
short-commit: ${{ steps.commit-id.outputs.SHORT_COMMIT }}
|
|
||||||
165
.github/workflows/sidestore-tests-build.yml
vendored
165
.github/workflows/sidestore-tests-build.yml
vendored
@@ -1,165 +0,0 @@
|
|||||||
name: SideStore Tests Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
release_tag:
|
|
||||||
type: string
|
|
||||||
short_commit:
|
|
||||||
type: string
|
|
||||||
secrets:
|
|
||||||
BUILD_LOG_ZIP_PASSWORD:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
tests-build:
|
|
||||||
name: Tests-Build SideStore - ${{ inputs.release_tag }}
|
|
||||||
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_BUILD == '1' }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- os: 'macos-26'
|
|
||||||
version: '26.0'
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Install dependencies - xcbeautify
|
|
||||||
run: |
|
|
||||||
brew install xcbeautify
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Setup Xcode
|
|
||||||
uses: maxim-lobanov/setup-xcode@v1.6.0
|
|
||||||
with:
|
|
||||||
xcode-version: '26.0'
|
|
||||||
|
|
||||||
# - name: (Tests-Build) Cache Build
|
|
||||||
# uses: irgaly/xcode-cache@v1.8.1
|
|
||||||
# with:
|
|
||||||
# key: xcode-cache-deriveddata-test-${{ github.ref_name }}-${{ github.sha }}
|
|
||||||
# # tests shouldn't restore cache unless it is same build
|
|
||||||
# # restore-keys: xcode-cache-deriveddata-test-${{ github.ref_name }}-
|
|
||||||
# swiftpm-cache-key: xcode-cache-sourcedata-test-${{ github.ref_name }}-${{ github.sha }}
|
|
||||||
# swiftpm-cache-restore-keys: |
|
|
||||||
# xcode-cache-sourcedata-test-${{ github.ref_name }}-
|
|
||||||
# delete-used-deriveddata-cache: true
|
|
||||||
|
|
||||||
- name: (Tests-Build) Restore Xcode & SwiftPM Cache (Exact match)
|
|
||||||
id: xcode-cache-restore
|
|
||||||
uses: actions/cache/restore@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/Library/Developer/Xcode/DerivedData
|
|
||||||
~/Library/Caches/org.swift.swiftpm
|
|
||||||
key: xcode-cache-tests-${{ github.ref_name }}-${{ github.sha }}
|
|
||||||
|
|
||||||
- name: (Tests-Build) Restore Xcode & SwiftPM Cache (Last Available)
|
|
||||||
id: xcode-cache-restore-recent
|
|
||||||
uses: actions/cache/restore@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/Library/Developer/Xcode/DerivedData
|
|
||||||
~/Library/Caches/org.swift.swiftpm
|
|
||||||
key: xcode-cache-tests-${{ github.ref_name }}-
|
|
||||||
|
|
||||||
- name: Clean Derived Data (if required)
|
|
||||||
if: ${{ vars.PERFORM_CLEAN_TESTS_BUILD == '1' }}
|
|
||||||
run: |
|
|
||||||
rm -rf ~/Library/Developer/Xcode/DerivedData/
|
|
||||||
make clean
|
|
||||||
xcodebuild clean
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: (Tests-Build) Clean previous build artifacts
|
|
||||||
run: |
|
|
||||||
make clean
|
|
||||||
mkdir -p build/logs
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: (Tests-Build) List Files and derived data
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
|
||||||
ls -la .
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> SideStore <<<<<<<<<<"
|
|
||||||
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
|
|
||||||
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
|
||||||
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
- name: Build SideStore Tests
|
|
||||||
# using 'tee' to intercept stdout and log for detailed build-log
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
NSUnbufferedIO=YES make -B build-tests 2>&1 | tee -a build/logs/tests-build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
|
|
||||||
|
|
||||||
- name: (Tests-Build) Save Xcode & SwiftPM Cache
|
|
||||||
id: cache-save
|
|
||||||
if: ${{ steps.xcode-cache-restore.outputs.cache-hit != 'true' }}
|
|
||||||
uses: actions/cache/save@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/Library/Developer/Xcode/DerivedData
|
|
||||||
~/Library/Caches/org.swift.swiftpm
|
|
||||||
key: xcode-cache-tests-${{ github.ref_name }}-${{ github.sha }}
|
|
||||||
|
|
||||||
- name: (Tests-Build) List Files and Build artifacts
|
|
||||||
if: always()
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
|
||||||
ls -la .
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> Build <<<<<<<<<<"
|
|
||||||
find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
|
||||||
find ~/Library/Developer/Xcode/DerivedData -maxdepth 8 -exec ls -ld {} + | grep "Build/Products" >> tests-build-deriveddata.txt || true
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: tests-build-deriveddata-${{ inputs.short_commit }}.txt
|
|
||||||
path: tests-build-deriveddata.txt
|
|
||||||
|
|
||||||
- name: Encrypt tests-build-logs for upload
|
|
||||||
id: encrypt-test-log
|
|
||||||
if: always()
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
DEFAULT_BUILD_LOG_PASSWORD=12345
|
|
||||||
|
|
||||||
BUILD_LOG_ZIP_PASSWORD=${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
|
|
||||||
BUILD_LOG_ZIP_PASSWORD=${BUILD_LOG_ZIP_PASSWORD:-$DEFAULT_BUILD_LOG_PASSWORD}
|
|
||||||
|
|
||||||
if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then
|
|
||||||
echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'."
|
|
||||||
fi
|
|
||||||
|
|
||||||
pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-tests-build-logs.zip * || popd
|
|
||||||
echo "::set-output name=encrypted::true"
|
|
||||||
|
|
||||||
- name: Upload encrypted-tests-build-logs.zip
|
|
||||||
id: attach-encrypted-test-log
|
|
||||||
if: always() && steps.encrypt-test-log.outputs.encrypted == 'true'
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: encrypted-tests-build-logs-${{ inputs.short_commit }}.zip
|
|
||||||
path: encrypted-tests-build-logs.zip
|
|
||||||
196
.github/workflows/sidestore-tests-run.yml
vendored
196
.github/workflows/sidestore-tests-run.yml
vendored
@@ -1,196 +0,0 @@
|
|||||||
name: SideStore Tests Run
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
release_tag:
|
|
||||||
type: string
|
|
||||||
short_commit:
|
|
||||||
type: string
|
|
||||||
secrets:
|
|
||||||
BUILD_LOG_ZIP_PASSWORD:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
tests-run:
|
|
||||||
name: Tests-Run SideStore - ${{ inputs.release_tag }}
|
|
||||||
if: ${{ vars.ENABLE_TESTS == '1' && vars.ENABLE_TESTS_RUN == '1' }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- os: 'macos-26'
|
|
||||||
version: '26.0'
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Boot Simulator async(nohup) for testing
|
|
||||||
run: |
|
|
||||||
mkdir -p build/logs
|
|
||||||
nohup make -B boot-sim-async </dev/null >> build/logs/tests-run.log 2>&1 &
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Setup Xcode
|
|
||||||
uses: maxim-lobanov/setup-xcode@v1.6.0
|
|
||||||
with:
|
|
||||||
xcode-version: '26.0'
|
|
||||||
|
|
||||||
# - name: (Tests-Run) Cache Build
|
|
||||||
# uses: irgaly/xcode-cache@v1.8.1
|
|
||||||
# with:
|
|
||||||
# # This comes from
|
|
||||||
# key: xcode-cache-deriveddata-test-${{ github.ref_name }}-${{ github.sha }}
|
|
||||||
# swiftpm-cache-key: xcode-cache-sourcedata-test-${{ github.ref_name }}-${{ github.sha }}
|
|
||||||
|
|
||||||
- name: (Tests-Build) Restore Xcode & SwiftPM Cache (Exact match) [from tests-build job]
|
|
||||||
id: xcode-cache-restore
|
|
||||||
uses: actions/cache/restore@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/Library/Developer/Xcode/DerivedData
|
|
||||||
~/Library/Caches/org.swift.swiftpm
|
|
||||||
key: xcode-cache-tests-${{ github.ref_name }}-${{ github.sha }}
|
|
||||||
|
|
||||||
- name: (Tests-Run) Clean previous build artifacts
|
|
||||||
run: |
|
|
||||||
make clean
|
|
||||||
mkdir -p build/logs
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: (Tests-Run) List Files and derived data
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
|
||||||
ls -la .
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> SideStore <<<<<<<<<<"
|
|
||||||
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
|
|
||||||
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
|
||||||
find ~/Library/Developer/Xcode/DerivedData -maxdepth 8 -exec ls -ld {} + | grep "Build/Products" >> tests-run-deriveddata.txt || true
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: tests-run-deriveddata-${{ inputs.short_commit }}.txt
|
|
||||||
path: tests-run-deriveddata.txt
|
|
||||||
|
|
||||||
# we expect simulator to have been booted by now, so exit otherwise
|
|
||||||
- name: Simulator Boot Check
|
|
||||||
run: |
|
|
||||||
mkdir -p build/logs
|
|
||||||
make -B sim-boot-check | tee -a build/logs/tests-run.log
|
|
||||||
exit ${PIPESTATUS[0]}
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Start Recording UI tests (if DEBUG_RECORD_TESTS is set to 1)
|
|
||||||
if: ${{ vars.DEBUG_RECORD_TESTS == '1' }}
|
|
||||||
run: |
|
|
||||||
nohup xcrun simctl io booted recordVideo -f tests-recording.mp4 --codec h264 </dev/null > tests-recording.log 2>&1 &
|
|
||||||
RECORD_PID=$!
|
|
||||||
echo "RECORD_PID=$RECORD_PID" >> $GITHUB_ENV
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Run SideStore Tests
|
|
||||||
# using 'tee' to intercept stdout and log for detailed build-log
|
|
||||||
run: |
|
|
||||||
make run-tests 2>&1 | tee -a build/logs/tests-run.log && exit ${PIPESTATUS[0]}
|
|
||||||
# NSUnbufferedIO=YES make -B run-tests 2>&1 | tee build/logs/tests-run.log | xcpretty -r junit --output ./build/tests/test-results.xml && exit ${PIPESTATUS[0]}
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Stop Recording tests
|
|
||||||
if: ${{ always() && env.RECORD_PID != '' }}
|
|
||||||
run: |
|
|
||||||
kill -INT ${{ env.RECORD_PID }}
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: (Tests-Run) List Files and Build artifacts
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
|
||||||
ls -la .
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> Build <<<<<<<<<<"
|
|
||||||
find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Encrypt tests-run-logs for upload
|
|
||||||
id: encrypt-test-log
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
DEFAULT_BUILD_LOG_PASSWORD=12345
|
|
||||||
|
|
||||||
BUILD_LOG_ZIP_PASSWORD=${{ secrets.BUILD_LOG_ZIP_PASSWORD }}
|
|
||||||
BUILD_LOG_ZIP_PASSWORD=${BUILD_LOG_ZIP_PASSWORD:-$DEFAULT_BUILD_LOG_PASSWORD}
|
|
||||||
|
|
||||||
if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then
|
|
||||||
echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'."
|
|
||||||
fi
|
|
||||||
|
|
||||||
pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-tests-run-logs.zip * || popd
|
|
||||||
echo "::set-output name=encrypted::true"
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Upload encrypted-tests-run-logs.zip
|
|
||||||
id: attach-encrypted-test-log
|
|
||||||
if: always() && steps.encrypt-test-log.outputs.encrypted == 'true'
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: encrypted-tests-run-logs-${{ inputs.short_commit }}.zip
|
|
||||||
path: encrypted-tests-run-logs.zip
|
|
||||||
|
|
||||||
- name: Print tests-recording.log contents (if exists)
|
|
||||||
if: ${{ always() && env.RECORD_PID != '' }}
|
|
||||||
run: |
|
|
||||||
if [ -f tests-recording.log ]; then
|
|
||||||
echo "tests-recording.log found. Its contents:"
|
|
||||||
cat tests-recording.log
|
|
||||||
else
|
|
||||||
echo "tests-recording.log not found."
|
|
||||||
fi
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Check for tests-recording.mp4 presence
|
|
||||||
id: check-recording
|
|
||||||
if: ${{ always() && env.RECORD_PID != '' }}
|
|
||||||
run: |
|
|
||||||
if [ -f tests-recording.mp4 ]; then
|
|
||||||
echo "::set-output name=found::true"
|
|
||||||
echo "tests-recording.mp4 found."
|
|
||||||
else
|
|
||||||
echo "tests-recording.mp4 not found, skipping upload."
|
|
||||||
echo "::set-output name=found::false"
|
|
||||||
fi
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Upload tests-recording.mp4
|
|
||||||
id: upload-recording
|
|
||||||
if: ${{ always() && steps.check-recording.outputs.found == 'true' }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: tests-recording-${{ inputs.short_commit }}.mp4
|
|
||||||
path: tests-recording.mp4
|
|
||||||
|
|
||||||
- name: Zip test-results
|
|
||||||
run: zip -r -9 ./test-results.zip ./build/tests
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Upload Test Artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: test-results-${{ inputs.short_commit }}.zip
|
|
||||||
path: test-results.zip
|
|
||||||
233
.github/workflows/stable.yml
vendored
233
.github/workflows/stable.yml
vendored
@@ -3,240 +3,79 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '[0-9]+.[0-9]+.[0-9]+' # example: 1.0.0
|
- '[0-9]+.[0-9]+.[0-9]+' # example: 1.0.0
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build SideStore - stable (on tag push)
|
name: Build and upload SideStore
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: 'macos-26'
|
- os: 'macos-12'
|
||||||
version: '26.0'
|
version: '14.2'
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Echo Build.xcconfig
|
- name: Install dependencies
|
||||||
run: |
|
run: brew install ldid
|
||||||
echo "cat Build.xcconfig"
|
|
||||||
cat Build.xcconfig
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
# - name: Change MARKETING_VERSION to the pushed tag that triggered this build
|
- name: Change version to tag
|
||||||
# run: sed -e '/MARKETING_VERSION = .*/s/= .*/= ${{ github.ref_name }}/' -i '' Build.xcconfig
|
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
|
- name: Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@v1.6.0
|
uses: maxim-lobanov/setup-xcode@v1.4.1
|
||||||
with:
|
with:
|
||||||
xcode-version: ${{ matrix.version }}
|
xcode-version: ${{ matrix.version }}
|
||||||
|
|
||||||
- name: (Build) Restore Xcode & SwiftPM Cache (Exact match)
|
- name: Build SideStore
|
||||||
id: xcode-cache-restore
|
run: make build | xcpretty && exit ${PIPESTATUS[0]}
|
||||||
uses: actions/cache/restore@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/Library/Developer/Xcode/DerivedData
|
|
||||||
~/Library/Caches/org.swift.swiftpm
|
|
||||||
key: xcode-cache-build-stable-${{ github.sha }}
|
|
||||||
|
|
||||||
- name: (Build) Restore Xcode & SwiftPM Cache (Last Available)
|
|
||||||
id: xcode-cache-restore-recent
|
|
||||||
uses: actions/cache/restore@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/Library/Developer/Xcode/DerivedData
|
|
||||||
~/Library/Caches/org.swift.swiftpm
|
|
||||||
key: xcode-cache-build-stable-
|
|
||||||
|
|
||||||
- name: (Build) Clean previous build artifacts
|
|
||||||
run: |
|
|
||||||
make clean
|
|
||||||
mkdir -p build/logs
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: (Build) List Files and derived data
|
|
||||||
if: always()
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo ">>>>>>>>> Workdir <<<<<<<<<<"
|
|
||||||
ls -la .
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> SideStore <<<<<<<<<<"
|
|
||||||
find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> Dependencies <<<<<<<<<<"
|
|
||||||
find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<"
|
|
||||||
ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
- name: Build SideStore.xcarchive
|
|
||||||
# using 'tee' to intercept stdout and log for detailed build-log
|
|
||||||
run: |
|
|
||||||
NSUnbufferedIO=YES make -B build 2>&1 | tee -a build/logs/build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]}
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Fakesign app
|
- name: Fakesign app
|
||||||
run: make fakesign | tee -a build/logs/build.log
|
run: make fakesign
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Convert to IPA
|
- name: Convert to IPA
|
||||||
run: make ipa | tee -a build/logs/build.log
|
run: make ipa
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: (Build) Save Xcode & SwiftPM Cache
|
- name: Upload Artifact
|
||||||
id: cache-save
|
uses: actions/upload-artifact@v3.1.0
|
||||||
if: ${{ steps.xcode-cache-restore.outputs.cache-hit != 'true' }}
|
|
||||||
uses: actions/cache/save@v3
|
|
||||||
with:
|
with:
|
||||||
path: |
|
name: SideStore.ipa
|
||||||
~/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
|
path: SideStore.ipa
|
||||||
|
|
||||||
- name: Zip dSYMs
|
- name: Get version
|
||||||
run: zip -r -9 ./SideStore.dSYMs.zip ./SideStore.xcarchive/dSYMs
|
id: version
|
||||||
shell: bash
|
run: echo "version=$(grep MARKETING_VERSION Build.xcconfig | sed -e "s/MARKETING_VERSION = //g")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- 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
|
- name: Get current date
|
||||||
id: date
|
id: date
|
||||||
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
|
run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Get current date in AltStore date form
|
- name: Get current date in AltStore date form
|
||||||
id: date_altstore
|
id: date_altstore
|
||||||
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Upload to releases
|
- name: Upload to new stable release
|
||||||
uses: IsaacShelton/update-existing-release@v1.3.1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
name: ${{ steps.version.outputs.version }}
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
draft: true
|
draft: true
|
||||||
release: ${{ github.ref_name }} # name
|
files: SideStore.ipa
|
||||||
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: |
|
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. -->
|
<!-- 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
|
## Changelog
|
||||||
|
|
||||||
- TODO
|
- TODO
|
||||||
|
|
||||||
## Build Info
|
## Build Info
|
||||||
|
|
||||||
Built at (UTC): `${{ steps.date.outputs.date }}`
|
Built at (UTC): `${{ steps.date.outputs.date }}`
|
||||||
Built at (UTC date): `${{ steps.date_altstore.outputs.date }}`
|
Built at (UTC date): `${{ steps.date_altstore.outputs.date }}`
|
||||||
Commit SHA: `${{ github.sha }}`
|
Commit SHA: `${{ github.sha }}`
|
||||||
Version: `${{ steps.version.outputs.version }}`
|
Version: `${{ steps.version.outputs.version }}`
|
||||||
|
|||||||
40
.gitignore
vendored
40
.gitignore
vendored
@@ -1,18 +1,14 @@
|
|||||||
# macOS
|
# macOS
|
||||||
#
|
#
|
||||||
**/*.DS_Store
|
*.DS_Store
|
||||||
|
|
||||||
# Xcode
|
# Xcode
|
||||||
#
|
#
|
||||||
|
|
||||||
## CocoaPods
|
|
||||||
Pods/
|
|
||||||
|
|
||||||
## Build generated
|
## Build generated
|
||||||
build/
|
build/
|
||||||
DerivedData
|
DerivedData
|
||||||
|
archive.xcarchive
|
||||||
SideStore.xcarchive
|
|
||||||
## Various settings
|
## Various settings
|
||||||
*.pbxuser
|
*.pbxuser
|
||||||
!default.pbxuser
|
!default.pbxuser
|
||||||
@@ -39,34 +35,12 @@ xcuserdata
|
|||||||
## AppCode specific
|
## AppCode specific
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
Payload/
|
.build
|
||||||
**/SideStore.ipa
|
|
||||||
**/AltBackup.ipa
|
|
||||||
**/*.dSYM
|
|
||||||
|
|
||||||
|
Payload/
|
||||||
|
SideStore.ipa
|
||||||
Dependencies/.*-prebuilt-fetch-*
|
Dependencies/.*-prebuilt-fetch-*
|
||||||
SideStore/minimuxer/*
|
Dependencies/minimuxer/*
|
||||||
SideStore/em_proxy/*
|
Dependencies/em_proxy/*
|
||||||
!Dependencies/**/.gitkeep
|
!Dependencies/**/.gitkeep
|
||||||
.nightly-build-num
|
.nightly-build-num
|
||||||
|
|
||||||
## em_proxy and minimuxer biaries
|
|
||||||
**/.last-prebuilt-fetch-em_proxy
|
|
||||||
**/.last-prebuilt-fetch-minimuxer
|
|
||||||
|
|
||||||
# misc
|
|
||||||
**/output.txt
|
|
||||||
SideStore/.skip-prebuilt-fetch-minimuxer
|
|
||||||
SideStore/.skip-prebuilt-fetch-em_proxy
|
|
||||||
|
|
||||||
.git.bkp/
|
|
||||||
# Never check-in this package.resolved file
|
|
||||||
# coz SPM then resolves packages using the stale entries in this file
|
|
||||||
*.xcodeproj/**/Package.resolved
|
|
||||||
*.xcworkspace/**/Package.resolved
|
|
||||||
|
|
||||||
# some more commandline build artifacts
|
|
||||||
test-recording.mp4
|
|
||||||
test-recording.log
|
|
||||||
altstore-sources.md
|
|
||||||
local-build.sh
|
|
||||||
77
.gitmodules
vendored
77
.gitmodules
vendored
@@ -1,68 +1,21 @@
|
|||||||
#-------------------------------
|
|
||||||
# When changing url/branch in this .gitmodules file,
|
|
||||||
# Always ensure you run:
|
|
||||||
# 1. `git rm --cached <submodule_relative_path>` # this removes the submodule entry from general git tracking
|
|
||||||
# 2. `rm -rf .git/modules/<submodule_relative_path>` # this removes the stale name entries in submodule tracker
|
|
||||||
# 3. `rm -rf <submodule_relative_path>` # removes the submodule completely
|
|
||||||
# 4. `git submodule --deinit <submodule_relative_path>` # make sure that the submodule is de-inited too (ignore errors at this point)
|
|
||||||
# 5. `git submodule add [-b <branch_name>] <repo_url> <submodule_relative_path>` # This adds the submodule back into general git tracking and also adds to the submodule tracker
|
|
||||||
# 6. Step 5 creates an entry in the .gitmodules when a submodule is added,
|
|
||||||
# So if you already had one entry, try to remove duplicates at this point
|
|
||||||
# 7. `git submodule sync --recursive` # this now sets/updates the submodule repo url tracker into git config
|
|
||||||
# 8. `git submodule update --init --recursive` # this now clones the updated repo set by .gitmodules
|
|
||||||
# But this will always fetch the latest commit sepecified by the custom(if set)/default branch
|
|
||||||
# 9. If you do want to have a specific commit in that submodule branch and not latest, you need to perform normal detached head checkout and check-in as follows:
|
|
||||||
# `pushd <submodule_relative_path>` # switch to the submodule repo
|
|
||||||
# `git checkout <commit-id>` # this creates a detached head state
|
|
||||||
# `popd` # get back to parent repo
|
|
||||||
# `git add <submodule_relative_path>` # check-in the changes in parent for this submodule link (tracker)
|
|
||||||
# `git commit -m <commit-message>` # commit it to parent repo
|
|
||||||
# `git push` # push to parent repo to preserve this entire change in the submodule repo/link file
|
|
||||||
#
|
|
||||||
# NOTES:
|
|
||||||
# 1. updating just this .gitmodules file is NOT ENOUGH when changing repo url and performing a simple `git submodule update --init --recursive`, need to do all the above listed steps for proper tracking
|
|
||||||
# 2. updating the branch in this .gitmodules for same repo is okay as long as `git submodule update --init --recursive` is also performed followed by it
|
|
||||||
# 3. Ensure there is no stale entries or duplicate entries in this .gitmodules file coz, `git submodule add ...` creates an entry here.
|
|
||||||
#-------------------------------
|
|
||||||
|
|
||||||
[submodule "Dependencies/Roxas"]
|
[submodule "Dependencies/Roxas"]
|
||||||
path = Dependencies/Roxas
|
path = Dependencies/Roxas
|
||||||
url = https://github.com/rileytestut/Roxas.git
|
url = https://github.com/rileytestut/Roxas.git
|
||||||
[submodule "Dependencies/libimobiledevice"]
|
[submodule "Dependencies/libimobiledevice"]
|
||||||
path = Dependencies/libimobiledevice
|
path = Dependencies/libimobiledevice
|
||||||
url = https://github.com/SideStore/libimobiledevice
|
url = https://github.com/libimobiledevice/libimobiledevice
|
||||||
[submodule "Dependencies/libusbmuxd"]
|
[submodule "Dependencies/libusbmuxd"]
|
||||||
path = Dependencies/libusbmuxd
|
path = Dependencies/libusbmuxd
|
||||||
url = https://github.com/libimobiledevice/libusbmuxd.git
|
url = https://github.com/libimobiledevice/libusbmuxd.git
|
||||||
[submodule "Dependencies/libplist"]
|
[submodule "Dependencies/libplist"]
|
||||||
path = Dependencies/libplist
|
path = Dependencies/libplist
|
||||||
url = https://github.com/SideStore/libplist.git
|
url = https://github.com/libimobiledevice/libplist.git
|
||||||
[submodule "Dependencies/MarkdownAttributedString"]
|
[submodule "Dependencies/MarkdownAttributedString"]
|
||||||
path = Dependencies/MarkdownAttributedString
|
path = Dependencies/MarkdownAttributedString
|
||||||
url = https://github.com/chockenberry/MarkdownAttributedString.git
|
url = https://github.com/chockenberry/MarkdownAttributedString.git
|
||||||
[submodule "Dependencies/libimobiledevice-glue"]
|
[submodule "Dependencies/libimobiledevice-glue"]
|
||||||
path = Dependencies/libimobiledevice-glue
|
path = Dependencies/libimobiledevice-glue
|
||||||
url = https://github.com/libimobiledevice/libimobiledevice-glue
|
url = https://github.com/libimobiledevice/libimobiledevice-glue
|
||||||
|
[submodule "Dependencies/libfragmentzip"]
|
||||||
|
path = Dependencies/libfragmentzip
|
||||||
#sidestore dependencies
|
url = https://github.com/SideStore/libfragmentzip.git
|
||||||
[submodule "SideStore/minimuxer"]
|
|
||||||
path = SideStore/minimuxer
|
|
||||||
url = https://github.com/SideStore/minimuxer
|
|
||||||
branch = master
|
|
||||||
[submodule "SideStore/em_proxy"]
|
|
||||||
path = SideStore/em_proxy
|
|
||||||
url = https://github.com/SideStore/em_proxy
|
|
||||||
branch = master
|
|
||||||
[submodule "SideStore/libfragmentzip"]
|
|
||||||
path = SideStore/libfragmentzip
|
|
||||||
url = https://github.com/SideStore/libfragmentzip
|
|
||||||
branch = master
|
|
||||||
[submodule "SideStore/apps-v2.json"]
|
|
||||||
path = SideStore/apps-v2.json
|
|
||||||
url = https://github.com/SideStore/apps-v2.json
|
|
||||||
branch = main
|
|
||||||
[submodule "SideStore/AltSign"]
|
|
||||||
path = SideStore/AltSign
|
|
||||||
url = https://github.com/SideStore/AltSign
|
|
||||||
branch = master
|
|
||||||
|
|||||||
3
AltBackup.xcconfig
Normal file
3
AltBackup.xcconfig
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#include "Build.xcconfig"
|
||||||
|
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = $(PRODUCT_BUNDLE_IDENTIFIER).AltBackup
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.$(GROUP_ID)</string>
|
<string>group.$(APP_GROUP_IDENTIFIER)</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import UIKit
|
|||||||
|
|
||||||
extension AppDelegate
|
extension AppDelegate
|
||||||
{
|
{
|
||||||
static let startBackupNotification = Notification.Name("io.sidestore.StartBackup")
|
static let startBackupNotification = Notification.Name("io.altstore.StartBackup")
|
||||||
static let startRestoreNotification = Notification.Name("io.sidestore.StartRestore")
|
static let startRestoreNotification = Notification.Name("io.altstore.StartRestore")
|
||||||
|
|
||||||
static let operationDidFinishNotification = Notification.Name("io.sidestore.BackupOperationFinished")
|
static let operationDidFinishNotification = Notification.Name("io.altstore.BackupOperationFinished")
|
||||||
|
|
||||||
static let operationResultKey = "result"
|
static let operationResultKey = "result"
|
||||||
}
|
}
|
||||||
@@ -88,25 +88,14 @@ private extension AppDelegate
|
|||||||
|
|
||||||
@objc func operationDidFinish(_ notification: Notification)
|
@objc func operationDidFinish(_ notification: Notification)
|
||||||
{
|
{
|
||||||
defer {
|
defer { self.currentBackupReturnURL = nil }
|
||||||
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
|
guard
|
||||||
let returnURL = self.currentBackupReturnURL,
|
let returnURL = self.currentBackupReturnURL,
|
||||||
let result = notification.userInfo?[AppDelegate.operationResultKey] as? Result<Void, Error>
|
let result = notification.userInfo?[AppDelegate.operationResultKey] as? Result<Void, Error>
|
||||||
else {
|
else { return }
|
||||||
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 {
|
guard var components = URLComponents(url: returnURL, resolvingAgainstBaseURL: false) else { return }
|
||||||
return // This is ASSERTION Failure, ie RETURN URL needs to be valid. So ignoring (eating up) response is not the solution
|
|
||||||
}
|
|
||||||
|
|
||||||
switch result
|
switch result
|
||||||
{
|
{
|
||||||
@@ -123,7 +112,6 @@ private extension AppDelegate
|
|||||||
guard let responseURL = components.url else { return }
|
guard let responseURL = components.url else { return }
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
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
|
UIApplication.shared.open(responseURL, options: [:]) { (success) in
|
||||||
print("Sent response to app with success:", success)
|
print("Sent response to app with success:", success)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,151 +1,91 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "40.png",
|
|
||||||
"idiom" : "iphone",
|
"idiom" : "iphone",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "20x20"
|
"size" : "20x20"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "60.png",
|
|
||||||
"idiom" : "iphone",
|
"idiom" : "iphone",
|
||||||
"scale" : "3x",
|
"scale" : "3x",
|
||||||
"size" : "20x20"
|
"size" : "20x20"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "29.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "58.png",
|
|
||||||
"idiom" : "iphone",
|
"idiom" : "iphone",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "29x29"
|
"size" : "29x29"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "87.png",
|
|
||||||
"idiom" : "iphone",
|
"idiom" : "iphone",
|
||||||
"scale" : "3x",
|
"scale" : "3x",
|
||||||
"size" : "29x29"
|
"size" : "29x29"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "80.png",
|
|
||||||
"idiom" : "iphone",
|
"idiom" : "iphone",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "40x40"
|
"size" : "40x40"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "120.png",
|
|
||||||
"idiom" : "iphone",
|
"idiom" : "iphone",
|
||||||
"scale" : "3x",
|
"scale" : "3x",
|
||||||
"size" : "40x40"
|
"size" : "40x40"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "57.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "57x57"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "114.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "57x57"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "120.png",
|
|
||||||
"idiom" : "iphone",
|
"idiom" : "iphone",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "60x60"
|
"size" : "60x60"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "180.png",
|
|
||||||
"idiom" : "iphone",
|
"idiom" : "iphone",
|
||||||
"scale" : "3x",
|
"scale" : "3x",
|
||||||
"size" : "60x60"
|
"size" : "60x60"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "20.png",
|
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "20x20"
|
"size" : "20x20"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "40.png",
|
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "20x20"
|
"size" : "20x20"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "29.png",
|
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "29x29"
|
"size" : "29x29"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "58.png",
|
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "29x29"
|
"size" : "29x29"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "40.png",
|
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "40x40"
|
"size" : "40x40"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "80.png",
|
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "40x40"
|
"size" : "40x40"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "50.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "50x50"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "100.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "50x50"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "72.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "72x72"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "144.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "72x72"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "76.png",
|
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "76x76"
|
"size" : "76x76"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "152.png",
|
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "76x76"
|
"size" : "76x76"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "167.png",
|
|
||||||
"idiom" : "ipad",
|
"idiom" : "ipad",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "83.5x83.5"
|
"size" : "83.5x83.5"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "1024.png",
|
|
||||||
"idiom" : "ios-marketing",
|
"idiom" : "ios-marketing",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
57
AltBackup/BackupController.swift
Executable file → Normal file
57
AltBackup/BackupController.swift
Executable file → Normal file
@@ -26,60 +26,20 @@ extension Error
|
|||||||
|
|
||||||
struct BackupError: ALTLocalizedError
|
struct BackupError: ALTLocalizedError
|
||||||
{
|
{
|
||||||
enum Code: ALTErrorEnum, RawRepresentable
|
enum Code
|
||||||
{
|
{
|
||||||
case invalidBundleID
|
case invalidBundleID
|
||||||
case appGroupNotFound(String?)
|
case appGroupNotFound(String?)
|
||||||
case randomError // Used for debugging.
|
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 code: Code
|
||||||
|
|
||||||
let sourceFile: String
|
let sourceFile: String
|
||||||
let sourceFileLine: Int
|
let sourceFileLine: Int
|
||||||
|
|
||||||
var failure: String?
|
var failure: String?
|
||||||
|
|
||||||
var errorTitle: String?
|
|
||||||
var errorFailure: String?
|
|
||||||
|
|
||||||
var failureReason: String? {
|
var failureReason: String? {
|
||||||
switch self.code
|
switch self.code
|
||||||
{
|
{
|
||||||
@@ -106,19 +66,12 @@ struct BackupError: ALTLocalizedError
|
|||||||
return userInfo.compactMapValues { $0 }
|
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)
|
init(_ code: Code, description: String? = nil, file: String = #file, line: Int = #line)
|
||||||
{
|
{
|
||||||
self.code = code
|
self.code = code
|
||||||
self.failure = description
|
self.failure = description
|
||||||
self.sourceFile = file
|
self.sourceFile = file
|
||||||
self.sourceFileLine = line
|
self.sourceFileLine = line
|
||||||
self.errorTitle = NSLocalizedString("Backup Error", comment: "")
|
|
||||||
self.errorFailure = description
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,9 +96,7 @@ class BackupController: NSObject
|
|||||||
guard
|
guard
|
||||||
let altstoreAppGroup = Bundle.main.altstoreAppGroup,
|
let altstoreAppGroup = Bundle.main.altstoreAppGroup,
|
||||||
let sharedDirectoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: altstoreAppGroup)
|
let sharedDirectoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: altstoreAppGroup)
|
||||||
else {
|
else { throw BackupError(.appGroupNotFound(nil), description: NSLocalizedString("Unable to create backup directory.", comment: "")) }
|
||||||
throw BackupError(.appGroupNotFound(nil), description: NSLocalizedString("Unable to create backup directory.", comment: ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
let backupsDirectory = sharedDirectoryURL.appendingPathComponent("Backups")
|
let backupsDirectory = sharedDirectoryURL.appendingPathComponent("Backups")
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<key>ALTAppGroups</key>
|
<key>ALTAppGroups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.$(APP_GROUP_IDENTIFIER)</string>
|
<string>group.$(APP_GROUP_IDENTIFIER)</string>
|
||||||
|
<string>group.com.SideStore.SideStore</string>
|
||||||
</array>
|
</array>
|
||||||
<key>ALTBundleIdentifier</key>
|
<key>ALTBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
@@ -28,15 +29,15 @@
|
|||||||
<key>CFBundleTypeRole</key>
|
<key>CFBundleTypeRole</key>
|
||||||
<string>Editor</string>
|
<string>Editor</string>
|
||||||
<key>CFBundleURLName</key>
|
<key>CFBundleURLName</key>
|
||||||
<string>SideBackup General</string>
|
<string>AltBackup General</string>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>sidebackup</string>
|
<string>altbackup</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>1</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>application-identifier</key>
|
|
||||||
<string>XYZ0123456.com.SideStore.SideStore.AltBackup</string>
|
|
||||||
<key>aps-environment</key>
|
|
||||||
<string>development</string>
|
|
||||||
<key>com.apple.developer.team-identifier</key>
|
|
||||||
<string>XYZ0123456</string>
|
|
||||||
<key>com.apple.security.application-groups</key>
|
|
||||||
<array>
|
|
||||||
<string>group.com.SideStore.SideStore</string>
|
|
||||||
</array>
|
|
||||||
<key>get-task-allow</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -82,25 +82,23 @@ class ViewController: UIViewController
|
|||||||
self.activityIndicatorView.color = .altstoreText
|
self.activityIndicatorView.color = .altstoreText
|
||||||
self.activityIndicatorView.startAnimating()
|
self.activityIndicatorView.startAnimating()
|
||||||
|
|
||||||
// TODO: @mahee96: Disabled these backup/restore buttons in altbackup.app screen which were present for debugging purpose.
|
#if DEBUG
|
||||||
// Can find something useful for these later, but these are not required by this backup/restore app
|
let button1 = UIButton(type: .system)
|
||||||
// #if DEBUG
|
button1.setTitle("Backup", for: .normal)
|
||||||
// let button1 = UIButton(type: .system)
|
button1.setTitleColor(.white, for: .normal)
|
||||||
// button1.setTitle("Backup", for: .normal)
|
button1.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
||||||
// button1.setTitleColor(.white, for: .normal)
|
button1.addTarget(self, action: #selector(ViewController.backup), for: .primaryActionTriggered)
|
||||||
// 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)
|
||||||
// let button2 = UIButton(type: .system)
|
button2.setTitleColor(.white, for: .normal)
|
||||||
// button2.setTitle("Restore", for: .normal)
|
button2.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
||||||
// button2.setTitleColor(.white, for: .normal)
|
button2.addTarget(self, action: #selector(ViewController.restore), for: .primaryActionTriggered)
|
||||||
// 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!, button1, button2]
|
|
||||||
// #else
|
|
||||||
let arrangedSubviews = [self.textLabel!, self.detailTextLabel!, self.activityIndicatorView!]
|
let arrangedSubviews = [self.textLabel!, self.detailTextLabel!, self.activityIndicatorView!]
|
||||||
// #endif
|
#endif
|
||||||
|
|
||||||
let stackView = UIStackView(arrangedSubviews: arrangedSubviews)
|
let stackView = UIStackView(arrangedSubviews: arrangedSubviews)
|
||||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -157,8 +155,7 @@ private extension ViewController
|
|||||||
self.textLabel.text = NSLocalizedString("Restoring app data…", comment: "")
|
self.textLabel.text = NSLocalizedString("Restoring app data…", comment: "")
|
||||||
self.detailTextLabel.isHidden = true
|
self.detailTextLabel.isHidden = true
|
||||||
self.activityIndicatorView.startAnimating()
|
self.activityIndicatorView.startAnimating()
|
||||||
|
|
||||||
// TODO: @mahee96: This is pointless since, app going in bg/fg should still report its last operation properly
|
|
||||||
case .none:
|
case .none:
|
||||||
self.textLabel.text = String(format: NSLocalizedString("%@ is inactive.", comment: ""),
|
self.textLabel.text = String(format: NSLocalizedString("%@ is inactive.", comment: ""),
|
||||||
Bundle.main.appName ?? NSLocalizedString("App", comment: ""))
|
Bundle.main.appName ?? NSLocalizedString("App", comment: ""))
|
||||||
@@ -201,9 +198,6 @@ private extension ViewController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: @mahee96: This doesn't account cases where backup is too long and user switched to other apps
|
|
||||||
// Now the user has lost his progress since current operation was cancelled due to switch between FG and BG
|
|
||||||
// if this just the reset for enum such that UI stops showing progress circle, then this is fine!
|
|
||||||
@objc func didEnterBackground(_ notification: Notification)
|
@objc func didEnterBackground(_ notification: Notification)
|
||||||
{
|
{
|
||||||
// Reset UI once we've left app (but not before).
|
// Reset UI once we've left app (but not before).
|
||||||
|
|||||||
59
AltDaemon/AltDaemon-Bridging-Header.h
Normal file
59
AltDaemon/AltDaemon-Bridging-Header.h
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
//
|
||||||
|
// Use this file to import your target's public headers that you would like to expose to Swift.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
// Shared
|
||||||
|
#import "ALTConstants.h"
|
||||||
|
#import "ALTConnection.h"
|
||||||
|
#import "NSError+ALTServerError.h"
|
||||||
|
#import "CFNotificationName+AltStore.h"
|
||||||
|
|
||||||
|
// libproc
|
||||||
|
int proc_pidpath(int pid, void * buffer, uint32_t buffersize);
|
||||||
|
|
||||||
|
// Security.framework
|
||||||
|
CF_ENUM(uint32_t) {
|
||||||
|
kSecCSInternalInformation = 1 << 0,
|
||||||
|
kSecCSSigningInformation = 1 << 1,
|
||||||
|
kSecCSRequirementInformation = 1 << 2,
|
||||||
|
kSecCSDynamicInformation = 1 << 3,
|
||||||
|
kSecCSContentInformation = 1 << 4,
|
||||||
|
kSecCSSkipResourceDirectory = 1 << 5,
|
||||||
|
kSecCSCalculateCMSDigest = 1 << 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
OSStatus SecStaticCodeCreateWithPath(CFURLRef path, uint32_t flags, void ** __nonnull CF_RETURNS_RETAINED staticCode);
|
||||||
|
OSStatus SecCodeCopySigningInformation(void *code, uint32_t flags, CFDictionaryRef * __nonnull CF_RETURNS_RETAINED information);
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@interface AKDevice : NSObject
|
||||||
|
|
||||||
|
@property (class, readonly) AKDevice *currentDevice;
|
||||||
|
|
||||||
|
@property (strong, readonly) NSString *serialNumber;
|
||||||
|
@property (strong, readonly) NSString *uniqueDeviceIdentifier;
|
||||||
|
@property (strong, readonly) NSString *serverFriendlyDescription;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface AKAppleIDSession : NSObject
|
||||||
|
|
||||||
|
- (instancetype)initWithIdentifier:(NSString *)identifier;
|
||||||
|
|
||||||
|
- (NSDictionary<NSString *, NSString *> *)appleIDHeadersForRequest:(NSURLRequest *)request;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
@interface LSApplicationWorkspace : NSObject
|
||||||
|
|
||||||
|
@property (class, readonly) LSApplicationWorkspace *defaultWorkspace;
|
||||||
|
|
||||||
|
- (BOOL)installApplication:(NSURL *)fileURL withOptions:(nullable NSDictionary<NSString *, id> *)options error:(NSError *_Nullable *)error;
|
||||||
|
- (BOOL)uninstallApplication:(NSString *)bundleIdentifier withOptions:(nullable NSDictionary *)options;
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
||||||
22
AltDaemon/AltDaemon.entitlements
Normal file
22
AltDaemon/AltDaemon.entitlements
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>application-identifier</key>
|
||||||
|
<string>$(DEVELOPMENT_TEAM).$(ORG_IDENTIFIER).AltDaemon</string>
|
||||||
|
<key>get-task-allow</key>
|
||||||
|
<true/>
|
||||||
|
<key>platform-application</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.authkit.client.private</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.private.mobileinstall.allowedSPI</key>
|
||||||
|
<array>
|
||||||
|
<string>Install</string>
|
||||||
|
<string>Uninstall</string>
|
||||||
|
<string>InstallForLaunchServices</string>
|
||||||
|
<string>UninstallForLaunchServices</string>
|
||||||
|
<string>InstallLocalProvisioned</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
65
AltDaemon/AnisetteDataManager.swift
Normal file
65
AltDaemon/AnisetteDataManager.swift
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
//
|
||||||
|
// AnisetteDataManager.swift
|
||||||
|
// AltDaemon
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 6/1/20.
|
||||||
|
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
import AltSign
|
||||||
|
|
||||||
|
private extension UserDefaults
|
||||||
|
{
|
||||||
|
@objc var localUserID: String? {
|
||||||
|
get { return self.string(forKey: #keyPath(UserDefaults.localUserID)) }
|
||||||
|
set { self.set(newValue, forKey: #keyPath(UserDefaults.localUserID)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AnisetteDataManager
|
||||||
|
{
|
||||||
|
static let shared = AnisetteDataManager()
|
||||||
|
|
||||||
|
private let dateFormatter = ISO8601DateFormatter()
|
||||||
|
|
||||||
|
private init()
|
||||||
|
{
|
||||||
|
dlopen("/System/Library/PrivateFrameworks/AuthKit.framework/AuthKit", RTLD_NOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAnisetteData() throws -> ALTAnisetteData
|
||||||
|
{
|
||||||
|
var request = URLRequest(url: URL(string: "https://developerservices2.apple.com/services/QH65B2/listTeams.action?clientId=XABBG36SBA")!)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
|
||||||
|
let akAppleIDSession = unsafeBitCast(NSClassFromString("AKAppleIDSession")!, to: AKAppleIDSession.Type.self)
|
||||||
|
let akDevice = unsafeBitCast(NSClassFromString("AKDevice")!, to: AKDevice.Type.self)
|
||||||
|
|
||||||
|
let session = akAppleIDSession.init(identifier: "com.apple.gs.xcode.auth")
|
||||||
|
let headers = session.appleIDHeaders(for: request)
|
||||||
|
|
||||||
|
let device = akDevice.current
|
||||||
|
let date = self.dateFormatter.date(from: headers["X-Apple-I-Client-Time"] ?? "") ?? Date()
|
||||||
|
|
||||||
|
var localUserID = UserDefaults.standard.localUserID
|
||||||
|
if localUserID == nil
|
||||||
|
{
|
||||||
|
localUserID = UUID().uuidString
|
||||||
|
UserDefaults.standard.localUserID = localUserID
|
||||||
|
}
|
||||||
|
|
||||||
|
let anisetteData = ALTAnisetteData(machineID: headers["X-Apple-I-MD-M"] ?? "",
|
||||||
|
oneTimePassword: headers["X-Apple-I-MD"] ?? "",
|
||||||
|
localUserID: headers["X-Apple-I-MD-LU"] ?? localUserID ?? "",
|
||||||
|
routingInfo: UInt64(headers["X-Apple-I-MD-RINFO"] ?? "") ?? 0,
|
||||||
|
deviceUniqueIdentifier: device.uniqueDeviceIdentifier,
|
||||||
|
deviceSerialNumber: device.serialNumber,
|
||||||
|
deviceDescription: "<MacBookPro15,1> <Mac OS X;10.15.2;19C57> <com.apple.AuthKit/1 (com.apple.dt.Xcode/3594.4.19)>",
|
||||||
|
date: date,
|
||||||
|
locale: .current,
|
||||||
|
timeZone: .current)
|
||||||
|
return anisetteData
|
||||||
|
}
|
||||||
|
}
|
||||||
138
AltDaemon/AppManager.swift
Normal file
138
AltDaemon/AppManager.swift
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
//
|
||||||
|
// AppManager.swift
|
||||||
|
// AltDaemon
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 6/1/20.
|
||||||
|
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
import AltSign
|
||||||
|
|
||||||
|
private extension URL
|
||||||
|
{
|
||||||
|
static let profilesDirectoryURL = URL(fileURLWithPath: "/var/MobileDevice/ProvisioningProfiles", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension CFNotificationName
|
||||||
|
{
|
||||||
|
static let updatedProvisioningProfiles = CFNotificationName("MISProvisioningProfileRemoved" as CFString)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppManager
|
||||||
|
{
|
||||||
|
static let shared = AppManager()
|
||||||
|
|
||||||
|
private let appQueue = DispatchQueue(label: "com.rileytestut.AltDaemon.appQueue", qos: .userInitiated)
|
||||||
|
private let profilesQueue = OperationQueue()
|
||||||
|
|
||||||
|
private let fileCoordinator = NSFileCoordinator()
|
||||||
|
|
||||||
|
private init()
|
||||||
|
{
|
||||||
|
self.profilesQueue.name = "com.rileytestut.AltDaemon.profilesQueue"
|
||||||
|
self.profilesQueue.qualityOfService = .userInitiated
|
||||||
|
}
|
||||||
|
|
||||||
|
func installApp(at fileURL: URL, bundleIdentifier: String, activeProfiles: Set<String>?, completionHandler: @escaping (Result<Void, Error>) -> Void)
|
||||||
|
{
|
||||||
|
self.appQueue.async {
|
||||||
|
let lsApplicationWorkspace = unsafeBitCast(NSClassFromString("LSApplicationWorkspace")!, to: LSApplicationWorkspace.Type.self)
|
||||||
|
|
||||||
|
let options = ["CFBundleIdentifier": bundleIdentifier, "AllowInstallLocalProvisioned": NSNumber(value: true)] as [String : Any]
|
||||||
|
let result = Result { try lsApplicationWorkspace.default.installApplication(fileURL, withOptions: options) }
|
||||||
|
|
||||||
|
completionHandler(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeApp(forBundleIdentifier bundleIdentifier: String, completionHandler: @escaping (Result<Void, Error>) -> Void)
|
||||||
|
{
|
||||||
|
self.appQueue.async {
|
||||||
|
let lsApplicationWorkspace = unsafeBitCast(NSClassFromString("LSApplicationWorkspace")!, to: LSApplicationWorkspace.Type.self)
|
||||||
|
lsApplicationWorkspace.default.uninstallApplication(bundleIdentifier, withOptions: nil)
|
||||||
|
|
||||||
|
completionHandler(.success(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func install(_ profiles: Set<ALTProvisioningProfile>, activeProfiles: Set<String>?, completionHandler: @escaping (Result<Void, Error>) -> Void)
|
||||||
|
{
|
||||||
|
let intent = NSFileAccessIntent.writingIntent(with: .profilesDirectoryURL, options: [])
|
||||||
|
self.fileCoordinator.coordinate(with: [intent], queue: self.profilesQueue) { (error) in
|
||||||
|
do
|
||||||
|
{
|
||||||
|
if let error = error
|
||||||
|
{
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
let installingBundleIDs = Set(profiles.map(\.bundleIdentifier))
|
||||||
|
|
||||||
|
let profileURLs = try FileManager.default.contentsOfDirectory(at: intent.url, includingPropertiesForKeys: nil, options: [])
|
||||||
|
|
||||||
|
// Remove all inactive profiles (if active profiles are provided), and the previous profiles.
|
||||||
|
for fileURL in profileURLs
|
||||||
|
{
|
||||||
|
// Use memory mapping to reduce peak memory usage and stay within limit.
|
||||||
|
guard let profile = try? ALTProvisioningProfile(url: fileURL, options: [.mappedIfSafe]) else { continue }
|
||||||
|
|
||||||
|
if installingBundleIDs.contains(profile.bundleIdentifier) || (activeProfiles?.contains(profile.bundleIdentifier) == false && profile.isFreeProvisioningProfile)
|
||||||
|
{
|
||||||
|
try FileManager.default.removeItem(at: fileURL)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
print("Ignoring:", profile.bundleIdentifier, profile.uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for profile in profiles
|
||||||
|
{
|
||||||
|
let destinationURL = URL.profilesDirectoryURL.appendingPathComponent(profile.uuid.uuidString.lowercased())
|
||||||
|
try profile.data.write(to: destinationURL, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
completionHandler(.success(()))
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
completionHandler(.failure(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify system to prevent accidentally untrusting developer certificate.
|
||||||
|
CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), .updatedProvisioningProfiles, nil, nil, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeProvisioningProfiles(forBundleIdentifiers bundleIdentifiers: Set<String>, completionHandler: @escaping (Result<Void, Error>) -> Void)
|
||||||
|
{
|
||||||
|
let intent = NSFileAccessIntent.writingIntent(with: .profilesDirectoryURL, options: [])
|
||||||
|
self.fileCoordinator.coordinate(with: [intent], queue: self.profilesQueue) { (error) in
|
||||||
|
do
|
||||||
|
{
|
||||||
|
let profileURLs = try FileManager.default.contentsOfDirectory(at: intent.url, includingPropertiesForKeys: nil, options: [])
|
||||||
|
|
||||||
|
for fileURL in profileURLs
|
||||||
|
{
|
||||||
|
guard let profile = ALTProvisioningProfile(url: fileURL) else { continue }
|
||||||
|
|
||||||
|
if bundleIdentifiers.contains(profile.bundleIdentifier)
|
||||||
|
{
|
||||||
|
try FileManager.default.removeItem(at: fileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completionHandler(.success(()))
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
completionHandler(.failure(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify system to prevent accidentally untrusting developer certificate.
|
||||||
|
CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), .updatedProvisioningProfiles, nil, nil, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
AltDaemon/DaemonRequestHandler.swift
Normal file
123
AltDaemon/DaemonRequestHandler.swift
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
//
|
||||||
|
// DaemonRequestHandler.swift
|
||||||
|
// AltDaemon
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 6/1/20.
|
||||||
|
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
typealias DaemonConnectionManager = ConnectionManager<DaemonRequestHandler>
|
||||||
|
|
||||||
|
private let connectionManager = ConnectionManager(requestHandler: DaemonRequestHandler(),
|
||||||
|
connectionHandlers: [XPCConnectionHandler()])
|
||||||
|
|
||||||
|
extension DaemonConnectionManager
|
||||||
|
{
|
||||||
|
static var shared: ConnectionManager {
|
||||||
|
return connectionManager
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DaemonRequestHandler: RequestHandler
|
||||||
|
{
|
||||||
|
func handleAnisetteDataRequest(_ request: AnisetteDataRequest, for connection: Connection, completionHandler: @escaping (Result<AnisetteDataResponse, Error>) -> Void)
|
||||||
|
{
|
||||||
|
do
|
||||||
|
{
|
||||||
|
let anisetteData = try AnisetteDataManager.shared.requestAnisetteData()
|
||||||
|
|
||||||
|
let response = AnisetteDataResponse(anisetteData: anisetteData)
|
||||||
|
completionHandler(.success(response))
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
completionHandler(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePrepareAppRequest(_ request: PrepareAppRequest, for connection: Connection, completionHandler: @escaping (Result<InstallationProgressResponse, Error>) -> Void)
|
||||||
|
{
|
||||||
|
guard let fileURL = request.fileURL else { return completionHandler(.failure(ALTServerError(.invalidRequest))) }
|
||||||
|
|
||||||
|
print("Awaiting begin installation request...")
|
||||||
|
|
||||||
|
connection.receiveRequest() { (result) in
|
||||||
|
print("Received begin installation request with result:", result)
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
guard case .beginInstallation(let request) = try result.get() else { throw ALTServerError(.unknownRequest) }
|
||||||
|
guard let bundleIdentifier = request.bundleIdentifier else { throw ALTServerError(.invalidRequest) }
|
||||||
|
|
||||||
|
AppManager.shared.installApp(at: fileURL, bundleIdentifier: bundleIdentifier, activeProfiles: request.activeProfiles) { (result) in
|
||||||
|
let result = result.map { InstallationProgressResponse(progress: 1.0) }
|
||||||
|
print("Installed app with result:", result)
|
||||||
|
|
||||||
|
completionHandler(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
completionHandler(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleInstallProvisioningProfilesRequest(_ request: InstallProvisioningProfilesRequest, for connection: Connection,
|
||||||
|
completionHandler: @escaping (Result<InstallProvisioningProfilesResponse, Error>) -> Void)
|
||||||
|
{
|
||||||
|
AppManager.shared.install(request.provisioningProfiles, activeProfiles: request.activeProfiles) { (result) in
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .failure(let error):
|
||||||
|
print("Failed to install profiles \(request.provisioningProfiles.map { $0.bundleIdentifier }):", error)
|
||||||
|
completionHandler(.failure(error))
|
||||||
|
|
||||||
|
case .success:
|
||||||
|
print("Installed profiles:", request.provisioningProfiles.map { $0.bundleIdentifier })
|
||||||
|
|
||||||
|
let response = InstallProvisioningProfilesResponse()
|
||||||
|
completionHandler(.success(response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRemoveProvisioningProfilesRequest(_ request: RemoveProvisioningProfilesRequest, for connection: Connection,
|
||||||
|
completionHandler: @escaping (Result<RemoveProvisioningProfilesResponse, Error>) -> Void)
|
||||||
|
{
|
||||||
|
AppManager.shared.removeProvisioningProfiles(forBundleIdentifiers: request.bundleIdentifiers) { (result) in
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .failure(let error):
|
||||||
|
print("Failed to remove profiles \(request.bundleIdentifiers):", error)
|
||||||
|
completionHandler(.failure(error))
|
||||||
|
|
||||||
|
case .success:
|
||||||
|
print("Removed profiles:", request.bundleIdentifiers)
|
||||||
|
|
||||||
|
let response = RemoveProvisioningProfilesResponse()
|
||||||
|
completionHandler(.success(response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRemoveAppRequest(_ request: RemoveAppRequest, for connection: Connection, completionHandler: @escaping (Result<RemoveAppResponse, Error>) -> Void)
|
||||||
|
{
|
||||||
|
AppManager.shared.removeApp(forBundleIdentifier: request.bundleIdentifier) { (result) in
|
||||||
|
switch result
|
||||||
|
{
|
||||||
|
case .failure(let error):
|
||||||
|
print("Failed to remove app \(request.bundleIdentifier):", error)
|
||||||
|
completionHandler(.failure(error))
|
||||||
|
|
||||||
|
case .success:
|
||||||
|
print("Removed app:", request.bundleIdentifier)
|
||||||
|
|
||||||
|
let response = RemoveAppResponse()
|
||||||
|
completionHandler(.success(response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
AltDaemon/XPCConnectionHandler.swift
Normal file
93
AltDaemon/XPCConnectionHandler.swift
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
//
|
||||||
|
// XPCConnectionHandler.swift
|
||||||
|
// AltDaemon
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 9/14/20.
|
||||||
|
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
class XPCConnectionHandler: NSObject, ConnectionHandler
|
||||||
|
{
|
||||||
|
var connectionHandler: ((Connection) -> Void)?
|
||||||
|
var disconnectionHandler: ((Connection) -> Void)?
|
||||||
|
|
||||||
|
private let dispatchQueue = DispatchQueue(label: "io.altstore.XPCConnectionListener", qos: .utility)
|
||||||
|
private let listeners = XPCConnection.machServiceNames.map { NSXPCListener.makeListener(machServiceName: $0) }
|
||||||
|
|
||||||
|
deinit
|
||||||
|
{
|
||||||
|
self.stopListening()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startListening()
|
||||||
|
{
|
||||||
|
for listener in self.listeners
|
||||||
|
{
|
||||||
|
listener.delegate = self
|
||||||
|
listener.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopListening()
|
||||||
|
{
|
||||||
|
self.listeners.forEach { $0.suspend() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension XPCConnectionHandler
|
||||||
|
{
|
||||||
|
func disconnect(_ connection: Connection)
|
||||||
|
{
|
||||||
|
connection.disconnect()
|
||||||
|
|
||||||
|
self.disconnectionHandler?(connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension XPCConnectionHandler: NSXPCListenerDelegate
|
||||||
|
{
|
||||||
|
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool
|
||||||
|
{
|
||||||
|
let maximumPathLength = 4 * UInt32(MAXPATHLEN)
|
||||||
|
|
||||||
|
let pathBuffer = UnsafeMutablePointer<CChar>.allocate(capacity: Int(maximumPathLength))
|
||||||
|
defer { pathBuffer.deallocate() }
|
||||||
|
|
||||||
|
proc_pidpath(newConnection.processIdentifier, pathBuffer, maximumPathLength)
|
||||||
|
|
||||||
|
let path = String(cString: pathBuffer)
|
||||||
|
let fileURL = URL(fileURLWithPath: path)
|
||||||
|
|
||||||
|
var code: UnsafeMutableRawPointer?
|
||||||
|
defer { code.map { Unmanaged<AnyObject>.fromOpaque($0).release() } }
|
||||||
|
|
||||||
|
var status = SecStaticCodeCreateWithPath(fileURL as CFURL, 0, &code)
|
||||||
|
guard status == 0 else { return false }
|
||||||
|
|
||||||
|
var signingInfo: CFDictionary?
|
||||||
|
defer { signingInfo.map { Unmanaged<AnyObject>.passUnretained($0).release() } }
|
||||||
|
|
||||||
|
status = SecCodeCopySigningInformation(code, kSecCSInternalInformation | kSecCSSigningInformation, &signingInfo)
|
||||||
|
guard status == 0 else { return false }
|
||||||
|
|
||||||
|
// Only accept connections from AltStore.
|
||||||
|
guard
|
||||||
|
let codeSigningInfo = signingInfo as? [String: Any],
|
||||||
|
let bundleIdentifier = codeSigningInfo["identifier"] as? String,
|
||||||
|
bundleIdentifier.contains(Bundle.Info.appbundleIdentifier)
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
let connection = XPCConnection(newConnection)
|
||||||
|
newConnection.invalidationHandler = { [weak self, weak connection] in
|
||||||
|
guard let self = self, let connection = connection else { return }
|
||||||
|
self.disconnect(connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.connectionHandler?(connection)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
14
AltDaemon/main.swift
Normal file
14
AltDaemon/main.swift
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// main.swift
|
||||||
|
// AltDaemon
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 6/2/20.
|
||||||
|
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
autoreleasepool {
|
||||||
|
DaemonConnectionManager.shared.start()
|
||||||
|
RunLoop.current.run()
|
||||||
|
}
|
||||||
10
AltDaemon/package/DEBIAN/control
Normal file
10
AltDaemon/package/DEBIAN/control
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
Package: com.rileytestut.altdaemon
|
||||||
|
Name: AltDaemon
|
||||||
|
Depends:
|
||||||
|
Version: 1.0
|
||||||
|
Architecture: iphoneos-arm
|
||||||
|
Description: AltDaemon allows AltStore to install and refresh apps without a computer.
|
||||||
|
Maintainer: Riley Testut
|
||||||
|
Author: Riley Testut
|
||||||
|
Homepage: https://altstore.io
|
||||||
|
Section: System
|
||||||
2
AltDaemon/package/DEBIAN/postinst
Executable file
2
AltDaemon/package/DEBIAN/postinst
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
launchctl load /Library/LaunchDaemons/com.rileytestut.altdaemon.plist
|
||||||
2
AltDaemon/package/DEBIAN/preinst
Executable file
2
AltDaemon/package/DEBIAN/preinst
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
launchctl unload /Library/LaunchDaemons/com.rileytestut.altdaemon.plist >> /dev/null 2>&1
|
||||||
2
AltDaemon/package/DEBIAN/prerm
Executable file
2
AltDaemon/package/DEBIAN/prerm
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
launchctl unload /Library/LaunchDaemons/com.rileytestut.altdaemon.plist
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.rileytestut.altdaemon</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/usr/bin/env</string>
|
||||||
|
<string>_MSSafeMode=1</string>
|
||||||
|
<string>_SafeMode=1</string>
|
||||||
|
<string>/usr/bin/AltDaemon</string>
|
||||||
|
</array>
|
||||||
|
<key>UserName</key>
|
||||||
|
<string>mobile</string>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<false/>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<false/>
|
||||||
|
<key>MachServices</key>
|
||||||
|
<dict>
|
||||||
|
<key>cy:io.altstore.altdaemon</key>
|
||||||
|
<true/>
|
||||||
|
<key>lh:io.altstore.altdaemon</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
BIN
AltDaemon/package/usr/bin/AltDaemon
Executable file
BIN
AltDaemon/package/usr/bin/AltDaemon
Executable file
Binary file not shown.
3
AltStore.xcconfig
Normal file
3
AltStore.xcconfig
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#include "Build.xcconfig"
|
||||||
|
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = $(ORG_PREFIX).$(PRODUCT_NAME)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "altsign",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/SideStore/AltSign",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "master",
|
||||||
|
"revision" : "7e0e7edcf8fbc44ac1e35da3e9030a297aa18b84"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "appcenter-sdk-apple",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/microsoft/appcenter-sdk-apple.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "8354a50fe01a7e54e196d3b5493b5ab53dd5866a",
|
||||||
|
"version" : "4.4.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "keychainaccess",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/kishikawakatsumi/KeychainAccess.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "84e546727d66f1adc5439debad16270d0fdd04e7",
|
||||||
|
"version" : "4.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "launchatlogin",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/sindresorhus/LaunchAtLogin.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "e8171b3e38a2816f579f58f3dac1522aa39efe41",
|
||||||
|
"version" : "4.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "nuke",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/kean/Nuke.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "9318d02a8a6d20af56505c9673261c1fd3b3aebe",
|
||||||
|
"version" : "7.6.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "openssl",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/krzyzanowskim/OpenSSL",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "033fcb41dac96b1b6effa945ca1f9ade002370b2",
|
||||||
|
"version" : "1.1.1501"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "plcrashreporter",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/microsoft/PLCrashReporter.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "6b27393cad517c067dceea85fadf050e70c4ceaa",
|
||||||
|
"version" : "1.10.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "semanticversion",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/SwiftPackageIndex/SemanticVersion.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "fc670910dc0903cc269b3d0b776cda5703979c4e",
|
||||||
|
"version" : "0.3.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "sparkle",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/sparkle-project/Sparkle.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "286edd1fa22505a9e54d170e9fd07d775ea233f2",
|
||||||
|
"version" : "2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "stprivilegedtask",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/JoeMatt/STPrivilegedTask.git",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "master",
|
||||||
|
"revision" : "10a9150ef32d444af326beba76356ae9af95a3e7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
||||||
111
AltStore.xcodeproj/xcshareddata/xcschemes/AltDaemon.xcscheme
Normal file
111
AltStore.xcodeproj/xcshareddata/xcschemes/AltDaemon.xcscheme
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1150"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "NO"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A7A6DC28A6D60809855FE404C6A3EA29"
|
||||||
|
BuildableName = "libPods-AltDaemon.a"
|
||||||
|
BlueprintName = "Pods-AltDaemon"
|
||||||
|
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BF1E314F22A0616100370A3C"
|
||||||
|
BuildableName = "libAltKit.a"
|
||||||
|
BlueprintName = "AltKit"
|
||||||
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BF18BFE624857D7900DD5981"
|
||||||
|
BuildableName = "AltDaemon"
|
||||||
|
BlueprintName = "AltDaemon"
|
||||||
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<Testables>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BF18BFE624857D7900DD5981"
|
||||||
|
BuildableName = "AltDaemon"
|
||||||
|
BlueprintName = "AltDaemon"
|
||||||
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "THEOS"
|
||||||
|
value = "~/theos"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BF18BFE624857D7900DD5981"
|
||||||
|
BuildableName = "AltDaemon"
|
||||||
|
BlueprintName = "AltDaemon"
|
||||||
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2610"
|
LastUpgradeVersion = "1120"
|
||||||
version = "1.7">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES"
|
buildImplicitDependencies = "YES">
|
||||||
buildArchitectures = "Automatic">
|
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
@@ -15,10 +14,10 @@
|
|||||||
buildForAnalyzing = "YES">
|
buildForAnalyzing = "YES">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "CA60C44C93D7A30E3695DD59"
|
BlueprintIdentifier = "BF5C5FC4237DF5AE00EDD0C6"
|
||||||
BuildableName = "libem_proxy_static.a"
|
BuildableName = "AltPlugin.mailbundle"
|
||||||
BlueprintName = "em_proxy-staticlib"
|
BlueprintName = "AltPlugin"
|
||||||
ReferencedContainer = "container:em_proxy.xcodeproj">
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
@@ -27,14 +26,15 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
shouldAutocreateTestPlan = "YES">
|
<Testables>
|
||||||
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
launchStyle = "0"
|
launchStyle = "1"
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
@@ -50,10 +50,10 @@
|
|||||||
<MacroExpansion>
|
<MacroExpansion>
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "CA60C44C93D7A30E3695DD59"
|
BlueprintIdentifier = "BF5C5FC4237DF5AE00EDD0C6"
|
||||||
BuildableName = "libem_proxy_static.a"
|
BuildableName = "AltPlugin.mailbundle"
|
||||||
BlueprintName = "em_proxy-staticlib"
|
BlueprintName = "AltPlugin"
|
||||||
ReferencedContainer = "container:em_proxy.xcodeproj">
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</MacroExpansion>
|
</MacroExpansion>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1610"
|
LastUpgradeVersion = "1020"
|
||||||
version = "1.7">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES"
|
buildImplicitDependencies = "YES">
|
||||||
buildArchitectures = "Automatic">
|
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
@@ -15,9 +14,9 @@
|
|||||||
buildForAnalyzing = "YES">
|
buildForAnalyzing = "YES">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "BF58047A246A28F7008AE704"
|
BlueprintIdentifier = "BF45868C229872EA00BD7491"
|
||||||
BuildableName = "AltBackup.app"
|
BuildableName = "AltServer.app"
|
||||||
BlueprintName = "AltBackup"
|
BlueprintName = "AltServer"
|
||||||
ReferencedContainer = "container:AltStore.xcodeproj">
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
@@ -27,8 +26,20 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
shouldAutocreateTestPlan = "YES">
|
<Testables>
|
||||||
|
</Testables>
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BF45868C229872EA00BD7491"
|
||||||
|
BuildableName = "AltServer.app"
|
||||||
|
BlueprintName = "AltServer"
|
||||||
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<AdditionalOptions>
|
||||||
|
</AdditionalOptions>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
@@ -44,42 +55,14 @@
|
|||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "BF58047A246A28F7008AE704"
|
BlueprintIdentifier = "BF45868C229872EA00BD7491"
|
||||||
BuildableName = "AltBackup.app"
|
BuildableName = "AltServer.app"
|
||||||
BlueprintName = "AltBackup"
|
BlueprintName = "AltServer"
|
||||||
ReferencedContainer = "container:AltStore.xcodeproj">
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
<CommandLineArguments>
|
<AdditionalOptions>
|
||||||
<CommandLineArgument
|
</AdditionalOptions>
|
||||||
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>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
@@ -91,9 +74,9 @@
|
|||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "BF58047A246A28F7008AE704"
|
BlueprintIdentifier = "BF45868C229872EA00BD7491"
|
||||||
BuildableName = "AltBackup.app"
|
BuildableName = "AltServer.app"
|
||||||
BlueprintName = "AltBackup"
|
BlueprintName = "AltServer"
|
||||||
ReferencedContainer = "container:AltStore.xcodeproj">
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1020"
|
LastUpgradeVersion = "1020"
|
||||||
version = "1.7">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
@@ -26,8 +26,9 @@
|
|||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
shouldAutocreateTestPlan = "YES">
|
<Testables>
|
||||||
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
@@ -52,33 +53,9 @@
|
|||||||
<CommandLineArguments>
|
<CommandLineArguments>
|
||||||
<CommandLineArgument
|
<CommandLineArgument
|
||||||
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
||||||
isEnabled = "NO">
|
|
||||||
</CommandLineArgument>
|
|
||||||
<CommandLineArgument
|
|
||||||
argument = "-com.apple.CoreData.MigrationDebug 1"
|
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</CommandLineArgument>
|
</CommandLineArgument>
|
||||||
<CommandLineArgument
|
|
||||||
argument = "-com.apple.CoreData.SQLiteIntegrityCheck 1"
|
|
||||||
isEnabled = "NO">
|
|
||||||
</CommandLineArgument>
|
|
||||||
<CommandLineArgument
|
|
||||||
argument = "-com.apple.CoreData.SQLDebug 1"
|
|
||||||
isEnabled = "NO">
|
|
||||||
</CommandLineArgument>
|
|
||||||
</CommandLineArguments>
|
</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>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1610"
|
LastUpgradeVersion = "1020"
|
||||||
version = "1.7">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES"
|
buildImplicitDependencies = "YES">
|
||||||
buildArchitectures = "Automatic">
|
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
@@ -28,28 +27,7 @@
|
|||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
<TestPlans>
|
|
||||||
<TestPlanReference
|
|
||||||
reference = "container:SideStore/Tests/SideStoreTests.xctestplan"
|
|
||||||
default = "YES">
|
|
||||||
</TestPlanReference>
|
|
||||||
</TestPlans>
|
|
||||||
<Testables>
|
<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>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
@@ -75,33 +53,9 @@
|
|||||||
<CommandLineArguments>
|
<CommandLineArguments>
|
||||||
<CommandLineArgument
|
<CommandLineArgument
|
||||||
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
||||||
isEnabled = "NO">
|
|
||||||
</CommandLineArgument>
|
|
||||||
<CommandLineArgument
|
|
||||||
argument = "-com.apple.CoreData.MigrationDebug 1"
|
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</CommandLineArgument>
|
</CommandLineArgument>
|
||||||
<CommandLineArgument
|
|
||||||
argument = "-com.apple.CoreData.SQLiteIntegrityCheck 1"
|
|
||||||
isEnabled = "NO">
|
|
||||||
</CommandLineArgument>
|
|
||||||
<CommandLineArgument
|
|
||||||
argument = "-com.apple.CoreData.SQLDebug 1"
|
|
||||||
isEnabled = "NO">
|
|
||||||
</CommandLineArgument>
|
|
||||||
</CommandLineArguments>
|
</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>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2610"
|
LastUpgradeVersion = "1230"
|
||||||
version = "1.7">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES"
|
buildImplicitDependencies = "YES">
|
||||||
buildArchitectures = "Automatic">
|
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
@@ -15,10 +14,10 @@
|
|||||||
buildForAnalyzing = "YES">
|
buildForAnalyzing = "YES">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "CA609C732349A560B9642892"
|
BlueprintIdentifier = "BFF7C903257844C900E55F36"
|
||||||
BuildableName = "libminimuxer_static.a"
|
BuildableName = "AltXPC.xpc"
|
||||||
BlueprintName = "minimuxer-staticlib"
|
BlueprintName = "AltXPC"
|
||||||
ReferencedContainer = "container:minimuxer.xcodeproj">
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
@@ -27,8 +26,9 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
shouldAutocreateTestPlan = "YES">
|
<Testables>
|
||||||
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
@@ -40,6 +40,16 @@
|
|||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
debugServiceExtension = "internal"
|
debugServiceExtension = "internal"
|
||||||
allowLocationSimulation = "YES">
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BF45868C229872EA00BD7491"
|
||||||
|
BuildableName = "AltServer.app"
|
||||||
|
BlueprintName = "AltServer"
|
||||||
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
@@ -50,10 +60,10 @@
|
|||||||
<MacroExpansion>
|
<MacroExpansion>
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "CA609C732349A560B9642892"
|
BlueprintIdentifier = "BFF7C903257844C900E55F36"
|
||||||
BuildableName = "libminimuxer_static.a"
|
BuildableName = "AltXPC.xpc"
|
||||||
BlueprintName = "minimuxer-staticlib"
|
BlueprintName = "AltXPC"
|
||||||
ReferencedContainer = "container:minimuxer.xcodeproj">
|
ReferencedContainer = "container:AltStore.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</MacroExpansion>
|
</MacroExpansion>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1620"
|
|
||||||
version = "1.7">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES"
|
|
||||||
buildArchitectures = "Automatic">
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
||||||
<TestPlans>
|
|
||||||
<TestPlanReference
|
|
||||||
reference = "container:SideStore/Tests/DataStructureTests.xctestplan"
|
|
||||||
default = "YES">
|
|
||||||
</TestPlanReference>
|
|
||||||
</TestPlans>
|
|
||||||
<Testables>
|
|
||||||
<TestableReference
|
|
||||||
skipped = "NO"
|
|
||||||
parallelizable = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "A81A8CC42D68BA610086C96F"
|
|
||||||
BuildableName = "DataStructureTests.xctest"
|
|
||||||
BlueprintName = "DataStructureTests"
|
|
||||||
ReferencedContainer = "container:AltStore.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
13
AltStore.xcworkspace/contents.xcworkspacedata
generated
13
AltStore.xcworkspace/contents.xcworkspacedata
generated
@@ -1,13 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
<FileRef
|
|
||||||
location = "group:AltStore.xcodeproj">
|
|
||||||
</FileRef>
|
|
||||||
<FileRef
|
|
||||||
location = "group:SideStore/AltSign">
|
|
||||||
</FileRef>
|
|
||||||
<FileRef
|
|
||||||
location = "group:Dependencies/Roxas/Roxas.xcodeproj">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
|
||||||
@@ -4,11 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
|
<key>com.apple.developer.siri</key>
|
||||||
<true/>
|
|
||||||
<key>com.apple.developer.kernel.increased-debugging-memory-limit</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.developer.kernel.increased-memory-limit</key>
|
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>com.apple.security.application-groups</key>
|
|
||||||
<array>
|
|
||||||
<string>group.$(APP_GROUP_IDENTIFIER)</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -14,13 +14,7 @@ import AppCenter
|
|||||||
import AppCenterAnalytics
|
import AppCenterAnalytics
|
||||||
import AppCenterCrashes
|
import AppCenterCrashes
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
private let appCenterAppSecret = "73532d3e-e573-4693-99a4-9f85840bbb44"
|
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
|
extension AnalyticsManager
|
||||||
{
|
{
|
||||||
@@ -30,14 +24,10 @@ extension AnalyticsManager
|
|||||||
case bundleIdentifier
|
case bundleIdentifier
|
||||||
case developerName
|
case developerName
|
||||||
case version
|
case version
|
||||||
case buildVersion
|
|
||||||
case size
|
case size
|
||||||
case tintColor
|
case tintColor
|
||||||
case sourceIdentifier
|
case sourceIdentifier
|
||||||
case sourceURL
|
case sourceURL
|
||||||
case patreonURL
|
|
||||||
case pledgeAmount
|
|
||||||
case pledgeCurrency
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Event
|
enum Event
|
||||||
@@ -69,14 +59,10 @@ extension AnalyticsManager
|
|||||||
.bundleIdentifier: app.bundleIdentifier,
|
.bundleIdentifier: app.bundleIdentifier,
|
||||||
.developerName: app.storeApp?.developerName,
|
.developerName: app.storeApp?.developerName,
|
||||||
.version: app.version,
|
.version: app.version,
|
||||||
.buildVersion: app.buildVersion,
|
|
||||||
.size: appBundleSize?.description,
|
.size: appBundleSize?.description,
|
||||||
.tintColor: app.storeApp?.tintColor?.hexString,
|
.tintColor: app.storeApp?.tintColor?.hexString,
|
||||||
.sourceIdentifier: app.storeApp?.sourceIdentifier,
|
.sourceIdentifier: app.storeApp?.sourceIdentifier,
|
||||||
.sourceURL: app.storeApp?.source?.sourceURL.absoluteString,
|
.sourceURL: app.storeApp?.source?.sourceURL.absoluteString
|
||||||
.patreonURL: app.storeApp?.source?.patreonURL?.absoluteString,
|
|
||||||
.pledgeAmount: app.storeApp?.pledgeAmount?.description,
|
|
||||||
.pledgeCurrency: app.storeApp?.pledgeCurrency
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ final class AppContentViewController: UITableViewController
|
|||||||
{
|
{
|
||||||
var app: StoreApp!
|
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 = {
|
private lazy var dateFormatter: DateFormatter = {
|
||||||
let dateFormatter = DateFormatter()
|
let dateFormatter = DateFormatter()
|
||||||
dateFormatter.dateStyle = .medium
|
dateFormatter.dateStyle = .medium
|
||||||
@@ -43,113 +45,150 @@ final class AppContentViewController: UITableViewController
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
@IBOutlet private var subtitleLabel: UILabel!
|
@IBOutlet private var subtitleLabel: UILabel!
|
||||||
// @IBOutlet private var descriptionTextView: CollapsingTextView!
|
@IBOutlet private var descriptionTextView: CollapsingTextView!
|
||||||
@IBOutlet private var descriptionTextView: CollapsingMarkdownView!
|
@IBOutlet private var versionDescriptionTextView: CollapsingTextView!
|
||||||
// @IBOutlet private var versionDescriptionTextView: CollapsingTextView!
|
|
||||||
@IBOutlet private var versionDescriptionTextView: CollapsingMarkdownView!
|
|
||||||
@IBOutlet private var versionLabel: UILabel!
|
@IBOutlet private var versionLabel: UILabel!
|
||||||
@IBOutlet private var versionDateLabel: UILabel!
|
@IBOutlet private var versionDateLabel: UILabel!
|
||||||
@IBOutlet private var sizeLabel: UILabel!
|
@IBOutlet private var sizeLabel: UILabel!
|
||||||
|
|
||||||
@IBOutlet private(set) var appScreenshotsViewController: AppScreenshotsViewController!
|
@IBOutlet private var screenshotsCollectionView: UICollectionView!
|
||||||
@IBOutlet private var appScreenshotsHeightConstraint: NSLayoutConstraint!
|
@IBOutlet private var permissionsCollectionView: UICollectionView!
|
||||||
|
|
||||||
@IBOutlet private(set) var appDetailCollectionViewController: AppDetailCollectionViewController!
|
var preferredScreenshotSize: CGSize? {
|
||||||
@IBOutlet private var appDetailCollectionViewHeightConstraint: NSLayoutConstraint!
|
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()
|
super.viewDidLoad()
|
||||||
|
|
||||||
self.tableView.contentInset.bottom = 20
|
self.tableView.contentInset.bottom = 20
|
||||||
|
|
||||||
|
self.screenshotsCollectionView.dataSource = self.screenshotsDataSource
|
||||||
|
self.screenshotsCollectionView.prefetchDataSource = self.screenshotsDataSource
|
||||||
|
|
||||||
|
self.permissionsCollectionView.dataSource = self.permissionsDataSource
|
||||||
|
|
||||||
self.subtitleLabel.text = self.app.subtitle
|
self.subtitleLabel.text = self.app.subtitle
|
||||||
let desc = self.app.localizedDescription
|
self.descriptionTextView.text = self.app.localizedDescription
|
||||||
self.descriptionTextView.text = desc
|
|
||||||
|
|
||||||
if let version = self.app.latestAvailableVersion {
|
if let version = self.app.latestVersion
|
||||||
self.versionDescriptionTextView.text = version.localizedDescription ?? "nil"
|
{
|
||||||
self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), version.localizedVersion)
|
self.versionDescriptionTextView.text = version.localizedDescription
|
||||||
self.versionDateLabel.text = Date().relativeDateString(since: version.date)
|
self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), version.version)
|
||||||
self.sizeLabel.text = ByteCountFormatter.string(fromByteCount: version.size, countStyle: .file)
|
self.versionDateLabel.text = Date().relativeDateString(since: version.date, dateFormatter: self.dateFormatter)
|
||||||
} else {
|
self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: version.size)
|
||||||
self.versionDescriptionTextView.text = "nil"
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.versionDescriptionTextView.text = nil
|
||||||
self.versionLabel.text = nil
|
self.versionLabel.text = nil
|
||||||
self.versionDateLabel.text = nil
|
self.versionDateLabel.text = nil
|
||||||
self.sizeLabel.text = ByteCountFormatter.string(fromByteCount: 0, countStyle: .file)
|
self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.descriptionTextView.maximumNumberOfLines = 5
|
self.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.maximumNumberOfLines = 3
|
||||||
self.versionDescriptionTextView.toggleButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
|
self.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLayoutSubviews()
|
override func viewDidLayoutSubviews()
|
||||||
{
|
{
|
||||||
super.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
|
let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
||||||
if self.appScreenshotsHeightConstraint.constant != screenshotsHeight && screenshotsHeight > 0
|
layout.itemSize = size
|
||||||
{
|
}
|
||||||
self.appScreenshotsHeightConstraint.constant = screenshotsHeight
|
|
||||||
needsTableViewUpdate = true
|
override func prepare(for segue: UIStoryboardSegue, sender: Any?)
|
||||||
}
|
{
|
||||||
|
guard segue.identifier == "showPermission" else { return }
|
||||||
|
|
||||||
let permissionsHeight = self.appDetailCollectionViewController.collectionView.contentSize.height
|
guard let cell = sender as? UICollectionViewCell, let indexPath = self.permissionsCollectionView.indexPath(for: cell) else { return }
|
||||||
if self.appDetailCollectionViewHeightConstraint.constant != permissionsHeight && permissionsHeight > 0
|
|
||||||
{
|
|
||||||
self.appDetailCollectionViewHeightConstraint.constant = permissionsHeight
|
|
||||||
needsTableViewUpdate = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if needsTableViewUpdate
|
let permission = self.permissionsDataSource.item(at: indexPath)
|
||||||
{
|
|
||||||
UIView.performWithoutAnimation {
|
let maximumWidth = self.view.bounds.width - 20
|
||||||
// Update row height without animation.
|
|
||||||
self.tableView.beginUpdates()
|
let permissionPopoverViewController = segue.destination as! PermissionPopoverViewController
|
||||||
self.tableView.endUpdates()
|
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
|
private extension AppContentViewController
|
||||||
{
|
{
|
||||||
@IBSegueAction
|
func makeScreenshotsDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>
|
||||||
func makeAppScreenshotsViewController(_ coder: NSCoder, sender: Any?) -> UIViewController?
|
|
||||||
{
|
{
|
||||||
let appScreenshotsViewController = AppScreenshotsViewController(app: self.app, coder: coder)
|
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: self.app.screenshotURLs as [NSURL])
|
||||||
self.appScreenshotsViewController = appScreenshotsViewController
|
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in
|
||||||
return appScreenshotsViewController
|
let cell = cell as! ScreenshotCollectionViewCell
|
||||||
}
|
cell.imageView.image = nil
|
||||||
|
cell.imageView.isIndicatingActivity = true
|
||||||
func makePermissionsDataSource() -> RSTArrayCollectionViewDataSource<AppPermission>
|
}
|
||||||
{
|
dataSource.prefetchHandler = { (imageURL, indexPath, completionHandler) in
|
||||||
let dataSource = RSTArrayCollectionViewDataSource(items: Array(self.app.permissions))
|
return RSTAsyncBlockOperation() { (operation) in
|
||||||
dataSource.cellConfigurationHandler = { (cell, permission, indexPath) in
|
let request = ImageRequest(url: imageURL as URL, processor: .screenshot)
|
||||||
let cell = cell as! PermissionCollectionViewCell
|
ImagePipeline.shared.loadImage(with: request, progress: nil, completion: { (response, error) in
|
||||||
// cell.button.setImage(permission.type.icon, for: .normal)
|
guard !operation.isCancelled else { return operation.finish() }
|
||||||
// cell.button.tintColor = .label
|
|
||||||
// cell.textLabel.text = permission.type.localizedShortName ?? permission.type.localizedName
|
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")
|
if let error = error
|
||||||
cell.button.setImage(icon, for: .normal)
|
{
|
||||||
|
print("Error loading image:", error)
|
||||||
cell.textLabel.text = permission.localizedDisplayName
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return dataSource
|
return dataSource
|
||||||
}
|
}
|
||||||
|
|
||||||
@IBSegueAction
|
func makePermissionsDataSource() -> RSTArrayCollectionViewDataSource<AppPermission>
|
||||||
func makeAppDetailCollectionViewController(_ coder: NSCoder, sender: Any?) -> UIViewController?
|
{
|
||||||
{
|
let dataSource = RSTArrayCollectionViewDataSource(items: self.app.permissions)
|
||||||
let appDetailViewController = AppDetailCollectionViewController(app: self.app, coder: coder)
|
dataSource.cellConfigurationHandler = { (cell, permission, indexPath) in
|
||||||
self.appDetailCollectionViewController = appDetailViewController
|
let cell = cell as! PermissionCollectionViewCell
|
||||||
return appDetailViewController
|
cell.button.setImage(permission.type.icon, for: .normal)
|
||||||
|
cell.button.tintColor = .label
|
||||||
|
cell.textLabel.text = permission.type.localizedShortName ?? permission.type.localizedName
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataSource
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,12 +200,8 @@ private extension AppContentViewController
|
|||||||
|
|
||||||
switch sender
|
switch sender
|
||||||
{
|
{
|
||||||
case self.descriptionTextView.toggleButton:
|
case self.descriptionTextView.moreButton: indexPath = IndexPath(row: Row.description.rawValue, section: 0)
|
||||||
indexPath = IndexPath(row: Row.description.rawValue, section: 0)
|
case self.versionDescriptionTextView.moreButton: indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0)
|
||||||
|
|
||||||
case self.versionDescriptionTextView.toggleButton:
|
|
||||||
indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0)
|
|
||||||
|
|
||||||
default: return
|
default: return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,15 +224,23 @@ extension AppContentViewController
|
|||||||
switch Row.allCases[indexPath.row]
|
switch Row.allCases[indexPath.row]
|
||||||
{
|
{
|
||||||
case .screenshots:
|
case .screenshots:
|
||||||
guard !self.app.allScreenshots.isEmpty else { return 0.0 }
|
guard let size = self.preferredScreenshotSize else { return 0.0 }
|
||||||
return UITableView.automaticDimension
|
return size.height
|
||||||
|
|
||||||
case .permissions:
|
case .permissions:
|
||||||
guard !self.app.permissions.isEmpty else { return 0.0 }
|
guard !self.app.permissions.isEmpty else { return 0.0 }
|
||||||
return UITableView.automaticDimension
|
return super.tableView(tableView, heightForRowAt: indexPath)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return super.tableView(tableView, heightForRowAt: indexPath)
|
return super.tableView(tableView, heightForRowAt: indexPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension AppContentViewController: UIPopoverPresentationControllerDelegate
|
||||||
|
{
|
||||||
|
func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle
|
||||||
|
{
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,300 +0,0 @@
|
|||||||
//
|
|
||||||
// AppDetailCollectionViewController.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/5/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
import AltStoreCore
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
extension AppDetailCollectionViewController
|
|
||||||
{
|
|
||||||
private enum Section: Int
|
|
||||||
{
|
|
||||||
case privacy
|
|
||||||
case knownEntitlements
|
|
||||||
case unknownEntitlements
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum ElementKind: String
|
|
||||||
{
|
|
||||||
case title
|
|
||||||
case button
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc(SafeAreaIgnoringCollectionView)
|
|
||||||
private class SafeAreaIgnoringCollectionView: UICollectionView
|
|
||||||
{
|
|
||||||
override var safeAreaInsets: UIEdgeInsets {
|
|
||||||
get {
|
|
||||||
// Fixes incorrect layout if collection view height is taller than safe area height.
|
|
||||||
return .zero
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
// There MUST be a setter for this to work, even if it does nothing ¯\_(ツ)_/¯
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AppDetailCollectionViewController: UICollectionViewController
|
|
||||||
{
|
|
||||||
let app: StoreApp
|
|
||||||
private let privacyPermissions: [AppPermission]
|
|
||||||
private let knownEntitlementPermissions: [AppPermission]
|
|
||||||
private let unknownEntitlementPermissions: [AppPermission]
|
|
||||||
|
|
||||||
private lazy var dataSource = self.makeDataSource()
|
|
||||||
private lazy var privacyDataSource = self.makePrivacyDataSource()
|
|
||||||
private lazy var entitlementsDataSource = self.makeEntitlementsDataSource()
|
|
||||||
|
|
||||||
private var headerRegistration: UICollectionView.SupplementaryRegistration<UICollectionViewListCell>!
|
|
||||||
|
|
||||||
override var collectionViewLayout: UICollectionViewCompositionalLayout {
|
|
||||||
return self.collectionView.collectionViewLayout as! UICollectionViewCompositionalLayout
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(app: StoreApp, coder: NSCoder)
|
|
||||||
{
|
|
||||||
self.app = app
|
|
||||||
|
|
||||||
let comparator: (AppPermission, AppPermission) -> Bool = { (permissionA, permissionB) -> Bool in
|
|
||||||
switch (permissionA.localizedName, permissionB.localizedName)
|
|
||||||
{
|
|
||||||
case (let nameA?, let nameB?):
|
|
||||||
// Sort by localizedName, if both have one.
|
|
||||||
return nameA.localizedStandardCompare(nameB) == .orderedAscending
|
|
||||||
|
|
||||||
case (nil, nil):
|
|
||||||
// Sort by raw permission value as fallback.
|
|
||||||
return permissionA.permission.rawValue < permissionB.permission.rawValue
|
|
||||||
|
|
||||||
// Sort "known" permissions before "unknown" ones.
|
|
||||||
case (_?, nil): return true
|
|
||||||
case (nil, _?): return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.privacyPermissions = app.permissions.filter { $0.type == .privacy }.sorted(by: comparator)
|
|
||||||
|
|
||||||
let entitlementPermissions = app.permissions.lazy.filter { $0.type == .entitlement }
|
|
||||||
self.knownEntitlementPermissions = entitlementPermissions.filter { $0.isKnown }.sorted(by: comparator)
|
|
||||||
self.unknownEntitlementPermissions = entitlementPermissions.filter { !$0.isKnown }.sorted(by: comparator)
|
|
||||||
|
|
||||||
super.init(coder: coder)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad()
|
|
||||||
{
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
// Allow parent background color to show through.
|
|
||||||
self.collectionView.backgroundColor = nil
|
|
||||||
|
|
||||||
// Match the parent table view margins.
|
|
||||||
self.collectionView.directionalLayoutMargins.leading = 20
|
|
||||||
self.collectionView.directionalLayoutMargins.trailing = 20
|
|
||||||
|
|
||||||
let collectionViewLayout = self.makeLayout()
|
|
||||||
self.collectionView.collectionViewLayout = collectionViewLayout
|
|
||||||
|
|
||||||
self.collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "PrivacyCell")
|
|
||||||
self.collectionView.register(UICollectionViewListCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
|
||||||
|
|
||||||
self.headerRegistration = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { [weak self] (headerView, elementKind, indexPath) in
|
|
||||||
var configuration = UIListContentConfiguration.plainHeader()
|
|
||||||
|
|
||||||
// Match parent table view section headers.
|
|
||||||
configuration.textProperties.font = UIFont.systemFont(ofSize: 22, weight: .bold) // .boldSystemFont(ofSize:) returns *semi-bold* color smh.
|
|
||||||
configuration.textProperties.color = .label
|
|
||||||
|
|
||||||
switch Section(rawValue: indexPath.section)!
|
|
||||||
{
|
|
||||||
case .privacy: break
|
|
||||||
case .knownEntitlements:
|
|
||||||
configuration.text = nil
|
|
||||||
|
|
||||||
configuration.secondaryTextProperties.font = UIFont.preferredFont(forTextStyle: .callout)
|
|
||||||
configuration.textToSecondaryTextVerticalPadding = 8
|
|
||||||
configuration.secondaryText = NSLocalizedString("Entitlements are additional permissions that grant access to certain system services, including potentially sensitive information.", comment: "")
|
|
||||||
|
|
||||||
case .unknownEntitlements:
|
|
||||||
configuration.text = NSLocalizedString("Other Entitlements", comment: "")
|
|
||||||
|
|
||||||
let action = UIAction(image: UIImage(systemName: "questionmark.circle")) { _ in
|
|
||||||
self?.showUnknownEntitlementsAlert()
|
|
||||||
}
|
|
||||||
|
|
||||||
let helpButton = UIButton(primaryAction: action)
|
|
||||||
let customAccessory = UICellAccessory.customView(configuration: .init(customView: helpButton, placement: .trailing(), tintColor: self?.app.tintColor ?? .altPrimary))
|
|
||||||
headerView.accessories = [customAccessory]
|
|
||||||
}
|
|
||||||
|
|
||||||
headerView.contentConfiguration = configuration
|
|
||||||
headerView.backgroundConfiguration = UIBackgroundConfiguration.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
self.dataSource.proxy = self
|
|
||||||
self.collectionView.dataSource = self.dataSource
|
|
||||||
self.collectionView.delegate = self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension AppDetailCollectionViewController
|
|
||||||
{
|
|
||||||
func makeLayout() -> UICollectionViewCompositionalLayout
|
|
||||||
{
|
|
||||||
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
|
|
||||||
layoutConfig.contentInsetsReference = .layoutMargins
|
|
||||||
|
|
||||||
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [privacyPermissions, knownEntitlementPermissions, unknownEntitlementPermissions] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
|
|
||||||
guard let section = Section(rawValue: sectionIndex) else { return nil }
|
|
||||||
switch section
|
|
||||||
{
|
|
||||||
case .privacy:
|
|
||||||
guard !privacyPermissions.isEmpty, #available(iOS 16, *) else { return nil } // Hide section pre-iOS 16.
|
|
||||||
|
|
||||||
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(50)) // Underestimate height to prevent jumping size abruptly.
|
|
||||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
|
||||||
|
|
||||||
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(50))
|
|
||||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
|
|
||||||
|
|
||||||
let layoutSection = NSCollectionLayoutSection(group: group)
|
|
||||||
layoutSection.interGroupSpacing = 10
|
|
||||||
return layoutSection
|
|
||||||
|
|
||||||
case .knownEntitlements where !knownEntitlementPermissions.isEmpty: fallthrough
|
|
||||||
case .unknownEntitlements where !unknownEntitlementPermissions.isEmpty:
|
|
||||||
var configuration = UICollectionLayoutListConfiguration(appearance: .plain)
|
|
||||||
configuration.headerMode = .supplementary
|
|
||||||
configuration.showsSeparators = false
|
|
||||||
configuration.backgroundColor = .altBackground
|
|
||||||
|
|
||||||
let layoutSection = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment)
|
|
||||||
layoutSection.contentInsets.top = 4
|
|
||||||
return layoutSection
|
|
||||||
|
|
||||||
case .knownEntitlements, .unknownEntitlements: return nil
|
|
||||||
}
|
|
||||||
}, configuration: layoutConfig)
|
|
||||||
|
|
||||||
return layout
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeDataSource() -> RSTCompositeCollectionViewDataSource<AppPermission>
|
|
||||||
{
|
|
||||||
let dataSource = RSTCompositeCollectionViewDataSource(dataSources: [self.privacyDataSource, self.entitlementsDataSource])
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func makePrivacyDataSource() -> RSTDynamicCollectionViewDataSource<AppPermission>
|
|
||||||
{
|
|
||||||
let dataSource = RSTDynamicCollectionViewDataSource<AppPermission>()
|
|
||||||
dataSource.cellIdentifierHandler = { _ in "PrivacyCell" }
|
|
||||||
dataSource.numberOfSectionsHandler = { 1 }
|
|
||||||
dataSource.cellConfigurationHandler = { [weak self] (cell, _, indexPath) in
|
|
||||||
guard let self, #available(iOS 16, *) else { return }
|
|
||||||
|
|
||||||
cell.contentConfiguration = UIHostingConfiguration {
|
|
||||||
AppPermissionsCard(title: "Privacy",
|
|
||||||
description: "\(self.app.name) may request access to the following:",
|
|
||||||
tintColor: Color(uiColor: self.app.tintColor ?? .altPrimary),
|
|
||||||
permissions: self.privacyPermissions)
|
|
||||||
}
|
|
||||||
.margins(.horizontal, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if #available(iOS 16, *)
|
|
||||||
{
|
|
||||||
dataSource.numberOfItemsHandler = { [privacyPermissions] _ in !privacyPermissions.isEmpty ? 1 : 0 }
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
dataSource.numberOfItemsHandler = { _ in 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeEntitlementsDataSource() -> RSTCompositeCollectionViewDataSource<AppPermission>
|
|
||||||
{
|
|
||||||
let knownEntitlementsDataSource = RSTArrayCollectionViewDataSource(items: self.knownEntitlementPermissions)
|
|
||||||
let unknownEntitlementsDataSource = RSTArrayCollectionViewDataSource(items: self.unknownEntitlementPermissions)
|
|
||||||
|
|
||||||
let dataSource = RSTCompositeCollectionViewDataSource(dataSources: [knownEntitlementsDataSource, unknownEntitlementsDataSource])
|
|
||||||
dataSource.cellConfigurationHandler = { [weak self] (cell, appPermission, _) in
|
|
||||||
let cell = cell as! UICollectionViewListCell
|
|
||||||
let tintColor = self?.app.tintColor ?? .altPrimary
|
|
||||||
|
|
||||||
var content = cell.defaultContentConfiguration()
|
|
||||||
content.text = appPermission.localizedDisplayName
|
|
||||||
content.secondaryText = appPermission.permission.rawValue
|
|
||||||
content.secondaryTextProperties.color = .secondaryLabel
|
|
||||||
|
|
||||||
if appPermission.isKnown
|
|
||||||
{
|
|
||||||
content.image = UIImage(systemName: appPermission.effectiveSymbolName)
|
|
||||||
content.imageProperties.tintColor = tintColor
|
|
||||||
|
|
||||||
if #available(iOS 15.4, *) /*, let self */ // Capturing self leads to strong-reference cycle.
|
|
||||||
{
|
|
||||||
let detailAccessory = UICellAccessory.detail(options: .init(tintColor: tintColor)) {
|
|
||||||
self?.showPermissionAlert(for: appPermission)
|
|
||||||
}
|
|
||||||
cell.accessories = [detailAccessory]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cell.contentConfiguration = content
|
|
||||||
cell.backgroundConfiguration = UIBackgroundConfiguration.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension AppDetailCollectionViewController
|
|
||||||
{
|
|
||||||
func showPermissionAlert(for permission: AppPermission)
|
|
||||||
{
|
|
||||||
let alertController = UIAlertController(title: permission.localizedDisplayName, message: permission.localizedDescription, preferredStyle: .alert)
|
|
||||||
alertController.addAction(.ok)
|
|
||||||
self.present(alertController, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func showUnknownEntitlementsAlert()
|
|
||||||
{
|
|
||||||
let alertController = UIAlertController(title: NSLocalizedString("Other Entitlements", comment: ""), message: NSLocalizedString("SideStore does not have detailed information for these entitlements.", comment: ""), preferredStyle: .alert)
|
|
||||||
alertController.addAction(.ok)
|
|
||||||
self.present(alertController, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppDetailCollectionViewController
|
|
||||||
{
|
|
||||||
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
|
|
||||||
{
|
|
||||||
let headerView = self.collectionView.dequeueConfiguredReusableSupplementary(using: self.headerRegistration, for: indexPath)
|
|
||||||
return headerView
|
|
||||||
}
|
|
||||||
|
|
||||||
override func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool
|
|
||||||
{
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool
|
|
||||||
{
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
//
|
|
||||||
// AppPermissionsCard.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/4/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
import AltStoreCore
|
|
||||||
|
|
||||||
@available(iOS 16, *)
|
|
||||||
extension AppPermissionsCard
|
|
||||||
{
|
|
||||||
private struct TransitionKey: Hashable
|
|
||||||
{
|
|
||||||
static func name(_ permission: Permission) -> TransitionKey {
|
|
||||||
TransitionKey(key: "name", permission: permission)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func icon(_ permission: Permission) -> TransitionKey {
|
|
||||||
TransitionKey(key: "icon", permission: permission)
|
|
||||||
}
|
|
||||||
|
|
||||||
let key: String
|
|
||||||
let permission: Permission
|
|
||||||
|
|
||||||
private init(key: String, permission: Permission)
|
|
||||||
{
|
|
||||||
self.key = key
|
|
||||||
self.permission = permission
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 16, *)
|
|
||||||
struct AppPermissionsCard<Permission: AppPermissionProtocol>: View
|
|
||||||
{
|
|
||||||
let title: LocalizedStringKey
|
|
||||||
let description: LocalizedStringKey
|
|
||||||
let tintColor: Color
|
|
||||||
|
|
||||||
let permissions: [Permission]
|
|
||||||
|
|
||||||
@State
|
|
||||||
private var selectedPermission: Permission?
|
|
||||||
|
|
||||||
@Namespace
|
|
||||||
private var animation
|
|
||||||
|
|
||||||
private var isTitleVisible: Bool {
|
|
||||||
if selectedPermission == nil
|
|
||||||
{
|
|
||||||
// Title should always be visible when showing all permissions.
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// If showing permission details, only show title if there
|
|
||||||
// are more than 2 permissions total to save vertical space.
|
|
||||||
let isTitleVisible = permissions.count > 2
|
|
||||||
return isTitleVisible
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
let title = Text(title)
|
|
||||||
.font(.title3)
|
|
||||||
.bold()
|
|
||||||
.minimumScaleFactor(0.1) // Avoid clipping during matchedGeometryEffect animation.
|
|
||||||
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
if isTitleVisible
|
|
||||||
{
|
|
||||||
// If title is visible, place _outside_ `content`
|
|
||||||
// to avoid being covered by permissionDetailView.
|
|
||||||
title
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = VStack(spacing: 8) {
|
|
||||||
if !isTitleVisible
|
|
||||||
{
|
|
||||||
// Place title inside `content` when not visible
|
|
||||||
// so it's covered by permissionDetailView.
|
|
||||||
title
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(spacing: 20) {
|
|
||||||
Text(description)
|
|
||||||
.font(.subheadline)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
|
|
||||||
Grid(verticalSpacing: 15) {
|
|
||||||
ForEach(permissions, id: \.self) { permission in
|
|
||||||
permissionRow(for: permission)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text("Tap a permission to learn more.")
|
|
||||||
.font(.subheadline)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let selectedPermission
|
|
||||||
{
|
|
||||||
// Hide content with overlay to preserve existing size.
|
|
||||||
content.hidden().overlay {
|
|
||||||
permissionDetailView(for: selectedPermission)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.overlay(alignment: .topTrailing) {
|
|
||||||
if selectedPermission != nil
|
|
||||||
{
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.imageScale(.medium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(20)
|
|
||||||
.overlay {
|
|
||||||
if selectedPermission != nil
|
|
||||||
{
|
|
||||||
// Make entire view tappable when overlay is visible.
|
|
||||||
SwiftUI.Button(action: hidePermission) {
|
|
||||||
VStack {}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundColor(.secondary) // Vibrancy
|
|
||||||
.background(.regularMaterial) // Blur background for auto-legibility correction.
|
|
||||||
.background(tintColor, in: RoundedRectangle(cornerRadius: 30, style: .continuous))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func permissionRow(for permission: Permission) -> some View
|
|
||||||
{
|
|
||||||
GridRow {
|
|
||||||
SwiftUI.Button(action: { show(permission) }) {
|
|
||||||
HStack {
|
|
||||||
let text = Text(permission.localizedDisplayName)
|
|
||||||
.font(.body)
|
|
||||||
.bold()
|
|
||||||
.minimumScaleFactor(0.33)
|
|
||||||
.lineLimit(.max) // Setting lineLimit to anything fixes text wrapping at large text sizes.
|
|
||||||
|
|
||||||
let image = Image(systemName: permission.effectiveSymbolName)
|
|
||||||
.gridColumnAlignment(.center)
|
|
||||||
|
|
||||||
if selectedPermission != nil
|
|
||||||
{
|
|
||||||
Label(title: { text }, icon: { image })
|
|
||||||
.hidden()
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Label {
|
|
||||||
text.matchedGeometryEffect(id: TransitionKey.name(permission), in: animation)
|
|
||||||
} icon: {
|
|
||||||
image.matchedGeometryEffect(id: TransitionKey.icon(permission), in: animation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "info.circle")
|
|
||||||
.imageScale(.large)
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle()) // Make entire HStack tappable.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(minHeight: 30) // Make row tall enough to tap.
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func permissionDetailView(for permission: Permission) -> some View
|
|
||||||
{
|
|
||||||
VStack(spacing: 15) {
|
|
||||||
Image(systemName: permission.effectiveSymbolName)
|
|
||||||
.font(.largeTitle)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.matchedGeometryEffect(id: TransitionKey.icon(permission), in: animation)
|
|
||||||
|
|
||||||
Text(permission.localizedDisplayName)
|
|
||||||
.font(.title2)
|
|
||||||
.bold()
|
|
||||||
.minimumScaleFactor(0.1) // Avoid clipping during matchedGeometryEffect animation.
|
|
||||||
.matchedGeometryEffect(id: TransitionKey.name(permission), in: animation)
|
|
||||||
|
|
||||||
if let usageDescription = permission.usageDescription
|
|
||||||
{
|
|
||||||
Text(usageDescription)
|
|
||||||
.font(.subheadline)
|
|
||||||
.minimumScaleFactor(0.75)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(title: LocalizedStringKey, description: LocalizedStringKey, tintColor: Color, permissions: [Permission])
|
|
||||||
{
|
|
||||||
self.init(title: title, description: description, tintColor: tintColor, permissions: permissions, selectedPermission: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate init(title: LocalizedStringKey, description: LocalizedStringKey, tintColor: Color, permissions: [Permission], selectedPermission: Permission? = nil)
|
|
||||||
{
|
|
||||||
self.title = title
|
|
||||||
self.description = description
|
|
||||||
self.tintColor = tintColor
|
|
||||||
self.permissions = permissions
|
|
||||||
|
|
||||||
// Set _selectedPermission directly or else the preview won't detect it.
|
|
||||||
self._selectedPermission = State(initialValue: selectedPermission)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 16, *)
|
|
||||||
private extension AppPermissionsCard
|
|
||||||
{
|
|
||||||
func show(_ permission: Permission)
|
|
||||||
{
|
|
||||||
withAnimation {
|
|
||||||
self.selectedPermission = permission
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func hidePermission()
|
|
||||||
{
|
|
||||||
withAnimation {
|
|
||||||
self.selectedPermission = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 16, *)
|
|
||||||
struct AppPermissionsCard_Previews: PreviewProvider
|
|
||||||
{
|
|
||||||
static var previews: some View {
|
|
||||||
let appPermissions = [
|
|
||||||
PreviewAppPermission(permission: ALTAppPrivacyPermission.localNetwork),
|
|
||||||
PreviewAppPermission(permission: ALTAppPrivacyPermission.microphone),
|
|
||||||
PreviewAppPermission(permission: ALTAppPrivacyPermission.photos),
|
|
||||||
PreviewAppPermission(permission: ALTAppPrivacyPermission.camera),
|
|
||||||
PreviewAppPermission(permission: ALTAppPrivacyPermission.faceID),
|
|
||||||
PreviewAppPermission(permission: ALTAppPrivacyPermission.appleMusic),
|
|
||||||
PreviewAppPermission(permission: ALTAppPrivacyPermission.bluetooth),
|
|
||||||
PreviewAppPermission(permission: ALTAppPrivacyPermission.calendars),
|
|
||||||
]
|
|
||||||
|
|
||||||
let tintColor = Color(uiColor: .deltaPrimary!)
|
|
||||||
|
|
||||||
return ForEach(1...8, id: \.self) { index in
|
|
||||||
AppPermissionsCard(title: "Privacy",
|
|
||||||
description: "Delta may request access to the following:",
|
|
||||||
tintColor: tintColor,
|
|
||||||
permissions: Array(appPermissions.prefix(index)))
|
|
||||||
.frame(width: 350)
|
|
||||||
.previewLayout(.sizeThatFits)
|
|
||||||
|
|
||||||
AppPermissionsCard(title: "Privacy",
|
|
||||||
description: "Delta may request access to the following:",
|
|
||||||
tintColor: tintColor,
|
|
||||||
permissions: Array(appPermissions.prefix(index)),
|
|
||||||
selectedPermission: appPermissions.first)
|
|
||||||
.frame(width: 350)
|
|
||||||
.previewLayout(.sizeThatFits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,22 +42,13 @@ final class AppViewController: UIViewController
|
|||||||
@IBOutlet private var navigationBarAppNameLabel: UILabel!
|
@IBOutlet private var navigationBarAppNameLabel: UILabel!
|
||||||
|
|
||||||
private var _shouldResetLayout = false
|
private var _shouldResetLayout = false
|
||||||
private var _viewDidAppear = false
|
|
||||||
private var _backgroundBlurEffect: UIBlurEffect?
|
private var _backgroundBlurEffect: UIBlurEffect?
|
||||||
private var _backgroundBlurTintColor: UIColor?
|
private var _backgroundBlurTintColor: UIColor?
|
||||||
|
|
||||||
private var _preferredStatusBarStyle: UIStatusBarStyle = .default
|
private var _preferredStatusBarStyle: UIStatusBarStyle = .default
|
||||||
|
|
||||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||||
if #available(iOS 17, *)
|
return _preferredStatusBarStyle
|
||||||
{
|
|
||||||
// On iOS 17+, .default will update the status bar automatically.
|
|
||||||
return .default
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return _preferredStatusBarStyle
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLoad()
|
override func viewDidLoad()
|
||||||
@@ -67,11 +58,6 @@ final class AppViewController: UIViewController
|
|||||||
self.navigationBarTitleView.sizeToFit()
|
self.navigationBarTitleView.sizeToFit()
|
||||||
self.navigationItem.titleView = self.navigationBarTitleView
|
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 = UIView()
|
||||||
self.contentViewControllerShadowView.backgroundColor = .white
|
self.contentViewControllerShadowView.backgroundColor = .white
|
||||||
self.contentViewControllerShadowView.layer.cornerRadius = 38
|
self.contentViewControllerShadowView.layer.cornerRadius = 38
|
||||||
@@ -87,7 +73,6 @@ final class AppViewController: UIViewController
|
|||||||
self.contentViewController.view.layer.masksToBounds = true
|
self.contentViewController.view.layer.masksToBounds = true
|
||||||
|
|
||||||
self.contentViewController.tableView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer)
|
self.contentViewController.tableView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer)
|
||||||
self.contentViewController.appDetailCollectionViewController.collectionView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer)
|
|
||||||
self.contentViewController.tableView.showsVerticalScrollIndicator = false
|
self.contentViewController.tableView.showsVerticalScrollIndicator = false
|
||||||
|
|
||||||
// Bring to front so the scroll indicators are visible.
|
// Bring to front so the scroll indicators are visible.
|
||||||
@@ -101,12 +86,15 @@ final class AppViewController: UIViewController
|
|||||||
self.bannerView.iconImageView.tintColor = self.app.tintColor
|
self.bannerView.iconImageView.tintColor = self.app.tintColor
|
||||||
self.bannerView.button.tintColor = self.app.tintColor
|
self.bannerView.button.tintColor = self.app.tintColor
|
||||||
self.bannerView.tintColor = self.app.tintColor
|
self.bannerView.tintColor = self.app.tintColor
|
||||||
|
|
||||||
|
self.bannerView.configure(for: self.app)
|
||||||
self.bannerView.accessibilityTraits.remove(.button)
|
self.bannerView.accessibilityTraits.remove(.button)
|
||||||
|
|
||||||
self.bannerView.button.addTarget(self, action: #selector(AppViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
self.bannerView.button.addTarget(self, action: #selector(AppViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
||||||
|
|
||||||
self.backButtonContainerView.tintColor = self.app.tintColor
|
self.backButtonContainerView.tintColor = self.app.tintColor
|
||||||
|
|
||||||
|
self.navigationController?.navigationBar.tintColor = self.app.tintColor
|
||||||
self.navigationBarDownloadButton.tintColor = self.app.tintColor
|
self.navigationBarDownloadButton.tintColor = self.app.tintColor
|
||||||
self.navigationBarAppNameLabel.text = self.app.name
|
self.navigationBarAppNameLabel.text = self.app.name
|
||||||
self.navigationBarAppIconImageView.tintColor = self.app.tintColor
|
self.navigationBarAppIconImageView.tintColor = self.app.tintColor
|
||||||
@@ -130,17 +118,13 @@ final class AppViewController: UIViewController
|
|||||||
{
|
{
|
||||||
imageView.isIndicatingActivity = true
|
imageView.isIndicatingActivity = true
|
||||||
|
|
||||||
Nuke.loadImage(with: self.app.iconURL, options: .shared, into: imageView, progress: nil) { [weak imageView] (result) in
|
Nuke.loadImage(with: self.app.iconURL, options: .shared, into: imageView, progress: nil) { [weak imageView] (response, error) in
|
||||||
switch result
|
if response?.image != nil
|
||||||
{
|
{
|
||||||
case .success: imageView?.isIndicatingActivity = false
|
imageView?.isIndicatingActivity = false
|
||||||
case .failure(let error): print("[ALTLog] Failed to load app icons.", error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start with navigation bar hidden.
|
|
||||||
self.hideNavigationBar()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool)
|
override func viewWillAppear(_ animated: Bool)
|
||||||
@@ -152,26 +136,42 @@ final class AppViewController: UIViewController
|
|||||||
// Update blur immediately.
|
// Update blur immediately.
|
||||||
self.view.setNeedsLayout()
|
self.view.setNeedsLayout()
|
||||||
self.view.layoutIfNeeded()
|
self.view.layoutIfNeeded()
|
||||||
}
|
|
||||||
|
self.transitionCoordinator?.animate(alongsideTransition: { (context) in
|
||||||
override func viewIsAppearing(_ animated: Bool)
|
self.hideNavigationBar()
|
||||||
{
|
}, completion: nil)
|
||||||
super.viewIsAppearing(animated)
|
|
||||||
|
|
||||||
// Prevent banner temporarily flashing a color due to being added back to self.view.
|
|
||||||
self.bannerView.backgroundEffectView.backgroundColor = .clear
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool)
|
override func viewDidAppear(_ animated: Bool)
|
||||||
{
|
{
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
self._viewDidAppear = true
|
|
||||||
|
|
||||||
self._shouldResetLayout = true
|
self._shouldResetLayout = true
|
||||||
self.view.setNeedsLayout()
|
self.view.setNeedsLayout()
|
||||||
self.view.layoutIfNeeded()
|
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)
|
override func viewDidDisappear(_ animated: Bool)
|
||||||
{
|
{
|
||||||
super.viewDidDisappear(animated)
|
super.viewDidDisappear(animated)
|
||||||
@@ -193,6 +193,7 @@ final class AppViewController: UIViewController
|
|||||||
{
|
{
|
||||||
// Fix navigation bar + tab bar appearance on iOS 15.
|
// Fix navigation bar + tab bar appearance on iOS 15.
|
||||||
self.setContentScrollView(self.scrollView)
|
self.setContentScrollView(self.scrollView)
|
||||||
|
self.navigationItem.scrollEdgeAppearance = self.navigationController?.navigationBar.standardAppearance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +205,11 @@ final class AppViewController: UIViewController
|
|||||||
{
|
{
|
||||||
// Various events can cause UI to mess up, so reset affected components now.
|
// Various events can cause UI to mess up, so reset affected components now.
|
||||||
|
|
||||||
|
if self.navigationController?.topViewController == self
|
||||||
|
{
|
||||||
|
self.hideNavigationBar()
|
||||||
|
}
|
||||||
|
|
||||||
self.prepareBlur()
|
self.prepareBlur()
|
||||||
|
|
||||||
// Reset navigation bar animation, and create a new one later in this method if necessary.
|
// Reset navigation bar animation, and create a new one later in this method if necessary.
|
||||||
@@ -211,22 +217,8 @@ final class AppViewController: UIViewController
|
|||||||
|
|
||||||
self._shouldResetLayout = false
|
self._shouldResetLayout = false
|
||||||
}
|
}
|
||||||
|
|
||||||
let statusBarHeight: Double
|
let statusBarHeight = UIApplication.shared.statusBarFrame.height
|
||||||
|
|
||||||
if let navigationController, navigationController.presentingViewController != nil, navigationController.modalPresentationStyle != .fullScreen
|
|
||||||
{
|
|
||||||
statusBarHeight = 20
|
|
||||||
}
|
|
||||||
else if let statusBarManager = (self.view.window ?? self.presentedViewController?.view.window)?.windowScene?.statusBarManager
|
|
||||||
{
|
|
||||||
statusBarHeight = statusBarManager.statusBarFrame.height
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
statusBarHeight = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
let cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius
|
let cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius
|
||||||
|
|
||||||
let inset = 12 as CGFloat
|
let inset = 12 as CGFloat
|
||||||
@@ -285,25 +277,13 @@ final class AppViewController: UIViewController
|
|||||||
}
|
}
|
||||||
|
|
||||||
let difference = self.scrollView.contentOffset.y - showNavigationBarThreshold
|
let difference = self.scrollView.contentOffset.y - showNavigationBarThreshold
|
||||||
|
let range = (headerFrame.height + padding) - (self.navigationController?.navigationBar.bounds.height ?? self.view.safeAreaInsets.top)
|
||||||
let range: Double
|
|
||||||
if self.presentingViewController == nil && self.parent?.presentingViewController == nil
|
|
||||||
{
|
|
||||||
// Not presented modally, so rely on safe area + navigation bar height.
|
|
||||||
range = (headerFrame.height + padding) - (self.navigationController?.navigationBar.bounds.height ?? self.view.safeAreaInsets.top)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Presented modally, so rely on maximumContentY.
|
|
||||||
range = maximumContentY - (maximumContentY - padding - headerFrame.height) - inset
|
|
||||||
}
|
|
||||||
|
|
||||||
let fractionComplete = min(difference, range) / range
|
let fractionComplete = min(difference, range) / range
|
||||||
self.navigationBarAnimator?.fractionComplete = fractionComplete
|
self.navigationBarAnimator?.fractionComplete = fractionComplete
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
self.navigationBarAnimator?.fractionComplete = 0.0
|
|
||||||
self.resetNavigationBarAnimation()
|
self.resetNavigationBarAnimation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +323,7 @@ final class AppViewController: UIViewController
|
|||||||
|
|
||||||
self.backButtonContainerView.layer.cornerRadius = self.backButtonContainerView.bounds.midY
|
self.backButtonContainerView.layer.cornerRadius = self.backButtonContainerView.bounds.midY
|
||||||
|
|
||||||
self.scrollView.verticalScrollIndicatorInsets.top = statusBarHeight
|
self.scrollView.scrollIndicatorInsets.top = statusBarHeight
|
||||||
|
|
||||||
// Adjust content offset + size.
|
// Adjust content offset + size.
|
||||||
let contentOffset = self.scrollView.contentOffset
|
let contentOffset = self.scrollView.contentOffset
|
||||||
@@ -360,11 +340,7 @@ final class AppViewController: UIViewController
|
|||||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?)
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?)
|
||||||
{
|
{
|
||||||
super.traitCollectionDidChange(previousTraitCollection)
|
super.traitCollectionDidChange(previousTraitCollection)
|
||||||
|
self._shouldResetLayout = true
|
||||||
if self._viewDidAppear
|
|
||||||
{
|
|
||||||
self._shouldResetLayout = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit
|
deinit
|
||||||
@@ -390,40 +366,46 @@ private extension AppViewController
|
|||||||
{
|
{
|
||||||
func update()
|
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!]
|
for button in [self.bannerView.button!, self.navigationBarDownloadButton!]
|
||||||
{
|
{
|
||||||
button.tintColor = self.app.tintColor
|
button.tintColor = self.app.tintColor
|
||||||
button.isIndicatingActivity = false
|
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)
|
if let versionDate = self.app.latestVersion?.date, versionDate > Date()
|
||||||
|
{
|
||||||
let title = self.bannerView.button.title(for: .normal)
|
self.bannerView.button.countdownDate = versionDate
|
||||||
self.navigationBarDownloadButton.setTitle(title, for: .normal)
|
self.navigationBarDownloadButton.countdownDate = versionDate
|
||||||
self.navigationBarDownloadButton.progress = self.bannerView.button.progress
|
}
|
||||||
self.navigationBarDownloadButton.countdownDate = self.bannerView.button.countdownDate
|
else
|
||||||
|
{
|
||||||
|
self.bannerView.button.countdownDate = nil
|
||||||
|
self.navigationBarDownloadButton.countdownDate = nil
|
||||||
|
}
|
||||||
|
|
||||||
let barButtonItem = self.navigationItem.rightBarButtonItem
|
let barButtonItem = self.navigationItem.rightBarButtonItem
|
||||||
self.navigationItem.rightBarButtonItem = nil
|
self.navigationItem.rightBarButtonItem = nil
|
||||||
self.navigationItem.rightBarButtonItem = barButtonItem
|
self.navigationItem.rightBarButtonItem = barButtonItem
|
||||||
}
|
}
|
||||||
|
|
||||||
func showNavigationBar()
|
func showNavigationBar(for navigationController: UINavigationController? = nil)
|
||||||
{
|
{
|
||||||
self.navigationBarAppIconImageView.alpha = 1.0
|
let navigationController = navigationController ?? self.navigationController
|
||||||
self.navigationBarAppNameLabel.alpha = 1.0
|
navigationController?.navigationBar.alpha = 1.0
|
||||||
self.navigationBarDownloadButton.alpha = 1.0
|
navigationController?.navigationBar.tintColor = .altPrimary
|
||||||
|
navigationController?.navigationBar.setNeedsLayout()
|
||||||
self.updateNavigationBarAppearance(isHidden: false)
|
|
||||||
|
|
||||||
if self.traitCollection.userInterfaceStyle == .dark
|
if self.traitCollection.userInterfaceStyle == .dark
|
||||||
{
|
{
|
||||||
@@ -434,51 +416,16 @@ private extension AppViewController
|
|||||||
self._preferredStatusBarStyle = .default
|
self._preferredStatusBarStyle = .default
|
||||||
}
|
}
|
||||||
|
|
||||||
if #unavailable(iOS 17)
|
navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||||
{
|
|
||||||
self.navigationController?.setNeedsStatusBarAppearanceUpdate()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func hideNavigationBar()
|
func hideNavigationBar(for navigationController: UINavigationController? = nil)
|
||||||
{
|
{
|
||||||
self.navigationBarAppIconImageView.alpha = 0.0
|
let navigationController = navigationController ?? self.navigationController
|
||||||
self.navigationBarAppNameLabel.alpha = 0.0
|
navigationController?.navigationBar.alpha = 0.0
|
||||||
self.navigationBarDownloadButton.alpha = 0.0
|
|
||||||
|
|
||||||
self.updateNavigationBarAppearance(isHidden: true)
|
|
||||||
|
|
||||||
self._preferredStatusBarStyle = .lightContent
|
self._preferredStatusBarStyle = .lightContent
|
||||||
|
navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||||
if #unavailable(iOS 17)
|
|
||||||
{
|
|
||||||
self.navigationController?.setNeedsStatusBarAppearanceUpdate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copied from HeaderContentViewController
|
|
||||||
func updateNavigationBarAppearance(isHidden: Bool)
|
|
||||||
{
|
|
||||||
let barAppearance = self.navigationItem.standardAppearance as? NavigationBarAppearance ?? NavigationBarAppearance()
|
|
||||||
|
|
||||||
if isHidden
|
|
||||||
{
|
|
||||||
barAppearance.configureWithTransparentBackground()
|
|
||||||
barAppearance.ignoresUserInteraction = true
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
barAppearance.configureWithDefaultBackground()
|
|
||||||
barAppearance.ignoresUserInteraction = false
|
|
||||||
}
|
|
||||||
|
|
||||||
barAppearance.titleTextAttributes = [.foregroundColor: UIColor.clear]
|
|
||||||
|
|
||||||
let tintColor = isHidden ? UIColor.clear : self.app.tintColor ?? .altPrimary
|
|
||||||
barAppearance.configureWithTintColor(tintColor)
|
|
||||||
|
|
||||||
self.navigationItem.standardAppearance = barAppearance
|
|
||||||
self.navigationItem.scrollEdgeAppearance = barAppearance
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareBlur()
|
func prepareBlur()
|
||||||
@@ -506,10 +453,8 @@ private extension AppViewController
|
|||||||
|
|
||||||
self.navigationBarAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in
|
self.navigationBarAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in
|
||||||
self?.showNavigationBar()
|
self?.showNavigationBar()
|
||||||
|
self?.navigationController?.navigationBar.tintColor = self?.app.tintColor
|
||||||
// Must call layoutIfNeeded() to animate appearance change.
|
self?.navigationController?.navigationBar.barTintColor = nil
|
||||||
self?.navigationController?.navigationBar.layoutIfNeeded()
|
|
||||||
|
|
||||||
self?.contentViewController.view.layer.cornerRadius = 0
|
self?.contentViewController.view.layer.cornerRadius = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,8 +466,6 @@ private extension AppViewController
|
|||||||
|
|
||||||
func resetNavigationBarAnimation()
|
func resetNavigationBarAnimation()
|
||||||
{
|
{
|
||||||
guard self.navigationBarAnimator != nil else { return }
|
|
||||||
|
|
||||||
self.navigationBarAnimator?.stopAnimation(true)
|
self.navigationBarAnimator?.stopAnimation(true)
|
||||||
self.navigationBarAnimator = nil
|
self.navigationBarAnimator = nil
|
||||||
|
|
||||||
@@ -543,15 +486,7 @@ extension AppViewController
|
|||||||
{
|
{
|
||||||
if let installedApp = self.app.installedApp
|
if let installedApp = self.app.installedApp
|
||||||
{
|
{
|
||||||
// if let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion), !self.app.isPledgeRequired || self.app.isPledged
|
self.open(installedApp)
|
||||||
if let latestVersion = self.app.latestAvailableVersion, installedApp.hasUpdate
|
|
||||||
{
|
|
||||||
self.updateApp(installedApp, to: latestVersion)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.open(installedApp)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -563,72 +498,38 @@ extension AppViewController
|
|||||||
{
|
{
|
||||||
guard self.app.installedApp == nil else { return }
|
guard self.app.installedApp == nil else { return }
|
||||||
|
|
||||||
Task<Void, Never>(priority: .userInitiated) {
|
let group = AppManager.shared.install(self.app, presentingViewController: self) { (result) in
|
||||||
let group = await AppManager.shared.installAsync(self.app, presentingViewController: self) { (result) in
|
do
|
||||||
do
|
{
|
||||||
{
|
_ = try result.get()
|
||||||
_ = try result.get()
|
}
|
||||||
}
|
catch OperationError.cancelled
|
||||||
catch OperationError.cancelled
|
{
|
||||||
{
|
// Ignore
|
||||||
// Ignore
|
}
|
||||||
}
|
catch
|
||||||
catch
|
{
|
||||||
{
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let toastView = ToastView(error: error)
|
|
||||||
toastView.opensErrorLog = true
|
|
||||||
toastView.show(in: self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.bannerView.button.progress = nil
|
let toastView = ToastView(error: error)
|
||||||
self.navigationBarDownloadButton.progress = nil
|
toastView.show(in: self)
|
||||||
self.update()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !group.progress.isCancelled
|
DispatchQueue.main.async {
|
||||||
{
|
self.bannerView.button.progress = nil
|
||||||
self.bannerView.button.progress = group.progress
|
self.navigationBarDownloadButton.progress = nil
|
||||||
self.navigationBarDownloadButton.progress = group.progress
|
self.update()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.bannerView.button.progress = group.progress
|
||||||
|
self.navigationBarDownloadButton.progress = group.progress
|
||||||
}
|
}
|
||||||
|
|
||||||
func open(_ installedApp: InstalledApp)
|
func open(_ installedApp: InstalledApp)
|
||||||
{
|
{
|
||||||
UIApplication.shared.open(installedApp.openAppURL)
|
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
|
private extension AppViewController
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ final class PermissionPopoverViewController: UIViewController
|
|||||||
{
|
{
|
||||||
super.viewDidLoad()
|
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
|
self.descriptionLabel.text = self.permission.usageDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
//
|
|
||||||
// AppScreenshotCollectionViewCell.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 10/11/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
import AltStoreCore
|
|
||||||
|
|
||||||
extension AppScreenshotCollectionViewCell
|
|
||||||
{
|
|
||||||
private class ImageView: UIImageView
|
|
||||||
{
|
|
||||||
override func layoutSubviews()
|
|
||||||
{
|
|
||||||
super.layoutSubviews()
|
|
||||||
|
|
||||||
// Explicitly layout cell to ensure rounded corners are accurate.
|
|
||||||
self.superview?.superview?.setNeedsLayout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AppScreenshotCollectionViewCell: UICollectionViewCell
|
|
||||||
{
|
|
||||||
let imageView: UIImageView
|
|
||||||
|
|
||||||
var aspectRatio: CGSize = AppScreenshot.defaultAspectRatio {
|
|
||||||
didSet {
|
|
||||||
self.updateAspectRatio()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isRounded: Bool = false {
|
|
||||||
didSet {
|
|
||||||
self.setNeedsLayout()
|
|
||||||
self.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var aspectRatioConstraint: NSLayoutConstraint?
|
|
||||||
|
|
||||||
override init(frame: CGRect)
|
|
||||||
{
|
|
||||||
self.imageView = ImageView(frame: .zero)
|
|
||||||
self.imageView.clipsToBounds = true
|
|
||||||
self.imageView.layer.cornerCurve = .continuous
|
|
||||||
self.imageView.layer.borderColor = UIColor.tertiaryLabel.cgColor
|
|
||||||
|
|
||||||
super.init(frame: frame)
|
|
||||||
|
|
||||||
self.imageView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
self.contentView.addSubview(self.imageView)
|
|
||||||
|
|
||||||
let widthConstraint = self.imageView.widthAnchor.constraint(equalTo: self.contentView.widthAnchor)
|
|
||||||
widthConstraint.priority = .defaultHigh
|
|
||||||
|
|
||||||
let heightConstraint = self.imageView.heightAnchor.constraint(equalTo: self.contentView.heightAnchor)
|
|
||||||
heightConstraint.priority = .defaultHigh
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
widthConstraint,
|
|
||||||
heightConstraint,
|
|
||||||
self.imageView.widthAnchor.constraint(lessThanOrEqualTo: self.contentView.widthAnchor),
|
|
||||||
self.imageView.heightAnchor.constraint(lessThanOrEqualTo: self.contentView.heightAnchor),
|
|
||||||
self.imageView.centerXAnchor.constraint(equalTo: self.contentView.centerXAnchor),
|
|
||||||
self.imageView.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor)
|
|
||||||
])
|
|
||||||
|
|
||||||
self.updateAspectRatio()
|
|
||||||
self.updateTraits()
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?)
|
|
||||||
{
|
|
||||||
super.traitCollectionDidChange(previousTraitCollection)
|
|
||||||
|
|
||||||
self.updateTraits()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews()
|
|
||||||
{
|
|
||||||
super.layoutSubviews()
|
|
||||||
|
|
||||||
if self.isRounded
|
|
||||||
{
|
|
||||||
let cornerRadius = self.imageView.bounds.width / 9.0 // Based on iPhone 15
|
|
||||||
self.imageView.layer.cornerRadius = cornerRadius
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.imageView.layer.cornerRadius = 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppScreenshotCollectionViewCell
|
|
||||||
{
|
|
||||||
func setImage(_ image: UIImage?)
|
|
||||||
{
|
|
||||||
guard var image, let cgImage = image.cgImage else {
|
|
||||||
self.imageView.image = image
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if image.size.width > image.size.height && self.aspectRatio.width < self.aspectRatio.height
|
|
||||||
{
|
|
||||||
// Image is landscape, but cell has portrait aspect ratio, so rotate image to match.
|
|
||||||
image = UIImage(cgImage: cgImage, scale: image.scale, orientation: .right)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.imageView.image = image
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension AppScreenshotCollectionViewCell
|
|
||||||
{
|
|
||||||
func updateAspectRatio()
|
|
||||||
{
|
|
||||||
self.aspectRatioConstraint?.isActive = false
|
|
||||||
|
|
||||||
self.aspectRatioConstraint = self.imageView.widthAnchor.constraint(equalTo: self.imageView.heightAnchor, multiplier: self.aspectRatio.width / self.aspectRatio.height)
|
|
||||||
self.aspectRatioConstraint?.isActive = true
|
|
||||||
|
|
||||||
let aspectRatio: Double
|
|
||||||
if self.aspectRatio.width > self.aspectRatio.height
|
|
||||||
{
|
|
||||||
aspectRatio = self.aspectRatio.height / self.aspectRatio.width
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
aspectRatio = self.aspectRatio.width / self.aspectRatio.height
|
|
||||||
}
|
|
||||||
|
|
||||||
let tolerance = 0.001 as Double
|
|
||||||
let modernAspectRatio = AppScreenshot.defaultAspectRatio.width / AppScreenshot.defaultAspectRatio.height
|
|
||||||
|
|
||||||
let isRounded = (aspectRatio >= modernAspectRatio - tolerance) && (aspectRatio <= modernAspectRatio + tolerance)
|
|
||||||
self.isRounded = isRounded
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateTraits()
|
|
||||||
{
|
|
||||||
let displayScale = (self.traitCollection.displayScale == 0.0) ? 1.0 : self.traitCollection.displayScale
|
|
||||||
self.imageView.layer.borderWidth = 1.0 / displayScale
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
//
|
|
||||||
// AppScreenshotsViewController.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 9/18/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
import AltStoreCore
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
import Nuke
|
|
||||||
|
|
||||||
class AppScreenshotsViewController: UICollectionViewController
|
|
||||||
{
|
|
||||||
let app: StoreApp
|
|
||||||
|
|
||||||
private lazy var dataSource = self.makeDataSource()
|
|
||||||
|
|
||||||
init?(app: StoreApp, coder: NSCoder)
|
|
||||||
{
|
|
||||||
self.app = app
|
|
||||||
|
|
||||||
super.init(coder: coder)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad()
|
|
||||||
{
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
self.collectionView.showsHorizontalScrollIndicator = false
|
|
||||||
|
|
||||||
// Allow parent background color to show through.
|
|
||||||
self.collectionView.backgroundColor = nil
|
|
||||||
|
|
||||||
// Match the parent table view margins.
|
|
||||||
self.collectionView.directionalLayoutMargins.top = 0
|
|
||||||
self.collectionView.directionalLayoutMargins.bottom = 0
|
|
||||||
self.collectionView.directionalLayoutMargins.leading = 20
|
|
||||||
self.collectionView.directionalLayoutMargins.trailing = 20
|
|
||||||
|
|
||||||
let collectionViewLayout = self.makeLayout()
|
|
||||||
self.collectionView.collectionViewLayout = collectionViewLayout
|
|
||||||
|
|
||||||
self.collectionView.register(AppScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
|
||||||
|
|
||||||
self.collectionView.dataSource = self.dataSource
|
|
||||||
self.collectionView.prefetchDataSource = self.dataSource
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension AppScreenshotsViewController
|
|
||||||
{
|
|
||||||
func makeLayout() -> UICollectionViewCompositionalLayout
|
|
||||||
{
|
|
||||||
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
|
|
||||||
layoutConfig.contentInsetsReference = .layoutMargins
|
|
||||||
|
|
||||||
let preferredHeight = 400.0
|
|
||||||
let estimatedWidth = preferredHeight * (AppScreenshot.defaultAspectRatio.width / AppScreenshot.defaultAspectRatio.height)
|
|
||||||
|
|
||||||
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [dataSource] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
|
|
||||||
let screenshotWidths = dataSource.items.map { screenshot in
|
|
||||||
var aspectRatio = screenshot.size ?? AppScreenshot.defaultAspectRatio
|
|
||||||
if aspectRatio.width > aspectRatio.height
|
|
||||||
{
|
|
||||||
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
|
||||||
}
|
|
||||||
|
|
||||||
let screenshotWidth = (preferredHeight * (aspectRatio.width / aspectRatio.height)).rounded()
|
|
||||||
return screenshotWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
let smallestWidth = screenshotWidths.sorted().first
|
|
||||||
let itemWidth = smallestWidth ?? estimatedWidth // Use smallestWidth to ensure we never overshoot an item when paging.
|
|
||||||
|
|
||||||
let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(itemWidth), heightDimension: .fractionalHeight(1.0))
|
|
||||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
|
||||||
|
|
||||||
let groupSize = NSCollectionLayoutSize(widthDimension: .estimated(itemWidth), heightDimension: .absolute(preferredHeight))
|
|
||||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
|
|
||||||
|
|
||||||
let layoutSection = NSCollectionLayoutSection(group: group)
|
|
||||||
layoutSection.interGroupSpacing = 10
|
|
||||||
layoutSection.orthogonalScrollingBehavior = .groupPaging
|
|
||||||
|
|
||||||
return layoutSection
|
|
||||||
}, configuration: layoutConfig)
|
|
||||||
|
|
||||||
return layout
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>
|
|
||||||
{
|
|
||||||
let screenshots = self.app.preferredScreenshots()
|
|
||||||
|
|
||||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>(items: screenshots)
|
|
||||||
dataSource.cellConfigurationHandler = { [weak self] (cell, screenshot, indexPath) in
|
|
||||||
let cell = cell as! AppScreenshotCollectionViewCell
|
|
||||||
cell.imageView.isIndicatingActivity = true
|
|
||||||
cell.setImage(nil)
|
|
||||||
|
|
||||||
var aspectRatio = screenshot.size ?? AppScreenshot.defaultAspectRatio
|
|
||||||
if aspectRatio.width > aspectRatio.height
|
|
||||||
{
|
|
||||||
switch screenshot.deviceType
|
|
||||||
{
|
|
||||||
case .iphone:
|
|
||||||
// Always rotate landscape iPhone screenshots regardless of horizontal size class.
|
|
||||||
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
|
||||||
|
|
||||||
case .ipad where self?.traitCollection.horizontalSizeClass == .compact:
|
|
||||||
// Only rotate landscape iPad screenshots if we're in horizontally compact environment.
|
|
||||||
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
|
||||||
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cell.aspectRatio = aspectRatio
|
|
||||||
}
|
|
||||||
dataSource.prefetchHandler = { (screenshot, indexPath, completionHandler) in
|
|
||||||
let imageURL = screenshot.imageURL
|
|
||||||
return RSTAsyncBlockOperation() { (operation) in
|
|
||||||
let request = ImageRequest(url: imageURL)
|
|
||||||
ImagePipeline.shared.loadImage(with: request, progress: nil) { result in
|
|
||||||
guard !operation.isCancelled else { return operation.finish() }
|
|
||||||
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .success(let response): completionHandler(response.image, nil)
|
|
||||||
case .failure(let error): completionHandler(nil, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
|
||||||
let cell = cell as! AppScreenshotCollectionViewCell
|
|
||||||
cell.imageView.isIndicatingActivity = false
|
|
||||||
cell.setImage(image)
|
|
||||||
|
|
||||||
if let error = error
|
|
||||||
{
|
|
||||||
print("Error loading image:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppScreenshotsViewController
|
|
||||||
{
|
|
||||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
|
||||||
{
|
|
||||||
let screenshot = self.dataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
let previewViewController = PreviewAppScreenshotsViewController(app: self.app)
|
|
||||||
previewViewController.currentScreenshot = screenshot
|
|
||||||
|
|
||||||
let navigationController = UINavigationController(rootViewController: previewViewController)
|
|
||||||
navigationController.modalPresentationStyle = .fullScreen
|
|
||||||
self.present(navigationController, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 17, *)
|
|
||||||
#Preview(traits: .portrait) {
|
|
||||||
DatabaseManager.shared.startForPreview()
|
|
||||||
|
|
||||||
let fetchRequest = StoreApp.fetchRequest()
|
|
||||||
let storeApp = try! DatabaseManager.shared.viewContext.fetch(fetchRequest).first!
|
|
||||||
|
|
||||||
let storyboard = UIStoryboard(name: "Main", bundle: .main)
|
|
||||||
let appViewConttroller = storyboard.instantiateViewController(withIdentifier: "appViewController") as! AppViewController
|
|
||||||
appViewConttroller.app = storeApp
|
|
||||||
|
|
||||||
let navigationController = UINavigationController(rootViewController: appViewConttroller)
|
|
||||||
return navigationController
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
//
|
|
||||||
// PreviewAppScreenshotsViewController.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 9/19/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
import AltStoreCore
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
import Nuke
|
|
||||||
|
|
||||||
class PreviewAppScreenshotsViewController: UICollectionViewController
|
|
||||||
{
|
|
||||||
let app: StoreApp
|
|
||||||
|
|
||||||
var currentScreenshot: AppScreenshot?
|
|
||||||
|
|
||||||
private lazy var dataSource = self.makeDataSource()
|
|
||||||
|
|
||||||
init(app: StoreApp)
|
|
||||||
{
|
|
||||||
self.app = app
|
|
||||||
|
|
||||||
super.init(collectionViewLayout: UICollectionViewFlowLayout())
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad()
|
|
||||||
{
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
let tintColor = self.app.tintColor ?? .altPrimary
|
|
||||||
self.navigationController?.view.tintColor = tintColor
|
|
||||||
|
|
||||||
self.view.backgroundColor = .systemBackground
|
|
||||||
self.collectionView.backgroundColor = nil
|
|
||||||
|
|
||||||
let collectionViewLayout = self.makeLayout()
|
|
||||||
self.collectionView.collectionViewLayout = collectionViewLayout
|
|
||||||
|
|
||||||
self.collectionView.directionalLayoutMargins.leading = 20
|
|
||||||
self.collectionView.directionalLayoutMargins.trailing = 20
|
|
||||||
|
|
||||||
self.collectionView.preservesSuperviewLayoutMargins = true
|
|
||||||
self.collectionView.insetsLayoutMarginsFromSafeArea = true
|
|
||||||
|
|
||||||
self.collectionView.alwaysBounceVertical = false
|
|
||||||
self.collectionView.register(AppScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
|
||||||
|
|
||||||
self.collectionView.dataSource = self.dataSource
|
|
||||||
self.collectionView.prefetchDataSource = self.dataSource
|
|
||||||
|
|
||||||
let doneButton = UIBarButtonItem(systemItem: .done, primaryAction: UIAction { [weak self] _ in
|
|
||||||
self?.dismissPreview()
|
|
||||||
})
|
|
||||||
self.navigationItem.rightBarButtonItem = doneButton
|
|
||||||
|
|
||||||
let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(PreviewAppScreenshotsViewController.dismissPreview))
|
|
||||||
swipeGestureRecognizer.direction = .down
|
|
||||||
self.view.addGestureRecognizer(swipeGestureRecognizer)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewIsAppearing(_ animated: Bool)
|
|
||||||
{
|
|
||||||
super.viewIsAppearing(animated)
|
|
||||||
|
|
||||||
if let screenshot = self.currentScreenshot, let index = self.dataSource.items.firstIndex(of: screenshot)
|
|
||||||
{
|
|
||||||
let indexPath = IndexPath(item: index, section: 0)
|
|
||||||
self.collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension PreviewAppScreenshotsViewController
|
|
||||||
{
|
|
||||||
func makeLayout() -> UICollectionViewCompositionalLayout
|
|
||||||
{
|
|
||||||
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
|
|
||||||
layoutConfig.contentInsetsReference = .none
|
|
||||||
|
|
||||||
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
|
|
||||||
guard let self else { return nil }
|
|
||||||
|
|
||||||
let contentInsets = self.collectionView.directionalLayoutMargins
|
|
||||||
let groupWidth = layoutEnvironment.container.contentSize.width - (contentInsets.leading + contentInsets.trailing)
|
|
||||||
let groupHeight = layoutEnvironment.container.contentSize.height - (contentInsets.top + contentInsets.bottom)
|
|
||||||
|
|
||||||
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
|
|
||||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
|
||||||
|
|
||||||
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(groupWidth), heightDimension: .absolute(groupHeight))
|
|
||||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
|
|
||||||
|
|
||||||
let layoutSection = NSCollectionLayoutSection(group: group)
|
|
||||||
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
|
|
||||||
layoutSection.interGroupSpacing = 10
|
|
||||||
return layoutSection
|
|
||||||
}, configuration: layoutConfig)
|
|
||||||
|
|
||||||
return layout
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>
|
|
||||||
{
|
|
||||||
let screenshots = self.app.preferredScreenshots()
|
|
||||||
|
|
||||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>(items: screenshots)
|
|
||||||
dataSource.cellConfigurationHandler = { [weak self] (cell, screenshot, indexPath) in
|
|
||||||
let cell = cell as! AppScreenshotCollectionViewCell
|
|
||||||
cell.imageView.isIndicatingActivity = true
|
|
||||||
cell.setImage(nil)
|
|
||||||
|
|
||||||
var aspectRatio = screenshot.size ?? AppScreenshot.defaultAspectRatio
|
|
||||||
if aspectRatio.width > aspectRatio.height
|
|
||||||
{
|
|
||||||
switch screenshot.deviceType
|
|
||||||
{
|
|
||||||
case .iphone:
|
|
||||||
// Always rotate landscape iPhone screenshots regardless of horizontal size class.
|
|
||||||
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
|
||||||
|
|
||||||
case .ipad where self?.traitCollection.horizontalSizeClass == .compact:
|
|
||||||
// Only rotate landscape iPad screenshots if we're in horizontally compact environment.
|
|
||||||
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
|
||||||
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cell.aspectRatio = aspectRatio
|
|
||||||
}
|
|
||||||
dataSource.prefetchHandler = { (screenshot, indexPath, completionHandler) in
|
|
||||||
let imageURL = screenshot.imageURL
|
|
||||||
return RSTAsyncBlockOperation() { (operation) in
|
|
||||||
let request = ImageRequest(url: imageURL)
|
|
||||||
ImagePipeline.shared.loadImage(with: request, progress: nil) { result in
|
|
||||||
guard !operation.isCancelled else { return operation.finish() }
|
|
||||||
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .success(let response): completionHandler(response.image, nil)
|
|
||||||
case .failure(let error): completionHandler(nil, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
|
||||||
let cell = cell as! AppScreenshotCollectionViewCell
|
|
||||||
cell.imageView.isIndicatingActivity = false
|
|
||||||
cell.setImage(image)
|
|
||||||
|
|
||||||
if let error = error
|
|
||||||
{
|
|
||||||
print("Error loading image:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension PreviewAppScreenshotsViewController
|
|
||||||
{
|
|
||||||
@objc func dismissPreview()
|
|
||||||
{
|
|
||||||
self.dismiss(animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 17, *)
|
|
||||||
#Preview(traits: .portrait) {
|
|
||||||
DatabaseManager.shared.startForPreview()
|
|
||||||
|
|
||||||
let fetchRequest = StoreApp.fetchRequest()
|
|
||||||
let storeApp = try! DatabaseManager.shared.viewContext.fetch(fetchRequest).first!
|
|
||||||
|
|
||||||
let previewViewController = PreviewAppScreenshotsViewController(app: storeApp)
|
|
||||||
|
|
||||||
let navigationController = UINavigationController(rootViewController: previewViewController)
|
|
||||||
return navigationController
|
|
||||||
}
|
|
||||||
@@ -72,11 +72,10 @@ private extension AppIDsViewController
|
|||||||
dataSource.cellConfigurationHandler = { (cell, appID, indexPath) in
|
dataSource.cellConfigurationHandler = { (cell, appID, indexPath) in
|
||||||
let tintColor = UIColor.altPrimary
|
let tintColor = UIColor.altPrimary
|
||||||
|
|
||||||
let cell = cell as! AppBannerCollectionViewCell
|
let cell = cell as! BannerCollectionViewCell
|
||||||
|
cell.layoutMargins.left = self.view.layoutMargins.left
|
||||||
|
cell.layoutMargins.right = self.view.layoutMargins.right
|
||||||
cell.tintColor = tintColor
|
cell.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.iconImageView.isHidden = true
|
||||||
cell.bannerView.button.isIndicatingActivity = false
|
cell.bannerView.button.isIndicatingActivity = false
|
||||||
@@ -91,22 +90,14 @@ private extension AppIDsViewController
|
|||||||
cell.bannerView.button.isUserInteractionEnabled = false
|
cell.bannerView.button.isUserInteractionEnabled = false
|
||||||
|
|
||||||
cell.bannerView.buttonLabel.isHidden = 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
|
let currentDate = Date()
|
||||||
attributedAccessibilityLabel.mutableString.append(timeIntervalText)
|
|
||||||
|
let numberOfDays = expirationDate.numberOfCalendarDays(since: currentDate)
|
||||||
|
let numberOfDaysText = (numberOfDays == 1) ? NSLocalizedString("1 day", comment: "") : String(format: NSLocalizedString("%@ days", comment: ""), NSNumber(value: numberOfDays))
|
||||||
|
cell.bannerView.button.setTitle(numberOfDaysText.uppercased(), for: .normal)
|
||||||
|
|
||||||
|
attributedAccessibilityLabel.mutableString.append(String(format: NSLocalizedString("Expires in %@.", comment: ""), numberOfDaysText) + " ")
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -119,11 +110,10 @@ private extension AppIDsViewController
|
|||||||
cell.bannerView.titleLabel.text = appID.name
|
cell.bannerView.titleLabel.text = appID.name
|
||||||
cell.bannerView.subtitleLabel.text = appID.bundleIdentifier
|
cell.bannerView.subtitleLabel.text = appID.bundleIdentifier
|
||||||
cell.bannerView.subtitleLabel.numberOfLines = 2
|
cell.bannerView.subtitleLabel.numberOfLines = 2
|
||||||
cell.bannerView.subtitleLabel.minimumScaleFactor = 1.0 // Disable font shrinking
|
|
||||||
|
|
||||||
let attributedBundleIdentifier = NSMutableAttributedString(string: appID.bundleIdentifier.lowercased(), attributes: [.accessibilitySpeechPunctuation: true])
|
let attributedBundleIdentifier = NSMutableAttributedString(string: appID.bundleIdentifier.lowercased(), attributes: [.accessibilitySpeechPunctuation: true])
|
||||||
|
|
||||||
if let team = appID.team, let range = attributedBundleIdentifier.string.range(of: team.identifier.lowercased())
|
if let team = appID.team, let range = attributedBundleIdentifier.string.range(of: team.identifier.lowercased()), #available(iOS 13, *)
|
||||||
{
|
{
|
||||||
// Prefer to speak the team ID one character at a time.
|
// Prefer to speak the team ID one character at a time.
|
||||||
let nsRange = NSRange(range, in: attributedBundleIdentifier.string)
|
let nsRange = NSRange(range, in: attributedBundleIdentifier.string)
|
||||||
@@ -184,18 +174,14 @@ extension AppIDsViewController: UICollectionViewDelegateFlowLayout
|
|||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize
|
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize
|
||||||
{
|
{
|
||||||
// let indexPath = IndexPath(row: 0, section: section)
|
let indexPath = IndexPath(row: 0, section: section)
|
||||||
// let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionHeader, at: indexPath)
|
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
|
// 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),
|
let size = headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height),
|
||||||
// withHorizontalFittingPriority: .required, // Width is fixed
|
withHorizontalFittingPriority: .required, // Width is fixed
|
||||||
// verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed
|
verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed
|
||||||
// return size
|
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
|
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ import AltSign
|
|||||||
import Roxas
|
import Roxas
|
||||||
import EmotionalDamage
|
import EmotionalDamage
|
||||||
|
|
||||||
import Nuke
|
|
||||||
|
|
||||||
extension UIApplication: LegacyBackgroundFetching {}
|
|
||||||
|
|
||||||
extension AppDelegate
|
extension AppDelegate
|
||||||
{
|
{
|
||||||
static let openPatreonSettingsDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".OpenPatreonSettingsDeepLinkNotification")
|
static let openPatreonSettingsDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".OpenPatreonSettingsDeepLinkNotification")
|
||||||
@@ -27,12 +23,10 @@ extension AppDelegate
|
|||||||
static let addSourceDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".AddSourceDeepLinkNotification")
|
static let addSourceDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".AddSourceDeepLinkNotification")
|
||||||
|
|
||||||
static let appBackupDidFinish = Notification.Name(Bundle.Info.appbundleIdentifier + ".AppBackupDidFinish")
|
static let appBackupDidFinish = Notification.Name(Bundle.Info.appbundleIdentifier + ".AppBackupDidFinish")
|
||||||
static let exportCertificateNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".ExportCertificateNotification")
|
|
||||||
|
|
||||||
static let importAppDeepLinkURLKey = "fileURL"
|
static let importAppDeepLinkURLKey = "fileURL"
|
||||||
static let appBackupResultKey = "result"
|
static let appBackupResultKey = "result"
|
||||||
static let addSourceDeepLinkURLKey = "sourceURL"
|
static let addSourceDeepLinkURLKey = "sourceURL"
|
||||||
static let exportCertificateCallbackTemplateKey = "callback"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
@@ -40,48 +34,33 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
|
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
|
|
||||||
private let intentHandler = IntentHandler()
|
@available(iOS 14, *)
|
||||||
private let viewAppIntentHandler = ViewAppIntentHandler()
|
private var intentHandler: IntentHandler {
|
||||||
|
get { _intentHandler as! IntentHandler }
|
||||||
|
set { _intentHandler = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
public let consoleLog = ConsoleLog()
|
@available(iOS 14, *)
|
||||||
|
private var viewAppIntentHandler: ViewAppIntentHandler {
|
||||||
|
get { _viewAppIntentHandler as! ViewAppIntentHandler }
|
||||||
|
set { _viewAppIntentHandler = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var _intentHandler: Any = {
|
||||||
|
guard #available(iOS 14, *) else { fatalError() }
|
||||||
|
return IntentHandler()
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var _viewAppIntentHandler: Any = {
|
||||||
|
guard #available(iOS 14, *) else { fatalError() }
|
||||||
|
return ViewAppIntentHandler()
|
||||||
|
}()
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool
|
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.
|
// Register default settings before doing anything else.
|
||||||
UserDefaults.registerDefaults()
|
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
|
DatabaseManager.shared.start { (error) in
|
||||||
if let error = error
|
if let error = error
|
||||||
{
|
{
|
||||||
@@ -96,13 +75,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
AnalyticsManager.shared.start()
|
AnalyticsManager.shared.start()
|
||||||
|
|
||||||
self.setTintColor()
|
self.setTintColor()
|
||||||
self.prepareImageCache()
|
|
||||||
|
|
||||||
// TODO: @mahee96: find if we need to start em_proxy as in altstore?
|
|
||||||
if UserDefaults.standard.enableEMPforWireguard {
|
|
||||||
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureValueTransformer.register()
|
SecureValueTransformer.register()
|
||||||
|
|
||||||
if UserDefaults.standard.firstLaunch == nil
|
if UserDefaults.standard.firstLaunch == nil
|
||||||
@@ -113,7 +86,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
|
|
||||||
UserDefaults.standard.preferredServerID = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.serverID) as? String
|
UserDefaults.standard.preferredServerID = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.serverID) as? String
|
||||||
|
|
||||||
#if DEBUG && targetEnvironment(simulator)
|
#if DEBUG || BETA
|
||||||
UserDefaults.standard.isDebugModeEnabled = true
|
UserDefaults.standard.isDebugModeEnabled = true
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -125,10 +98,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
func applicationDidEnterBackground(_ application: UIApplication)
|
func applicationDidEnterBackground(_ application: UIApplication)
|
||||||
{
|
{
|
||||||
// Make sure to update SceneDelegate.sceneDidEnterBackground() as well.
|
// Make sure to update SceneDelegate.sceneDidEnterBackground() as well.
|
||||||
// TODO: @mahee96: find if we need to stop em_proxy as in altstore?
|
|
||||||
if UserDefaults.standard.enableEMPforWireguard {
|
|
||||||
stop_em_proxy()
|
|
||||||
}
|
|
||||||
guard let oneMonthAgo = Calendar.current.date(byAdding: .month, value: -1, to: Date()) else { return }
|
guard let oneMonthAgo = Calendar.current.date(byAdding: .month, value: -1, to: Date()) else { return }
|
||||||
|
|
||||||
let midnightOneMonthAgo = Calendar.current.startOfDay(for: oneMonthAgo)
|
let midnightOneMonthAgo = Calendar.current.startOfDay(for: oneMonthAgo)
|
||||||
@@ -145,11 +115,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
func applicationWillEnterForeground(_ application: UIApplication)
|
func applicationWillEnterForeground(_ application: UIApplication)
|
||||||
{
|
{
|
||||||
AppManager.shared.update()
|
AppManager.shared.update()
|
||||||
if UserDefaults.standard.enableEMPforWireguard {
|
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
||||||
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
PatreonAPI.shared.refreshPatreonAccount()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) -> Bool
|
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) -> Bool
|
||||||
@@ -159,6 +125,8 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
|
|
||||||
func application(_ application: UIApplication, handlerFor intent: INIntent) -> Any?
|
func application(_ application: UIApplication, handlerFor intent: INIntent) -> Any?
|
||||||
{
|
{
|
||||||
|
guard #available(iOS 14, *) else { return nil }
|
||||||
|
|
||||||
switch intent
|
switch intent
|
||||||
{
|
{
|
||||||
case is RefreshAllIntent: return self.intentHandler
|
case is RefreshAllIntent: return self.intentHandler
|
||||||
@@ -166,19 +134,9 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
default: return nil
|
default: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
|
||||||
// Stop console logging and clean up resources
|
|
||||||
print("\n ")
|
|
||||||
print("===================================================")
|
|
||||||
print("| Console Logger stopped capturing output streams |")
|
|
||||||
print("===================================================")
|
|
||||||
print("| App is being terminated |")
|
|
||||||
print("===================================================")
|
|
||||||
consoleLog.stopCapturing()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(iOS 13, *)
|
||||||
extension AppDelegate
|
extension AppDelegate
|
||||||
{
|
{
|
||||||
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration
|
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration
|
||||||
@@ -203,33 +161,6 @@ private extension AppDelegate
|
|||||||
self.window?.tintColor = .altPrimary
|
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
|
func open(_ url: URL) -> Bool
|
||||||
{
|
{
|
||||||
if url.isFileURL
|
if url.isFileURL
|
||||||
@@ -300,26 +231,6 @@ private extension AppDelegate
|
|||||||
|
|
||||||
return true
|
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
|
default: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,12 +242,12 @@ extension AppDelegate
|
|||||||
private func prepareForBackgroundFetch()
|
private func prepareForBackgroundFetch()
|
||||||
{
|
{
|
||||||
// "Fetch" every hour, but then refresh only those that need to be refreshed (so we don't drain the battery).
|
// "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
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { (success, error) in
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG && targetEnvironment(simulator)
|
#if DEBUG
|
||||||
UIApplication.shared.registerForRemoteNotifications()
|
UIApplication.shared.registerForRemoteNotifications()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -445,12 +356,10 @@ private extension AppDelegate
|
|||||||
{
|
{
|
||||||
let (sources, context) = try result.get()
|
let (sources, context) = try result.get()
|
||||||
|
|
||||||
let previousUpdatesFetchRequest = InstalledApp.supportedUpdatesFetchRequest() as! NSFetchRequest<NSFetchRequestResult>
|
let previousUpdatesFetchRequest = InstalledApp.updatesFetchRequest() as! NSFetchRequest<NSFetchRequestResult>
|
||||||
previousUpdatesFetchRequest.includesPendingChanges = false
|
previousUpdatesFetchRequest.includesPendingChanges = false
|
||||||
previousUpdatesFetchRequest.resultType = .dictionaryResultType
|
previousUpdatesFetchRequest.resultType = .dictionaryResultType
|
||||||
previousUpdatesFetchRequest.propertiesToFetch = [#keyPath(InstalledApp.bundleIdentifier),
|
previousUpdatesFetchRequest.propertiesToFetch = [#keyPath(InstalledApp.bundleIdentifier)]
|
||||||
#keyPath(InstalledApp.storeApp.latestSupportedVersion.version),
|
|
||||||
#keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion)]
|
|
||||||
|
|
||||||
let previousNewsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NSFetchRequestResult>
|
let previousNewsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NSFetchRequestResult>
|
||||||
previousNewsItemsFetchRequest.includesPendingChanges = false
|
previousNewsItemsFetchRequest.includesPendingChanges = false
|
||||||
@@ -462,9 +371,7 @@ private extension AppDelegate
|
|||||||
|
|
||||||
try context.save()
|
try context.save()
|
||||||
|
|
||||||
|
let updatesFetchRequest = InstalledApp.updatesFetchRequest()
|
||||||
|
|
||||||
let updatesFetchRequest = InstalledApp.supportedUpdatesFetchRequest()
|
|
||||||
let newsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NewsItem>
|
let newsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NewsItem>
|
||||||
|
|
||||||
let updates = try context.fetch(updatesFetchRequest)
|
let updates = try context.fetch(updatesFetchRequest)
|
||||||
@@ -472,23 +379,12 @@ private extension AppDelegate
|
|||||||
|
|
||||||
for update in updates
|
for update in updates
|
||||||
{
|
{
|
||||||
guard let storeApp = update.storeApp, let latestSupportedVersion = storeApp.latestSupportedVersion, latestSupportedVersion.isSupported else { continue }
|
guard !previousUpdates.contains(where: { $0[#keyPath(InstalledApp.bundleIdentifier)] == update.bundleIdentifier }) else { continue }
|
||||||
|
guard let storeApp = update.storeApp, let version = storeApp.version else { continue }
|
||||||
if let previousUpdate = previousUpdates.first(where: { $0[#keyPath(InstalledApp.bundleIdentifier)] == update.bundleIdentifier })
|
|
||||||
{
|
|
||||||
// An update for this app was already available, so check whether the version or build version is different.
|
|
||||||
guard let previousVersion = previousUpdate[#keyPath(InstalledApp.storeApp.latestSupportedVersion.version)] else { continue }
|
|
||||||
|
|
||||||
// previousUpdate might not contain buildVersion, but if it does then map empty string to nil to match AppVersion.
|
|
||||||
let previousBuildVersion = previousUpdate[#keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion)].map { $0.isEmpty ? nil : "" }
|
|
||||||
|
|
||||||
// Only show notification if previous latestSupportedVersion does not _exactly_ match current latestSupportedVersion.
|
|
||||||
guard previousVersion != latestSupportedVersion.version || previousBuildVersion != latestSupportedVersion.buildVersion else { continue }
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = NSLocalizedString("New Update Available", comment: "")
|
content.title = NSLocalizedString("New Update Available", comment: "")
|
||||||
content.body = String(format: NSLocalizedString("%@ %@ is now available for download.", comment: ""), update.name, latestSupportedVersion.localizedVersion)
|
content.body = String(format: NSLocalizedString("%@ %@ is now available for download.", comment: ""), update.name, version)
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
|
|
||||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<deployment identifier="iOS"/>
|
<deployment identifier="iOS"/>
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
|
||||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
<scene sceneID="lNR-II-WoW">
|
<scene sceneID="lNR-II-WoW">
|
||||||
<objects>
|
<objects>
|
||||||
<navigationController storyboardIdentifier="navigationController" id="ZTo-53-dSL" sceneMemberID="viewController">
|
<navigationController storyboardIdentifier="navigationController" id="ZTo-53-dSL" sceneMemberID="viewController">
|
||||||
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" barStyle="black" largeTitles="YES" id="Aej-RF-PfV" customClass="NavigationBar" customModule="SideStore" customModuleProvider="target">
|
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" barStyle="black" largeTitles="YES" id="Aej-RF-PfV" customClass="NavigationBar" customModule="AltStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="20" width="375" height="96"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="96"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<color key="barTintColor" name="SettingsBackground"/>
|
<color key="barTintColor" name="SettingsBackground"/>
|
||||||
@@ -36,19 +36,19 @@
|
|||||||
<!--Authentication View Controller-->
|
<!--Authentication View Controller-->
|
||||||
<scene sceneID="OCd-xc-Ms7">
|
<scene sceneID="OCd-xc-Ms7">
|
||||||
<objects>
|
<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">
|
<view key="view" contentMode="scaleToFill" id="mjy-4S-hyH">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<view hidden="YES" userInteractionEnabled="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="oyW-Fd-ojD" userLabel="Sizing View">
|
<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>
|
</view>
|
||||||
<scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" alwaysBounceVertical="YES" indicatorStyle="white" keyboardDismissMode="onDrag" translatesAutoresizingMaskIntoConstraints="NO" id="WXx-hX-AXv">
|
<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"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" translatesAutoresizingMaskIntoConstraints="NO" id="2wp-qG-f0Z">
|
<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>
|
<subviews>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" axis="vertical" spacing="50" translatesAutoresizingMaskIntoConstraints="NO" id="YmX-7v-pxh">
|
<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"/>
|
<rect key="frame" x="16" y="6" width="343" height="359.5"/>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
<rect key="frame" x="0.0" y="0.0" width="343" height="67.5"/>
|
<rect key="frame" x="0.0" y="0.0" width="343" height="67.5"/>
|
||||||
<subviews>
|
<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">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Welcome to SideStore." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="EI2-V3-zQZ">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="343" height="41"/>
|
<rect key="frame" x="0.0" y="0.0" width="332" height="41"/>
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="34"/>
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="34"/>
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</stackView>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="250" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="DBk-rT-ZE8">
|
<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>
|
<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">
|
<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"/>
|
<rect key="frame" x="0.0" y="0.0" width="343" height="20.5"/>
|
||||||
@@ -198,10 +198,6 @@
|
|||||||
</stackView>
|
</stackView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<constraints>
|
<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"/>
|
<constraint firstItem="DBk-rT-ZE8" firstAttribute="top" relation="greaterThanOrEqual" secondItem="YmX-7v-pxh" secondAttribute="bottom" constant="8" symbolic="YES" id="zTU-eY-DWd"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
@@ -219,15 +215,19 @@
|
|||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="bottom" secondItem="WXx-hX-AXv" secondAttribute="bottom" id="0jL-Ky-ju6"/>
|
<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 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="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="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="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 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 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="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="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="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="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"/>
|
<constraint firstItem="2wp-qG-f0Z" firstAttribute="width" secondItem="oyW-Fd-ojD" secondAttribute="width" id="rYO-GN-0Lk"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
@@ -258,13 +258,13 @@
|
|||||||
<!--How it works-->
|
<!--How it works-->
|
||||||
<scene sceneID="dMt-EA-SGy">
|
<scene sceneID="dMt-EA-SGy">
|
||||||
<objects>
|
<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">
|
<view key="view" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" id="Otz-hn-WGS">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" axis="vertical" distribution="equalSpacing" translatesAutoresizingMaskIntoConstraints="NO" id="bp6-55-IG2">
|
<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>
|
<subviews>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="FjP-tm-w7K">
|
<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"/>
|
<rect key="frame" x="16" y="35" width="343" height="95.5"/>
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</stackView>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="LpI-Jt-SzX">
|
<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>
|
<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">
|
<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"/>
|
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
|
||||||
@@ -310,7 +310,7 @@
|
|||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="dMu-eg-gIO">
|
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="dMu-eg-gIO">
|
||||||
<rect key="frame" x="79" y="16" width="264" height="64"/>
|
<rect key="frame" x="79" y="17" width="264" height="61.5"/>
|
||||||
<subviews>
|
<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 Wi-Fi and VPN" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="esj-pD-D4A">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
||||||
@@ -318,8 +318,8 @@
|
|||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Enable LocalDevVPN and use Sidestore on the go." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="4rk-ge-FSj">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Enable SideStore VPN in Wireguard and be able to use Sidestore on the go." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="4rk-ge-FSj">
|
||||||
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
|
<rect key="frame" x="0.0" y="25.5" width="264" height="36"/>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
||||||
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
@@ -329,7 +329,7 @@
|
|||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</stackView>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="tfb-ja-9UC">
|
<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>
|
<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">
|
<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"/>
|
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
|
||||||
@@ -341,7 +341,7 @@
|
|||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="z6Y-zi-teL">
|
<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>
|
<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">
|
<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"/>
|
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
||||||
@@ -360,7 +360,7 @@
|
|||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</stackView>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="X3r-G1-vf2">
|
<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>
|
<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">
|
<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"/>
|
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
|
||||||
@@ -393,7 +393,7 @@
|
|||||||
</subviews>
|
</subviews>
|
||||||
<edgeInsets key="layoutMargins" top="35" left="0.0" bottom="35" right="0.0"/>
|
<edgeInsets key="layoutMargins" top="35" left="0.0" bottom="35" right="0.0"/>
|
||||||
</stackView>
|
</stackView>
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qZ9-AR-2zK">
|
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qZ9-AR-2zK">
|
||||||
<rect key="frame" x="16" y="608" width="343" height="51"/>
|
<rect key="frame" x="16" y="608" width="343" height="51"/>
|
||||||
<color key="backgroundColor" name="SettingsHighlighted"/>
|
<color key="backgroundColor" name="SettingsHighlighted"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
@@ -431,10 +431,10 @@
|
|||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="1353" y="736"/>
|
<point key="canvasLocation" x="1353" y="736"/>
|
||||||
</scene>
|
</scene>
|
||||||
<!--Refresh SideStore-->
|
<!--Refresh AltStore-->
|
||||||
<scene sceneID="9Vh-dM-OqX">
|
<scene sceneID="9Vh-dM-OqX">
|
||||||
<objects>
|
<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">
|
<view key="view" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" id="R83-kV-365">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<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">
|
<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"/>
|
<rect key="frame" x="16" y="570" width="343" height="89"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="xcg-hT-tDe" customClass="PillButton" customModule="SideStore" customModuleProvider="target">
|
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="xcg-hT-tDe" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="343" height="51"/>
|
<rect key="frame" x="0.0" y="0.0" width="343" height="51"/>
|
||||||
<color key="backgroundColor" name="SettingsHighlighted"/>
|
<color key="backgroundColor" name="SettingsHighlighted"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
<action selector="refreshAltStore:" destination="aoK-yE-UVT" eventType="primaryActionTriggered" id="WQu-9b-Zgg"/>
|
<action selector="refreshAltStore:" destination="aoK-yE-UVT" eventType="primaryActionTriggered" id="WQu-9b-Zgg"/>
|
||||||
</connections>
|
</connections>
|
||||||
</button>
|
</button>
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qua-VA-asJ">
|
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qua-VA-asJ">
|
||||||
<rect key="frame" x="0.0" y="59" width="343" height="30"/>
|
<rect key="frame" x="0.0" y="59" width="343" height="30"/>
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="15"/>
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="15"/>
|
||||||
<state key="normal" title="Refresh Later">
|
<state key="normal" title="Refresh Later">
|
||||||
@@ -485,7 +485,7 @@
|
|||||||
<constraint firstItem="tDQ-ao-1Jg" firstAttribute="leading" secondItem="R83-kV-365" secondAttribute="leadingMargin" id="zEt-Xr-kJx"/>
|
<constraint firstItem="tDQ-ao-1Jg" firstAttribute="leading" secondItem="R83-kV-365" secondAttribute="leadingMargin" id="zEt-Xr-kJx"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</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"/>
|
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
|
||||||
<connections>
|
<connections>
|
||||||
<outlet property="placeholderView" destination="fpO-Bf-gFY" id="q7d-au-d94"/>
|
<outlet property="placeholderView" destination="fpO-Bf-gFY" id="q7d-au-d94"/>
|
||||||
@@ -493,12 +493,12 @@
|
|||||||
</viewController>
|
</viewController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="Chr-7g-qEw" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="Chr-7g-qEw" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="3025" y="734"/>
|
<point key="canvasLocation" x="2967" y="736"/>
|
||||||
</scene>
|
</scene>
|
||||||
<!--Select a Team-->
|
<!--Select a Team-->
|
||||||
<scene sceneID="ioQ-WB-CLJ">
|
<scene sceneID="ioQ-WB-CLJ">
|
||||||
<objects>
|
<objects>
|
||||||
<viewController storyboardIdentifier="selectTeamViewController" hidesBottomBarWhenPushed="YES" id="kOD-4P-a6L" customClass="SelectTeamViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
<viewController storyboardIdentifier="selectTeamViewController" hidesBottomBarWhenPushed="YES" id="kOD-4P-a6L" customClass="SelectTeamViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||||
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" indicatorStyle="white" dataMode="prototypes" style="grouped" separatorStyle="none" rowHeight="60" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="fWW-kX-ifH">
|
<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"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
@@ -506,11 +506,11 @@
|
|||||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<color key="separatorColor" white="1" alpha="0.25" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="separatorColor" white="1" alpha="0.25" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<prototypes>
|
<prototypes>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" reuseIdentifier="TeamCell" textLabel="6ip-34-gmM" detailTextLabel="knk-Wf-PKf" style="IBUITableViewCellStyleSubtitle" id="qeQ-eb-2SC" customClass="InsetGroupTableViewCell" customModule="SideStore" customModuleProvider="target">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" reuseIdentifier="TeamCell" textLabel="6ip-34-gmM" detailTextLabel="knk-Wf-PKf" style="IBUITableViewCellStyleSubtitle" id="qeQ-eb-2SC" customClass="InsetGroupTableViewCell" customModule="AltStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="55.5" width="375" height="60"/>
|
<rect key="frame" x="0.0" y="55.5" width="375" height="60"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<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">
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="qeQ-eb-2SC" id="bT4-Fc-u6I">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="334.5" height="60"/>
|
<rect key="frame" x="0.0" y="0.0" width="334" height="60"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<subviews>
|
<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">
|
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Team 1" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="6ip-34-gmM">
|
||||||
@@ -550,19 +550,20 @@
|
|||||||
</viewController>
|
</viewController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="yH5-jU-aez" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="yH5-jU-aez" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="2114" y="734"/>
|
<point key="canvasLocation" x="1401" y="734"/>
|
||||||
|
|
||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
<color key="tintColor" name="Primary"/>
|
<color key="tintColor" name="Primary"/>
|
||||||
<resources>
|
<resources>
|
||||||
<namedColor name="Primary">
|
<namedColor name="Primary">
|
||||||
<color red="0.64313725490196083" green="0.019607843137254902" blue="0.98039215686274506" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color red="0.0040000001899898052" green="0.50199997425079346" blue="0.51800000667572021" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
</namedColor>
|
</namedColor>
|
||||||
<namedColor name="SettingsBackground">
|
<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>
|
||||||
<namedColor name="SettingsHighlighted">
|
<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>
|
</namedColor>
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
|
|||||||
@@ -31,21 +31,7 @@ final class AuthenticationViewController: UIViewController
|
|||||||
{
|
{
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
// fetch anisette servers asap when loading Auth Screen (if list is empty
|
|
||||||
if(UserDefaults.standard.menuAnisetteServersList.isEmpty){
|
|
||||||
Task{
|
|
||||||
let sourceURL = UserDefaults.standard.menuAnisetteList
|
|
||||||
do{
|
|
||||||
_ = try await AnisetteViewModel.getListOfServers(serverSource: sourceURL)
|
|
||||||
print("AuthenticationViewController: Server list refresh request completed for sourceURL: \(sourceURL)")
|
|
||||||
}catch{
|
|
||||||
print("AuthenticationViewController: Server list refresh request Failed for sourceURL: \(sourceURL) Error: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.signInButton.activityIndicatorView.style = .medium
|
self.signInButton.activityIndicatorView.style = .medium
|
||||||
self.signInButton.activityIndicatorView.color = .white
|
|
||||||
|
|
||||||
for view in [self.appleIDBackgroundView!, self.passwordBackgroundView!, self.signInButton!]
|
for view in [self.appleIDBackgroundView!, self.passwordBackgroundView!, self.signInButton!]
|
||||||
{
|
{
|
||||||
@@ -122,12 +108,12 @@ private extension AuthenticationViewController
|
|||||||
|
|
||||||
case .failure(let error as NSError):
|
case .failure(let error as NSError):
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
let error = error.withLocalizedTitle(NSLocalizedString("Failed to Log In", comment: ""))
|
let error = error.withLocalizedFailure(NSLocalizedString("Failed to Log In", comment: ""))
|
||||||
|
|
||||||
let toastView = ToastView(error: error)
|
let toastView = ToastView(error: error)
|
||||||
|
toastView.textLabel.textColor = .altPink
|
||||||
|
toastView.detailTextLabel.textColor = .altPink
|
||||||
toastView.show(in: self)
|
toastView.show(in: self)
|
||||||
toastView.backgroundColor = .white
|
|
||||||
toastView.textLabel.textColor = .altPrimary
|
|
||||||
toastView.detailTextLabel.textColor = .altPrimary
|
|
||||||
self.toastView = toastView
|
self.toastView = toastView
|
||||||
|
|
||||||
self.signInButton.isIndicatingActivity = false
|
self.signInButton.isIndicatingActivity = false
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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="21223" 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"/>
|
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<deployment identifier="iOS"/>
|
<deployment identifier="iOS"/>
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21204"/>
|
||||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
<capability name="System colors in document resources" minToolsVersion="11.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"/>
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<scenes>
|
<scenes>
|
||||||
@@ -37,11 +36,11 @@
|
|||||||
</tabBar>
|
</tabBar>
|
||||||
<connections>
|
<connections>
|
||||||
<segue destination="kjR-gi-fgT" kind="relationship" relationship="viewControllers" id="eWy-uk-nwG"/>
|
<segue destination="kjR-gi-fgT" kind="relationship" relationship="viewControllers" id="eWy-uk-nwG"/>
|
||||||
|
<segue destination="faz-B4-Sub" kind="relationship" relationship="viewControllers" id="dXz-Tu-hW8"/>
|
||||||
|
<segue destination="3Ew-ox-i4n" kind="relationship" relationship="viewControllers" id="zii-dF-qEt"/>
|
||||||
|
<segue destination="p3d-dP-Swg" kind="relationship" relationship="viewControllers" id="4Nf-rL-P4c"/>
|
||||||
|
<segue destination="Qo4-72-Hmr" kind="presentation" identifier="presentSources" id="Qd6-ba-dIo"/>
|
||||||
<segue destination="bTL-bY-9Yq" kind="presentation" identifier="finishJailbreak" id="cIc-Ta-uNk"/>
|
<segue destination="bTL-bY-9Yq" kind="presentation" identifier="finishJailbreak" id="cIc-Ta-uNk"/>
|
||||||
<segue destination="HCK-G6-KdY" kind="relationship" relationship="viewControllers" id="X0t-T6-JeA"/>
|
|
||||||
<segue destination="faz-B4-Sub" kind="relationship" relationship="viewControllers" id="OLu-kM-z1J"/>
|
|
||||||
<segue destination="3Ew-ox-i4n" kind="relationship" relationship="viewControllers" id="phQ-Pc-pqw"/>
|
|
||||||
<segue destination="p3d-dP-Swg" kind="relationship" relationship="viewControllers" id="cQE-Az-fdo"/>
|
|
||||||
</connections>
|
</connections>
|
||||||
</tabBarController>
|
</tabBarController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="HuB-VB-40B" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="HuB-VB-40B" sceneMemberID="firstResponder"/>
|
||||||
@@ -51,7 +50,7 @@
|
|||||||
<!--Browse-->
|
<!--Browse-->
|
||||||
<scene sceneID="rXq-UR-qQp">
|
<scene sceneID="rXq-UR-qQp">
|
||||||
<objects>
|
<objects>
|
||||||
<collectionViewController storyboardIdentifier="browseViewController" id="e3L-BF-iXp" customClass="BrowseViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
<collectionViewController id="e3L-BF-iXp" customClass="BrowseViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||||
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" dataMode="prototypes" id="CaT-1q-qnx">
|
<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"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
@@ -68,11 +67,20 @@
|
|||||||
<outlet property="delegate" destination="e3L-BF-iXp" id="Mdp-x4-hZe"/>
|
<outlet property="delegate" destination="e3L-BF-iXp" id="Mdp-x4-hZe"/>
|
||||||
</connections>
|
</connections>
|
||||||
</collectionView>
|
</collectionView>
|
||||||
<navigationItem key="navigationItem" title="Browse" id="pUx-6k-rtr"/>
|
<navigationItem key="navigationItem" title="Browse" id="pUx-6k-rtr">
|
||||||
|
<barButtonItem key="rightBarButtonItem" title="Sources" id="6Ul-JW-TMT">
|
||||||
|
<connections>
|
||||||
|
<segue destination="Qo4-72-Hmr" kind="presentation" id="de9-NH-aec"/>
|
||||||
|
</connections>
|
||||||
|
</barButtonItem>
|
||||||
|
</navigationItem>
|
||||||
|
<connections>
|
||||||
|
<outlet property="sourcesBarButtonItem" destination="6Ul-JW-TMT" id="99s-O4-OpX"/>
|
||||||
|
</connections>
|
||||||
</collectionViewController>
|
</collectionViewController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="cMN-i4-Bxk" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="cMN-i4-Bxk" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="2730" y="-373"/>
|
<point key="canvasLocation" x="1730" y="-17"/>
|
||||||
</scene>
|
</scene>
|
||||||
<!--App View Controller-->
|
<!--App View Controller-->
|
||||||
<scene sceneID="TgT-LO-3Er">
|
<scene sceneID="TgT-LO-3Er">
|
||||||
@@ -216,7 +224,7 @@
|
|||||||
</viewController>
|
</viewController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="C9o-C3-sMK" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="C9o-C3-sMK" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="2730" y="439"/>
|
<point key="canvasLocation" x="2525.5999999999999" y="-17.541229385307346"/>
|
||||||
</scene>
|
</scene>
|
||||||
<!--App-->
|
<!--App-->
|
||||||
<scene sceneID="CgX-7h-sRI">
|
<scene sceneID="CgX-7h-sRI">
|
||||||
@@ -254,40 +262,51 @@
|
|||||||
<inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
|
<inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
|
||||||
</tableViewCell>
|
</tableViewCell>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="nI6-wC-H2d">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="nI6-wC-H2d">
|
||||||
<rect key="frame" x="0.0" y="107" width="375" height="300"/>
|
<rect key="frame" x="0.0" y="107" width="375" height="44"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<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">
|
<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"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<containerView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5yj-Nb-f5H">
|
<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="300"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
|
||||||
<constraints>
|
<color key="backgroundColor" name="Background"/>
|
||||||
<constraint firstAttribute="height" priority="999" constant="300" id="dpf-ba-NNr"/>
|
<collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" minimumLineSpacing="10" minimumInteritemSpacing="15" id="ace-Ns-Jd2">
|
||||||
</constraints>
|
<size key="itemSize" width="189" height="406"/>
|
||||||
<connections>
|
<size key="headerReferenceSize" width="0.0" height="0.0"/>
|
||||||
<segue destination="nX2-hQ-qjX" kind="embed" destinationCreationSelector="makeAppScreenshotsViewController:sender:" id="VxG-Pu-Kf1"/>
|
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
||||||
</connections>
|
<inset key="sectionInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
|
||||||
</containerView>
|
</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>
|
</subviews>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="trailing" secondItem="5yj-Nb-f5H" secondAttribute="trailing" id="2DI-44-pC1"/>
|
<constraint firstAttribute="trailing" secondItem="ppk-lL-at8" secondAttribute="trailing" id="3QR-Y2-v26"/>
|
||||||
<constraint firstItem="5yj-Nb-f5H" firstAttribute="top" secondItem="Z4y-vb-Z4Q" secondAttribute="top" id="URh-5T-73x"/>
|
<constraint firstAttribute="bottom" secondItem="ppk-lL-at8" secondAttribute="bottom" id="EgJ-Uw-5ta"/>
|
||||||
<constraint firstAttribute="bottom" secondItem="5yj-Nb-f5H" secondAttribute="bottom" id="Yb6-aZ-qNF"/>
|
<constraint firstItem="ppk-lL-at8" firstAttribute="leading" secondItem="Z4y-vb-Z4Q" secondAttribute="leading" id="wHf-S9-gMV"/>
|
||||||
<constraint firstItem="5yj-Nb-f5H" firstAttribute="leading" secondItem="Z4y-vb-Z4Q" secondAttribute="leading" id="rpG-Ip-qZU"/>
|
<constraint firstItem="ppk-lL-at8" firstAttribute="top" secondItem="Z4y-vb-Z4Q" secondAttribute="top" id="xY5-w8-roA"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</tableViewCellContentView>
|
</tableViewCellContentView>
|
||||||
<color key="backgroundColor" name="Background"/>
|
<color key="backgroundColor" name="Background"/>
|
||||||
<inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
|
<inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
|
||||||
</tableViewCell>
|
</tableViewCell>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="EL5-UC-RIw" customClass="AppContentTableViewCell" customModule="SideStore" customModuleProvider="target">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="EL5-UC-RIw" customClass="AppContentTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="407" width="375" height="98"/>
|
<rect key="frame" x="0.0" y="151" width="375" height="98"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<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">
|
<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"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="98"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" text="Hello Me" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="Pyt-8D-BZA" customClass="CollapsingMarkdownView" customModule="SideStore" customModuleProvider="target">
|
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" text="Hello Me" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="Pyt-8D-BZA" customClass="CollapsingTextView" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="20" y="20" width="335" height="34"/>
|
<rect key="frame" x="20" y="20" width="335" height="34"/>
|
||||||
<color key="backgroundColor" name="Background"/>
|
<color key="backgroundColor" name="Background"/>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="15"/>
|
<fontDescription key="fontDescription" type="system" pointSize="15"/>
|
||||||
@@ -305,7 +324,7 @@
|
|||||||
<inset key="separatorInset" minX="1000" minY="0.0" maxX="0.0" maxY="0.0"/>
|
<inset key="separatorInset" minX="1000" minY="0.0" maxX="0.0" maxY="0.0"/>
|
||||||
</tableViewCell>
|
</tableViewCell>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="47M-El-a4G" customClass="AppContentTableViewCell" customModule="SideStore" customModuleProvider="target">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="47M-El-a4G" customClass="AppContentTableViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="505" width="375" height="137.5"/>
|
<rect key="frame" x="0.0" y="249" width="375" height="137.5"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<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">
|
<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="137.5"/>
|
||||||
@@ -353,7 +372,7 @@
|
|||||||
</stackView>
|
</stackView>
|
||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</stackView>
|
||||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="wQF-WY-Gdz" customClass="CollapsingMarkdownView" customModule="SideStore" customModuleProvider="target">
|
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="wQF-WY-Gdz" customClass="CollapsingTextView" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="20" y="59.5" width="335" height="34"/>
|
<rect key="frame" x="20" y="59.5" width="335" height="34"/>
|
||||||
<color key="backgroundColor" name="Background"/>
|
<color key="backgroundColor" name="Background"/>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="15"/>
|
<fontDescription key="fontDescription" type="system" pointSize="15"/>
|
||||||
@@ -373,35 +392,83 @@
|
|||||||
<color key="backgroundColor" name="Background"/>
|
<color key="backgroundColor" name="Background"/>
|
||||||
<inset key="separatorInset" minX="1000" minY="0.0" maxX="0.0" maxY="0.0"/>
|
<inset key="separatorInset" minX="1000" minY="0.0" maxX="0.0" maxY="0.0"/>
|
||||||
</tableViewCell>
|
</tableViewCell>
|
||||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" rowHeight="300" id="nM7-vJ-W8b">
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" rowHeight="149" id="nM7-vJ-W8b">
|
||||||
<rect key="frame" x="0.0" y="642.5" width="375" height="300"/>
|
<rect key="frame" x="0.0" y="386.5" width="375" height="149"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<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">
|
<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"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" distribution="equalSpacing" alignment="center" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="Jvb-r8-XrY">
|
<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>
|
<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">
|
<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"/>
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="22"/>
|
||||||
<nil key="textColor"/>
|
<nil key="textColor"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<containerView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wus-dU-ZqZ">
|
<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="80" width="375" height="200"/>
|
<rect key="frame" x="0.0" y="41" width="375" height="88"/>
|
||||||
|
<color key="backgroundColor" name="Background"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="height" constant="200" id="HFx-PP-dAt"/>
|
<constraint firstAttribute="height" priority="999" constant="88" id="6Lk-OO-MsA"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
<connections>
|
<collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" minimumLineSpacing="10" minimumInteritemSpacing="10" id="2HF-4d-3Im">
|
||||||
<segue destination="OYP-I1-A3i" kind="embed" destinationCreationSelector="makeAppDetailCollectionViewController:sender:" id="Uxh-GM-nzb"/>
|
<size key="itemSize" width="60" height="88"/>
|
||||||
</connections>
|
<size key="headerReferenceSize" width="0.0" height="0.0"/>
|
||||||
</containerView>
|
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
||||||
|
<inset key="sectionInset" minX="20" minY="0.0" maxX="20" maxY="0.0"/>
|
||||||
|
</collectionViewFlowLayout>
|
||||||
|
<cells>
|
||||||
|
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="Cell" id="WYy-bZ-h3T" customClass="PermissionCollectionViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
|
<rect key="frame" x="20" y="0.0" width="60" height="88"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||||
|
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="60" height="88"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<subviews>
|
||||||
|
<stackView verifyAmbiguity="off" opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="center" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="fSx-We-L4W">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="60" height="87.5"/>
|
||||||
|
<subviews>
|
||||||
|
<button opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="79g-9q-mE2">
|
||||||
|
<rect key="frame" x="5" y="0.0" width="50" height="50"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstAttribute="width" constant="50" id="0LZ-4n-COH"/>
|
||||||
|
<constraint firstAttribute="height" constant="50" id="keD-mf-Rga"/>
|
||||||
|
</constraints>
|
||||||
|
</button>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" textAlignment="center" lineBreakMode="wordWrap" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="pQi-FD-18P">
|
||||||
|
<rect key="frame" x="12.5" y="56" width="35.5" height="31.5"/>
|
||||||
|
<string key="text">Hello
|
||||||
|
World</string>
|
||||||
|
<fontDescription key="fontDescription" type="system" pointSize="13"/>
|
||||||
|
<nil key="textColor"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
</subviews>
|
||||||
|
</stackView>
|
||||||
|
</subviews>
|
||||||
|
</view>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstAttribute="trailing" secondItem="fSx-We-L4W" secondAttribute="trailing" id="IyD-vD-tA4"/>
|
||||||
|
<constraint firstItem="fSx-We-L4W" firstAttribute="leading" secondItem="WYy-bZ-h3T" secondAttribute="leading" id="bTq-op-ivD"/>
|
||||||
|
<constraint firstItem="fSx-We-L4W" firstAttribute="top" secondItem="WYy-bZ-h3T" secondAttribute="top" id="sMw-NS-jtY"/>
|
||||||
|
</constraints>
|
||||||
|
<connections>
|
||||||
|
<outlet property="button" destination="79g-9q-mE2" id="G5V-SS-vaA"/>
|
||||||
|
<outlet property="textLabel" destination="pQi-FD-18P" id="D5d-20-cm3"/>
|
||||||
|
<segue destination="Ojq-DN-xcF" kind="popoverPresentation" identifier="showPermission" popoverAnchorView="r8T-dj-wQX" id="ftM-H7-Q7G">
|
||||||
|
<popoverArrowDirection key="popoverArrowDirection" down="YES"/>
|
||||||
|
</segue>
|
||||||
|
</connections>
|
||||||
|
</collectionViewCell>
|
||||||
|
</cells>
|
||||||
|
</collectionView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="dj7-G8-GFv" firstAttribute="leading" secondItem="Jvb-r8-XrY" secondAttribute="leading" constant="20" id="9pB-Md-91A"/>
|
<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>
|
</constraints>
|
||||||
<edgeInsets key="layoutMargins" top="0.0" left="0.0" bottom="20" right="0.0"/>
|
<edgeInsets key="layoutMargins" top="0.0" left="0.0" bottom="20" right="0.0"/>
|
||||||
</stackView>
|
</stackView>
|
||||||
@@ -427,9 +494,9 @@
|
|||||||
<navigationItem key="navigationItem" title="App" largeTitleDisplayMode="never" id="sWo-Y8-aF6"/>
|
<navigationItem key="navigationItem" title="App" largeTitleDisplayMode="never" id="sWo-Y8-aF6"/>
|
||||||
<size key="freeformSize" width="375" height="667"/>
|
<size key="freeformSize" width="375" height="667"/>
|
||||||
<connections>
|
<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="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="sizeLabel" destination="DgM-bD-bBY" id="Oky-ax-u20"/>
|
||||||
<outlet property="subtitleLabel" destination="BsL-O2-UjD" id="cfe-cf-4a9"/>
|
<outlet property="subtitleLabel" destination="BsL-O2-UjD" id="cfe-cf-4a9"/>
|
||||||
<outlet property="versionDateLabel" destination="wGD-mS-8fO" id="icB-lC-g9x"/>
|
<outlet property="versionDateLabel" destination="wGD-mS-8fO" id="icB-lC-g9x"/>
|
||||||
@@ -439,52 +506,52 @@
|
|||||||
</tableViewController>
|
</tableViewController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dhh-ZN-LoG" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="dhh-ZN-LoG" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="3506" y="437"/>
|
<point key="canvasLocation" x="3302" y="-18"/>
|
||||||
</scene>
|
</scene>
|
||||||
<!--App Screenshots View Controller-->
|
<!--Permission Popover View Controller-->
|
||||||
<scene sceneID="E6k-TI-c4N">
|
<scene sceneID="24j-EJ-G4e">
|
||||||
<objects>
|
<objects>
|
||||||
<collectionViewController storyboardIdentifier="appScreenshotsViewController" id="nX2-hQ-qjX" customClass="AppScreenshotsViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
<viewController id="Ojq-DN-xcF" customClass="PermissionPopoverViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||||
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" contentInsetAdjustmentBehavior="never" dataMode="prototypes" id="zXl-if-KtH">
|
<view key="view" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="IgU-aM-YrX">
|
||||||
<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="217"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
<subviews>
|
||||||
<collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" automaticEstimatedItemSize="YES" minimumLineSpacing="10" minimumInteritemSpacing="10" id="MGS-YY-5g9">
|
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="xnC-tS-ZdV">
|
||||||
<size key="itemSize" width="150" height="300"/>
|
<rect key="frame" x="20" y="10" width="335" height="197"/>
|
||||||
<size key="headerReferenceSize" width="0.0" height="0.0"/>
|
<subviews>
|
||||||
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
<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">
|
||||||
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
|
<rect key="frame" x="0.0" y="0.0" width="335" height="17"/>
|
||||||
</collectionViewFlowLayout>
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
|
||||||
<cells/>
|
<nil key="textColor"/>
|
||||||
<connections>
|
<nil key="highlightedColor"/>
|
||||||
<outlet property="dataSource" destination="nX2-hQ-qjX" id="QRj-01-ddR"/>
|
</label>
|
||||||
<outlet property="delegate" destination="nX2-hQ-qjX" id="Ha5-Xa-Q6e"/>
|
<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">
|
||||||
</connections>
|
<rect key="frame" x="0.0" y="21" width="335" height="176"/>
|
||||||
</collectionView>
|
<fontDescription key="fontDescription" type="system" pointSize="13"/>
|
||||||
</collectionViewController>
|
<nil key="textColor"/>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="np0-Hj-vy7" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
</subviews>
|
||||||
|
<edgeInsets key="layoutMargins" top="0.0" left="0.0" bottom="0.0" right="0.0"/>
|
||||||
|
</stackView>
|
||||||
|
</subviews>
|
||||||
|
<viewLayoutGuide key="safeArea" id="c7x-ee-3HH"/>
|
||||||
|
<color key="backgroundColor" systemColor="tertiarySystemBackgroundColor"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="xnC-tS-ZdV" firstAttribute="leading" secondItem="c7x-ee-3HH" secondAttribute="leading" constant="20" id="LO8-Au-SYF"/>
|
||||||
|
<constraint firstItem="c7x-ee-3HH" firstAttribute="bottom" secondItem="xnC-tS-ZdV" secondAttribute="bottom" constant="10" id="NZ9-iG-E10"/>
|
||||||
|
<constraint firstItem="c7x-ee-3HH" firstAttribute="trailing" secondItem="xnC-tS-ZdV" secondAttribute="trailing" constant="20" id="ZkD-tb-mBf"/>
|
||||||
|
<constraint firstItem="xnC-tS-ZdV" firstAttribute="top" secondItem="c7x-ee-3HH" secondAttribute="top" constant="10" id="oKq-9e-DtW"/>
|
||||||
|
</constraints>
|
||||||
|
</view>
|
||||||
|
<connections>
|
||||||
|
<outlet property="descriptionLabel" destination="ErG-8A-uqY" id="iuN-kE-IEm"/>
|
||||||
|
<outlet property="nameLabel" destination="4fh-lO-rAn" id="GWh-7k-yWw"/>
|
||||||
|
</connections>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="7Tu-x9-xBb" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="4302" y="20"/>
|
<point key="canvasLocation" x="4257" y="-412"/>
|
||||||
</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"/>
|
|
||||||
</scene>
|
</scene>
|
||||||
<!--Settings-->
|
<!--Settings-->
|
||||||
<scene sceneID="KlD-j0-ROn">
|
<scene sceneID="KlD-j0-ROn">
|
||||||
@@ -494,7 +561,7 @@
|
|||||||
</viewControllerPlaceholder>
|
</viewControllerPlaceholder>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="HgE-PD-dC2" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="HgE-PD-dC2" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="233" y="550"/>
|
<point key="canvasLocation" x="962" y="1197"/>
|
||||||
</scene>
|
</scene>
|
||||||
<!--News-->
|
<!--News-->
|
||||||
<scene sceneID="bqw-wB-hyB">
|
<scene sceneID="bqw-wB-hyB">
|
||||||
@@ -529,14 +596,13 @@
|
|||||||
<tabBarItem key="tabBarItem" title="Browse" image="Browse" id="Uwh-Bg-Ymq"/>
|
<tabBarItem key="tabBarItem" title="Browse" image="Browse" id="Uwh-Bg-Ymq"/>
|
||||||
<toolbarItems/>
|
<toolbarItems/>
|
||||||
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="dIv-qd-9L5" customClass="NavigationBar" customModule="SideStore" customModuleProvider="target">
|
<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"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="96"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<color key="tintColor" name="Primary"/>
|
<color key="tintColor" name="Primary"/>
|
||||||
<edgeInsets key="layoutMargins" top="8" left="20" bottom="8" right="8"/>
|
|
||||||
</navigationBar>
|
</navigationBar>
|
||||||
<nil name="viewControllers"/>
|
<nil name="viewControllers"/>
|
||||||
<connections>
|
<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>
|
</connections>
|
||||||
</navigationController>
|
</navigationController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="OkH-49-O0J" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="OkH-49-O0J" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
@@ -549,7 +615,7 @@
|
|||||||
<viewControllerPlaceholder storyboardName="PatchApp" id="bTL-bY-9Yq" sceneMemberID="viewController"/>
|
<viewControllerPlaceholder storyboardName="PatchApp" id="bTL-bY-9Yq" sceneMemberID="viewController"/>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="NyZ-z6-R2q" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="NyZ-z6-R2q" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="-228" y="551"/>
|
<point key="canvasLocation" x="-1" y="545"/>
|
||||||
</scene>
|
</scene>
|
||||||
<!--My Apps-->
|
<!--My Apps-->
|
||||||
<scene sceneID="nhh-BJ-XiT">
|
<scene sceneID="nhh-BJ-XiT">
|
||||||
@@ -560,9 +626,8 @@
|
|||||||
</tabBarItem>
|
</tabBarItem>
|
||||||
<toolbarItems/>
|
<toolbarItems/>
|
||||||
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="CzO-Kt-BlZ" customClass="NavigationBar" customModule="SideStore" customModuleProvider="target">
|
<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"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="96"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<edgeInsets key="layoutMargins" top="8" left="20" bottom="8" right="8"/>
|
|
||||||
</navigationBar>
|
</navigationBar>
|
||||||
<nil name="viewControllers"/>
|
<nil name="viewControllers"/>
|
||||||
<connections>
|
<connections>
|
||||||
@@ -639,19 +704,12 @@
|
|||||||
<color key="textColor" name="Primary"/>
|
<color key="textColor" name="Primary"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</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>
|
</subviews>
|
||||||
<constraints>
|
<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 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 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="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="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>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
<vibrancyEffect style="secondaryLabel">
|
<vibrancyEffect style="secondaryLabel">
|
||||||
@@ -679,8 +737,6 @@
|
|||||||
</constraints>
|
</constraints>
|
||||||
<connections>
|
<connections>
|
||||||
<outlet property="blurView" destination="7iO-O4-Mr9" id="kQ4-9N-nnv"/>
|
<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>
|
</connections>
|
||||||
</collectionViewCell>
|
</collectionViewCell>
|
||||||
</cells>
|
</cells>
|
||||||
@@ -709,7 +765,7 @@
|
|||||||
</stackView>
|
</stackView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="bottom" secondItem="GFQ-Wy-Qhy" secondAttribute="bottom" priority="999" constant="8" id="HGl-P6-G2v"/>
|
<constraint firstAttribute="bottom" secondItem="GFQ-Wy-Qhy" secondAttribute="bottom" constant="8" id="HGl-P6-G2v"/>
|
||||||
<constraint firstItem="GFQ-Wy-Qhy" firstAttribute="top" secondItem="HYs-co-nJZ" secondAttribute="top" id="gg9-XU-2ej"/>
|
<constraint firstItem="GFQ-Wy-Qhy" firstAttribute="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 firstItem="GFQ-Wy-Qhy" firstAttribute="centerX" secondItem="HYs-co-nJZ" secondAttribute="centerX" id="vyo-h4-yD9"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
@@ -736,56 +792,7 @@
|
|||||||
</collectionViewController>
|
</collectionViewController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="kiO-UO-esV" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="kiO-UO-esV" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="1729" y="716"/>
|
<point key="canvasLocation" x="1728.8" y="716.49175412293857"/>
|
||||||
</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>
|
</scene>
|
||||||
<!--App IDs-->
|
<!--App IDs-->
|
||||||
<scene sceneID="kvf-US-rRe">
|
<scene sceneID="kvf-US-rRe">
|
||||||
@@ -802,22 +809,30 @@
|
|||||||
<inset key="sectionInset" minX="0.0" minY="10" maxX="0.0" maxY="20"/>
|
<inset key="sectionInset" minX="0.0" minY="10" maxX="0.0" maxY="20"/>
|
||||||
</collectionViewFlowLayout>
|
</collectionViewFlowLayout>
|
||||||
<cells>
|
<cells>
|
||||||
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" reuseIdentifier="Cell" id="XWu-DU-xbh" customClass="AppBannerCollectionViewCell" customModule="SideStore" customModuleProvider="target">
|
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" reuseIdentifier="Cell" id="XWu-DU-xbh" customClass="BannerCollectionViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="70" width="375" height="80"/>
|
<rect key="frame" x="0.0" y="70" width="375" height="80"/>
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
|
<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"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="80"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="1w8-fI-98T" customClass="AppBannerView" customModule="SideStore" customModuleProvider="target">
|
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="1w8-fI-98T" customClass="AppBannerView" customModule="SideStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="8" y="0.0" width="359" height="80"/>
|
<rect key="frame" x="8" y="0.0" width="359" height="80"/>
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
|
||||||
<accessibility key="accessibilityConfiguration">
|
<accessibility key="accessibilityConfiguration">
|
||||||
<bool key="isElement" value="YES"/>
|
<bool key="isElement" value="YES"/>
|
||||||
</accessibility>
|
</accessibility>
|
||||||
</view>
|
</view>
|
||||||
</subviews>
|
</subviews>
|
||||||
</view>
|
</view>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstAttribute="trailingMargin" secondItem="1w8-fI-98T" secondAttribute="trailing" id="0bS-49-dqo"/>
|
||||||
|
<constraint firstAttribute="bottom" secondItem="1w8-fI-98T" secondAttribute="bottom" id="Bif-xB-0gt"/>
|
||||||
|
<constraint firstItem="1w8-fI-98T" firstAttribute="top" secondItem="XWu-DU-xbh" secondAttribute="top" id="aEf-KK-MHU"/>
|
||||||
|
<constraint firstItem="1w8-fI-98T" firstAttribute="leading" secondItem="XWu-DU-xbh" secondAttribute="leadingMargin" id="mFW-ti-cVB"/>
|
||||||
|
</constraints>
|
||||||
|
<connections>
|
||||||
|
<outlet property="bannerView" destination="1w8-fI-98T" id="OH8-L9-TZn"/>
|
||||||
|
</connections>
|
||||||
</collectionViewCell>
|
</collectionViewCell>
|
||||||
</cells>
|
</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">
|
<collectionReusableView key="sectionHeaderView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="Header" id="th0-G6-bRt" customClass="TextCollectionReusableView" customModule="SideStore" customModuleProvider="target">
|
||||||
@@ -866,9 +881,9 @@
|
|||||||
</connections>
|
</connections>
|
||||||
</collectionView>
|
</collectionView>
|
||||||
<navigationItem key="navigationItem" title="App IDs" id="3Co-uv-Fhb">
|
<navigationItem key="navigationItem" title="App IDs" id="3Co-uv-Fhb">
|
||||||
<barButtonItem key="leftBarButtonItem" id="Aqs-QK-Ups">
|
<barButtonItem key="leftBarButtonItem" style="plain" id="Aqs-QK-Ups">
|
||||||
<view key="customView" contentMode="scaleToFill" id="p0q-Fg-3Ba">
|
<view key="customView" contentMode="scaleToFill" id="p0q-Fg-3Ba">
|
||||||
<rect key="frame" x="16" y="7" width="83" height="42"/>
|
<rect key="frame" x="16" y="1" width="83" height="42"/>
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||||
</view>
|
</view>
|
||||||
</barButtonItem>
|
</barButtonItem>
|
||||||
@@ -885,7 +900,7 @@
|
|||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="Lvd-jC-AZO" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="Lvd-jC-AZO" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||||
<exit id="eS1-sQ-VUA" userLabel="Exit" sceneMemberID="exit"/>
|
<exit id="eS1-sQ-VUA" userLabel="Exit" sceneMemberID="exit"/>
|
||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="3506" y="1121"/>
|
<point key="canvasLocation" x="3301.5999999999999" y="715.59220389805103"/>
|
||||||
</scene>
|
</scene>
|
||||||
<!--News-->
|
<!--News-->
|
||||||
<scene sceneID="BV8-6J-nIv">
|
<scene sceneID="BV8-6J-nIv">
|
||||||
@@ -894,7 +909,7 @@
|
|||||||
<tabBarItem key="tabBarItem" title="News" image="News" id="fVN-ed-uO1"/>
|
<tabBarItem key="tabBarItem" title="News" image="News" id="fVN-ed-uO1"/>
|
||||||
<toolbarItems/>
|
<toolbarItems/>
|
||||||
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="525-jF-uDK" customClass="NavigationBar" customModule="SideStore" customModuleProvider="target">
|
<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"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="96"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<edgeInsets key="layoutMargins" top="8" left="20" bottom="8" right="8"/>
|
<edgeInsets key="layoutMargins" top="8" left="20" bottom="8" right="8"/>
|
||||||
</navigationBar>
|
</navigationBar>
|
||||||
@@ -913,9 +928,8 @@
|
|||||||
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="IXk-qg-mFJ" sceneMemberID="viewController">
|
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="IXk-qg-mFJ" sceneMemberID="viewController">
|
||||||
<toolbarItems/>
|
<toolbarItems/>
|
||||||
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="9sB-f3-Fnk">
|
<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"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="96"/>
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
<edgeInsets key="layoutMargins" top="8" left="20" bottom="8" right="8"/>
|
|
||||||
</navigationBar>
|
</navigationBar>
|
||||||
<nil name="viewControllers"/>
|
<nil name="viewControllers"/>
|
||||||
<connections>
|
<connections>
|
||||||
@@ -924,40 +938,176 @@
|
|||||||
</navigationController>
|
</navigationController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="3LN-mt-qWn" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="3LN-mt-qWn" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="2730" y="1120"/>
|
<point key="canvasLocation" x="2526" y="731"/>
|
||||||
</scene>
|
</scene>
|
||||||
<!--Sources-->
|
<!--Sources-->
|
||||||
<scene sceneID="Vzf-tb-LIH">
|
<scene sceneID="0S1-zn-9KZ">
|
||||||
<objects>
|
<objects>
|
||||||
<viewControllerPlaceholder storyboardName="Sources" id="HCK-G6-KdY" sceneMemberID="viewController">
|
<collectionViewController title="Sources" id="cHC-TX-KzQ" customClass="SourcesViewController" customModule="SideStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||||
<tabBarItem key="tabBarItem" title="Item" id="Q7y-bi-ncT"/>
|
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" alwaysBounceVertical="YES" dataMode="prototypes" id="S36-hD-vu2">
|
||||||
</viewControllerPlaceholder>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="647"/>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="VTd-he-VYb" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||||
|
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="15" minimumInteritemSpacing="10" id="X20-b5-XEP">
|
||||||
|
<size key="itemSize" width="375" height="80"/>
|
||||||
|
<size key="headerReferenceSize" width="50" height="200"/>
|
||||||
|
<size key="footerReferenceSize" width="50" height="50"/>
|
||||||
|
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
|
||||||
|
</collectionViewFlowLayout>
|
||||||
|
<cells>
|
||||||
|
<collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" reuseIdentifier="Cell" id="XcN-o4-9qm" customClass="BannerCollectionViewCell" customModule="SideStore" customModuleProvider="target">
|
||||||
|
<rect key="frame" x="0.0" y="200" width="375" height="80"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||||
|
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="375" height="80"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<subviews>
|
||||||
|
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LW1-CC-bWu" customClass="AppBannerView" customModule="SideStore" customModuleProvider="target">
|
||||||
|
<rect key="frame" x="8" y="0.0" width="359" height="80"/>
|
||||||
|
<accessibility key="accessibilityConfiguration">
|
||||||
|
<bool key="isElement" value="YES"/>
|
||||||
|
</accessibility>
|
||||||
|
</view>
|
||||||
|
</subviews>
|
||||||
|
</view>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstAttribute="bottom" secondItem="LW1-CC-bWu" secondAttribute="bottom" id="Pkr-zO-0wx"/>
|
||||||
|
<constraint firstItem="LW1-CC-bWu" firstAttribute="leading" secondItem="XcN-o4-9qm" secondAttribute="leadingMargin" id="egJ-X3-yEz"/>
|
||||||
|
<constraint firstItem="LW1-CC-bWu" firstAttribute="top" secondItem="XcN-o4-9qm" secondAttribute="top" id="glF-aM-4xQ"/>
|
||||||
|
<constraint firstAttribute="trailingMargin" secondItem="LW1-CC-bWu" secondAttribute="trailing" id="tQx-yV-LTq"/>
|
||||||
|
</constraints>
|
||||||
|
<connections>
|
||||||
|
<outlet property="bannerView" destination="LW1-CC-bWu" id="mwO-Ne-L1L"/>
|
||||||
|
</connections>
|
||||||
|
</collectionViewCell>
|
||||||
|
</cells>
|
||||||
|
<collectionReusableView key="sectionHeaderView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="Header" id="8N7-JY-mcA" customClass="TextCollectionReusableView" customModule="SideStore" customModuleProvider="target">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="375" height="200"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<subviews>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Manage sources to control which apps are available to download through SideStore." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="TZv-TM-uJj">
|
||||||
|
<rect key="frame" x="8" y="14" width="359" height="171"/>
|
||||||
|
<fontDescription key="fontDescription" style="UICTFontTextStyleCallout"/>
|
||||||
|
<color key="textColor" white="0.5" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
</subviews>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="TZv-TM-uJj" firstAttribute="top" secondItem="8N7-JY-mcA" secondAttribute="top" constant="14" id="2zE-UV-24S"/>
|
||||||
|
<constraint firstAttribute="bottom" secondItem="TZv-TM-uJj" secondAttribute="bottom" constant="15" id="Aml-PC-dko"/>
|
||||||
|
<constraint firstAttribute="trailingMargin" secondItem="TZv-TM-uJj" secondAttribute="trailing" id="V0U-al-5eb"/>
|
||||||
|
<constraint firstAttribute="leadingMargin" secondItem="TZv-TM-uJj" secondAttribute="leading" id="aS5-6Y-rMd"/>
|
||||||
|
</constraints>
|
||||||
|
<connections>
|
||||||
|
<outlet property="bottomLayoutConstraint" destination="Aml-PC-dko" id="I1s-ae-C8A"/>
|
||||||
|
<outlet property="leadingLayoutConstraint" destination="aS5-6Y-rMd" id="An8-KN-xfb"/>
|
||||||
|
<outlet property="textLabel" destination="TZv-TM-uJj" id="kWV-Wv-5gz"/>
|
||||||
|
<outlet property="topLayoutConstraint" destination="2zE-UV-24S" id="mjq-yH-v8J"/>
|
||||||
|
<outlet property="trailingLayoutConstraint" destination="V0U-al-5eb" id="z8b-2G-SgY"/>
|
||||||
|
</connections>
|
||||||
|
</collectionReusableView>
|
||||||
|
<collectionReusableView key="sectionFooterView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="Footer" id="X5B-Kp-w1p" customClass="SourcesFooterView">
|
||||||
|
<rect key="frame" x="0.0" y="280" width="375" height="50"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<subviews>
|
||||||
|
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="j0O-xE-gyd">
|
||||||
|
<rect key="frame" x="8" y="0.0" width="359" height="50"/>
|
||||||
|
<subviews>
|
||||||
|
<activityIndicatorView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" hidesWhenStopped="YES" animating="YES" style="medium" translatesAutoresizingMaskIntoConstraints="NO" id="PNx-uR-y2F">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="359" height="0.0"/>
|
||||||
|
</activityIndicatorView>
|
||||||
|
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="800" scrollEnabled="NO" contentInsetAdjustmentBehavior="never" editable="NO" text="Test" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="66c-H8-KJx">
|
||||||
|
<rect key="frame" x="0.0" y="15" width="359" height="35"/>
|
||||||
|
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||||
|
<color key="textColor" white="0.5" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
|
||||||
|
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||||
|
</textView>
|
||||||
|
</subviews>
|
||||||
|
</stackView>
|
||||||
|
</subviews>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstAttribute="bottom" secondItem="j0O-xE-gyd" secondAttribute="bottom" id="BQ5-11-BzK"/>
|
||||||
|
<constraint firstItem="j0O-xE-gyd" firstAttribute="top" secondItem="X5B-Kp-w1p" secondAttribute="top" id="KZg-fd-8Cp" propertyAccessControl="none"/>
|
||||||
|
<constraint firstItem="j0O-xE-gyd" firstAttribute="leading" secondItem="X5B-Kp-w1p" secondAttribute="leadingMargin" id="R2x-Io-bXD"/>
|
||||||
|
<constraint firstAttribute="trailingMargin" secondItem="j0O-xE-gyd" secondAttribute="trailing" id="aBK-Bq-P9O"/>
|
||||||
|
</constraints>
|
||||||
|
<connections>
|
||||||
|
<outlet property="activityIndicatorView" destination="PNx-uR-y2F" id="7Le-VW-GYK"/>
|
||||||
|
<outlet property="bottomLayoutConstraint" destination="BQ5-11-BzK" id="iJR-4o-u9l"/>
|
||||||
|
<outlet property="leadingLayoutConstraint" destination="R2x-Io-bXD" id="plZ-Yj-zTc"/>
|
||||||
|
<outlet property="textView" destination="66c-H8-KJx" id="kwc-OH-U6i"/>
|
||||||
|
<outlet property="topLayoutConstraint" destination="KZg-fd-8Cp" id="zNM-UU-feF"/>
|
||||||
|
<outlet property="trailingLayoutConstraint" destination="aBK-Bq-P9O" id="L2r-VL-ruT"/>
|
||||||
|
</connections>
|
||||||
|
</collectionReusableView>
|
||||||
|
<connections>
|
||||||
|
<outlet property="dataSource" destination="cHC-TX-KzQ" id="VHQ-ls-gde"/>
|
||||||
|
<outlet property="delegate" destination="cHC-TX-KzQ" id="MWr-Xg-N2k"/>
|
||||||
|
</connections>
|
||||||
|
</collectionView>
|
||||||
|
<navigationItem key="navigationItem" title="Sources" id="QTB-W7-6BG">
|
||||||
|
<barButtonItem key="leftBarButtonItem" systemItem="add" id="kBB-5c-8gw">
|
||||||
|
<connections>
|
||||||
|
<action selector="addSource" destination="cHC-TX-KzQ" id="WiB-Jg-NzT"/>
|
||||||
|
</connections>
|
||||||
|
</barButtonItem>
|
||||||
|
<barButtonItem key="rightBarButtonItem" style="done" systemItem="done" id="NQF-u2-PZv">
|
||||||
|
<connections>
|
||||||
|
<segue destination="zjS-Nr-VTw" kind="unwind" unwindAction="unwindFromSourcesViewController:" id="la1-dJ-UhL"/>
|
||||||
|
</connections>
|
||||||
|
</barButtonItem>
|
||||||
|
</navigationItem>
|
||||||
|
</collectionViewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="TrV-p3-ZAt" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||||
|
<exit id="zjS-Nr-VTw" userLabel="Exit" sceneMemberID="exit"/>
|
||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="-2" y="550"/>
|
<point key="canvasLocation" x="3302" y="1430"/>
|
||||||
|
</scene>
|
||||||
|
<!--Navigation Controller-->
|
||||||
|
<scene sceneID="6NV-LQ-gKB">
|
||||||
|
<objects>
|
||||||
|
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="Qo4-72-Hmr" sceneMemberID="viewController">
|
||||||
|
<toolbarItems/>
|
||||||
|
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" largeTitles="YES" id="mcx-oR-qPe">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="375" height="96"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
</navigationBar>
|
||||||
|
<nil name="viewControllers"/>
|
||||||
|
<connections>
|
||||||
|
<segue destination="cHC-TX-KzQ" kind="relationship" relationship="rootViewController" id="BC5-Fs-dCj"/>
|
||||||
|
</connections>
|
||||||
|
</navigationController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="4mO-93-4qk" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="2526" y="1445"/>
|
||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
<inferredMetricsTieBreakers>
|
<inferredMetricsTieBreakers>
|
||||||
|
<segue reference="Qd6-ba-dIo"/>
|
||||||
<segue reference="cnd-KK-o60"/>
|
<segue reference="cnd-KK-o60"/>
|
||||||
</inferredMetricsTieBreakers>
|
</inferredMetricsTieBreakers>
|
||||||
<color key="tintColor" name="Primary"/>
|
<color key="tintColor" name="Primary"/>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="Back" width="18" height="18"/>
|
<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="MyApps" width="20" height="20"/>
|
||||||
<image name="News" width="19" height="20"/>
|
<image name="News" width="19" height="20"/>
|
||||||
<image name="Settings" width="20" height="20"/>
|
<image name="Settings" width="20" height="20"/>
|
||||||
<namedColor name="Background">
|
<namedColor name="Background">
|
||||||
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color red="0.6431" green="0.0196" blue="0.9804" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
</namedColor>
|
</namedColor>
|
||||||
<namedColor name="BlurTint">
|
<namedColor name="BlurTint">
|
||||||
<color red="1" green="1" blue="1" alpha="0.30000001192092896" colorSpace="custom" customColorSpace="sRGB"/>
|
<color red="1" green="1" blue="1" alpha="0.3" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
</namedColor>
|
</namedColor>
|
||||||
<namedColor name="Primary">
|
<namedColor name="Primary">
|
||||||
<color red="0.64313725490196083" green="0.019607843137254902" blue="0.98039215686274506" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color red="0.6431" green="0.0196" blue="0.9804" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
</namedColor>
|
</namedColor>
|
||||||
<systemColor name="systemBackgroundColor">
|
<systemColor name="systemBackgroundColor">
|
||||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
</systemColor>
|
</systemColor>
|
||||||
|
<systemColor name="tertiarySystemBackgroundColor">
|
||||||
|
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
</systemColor>
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
|
|||||||
99
AltStore/Browse/BrowseCollectionViewCell.swift
Normal file
99
AltStore/Browse/BrowseCollectionViewCell.swift
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// BrowseCollectionViewCell.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 7/15/19.
|
||||||
|
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
import Roxas
|
||||||
|
|
||||||
|
import Nuke
|
||||||
|
|
||||||
|
@objc final class BrowseCollectionViewCell: UICollectionViewCell
|
||||||
|
{
|
||||||
|
var imageURLs: [URL] = [] {
|
||||||
|
didSet {
|
||||||
|
self.dataSource.items = self.imageURLs as [NSURL]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private lazy var dataSource = self.makeDataSource()
|
||||||
|
|
||||||
|
@IBOutlet var bannerView: AppBannerView!
|
||||||
|
@IBOutlet var subtitleLabel: UILabel!
|
||||||
|
|
||||||
|
@IBOutlet private(set) var screenshotsCollectionView: UICollectionView!
|
||||||
|
|
||||||
|
override func awakeFromNib()
|
||||||
|
{
|
||||||
|
super.awakeFromNib()
|
||||||
|
|
||||||
|
self.contentView.preservesSuperviewLayoutMargins = true
|
||||||
|
|
||||||
|
// Must be registered programmatically, not in BrowseCollectionViewCell.xib, or else it'll throw an exception 🤷♂️.
|
||||||
|
self.screenshotsCollectionView.register(ScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||||
|
|
||||||
|
self.screenshotsCollectionView.delegate = self
|
||||||
|
self.screenshotsCollectionView.dataSource = self.dataSource
|
||||||
|
self.screenshotsCollectionView.prefetchDataSource = self.dataSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension BrowseCollectionViewCell
|
||||||
|
{
|
||||||
|
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>
|
||||||
|
{
|
||||||
|
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: [])
|
||||||
|
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in
|
||||||
|
let cell = cell as! ScreenshotCollectionViewCell
|
||||||
|
cell.imageView.image = nil
|
||||||
|
cell.imageView.isIndicatingActivity = true
|
||||||
|
}
|
||||||
|
dataSource.prefetchHandler = { (imageURL, indexPath, completionHandler) in
|
||||||
|
return RSTAsyncBlockOperation() { (operation) in
|
||||||
|
let request = ImageRequest(url: imageURL as URL, processor: .screenshot)
|
||||||
|
ImagePipeline.shared.loadImage(with: request, progress: nil, completion: { (response, error) in
|
||||||
|
guard !operation.isCancelled else { return operation.finish() }
|
||||||
|
|
||||||
|
if let image = response?.image
|
||||||
|
{
|
||||||
|
completionHandler(image, nil)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
completionHandler(nil, error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
||||||
|
let cell = cell as! ScreenshotCollectionViewCell
|
||||||
|
cell.imageView.isIndicatingActivity = false
|
||||||
|
cell.imageView.image = image
|
||||||
|
|
||||||
|
if let error = error
|
||||||
|
{
|
||||||
|
print("Error loading image:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BrowseCollectionViewCell: UICollectionViewDelegateFlowLayout
|
||||||
|
{
|
||||||
|
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
|
||||||
|
{
|
||||||
|
// Assuming 9.0 / 16.0 ratio for now.
|
||||||
|
let aspectRatio: CGFloat = 9.0 / 16.0
|
||||||
|
|
||||||
|
let itemHeight = collectionView.bounds.height
|
||||||
|
let itemWidth = itemHeight * aspectRatio
|
||||||
|
|
||||||
|
let size = CGSize(width: itemWidth.rounded(.down), height: itemHeight.rounded(.down))
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
}
|
||||||
64
AltStore/Browse/BrowseCollectionViewCell.xib
Normal file
64
AltStore/Browse/BrowseCollectionViewCell.xib
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15400" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||||
|
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15404"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
|
</dependencies>
|
||||||
|
<objects>
|
||||||
|
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||||
|
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="Cell" id="ln4-pC-7KY" customClass="BrowseCollectionViewCell" customModule="AltStore" customModuleProvider="target">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="375" height="369"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||||
|
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="375" height="369"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<subviews>
|
||||||
|
<stackView verifyAmbiguity="off" opaque="NO" contentMode="scaleToFill" ambiguous="YES" axis="vertical" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="5gU-g3-Fsy">
|
||||||
|
<rect key="frame" x="16" y="0.0" width="343" height="369"/>
|
||||||
|
<subviews>
|
||||||
|
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ziA-mP-AY2" customClass="AppBannerView" customModule="AltStore" customModuleProvider="target">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="343" height="88"/>
|
||||||
|
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstAttribute="height" constant="88" id="z3D-cI-jhp"/>
|
||||||
|
</constraints>
|
||||||
|
</view>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Classic Nintendo games in your pocket." textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="Imx-Le-bcy">
|
||||||
|
<rect key="frame" x="0.0" y="103" width="343" height="17"/>
|
||||||
|
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
||||||
|
<nil key="textColor"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
<collectionView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" scrollEnabled="NO" contentInsetAdjustmentBehavior="never" dataMode="none" translatesAutoresizingMaskIntoConstraints="NO" id="RFs-qp-Ca4">
|
||||||
|
<rect key="frame" x="0.0" y="135" width="343" height="234"/>
|
||||||
|
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="0.0" minimumInteritemSpacing="15" id="jH9-Jo-IHA">
|
||||||
|
<size key="itemSize" width="120" height="213"/>
|
||||||
|
<size key="headerReferenceSize" width="0.0" height="0.0"/>
|
||||||
|
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
||||||
|
<inset key="sectionInset" minX="8" minY="0.0" maxX="8" maxY="0.0"/>
|
||||||
|
</collectionViewFlowLayout>
|
||||||
|
<cells/>
|
||||||
|
</collectionView>
|
||||||
|
</subviews>
|
||||||
|
</stackView>
|
||||||
|
</subviews>
|
||||||
|
</view>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="5gU-g3-Fsy" firstAttribute="top" secondItem="ln4-pC-7KY" secondAttribute="top" id="DnT-vq-BOc"/>
|
||||||
|
<constraint firstItem="5gU-g3-Fsy" firstAttribute="leading" secondItem="ln4-pC-7KY" secondAttribute="leadingMargin" id="YPy-xL-iUn"/>
|
||||||
|
<constraint firstAttribute="bottom" secondItem="5gU-g3-Fsy" secondAttribute="bottom" id="gRu-Hz-CNL"/>
|
||||||
|
<constraint firstAttribute="trailingMargin" secondItem="5gU-g3-Fsy" secondAttribute="trailing" id="vf4-ql-4Vq"/>
|
||||||
|
</constraints>
|
||||||
|
<connections>
|
||||||
|
<outlet property="bannerView" destination="ziA-mP-AY2" id="yxo-ar-Cha"/>
|
||||||
|
<outlet property="screenshotsCollectionView" destination="RFs-qp-Ca4" id="xfi-AN-l17"/>
|
||||||
|
<outlet property="subtitleLabel" destination="Imx-Le-bcy" id="JVW-ZZ-51O"/>
|
||||||
|
</connections>
|
||||||
|
<point key="canvasLocation" x="136.95652173913044" y="152.67857142857142"/>
|
||||||
|
</collectionViewCell>
|
||||||
|
</objects>
|
||||||
|
</document>
|
||||||
@@ -7,69 +7,23 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
|
||||||
|
|
||||||
import minimuxer
|
|
||||||
import AltStoreCore
|
import AltStoreCore
|
||||||
import Roxas
|
import Roxas
|
||||||
|
|
||||||
import Nuke
|
import Nuke
|
||||||
|
|
||||||
class BrowseViewController: UICollectionViewController, PeekPopPreviewing
|
class BrowseViewController: UICollectionViewController
|
||||||
{
|
{
|
||||||
// Nil == Show apps from all sources.
|
|
||||||
let source: Source?
|
|
||||||
|
|
||||||
private(set) var category: StoreCategory? {
|
|
||||||
didSet {
|
|
||||||
self.updateDataSource()
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var searchPredicate: NSPredicate? {
|
|
||||||
didSet {
|
|
||||||
self.updateDataSource()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private lazy var dataSource = self.makeDataSource()
|
private lazy var dataSource = self.makeDataSource()
|
||||||
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
|
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
|
||||||
|
|
||||||
private let prototypeCell = AppCardCollectionViewCell(frame: .zero)
|
private let prototypeCell = BrowseCollectionViewCell.instantiate(with: BrowseCollectionViewCell.nib!)!
|
||||||
private var sortButton: UIBarButtonItem?
|
|
||||||
|
|
||||||
private var preferredAppSorting: AppSorting = UserDefaults.shared.preferredAppSorting
|
private var loadingState: LoadingState = .loading {
|
||||||
|
didSet {
|
||||||
private var cancellables = Set<AnyCancellable>()
|
self.update()
|
||||||
|
}
|
||||||
private var titleStackView: UIStackView!
|
|
||||||
private var titleSourceIconView: AppIconImageView!
|
|
||||||
private var titleCategoryIconView: UIImageView!
|
|
||||||
private var titleLabel: UILabel!
|
|
||||||
|
|
||||||
init?(source: Source?, coder: NSCoder)
|
|
||||||
{
|
|
||||||
self.source = source
|
|
||||||
self.category = nil
|
|
||||||
|
|
||||||
super.init(coder: coder)
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(category: StoreCategory?, coder: NSCoder)
|
|
||||||
{
|
|
||||||
self.source = nil
|
|
||||||
self.category = category
|
|
||||||
|
|
||||||
super.init(coder: coder)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder)
|
|
||||||
{
|
|
||||||
self.source = nil
|
|
||||||
self.category = nil
|
|
||||||
|
|
||||||
super.init(coder: coder)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var cachedItemSizes = [String: CGSize]()
|
private var cachedItemSizes = [String: CGSize]()
|
||||||
@@ -80,80 +34,20 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing
|
|||||||
{
|
{
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
self.collectionView.backgroundColor = .altBackground
|
#if BETA
|
||||||
self.collectionView.alwaysBounceVertical = true
|
self.dataSource.searchController.searchableKeyPaths = [#keyPath(InstalledApp.name)]
|
||||||
|
|
||||||
self.dataSource.searchController.searchableKeyPaths = [#keyPath(StoreApp.name),
|
|
||||||
#keyPath(StoreApp.subtitle),
|
|
||||||
#keyPath(StoreApp.developerName),
|
|
||||||
#keyPath(StoreApp.bundleIdentifier)]
|
|
||||||
self.navigationItem.searchController = self.dataSource.searchController
|
self.navigationItem.searchController = self.dataSource.searchController
|
||||||
|
#endif
|
||||||
|
|
||||||
self.prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
self.prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
self.collectionView.register(AppCardCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
self.collectionView.register(BrowseCollectionViewCell.nib, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||||
|
|
||||||
self.collectionView.dataSource = self.dataSource
|
self.collectionView.dataSource = self.dataSource
|
||||||
self.collectionView.prefetchDataSource = self.dataSource
|
self.collectionView.prefetchDataSource = self.dataSource
|
||||||
|
|
||||||
let collectionViewLayout = self.collectionViewLayout as! UICollectionViewFlowLayout
|
self.registerForPreviewing(with: self, sourceView: self.collectionView)
|
||||||
collectionViewLayout.minimumLineSpacing = 30
|
|
||||||
|
|
||||||
(self as PeekPopPreviewing).registerForPreviewing(with: self, sourceView: self.collectionView)
|
|
||||||
|
|
||||||
let refreshControl = UIRefreshControl(frame: .zero, primaryAction: UIAction { [weak self] _ in
|
|
||||||
self?.updateSources()
|
|
||||||
})
|
|
||||||
self.collectionView.refreshControl = refreshControl
|
|
||||||
|
|
||||||
if self.category != nil, #available(iOS 16, *)
|
|
||||||
{
|
|
||||||
let categoriesMenu = UIMenu(children: [
|
|
||||||
UIDeferredMenuElement.uncached { [weak self] completion in
|
|
||||||
let actions = self?.makeCategoryActions() ?? []
|
|
||||||
completion(actions)
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
self.navigationItem.titleMenuProvider = { _ in categoriesMenu }
|
|
||||||
}
|
|
||||||
|
|
||||||
self.titleSourceIconView = AppIconImageView(style: .circular)
|
|
||||||
|
|
||||||
self.titleCategoryIconView = UIImageView(frame: .zero)
|
|
||||||
self.titleCategoryIconView.contentMode = .scaleAspectFit
|
|
||||||
|
|
||||||
self.titleLabel = UILabel()
|
|
||||||
self.titleLabel.font = UIFont.preferredFont(forTextStyle: .headline)
|
|
||||||
|
|
||||||
self.titleStackView = UIStackView(arrangedSubviews: [self.titleSourceIconView, self.titleCategoryIconView, self.titleLabel])
|
|
||||||
self.titleStackView.spacing = 4
|
|
||||||
self.titleStackView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
self.navigationItem.largeTitleDisplayMode = .never
|
|
||||||
|
|
||||||
if #available(iOS 16, *)
|
|
||||||
{
|
|
||||||
self.navigationItem.preferredSearchBarPlacement = .automatic
|
|
||||||
}
|
|
||||||
|
|
||||||
if #available(iOS 15, *)
|
|
||||||
{
|
|
||||||
self.prepareAppSorting()
|
|
||||||
}
|
|
||||||
|
|
||||||
self.preparePipeline()
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
// Source icon = equal width and height
|
|
||||||
self.titleSourceIconView.heightAnchor.constraint(equalToConstant: 26),
|
|
||||||
self.titleSourceIconView.widthAnchor.constraint(equalTo: self.titleSourceIconView.heightAnchor),
|
|
||||||
|
|
||||||
// Category icon = constant height, variable widths
|
|
||||||
self.titleCategoryIconView.heightAnchor.constraint(equalToConstant: 26)
|
|
||||||
])
|
|
||||||
|
|
||||||
self.updateDataSource()
|
|
||||||
self.update()
|
self.update()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,371 +55,186 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing
|
|||||||
{
|
{
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
|
self.fetchSource()
|
||||||
|
self.updateDataSource()
|
||||||
|
|
||||||
self.update()
|
self.update()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidDisappear(_ animated: Bool)
|
@IBAction private func unwindFromSourcesViewController(_ segue: UIStoryboardSegue)
|
||||||
{
|
{
|
||||||
super.viewDidDisappear(animated)
|
self.fetchSource()
|
||||||
|
|
||||||
self.navigationController?.navigationBar.tintColor = nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension BrowseViewController
|
private extension BrowseViewController
|
||||||
{
|
{
|
||||||
func preparePipeline()
|
|
||||||
{
|
|
||||||
AppManager.shared.$updateSourcesResult
|
|
||||||
.receive(on: RunLoop.main) // Delay to next run loop so we receive _current_ value (not previous value).
|
|
||||||
.sink { [weak self] result in
|
|
||||||
self?.update()
|
|
||||||
}
|
|
||||||
.store(in: &self.cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeFetchRequest() -> NSFetchRequest<StoreApp>
|
|
||||||
{
|
|
||||||
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
|
||||||
fetchRequest.returnsObjectsAsFaults = false
|
|
||||||
|
|
||||||
let predicate = StoreApp.visibleAppsPredicate
|
|
||||||
|
|
||||||
if let source = self.source
|
|
||||||
{
|
|
||||||
let filterPredicate = NSPredicate(format: "%K == %@", #keyPath(StoreApp._source), source)
|
|
||||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [filterPredicate, predicate])
|
|
||||||
}
|
|
||||||
else if let category = self.category
|
|
||||||
{
|
|
||||||
let categoryPredicate = switch category {
|
|
||||||
case .other: StoreApp.otherCategoryPredicate
|
|
||||||
default: NSPredicate(format: "%K == %@", #keyPath(StoreApp._category), category.rawValue)
|
|
||||||
}
|
|
||||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [categoryPredicate, predicate])
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
fetchRequest.predicate = predicate
|
|
||||||
}
|
|
||||||
|
|
||||||
var sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp.name, ascending: true),
|
|
||||||
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true),
|
|
||||||
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true)]
|
|
||||||
|
|
||||||
switch self.preferredAppSorting
|
|
||||||
{
|
|
||||||
case .default:
|
|
||||||
let descriptor = NSSortDescriptor(keyPath: \StoreApp.sortIndex, ascending: self.preferredAppSorting.isAscending)
|
|
||||||
sortDescriptors.insert(descriptor, at: 0)
|
|
||||||
|
|
||||||
case .name:
|
|
||||||
// Already sorting by name, no need to prepend additional sort descriptor.
|
|
||||||
break
|
|
||||||
|
|
||||||
case .developer:
|
|
||||||
let descriptor = NSSortDescriptor(keyPath: \StoreApp.developerName, ascending: self.preferredAppSorting.isAscending)
|
|
||||||
sortDescriptors.insert(descriptor, at: 0)
|
|
||||||
|
|
||||||
case .lastUpdated:
|
|
||||||
let descriptor = NSSortDescriptor(keyPath: \StoreApp.latestSupportedVersion?.date, ascending: self.preferredAppSorting.isAscending)
|
|
||||||
sortDescriptors.insert(descriptor, at: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchRequest.sortDescriptors = sortDescriptors
|
|
||||||
|
|
||||||
return fetchRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
||||||
{
|
{
|
||||||
let fetchRequest = self.makeFetchRequest()
|
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
||||||
|
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true),
|
||||||
|
NSSortDescriptor(keyPath: \StoreApp.sortIndex, ascending: true),
|
||||||
|
NSSortDescriptor(keyPath: \StoreApp.name, ascending: true),
|
||||||
|
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true)]
|
||||||
|
fetchRequest.returnsObjectsAsFaults = false
|
||||||
|
fetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID)
|
||||||
|
|
||||||
let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext
|
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
||||||
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: context)
|
dataSource.cellConfigurationHandler = { (cell, app, indexPath) in
|
||||||
dataSource.placeholderView = self.placeholderView
|
let cell = cell as! BrowseCollectionViewCell
|
||||||
dataSource.cellConfigurationHandler = { [weak self] (cell, app, indexPath) in
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
let cell = cell as! AppCardCollectionViewCell
|
|
||||||
cell.layoutMargins.left = self.view.layoutMargins.left
|
cell.layoutMargins.left = self.view.layoutMargins.left
|
||||||
cell.layoutMargins.right = self.view.layoutMargins.right
|
cell.layoutMargins.right = self.view.layoutMargins.right
|
||||||
|
|
||||||
let showSourceIcon = (self.source == nil) // Hide source icon if redundant
|
cell.subtitleLabel.text = app.subtitle
|
||||||
cell.configure(for: app, showSourceIcon: showSourceIcon)
|
cell.imageURLs = Array(app.screenshotURLs.prefix(2))
|
||||||
|
|
||||||
|
cell.bannerView.configure(for: app)
|
||||||
|
|
||||||
cell.bannerView.iconImageView.image = nil
|
cell.bannerView.iconImageView.image = nil
|
||||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||||
|
|
||||||
cell.bannerView.button.addTarget(self, action: #selector(BrowseViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
cell.bannerView.button.addTarget(self, action: #selector(BrowseViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
||||||
cell.bannerView.button.activityIndicatorView.style = .medium
|
cell.bannerView.button.activityIndicatorView.style = .medium
|
||||||
cell.bannerView.button.activityIndicatorView.color = .white
|
|
||||||
|
// Explicitly set to false to ensure we're starting from a non-activity indicating state.
|
||||||
|
// Otherwise, cell reuse can mess up some cached values.
|
||||||
|
cell.bannerView.button.isIndicatingActivity = false
|
||||||
|
|
||||||
let tintColor = app.tintColor ?? .altPrimary
|
let tintColor = app.tintColor ?? .altPrimary
|
||||||
cell.tintColor = tintColor
|
cell.tintColor = tintColor
|
||||||
|
|
||||||
|
if app.installedApp == nil
|
||||||
|
{
|
||||||
|
let buttonTitle = NSLocalizedString("Free", comment: "")
|
||||||
|
cell.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||||
|
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name)
|
||||||
|
cell.bannerView.button.accessibilityValue = buttonTitle
|
||||||
|
|
||||||
|
let progress = AppManager.shared.installationProgress(for: app)
|
||||||
|
cell.bannerView.button.progress = progress
|
||||||
|
|
||||||
|
if let versionDate = app.latestVersion?.date, versionDate > Date()
|
||||||
|
{
|
||||||
|
cell.bannerView.button.countdownDate = app.versionDate
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cell.bannerView.button.countdownDate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cell.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
|
||||||
|
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), app.name)
|
||||||
|
cell.bannerView.button.accessibilityValue = nil
|
||||||
|
cell.bannerView.button.progress = nil
|
||||||
|
cell.bannerView.button.countdownDate = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
dataSource.prefetchHandler = { (storeApp, indexPath, completionHandler) -> Foundation.Operation? in
|
dataSource.prefetchHandler = { (storeApp, indexPath, completionHandler) -> Foundation.Operation? in
|
||||||
let iconURL = storeApp.iconURL
|
let iconURL = storeApp.iconURL
|
||||||
|
|
||||||
return RSTAsyncBlockOperation() { (operation) in
|
return RSTAsyncBlockOperation() { (operation) in
|
||||||
ImagePipeline.shared.loadImage(with: iconURL, progress: nil) { result in
|
ImagePipeline.shared.loadImage(with: iconURL, progress: nil, completion: { (response, error) in
|
||||||
guard !operation.isCancelled else { return operation.finish() }
|
guard !operation.isCancelled else { return operation.finish() }
|
||||||
|
|
||||||
switch result
|
if let image = response?.image
|
||||||
{
|
{
|
||||||
case .success(let response): completionHandler(response.image, nil)
|
completionHandler(image, nil)
|
||||||
case .failure(let error): completionHandler(nil, error)
|
|
||||||
}
|
}
|
||||||
}
|
else
|
||||||
|
{
|
||||||
|
completionHandler(nil, error)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dataSource.prefetchCompletionHandler = { [weak dataSource] (cell, image, indexPath, error) in
|
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
||||||
let cell = cell as! AppCardCollectionViewCell
|
let cell = cell as! BrowseCollectionViewCell
|
||||||
cell.bannerView.iconImageView.isIndicatingActivity = false
|
cell.bannerView.iconImageView.isIndicatingActivity = false
|
||||||
cell.bannerView.iconImageView.image = image
|
cell.bannerView.iconImageView.image = image
|
||||||
|
|
||||||
if let error = error, let dataSource
|
if let error = error
|
||||||
{
|
{
|
||||||
let app = dataSource.item(at: indexPath)
|
print("Error loading image:", error)
|
||||||
Logger.main.debug("Failed to load app icon from \(app.iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dataSource.placeholderView = self.placeholderView
|
||||||
|
|
||||||
return dataSource
|
return dataSource
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateDataSource()
|
func updateDataSource()
|
||||||
{
|
{
|
||||||
let fetchRequest = self.makeFetchRequest()
|
self.dataSource.predicate = nil
|
||||||
|
|
||||||
let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext
|
|
||||||
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil)
|
|
||||||
self.dataSource.fetchedResultsController = fetchedResultsController
|
|
||||||
|
|
||||||
self.dataSource.predicate = self.searchPredicate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateSources()
|
func fetchSource()
|
||||||
{
|
{
|
||||||
AppManager.shared.updateAllSources { result in
|
self.loadingState = .loading
|
||||||
self.collectionView.refreshControl?.endRefreshing()
|
|
||||||
|
AppManager.shared.fetchSources() { (result) in
|
||||||
guard case .failure(let error) = result else { return }
|
do
|
||||||
|
|
||||||
if self.dataSource.itemCount > 0
|
|
||||||
{
|
{
|
||||||
let toastView = ToastView(error: error)
|
do
|
||||||
toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside)
|
{
|
||||||
toastView.show(in: self)
|
let (_, context) = try result.get()
|
||||||
|
try context.save()
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.loadingState = .finished(.success(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch let error as AppManager.FetchSourcesError
|
||||||
|
{
|
||||||
|
try error.managedObjectContext?.save()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if self.dataSource.itemCount > 0
|
||||||
|
{
|
||||||
|
let toastView = ToastView(error: error)
|
||||||
|
toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside)
|
||||||
|
toastView.show(in: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.loadingState = .finished(.failure(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func update()
|
func update()
|
||||||
{
|
{
|
||||||
if self.searchPredicate != nil
|
switch self.loadingState
|
||||||
{
|
{
|
||||||
self.placeholderView.textLabel.text = NSLocalizedString("No Apps", comment: "")
|
case .loading:
|
||||||
self.placeholderView.textLabel.isHidden = false
|
self.placeholderView.textLabel.isHidden = true
|
||||||
|
|
||||||
self.placeholderView.detailTextLabel.text = NSLocalizedString("Please make sure your spelling is correct, or try searching for another app.", comment: "")
|
|
||||||
self.placeholderView.detailTextLabel.isHidden = false
|
self.placeholderView.detailTextLabel.isHidden = false
|
||||||
|
|
||||||
|
self.placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "")
|
||||||
|
|
||||||
|
self.placeholderView.activityIndicatorView.startAnimating()
|
||||||
|
|
||||||
|
case .finished(.failure(let error)):
|
||||||
|
self.placeholderView.textLabel.isHidden = false
|
||||||
|
self.placeholderView.detailTextLabel.isHidden = false
|
||||||
|
|
||||||
|
self.placeholderView.textLabel.text = NSLocalizedString("Unable to Fetch Apps", comment: "")
|
||||||
|
self.placeholderView.detailTextLabel.text = error.localizedDescription
|
||||||
|
|
||||||
|
self.placeholderView.activityIndicatorView.stopAnimating()
|
||||||
|
|
||||||
|
case .finished(.success):
|
||||||
|
self.placeholderView.textLabel.isHidden = true
|
||||||
|
self.placeholderView.detailTextLabel.isHidden = true
|
||||||
|
|
||||||
self.placeholderView.activityIndicatorView.stopAnimating()
|
self.placeholderView.activityIndicatorView.stopAnimating()
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
switch AppManager.shared.updateSourcesResult
|
|
||||||
{
|
|
||||||
case nil:
|
|
||||||
self.placeholderView.textLabel.isHidden = true
|
|
||||||
self.placeholderView.detailTextLabel.isHidden = false
|
|
||||||
|
|
||||||
self.placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "")
|
|
||||||
|
|
||||||
self.placeholderView.activityIndicatorView.startAnimating()
|
|
||||||
|
|
||||||
case .failure(let error):
|
|
||||||
self.placeholderView.textLabel.isHidden = false
|
|
||||||
self.placeholderView.detailTextLabel.isHidden = false
|
|
||||||
|
|
||||||
self.placeholderView.textLabel.text = NSLocalizedString("Unable to Fetch Apps", comment: "")
|
|
||||||
self.placeholderView.detailTextLabel.text = error.localizedDescription
|
|
||||||
|
|
||||||
self.placeholderView.activityIndicatorView.stopAnimating()
|
|
||||||
|
|
||||||
case .success:
|
|
||||||
self.placeholderView.textLabel.text = NSLocalizedString("No Apps", comment: "")
|
|
||||||
self.placeholderView.textLabel.isHidden = false
|
|
||||||
self.placeholderView.detailTextLabel.isHidden = true
|
|
||||||
|
|
||||||
self.placeholderView.activityIndicatorView.stopAnimating()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let tintColor: UIColor
|
|
||||||
|
|
||||||
if let source = self.source
|
|
||||||
{
|
|
||||||
tintColor = source.effectiveTintColor?.adjustedForDisplay ?? .altPrimary
|
|
||||||
|
|
||||||
self.title = source.name
|
|
||||||
|
|
||||||
self.titleSourceIconView.backgroundColor = tintColor
|
|
||||||
self.titleSourceIconView.isHidden = false
|
|
||||||
|
|
||||||
self.titleCategoryIconView.isHidden = true
|
|
||||||
|
|
||||||
if let iconURL = source.effectiveIconURL
|
|
||||||
{
|
|
||||||
Nuke.loadImage(with: iconURL, into: self.titleSourceIconView) { result in
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error): Logger.main.error("Failed to fetch source icon at \(iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
|
||||||
case .success: self.titleSourceIconView.backgroundColor = .white
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if let category = self.category
|
|
||||||
{
|
|
||||||
tintColor = category.tintColor
|
|
||||||
|
|
||||||
self.title = category.localizedName
|
|
||||||
|
|
||||||
let image = UIImage(systemName: category.filledSymbolName)?.withTintColor(tintColor, renderingMode: .alwaysOriginal)
|
|
||||||
self.titleCategoryIconView.image = image
|
|
||||||
self.titleCategoryIconView.isHidden = false
|
|
||||||
|
|
||||||
self.titleSourceIconView.isHidden = true
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
tintColor = .altPrimary
|
|
||||||
|
|
||||||
self.title = NSLocalizedString("Browse", comment: "")
|
|
||||||
|
|
||||||
self.titleSourceIconView.isHidden = true
|
|
||||||
self.titleCategoryIconView.isHidden = true
|
|
||||||
}
|
|
||||||
|
|
||||||
self.titleLabel.text = self.title
|
|
||||||
self.titleStackView.sizeToFit()
|
|
||||||
self.navigationItem.titleView = self.titleStackView
|
|
||||||
|
|
||||||
self.view.tintColor = tintColor
|
|
||||||
|
|
||||||
let appearance = NavigationBarAppearance()
|
|
||||||
appearance.configureWithTintColor(tintColor)
|
|
||||||
appearance.configureWithDefaultBackground()
|
|
||||||
|
|
||||||
let edgeAppearance = appearance.copy()
|
|
||||||
edgeAppearance.configureWithTransparentBackground()
|
|
||||||
|
|
||||||
self.navigationItem.standardAppearance = appearance
|
|
||||||
self.navigationItem.scrollEdgeAppearance = edgeAppearance
|
|
||||||
|
|
||||||
// Necessary to tint UISearchController's inline bar button.
|
|
||||||
self.navigationController?.navigationBar.tintColor = tintColor
|
|
||||||
|
|
||||||
if let sortButton
|
|
||||||
{
|
|
||||||
sortButton.image = sortButton.image?.withTintColor(tintColor, renderingMode: .alwaysOriginal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeCategoryActions() -> [UIAction]
|
|
||||||
{
|
|
||||||
let handler = { [weak self] (category: StoreCategory) in
|
|
||||||
self?.category = category
|
|
||||||
}
|
|
||||||
|
|
||||||
let fetchRequest = NSFetchRequest(entityName: StoreApp.entity().name!) as NSFetchRequest<NSDictionary>
|
|
||||||
fetchRequest.resultType = .dictionaryResultType
|
|
||||||
fetchRequest.returnsDistinctResults = true
|
|
||||||
fetchRequest.propertiesToFetch = [#keyPath(StoreApp._category)]
|
|
||||||
fetchRequest.predicate = StoreApp.visibleAppsPredicate
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let dictionaries = try DatabaseManager.shared.viewContext.fetch(fetchRequest)
|
|
||||||
|
|
||||||
// Keep nil values
|
|
||||||
let categories = dictionaries.map { $0[#keyPath(StoreApp._category)] as? String? ?? nil }.map { rawCategory -> StoreCategory in
|
|
||||||
guard let rawCategory else { return .other }
|
|
||||||
return StoreCategory(rawValue: rawCategory) ?? .other
|
|
||||||
}
|
|
||||||
|
|
||||||
var sortedCategories = Set(categories).sorted(by: { $0.localizedName.localizedStandardCompare($1.localizedName) == .orderedAscending })
|
|
||||||
if let otherIndex = sortedCategories.firstIndex(of: .other)
|
|
||||||
{
|
|
||||||
// Ensure "Other" is always last
|
|
||||||
sortedCategories.move(fromOffsets: [otherIndex], toOffset: sortedCategories.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
let actions = sortedCategories.map { category in
|
|
||||||
let state: UIAction.State = (category == self.category) ? .on : .off
|
|
||||||
let image = UIImage(systemName: category.filledSymbolName)?.withTintColor(category.tintColor, renderingMode: .alwaysOriginal)
|
|
||||||
return UIAction(title: category.localizedName, image: image, state: state) { _ in
|
|
||||||
handler(category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
Logger.main.error("Failed to fetch categories. \(error.localizedDescription, privacy: .public)")
|
|
||||||
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 15, *)
|
|
||||||
func prepareAppSorting()
|
|
||||||
{
|
|
||||||
if self.preferredAppSorting == .default && self.source == nil
|
|
||||||
{
|
|
||||||
// Only allow `default` sorting if source is non-nil.
|
|
||||||
// Otherwise, fall back to `lastUpdated` sorting.
|
|
||||||
self.preferredAppSorting = .lastUpdated
|
|
||||||
|
|
||||||
// Don't update UserDefaults unless explicitly changed by user.
|
|
||||||
// UserDefaults.shared.preferredAppSorting = .lastUpdated
|
|
||||||
}
|
|
||||||
|
|
||||||
let children = UIDeferredMenuElement.uncached { [weak self] completion in
|
|
||||||
guard let self else { return completion([]) }
|
|
||||||
|
|
||||||
var sortingOptions = AppSorting.allCases
|
|
||||||
if self.source == nil
|
|
||||||
{
|
|
||||||
// Only allow `default` sorting when source is non-nil.
|
|
||||||
sortingOptions = sortingOptions.filter { $0 != .default }
|
|
||||||
}
|
|
||||||
|
|
||||||
let actions = sortingOptions.map { sorting in
|
|
||||||
let state: UIMenuElement.State = (sorting == self.preferredAppSorting) ? .on : .off
|
|
||||||
let action = UIAction(title: sorting.localizedName, image: nil, state: state) { action in
|
|
||||||
self.preferredAppSorting = sorting
|
|
||||||
UserDefaults.shared.preferredAppSorting = sorting // Update separately to save change.
|
|
||||||
|
|
||||||
self.updateDataSource()
|
|
||||||
}
|
|
||||||
|
|
||||||
return action
|
|
||||||
}
|
|
||||||
|
|
||||||
completion(actions)
|
|
||||||
}
|
|
||||||
|
|
||||||
let sortMenu = UIMenu(title: NSLocalizedString("Sort by…", comment: ""), options: [.singleSelection], children: [children])
|
|
||||||
let sortIcon = UIImage(systemName: "arrow.up.arrow.down")
|
|
||||||
|
|
||||||
let sortButton = UIBarButtonItem(title: NSLocalizedString("Sort by…", comment: ""), image: sortIcon, primaryAction: nil, menu: sortMenu)
|
|
||||||
self.sortButton = sortButton
|
|
||||||
|
|
||||||
self.navigationItem.rightBarButtonItems = [sortButton]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,8 +247,7 @@ private extension BrowseViewController
|
|||||||
|
|
||||||
let app = self.dataSource.item(at: indexPath)
|
let app = self.dataSource.item(at: indexPath)
|
||||||
|
|
||||||
// if let installedApp = app.installedApp, !installedApp.isUpdateAvailable
|
if let installedApp = app.installedApp
|
||||||
if let installedApp = app.installedApp, !installedApp.hasUpdate
|
|
||||||
{
|
{
|
||||||
self.open(installedApp)
|
self.open(installedApp)
|
||||||
}
|
}
|
||||||
@@ -556,55 +264,24 @@ private extension BrowseViewController
|
|||||||
previousProgress?.cancel()
|
previousProgress?.cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !minimuxer.ready() {
|
|
||||||
let toastView = ToastView(error: MinimuxerError.NoConnection)
|
|
||||||
toastView.show(in: self)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Task<Void, Never>(priority: .userInitiated) { @MainActor in
|
_ = AppManager.shared.install(app, presentingViewController: self) { (result) in
|
||||||
// if let installedApp = app.installedApp, installedApp.isUpdateAvailable
|
|
||||||
if let installedApp = app.installedApp, installedApp.hasUpdate
|
|
||||||
{
|
|
||||||
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await AppManager.shared.installAsync(app, presentingViewController: self, completionHandler: finish(_:))
|
|
||||||
}
|
|
||||||
|
|
||||||
UIView.performWithoutAnimation {
|
|
||||||
self.collectionView.reloadItems(at: [indexPath])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func finish(_ result: Result<InstalledApp, Error>)
|
|
||||||
{
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
switch result
|
switch result
|
||||||
{
|
{
|
||||||
case .failure(OperationError.cancelled): break // Ignore
|
case .failure(OperationError.cancelled): break // Ignore
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
let toastView = ToastView(error: error, opensLog: true)
|
let toastView = ToastView(error: error)
|
||||||
toastView.show(in: self)
|
toastView.show(in: self)
|
||||||
|
|
||||||
case .success: print("Installed app:", app.bundleIdentifier)
|
case .success: print("Installed app:", app.bundleIdentifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
UIView.performWithoutAnimation {
|
self.collectionView.reloadItems(at: [indexPath])
|
||||||
if let indexPath = self.dataSource.fetchedResultsController.indexPath(forObject: app)
|
|
||||||
{
|
|
||||||
self.collectionView.reloadItems(at: [indexPath])
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.collectionView.reloadItems(at: [indexPath])
|
||||||
}
|
}
|
||||||
|
|
||||||
func open(_ installedApp: InstalledApp)
|
func open(_ installedApp: InstalledApp)
|
||||||
@@ -618,18 +295,21 @@ extension BrowseViewController: UICollectionViewDelegateFlowLayout
|
|||||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
|
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
|
||||||
{
|
{
|
||||||
let item = self.dataSource.item(at: indexPath)
|
let item = self.dataSource.item(at: indexPath)
|
||||||
let itemID = item.globallyUniqueID ?? item.bundleIdentifier
|
|
||||||
|
|
||||||
if let previousSize = self.cachedItemSizes[itemID]
|
if let previousSize = self.cachedItemSizes[item.bundleIdentifier]
|
||||||
{
|
{
|
||||||
return previousSize
|
return previousSize
|
||||||
}
|
}
|
||||||
|
|
||||||
self.dataSource.cellConfigurationHandler(self.prototypeCell, item, indexPath)
|
|
||||||
|
|
||||||
let insets = (self.view.layoutMargins.left + self.view.layoutMargins.right)
|
|
||||||
|
|
||||||
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width - insets)
|
let maxVisibleScreenshots = 2 as CGFloat
|
||||||
|
let aspectRatio: CGFloat = 16.0 / 9.0
|
||||||
|
|
||||||
|
let layout = self.prototypeCell.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
||||||
|
let padding = (layout.minimumInteritemSpacing * (maxVisibleScreenshots - 1)) + layout.sectionInset.left + layout.sectionInset.right
|
||||||
|
|
||||||
|
self.dataSource.cellConfigurationHandler(self.prototypeCell, item, indexPath)
|
||||||
|
|
||||||
|
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
|
||||||
widthConstraint.isActive = true
|
widthConstraint.isActive = true
|
||||||
defer { widthConstraint.isActive = false }
|
defer { widthConstraint.isActive = false }
|
||||||
|
|
||||||
@@ -637,25 +317,31 @@ extension BrowseViewController: UICollectionViewDelegateFlowLayout
|
|||||||
self.prototypeCell.frame.size.width = widthConstraint.constant
|
self.prototypeCell.frame.size.width = widthConstraint.constant
|
||||||
self.prototypeCell.layoutIfNeeded()
|
self.prototypeCell.layoutIfNeeded()
|
||||||
|
|
||||||
|
let collectionViewWidth = self.prototypeCell.screenshotsCollectionView.bounds.width
|
||||||
|
let screenshotWidth = ((collectionViewWidth - padding) / maxVisibleScreenshots).rounded(.down)
|
||||||
|
let screenshotHeight = screenshotWidth * aspectRatio
|
||||||
|
|
||||||
|
let heightConstraint = self.prototypeCell.screenshotsCollectionView.heightAnchor.constraint(equalToConstant: screenshotHeight)
|
||||||
|
heightConstraint.priority = .defaultHigh // Prevent temporary unsatisfiable constraints error.
|
||||||
|
heightConstraint.isActive = true
|
||||||
|
defer { heightConstraint.isActive = false }
|
||||||
|
|
||||||
let itemSize = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
let itemSize = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||||
self.cachedItemSizes[itemID] = itemSize
|
self.cachedItemSizes[item.bundleIdentifier] = itemSize
|
||||||
return itemSize
|
return itemSize
|
||||||
}
|
}
|
||||||
|
|
||||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
||||||
{
|
{
|
||||||
let app = self.dataSource.item(at: indexPath)
|
let app = self.dataSource.item(at: indexPath)
|
||||||
let appViewController = AppViewController.makeAppViewController(app: app)
|
|
||||||
|
|
||||||
// Fall back to presentingViewController.navigationController in case we're being used for search results.
|
let appViewController = AppViewController.makeAppViewController(app: app)
|
||||||
let navigationController = self.navigationController ?? self.presentingViewController?.navigationController
|
self.navigationController?.pushViewController(appViewController, animated: true)
|
||||||
navigationController?.pushViewController(appViewController, animated: true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension BrowseViewController: UIViewControllerPreviewingDelegate
|
extension BrowseViewController: UIViewControllerPreviewingDelegate
|
||||||
{
|
{
|
||||||
@available(iOS, deprecated: 13.0)
|
|
||||||
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController?
|
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController?
|
||||||
{
|
{
|
||||||
guard
|
guard
|
||||||
@@ -671,22 +357,8 @@ extension BrowseViewController: UIViewControllerPreviewingDelegate
|
|||||||
return appViewController
|
return appViewController
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS, deprecated: 13.0)
|
|
||||||
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController)
|
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController)
|
||||||
{
|
{
|
||||||
self.navigationController?.pushViewController(viewControllerToCommit, animated: true)
|
self.navigationController?.pushViewController(viewControllerToCommit, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 17, *)
|
|
||||||
#Preview(traits: .portrait) {
|
|
||||||
DatabaseManager.shared.startForPreview()
|
|
||||||
|
|
||||||
let storyboard = UIStoryboard(name: "Main", bundle: .main)
|
|
||||||
let browseViewController = storyboard.instantiateViewController(identifier: "browseViewController") { coder in
|
|
||||||
BrowseViewController(source: nil, coder: coder)
|
|
||||||
}
|
|
||||||
|
|
||||||
let navigationController = UINavigationController(rootViewController: browseViewController)
|
|
||||||
return navigationController
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
//
|
|
||||||
// FeaturedComponents.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 12/4/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class LargeIconCollectionViewCell: UICollectionViewCell
|
|
||||||
{
|
|
||||||
let textLabel = UILabel(frame: .zero)
|
|
||||||
let imageView = UIImageView(frame: .zero)
|
|
||||||
|
|
||||||
override init(frame: CGRect)
|
|
||||||
{
|
|
||||||
self.textLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
self.textLabel.textColor = .white
|
|
||||||
self.textLabel.font = .preferredFont(forTextStyle: .headline)
|
|
||||||
|
|
||||||
self.imageView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
self.imageView.contentMode = .center
|
|
||||||
self.imageView.tintColor = .white
|
|
||||||
self.imageView.alpha = 0.4
|
|
||||||
self.imageView.preferredSymbolConfiguration = .init(pointSize: 80)
|
|
||||||
|
|
||||||
super.init(frame: frame)
|
|
||||||
|
|
||||||
self.contentView.clipsToBounds = true
|
|
||||||
self.contentView.layer.cornerRadius = 16
|
|
||||||
self.contentView.layer.cornerCurve = .continuous
|
|
||||||
|
|
||||||
self.contentView.addSubview(self.textLabel)
|
|
||||||
self.contentView.addSubview(self.imageView)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
self.textLabel.leadingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.leadingAnchor, constant: 4),
|
|
||||||
self.textLabel.bottomAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.bottomAnchor, constant: -4),
|
|
||||||
|
|
||||||
self.imageView.centerXAnchor.constraint(equalTo: self.contentView.trailingAnchor, constant: -30),
|
|
||||||
self.imageView.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor, constant: 0),
|
|
||||||
self.imageView.heightAnchor.constraint(equalTo: self.contentView.heightAnchor, constant: 0),
|
|
||||||
self.imageView.widthAnchor.constraint(equalTo: self.imageView.heightAnchor)
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class IconButtonCollectionReusableView: UICollectionReusableView
|
|
||||||
{
|
|
||||||
let iconButton: UIButton
|
|
||||||
let titleButton: UIButton
|
|
||||||
|
|
||||||
private let stackView: UIStackView
|
|
||||||
|
|
||||||
override init(frame: CGRect)
|
|
||||||
{
|
|
||||||
let iconHeight = 26.0
|
|
||||||
|
|
||||||
self.iconButton = UIButton(type: .custom)
|
|
||||||
self.iconButton.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
self.iconButton.clipsToBounds = true
|
|
||||||
self.iconButton.layer.cornerRadius = iconHeight / 2
|
|
||||||
|
|
||||||
let content = UIListContentConfiguration.plainHeader()
|
|
||||||
self.titleButton = UIButton(type: .system)
|
|
||||||
self.titleButton.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
self.titleButton.titleLabel?.font = content.textProperties.font
|
|
||||||
self.titleButton.setTitleColor(content.textProperties.color, for: .normal)
|
|
||||||
|
|
||||||
self.stackView = UIStackView(arrangedSubviews: [self.iconButton, self.titleButton])
|
|
||||||
self.stackView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
self.stackView.axis = .horizontal
|
|
||||||
self.stackView.alignment = .center
|
|
||||||
self.stackView.spacing = UIStackView.spacingUseSystem
|
|
||||||
self.stackView.isLayoutMarginsRelativeArrangement = false
|
|
||||||
|
|
||||||
super.init(frame: frame)
|
|
||||||
|
|
||||||
self.addSubview(self.stackView)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
self.iconButton.heightAnchor.constraint(equalToConstant: iconHeight),
|
|
||||||
self.iconButton.widthAnchor.constraint(equalTo: self.iconButton.heightAnchor),
|
|
||||||
|
|
||||||
self.stackView.topAnchor.constraint(equalTo: self.topAnchor),
|
|
||||||
self.stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
|
||||||
self.stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
|
||||||
self.stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,747 +0,0 @@
|
|||||||
//
|
|
||||||
// FeaturedViewController.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 11/8/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
import AltStoreCore
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
import Nuke
|
|
||||||
|
|
||||||
extension UIAction.Identifier
|
|
||||||
{
|
|
||||||
fileprivate static let showAllApps = Self("io.sidestore.ShowAllApps")
|
|
||||||
fileprivate static let showSourceDetails = Self("io.sidestore.ShowSourceDetails")
|
|
||||||
}
|
|
||||||
|
|
||||||
extension FeaturedViewController
|
|
||||||
{
|
|
||||||
// Open-ended because each Source is its own section
|
|
||||||
private struct Section: RawRepresentable, Equatable
|
|
||||||
{
|
|
||||||
static let recentlyUpdated = Section(rawValue: 0)
|
|
||||||
static let categories = Section(rawValue: 1)
|
|
||||||
static let featuredHeader = Section(rawValue: 2)
|
|
||||||
|
|
||||||
let rawValue: Int
|
|
||||||
|
|
||||||
var isFeaturedAppsSection: Bool {
|
|
||||||
return self.rawValue > Section.featuredHeader.rawValue
|
|
||||||
}
|
|
||||||
|
|
||||||
init(rawValue: Int)
|
|
||||||
{
|
|
||||||
self.rawValue = rawValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum ReuseID: String
|
|
||||||
{
|
|
||||||
case recent = "RecentCell"
|
|
||||||
case category = "CategoryCell"
|
|
||||||
case featuredApp = "FeaturedAppCell"
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum ElementKind: String
|
|
||||||
{
|
|
||||||
case sectionHeader
|
|
||||||
case sourceHeader
|
|
||||||
case button
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FeaturedViewController: UICollectionViewController
|
|
||||||
{
|
|
||||||
private lazy var dataSource = self.makeDataSource()
|
|
||||||
private lazy var recentlyUpdatedDataSource = self.makeRecentlyUpdatedDataSource()
|
|
||||||
private lazy var categoriesDataSource = self.makeCategoriesDataSource()
|
|
||||||
private lazy var featuredAppsDataSource = self.makeFeaturedAppsDataSource()
|
|
||||||
|
|
||||||
private var searchController: RSTSearchController!
|
|
||||||
private var searchBrowseViewController: BrowseViewController!
|
|
||||||
|
|
||||||
override func viewDidLoad()
|
|
||||||
{
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
self.title = NSLocalizedString("Browse", comment: "")
|
|
||||||
|
|
||||||
let layout = Self.makeLayout()
|
|
||||||
self.collectionView.collectionViewLayout = layout
|
|
||||||
|
|
||||||
self.dataSource.proxy = self
|
|
||||||
self.collectionView.dataSource = self.dataSource
|
|
||||||
self.collectionView.prefetchDataSource = self.dataSource
|
|
||||||
|
|
||||||
self.collectionView.register(AppBannerCollectionViewCell.self, forCellWithReuseIdentifier: ReuseID.recent.rawValue)
|
|
||||||
self.collectionView.register(LargeIconCollectionViewCell.self, forCellWithReuseIdentifier: ReuseID.category.rawValue)
|
|
||||||
self.collectionView.register(AppCardCollectionViewCell.self, forCellWithReuseIdentifier: ReuseID.featuredApp.rawValue)
|
|
||||||
|
|
||||||
self.collectionView.register(UICollectionViewListCell.self, forSupplementaryViewOfKind: ElementKind.sectionHeader.rawValue, withReuseIdentifier: ElementKind.sectionHeader.rawValue)
|
|
||||||
self.collectionView.register(IconButtonCollectionReusableView.self, forSupplementaryViewOfKind: ElementKind.sourceHeader.rawValue, withReuseIdentifier: ElementKind.sourceHeader.rawValue)
|
|
||||||
self.collectionView.register(ButtonCollectionReusableView.self, forSupplementaryViewOfKind: ElementKind.button.rawValue, withReuseIdentifier: ElementKind.button.rawValue)
|
|
||||||
|
|
||||||
self.collectionView.backgroundColor = .altBackground
|
|
||||||
self.collectionView.directionalLayoutMargins.leading = 20
|
|
||||||
self.collectionView.directionalLayoutMargins.trailing = 20
|
|
||||||
|
|
||||||
let storyboard = UIStoryboard(name: "Main", bundle: nil)
|
|
||||||
self.searchBrowseViewController = storyboard.instantiateViewController(identifier: "browseViewController") { coder in
|
|
||||||
let browseViewController = BrowseViewController(coder: coder)
|
|
||||||
return browseViewController
|
|
||||||
}
|
|
||||||
|
|
||||||
self.searchController = RSTSearchController(searchResultsController: self.searchBrowseViewController)
|
|
||||||
self.searchController.searchableKeyPaths = [#keyPath(StoreApp.name),
|
|
||||||
#keyPath(StoreApp.developerName),
|
|
||||||
#keyPath(StoreApp.subtitle),
|
|
||||||
#keyPath(StoreApp.bundleIdentifier)]
|
|
||||||
self.searchController.searchHandler = { [weak searchBrowseViewController] (searchValue, _) in
|
|
||||||
searchBrowseViewController?.searchPredicate = searchValue.predicate
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
self.navigationItem.searchController = self.searchController
|
|
||||||
self.navigationItem.hidesSearchBarWhenScrolling = true
|
|
||||||
|
|
||||||
self.navigationItem.largeTitleDisplayMode = .always
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool)
|
|
||||||
{
|
|
||||||
super.viewDidAppear(animated)
|
|
||||||
|
|
||||||
self.navigationController?.navigationBar.tintColor = .altPrimary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension FeaturedViewController
|
|
||||||
{
|
|
||||||
class func makeLayout() -> UICollectionViewCompositionalLayout
|
|
||||||
{
|
|
||||||
let config = UICollectionViewCompositionalLayoutConfiguration()
|
|
||||||
config.interSectionSpacing = 0 // Must be 0 for Section.featuredHeader
|
|
||||||
config.contentInsetsReference = .layoutMargins
|
|
||||||
|
|
||||||
let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
|
|
||||||
let section = Section(rawValue: sectionIndex)
|
|
||||||
|
|
||||||
let spacing = 10.0
|
|
||||||
let interSectionSpacing = 30.0
|
|
||||||
let titleSize = NSCollectionLayoutSize(widthDimension: .estimated(100), heightDimension: .estimated(30))
|
|
||||||
|
|
||||||
switch section
|
|
||||||
{
|
|
||||||
case .recentlyUpdated:
|
|
||||||
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(AppBannerView.standardHeight))
|
|
||||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
|
||||||
|
|
||||||
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(AppBannerView.standardHeight * 2 + spacing))
|
|
||||||
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item, item]) // 2 items per group
|
|
||||||
group.interItemSpacing = .fixed(spacing)
|
|
||||||
|
|
||||||
let layoutSection = NSCollectionLayoutSection(group: group)
|
|
||||||
layoutSection.interGroupSpacing = spacing
|
|
||||||
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
|
|
||||||
layoutSection.contentInsets.bottom = interSectionSpacing
|
|
||||||
layoutSection.boundarySupplementaryItems = [
|
|
||||||
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sectionHeader.rawValue, alignment: .topLeading)
|
|
||||||
]
|
|
||||||
return layoutSection
|
|
||||||
|
|
||||||
case .categories:
|
|
||||||
let itemWidth = (layoutEnvironment.container.effectiveContentSize.width - spacing) / 2
|
|
||||||
let itemHeight = 90.0
|
|
||||||
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth), heightDimension: .absolute(itemHeight))
|
|
||||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
|
||||||
|
|
||||||
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(itemHeight))
|
|
||||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, item]) // 2 items per group
|
|
||||||
group.interItemSpacing = .fixed(spacing)
|
|
||||||
|
|
||||||
let layoutSection = NSCollectionLayoutSection(group: group)
|
|
||||||
layoutSection.interGroupSpacing = spacing
|
|
||||||
layoutSection.orthogonalScrollingBehavior = .none
|
|
||||||
layoutSection.contentInsets.bottom = interSectionSpacing
|
|
||||||
layoutSection.boundarySupplementaryItems = [
|
|
||||||
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sectionHeader.rawValue, alignment: .topLeading)
|
|
||||||
]
|
|
||||||
return layoutSection
|
|
||||||
|
|
||||||
case .featuredHeader:
|
|
||||||
// We don't want to show any items, so set height to 1.0
|
|
||||||
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(1.0))
|
|
||||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
|
||||||
|
|
||||||
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
|
|
||||||
|
|
||||||
let layoutSection = NSCollectionLayoutSection(group: group)
|
|
||||||
layoutSection.contentInsets.top = 0
|
|
||||||
layoutSection.contentInsets.bottom = 0
|
|
||||||
layoutSection.boundarySupplementaryItems = [
|
|
||||||
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sectionHeader.rawValue, alignment: .topLeading)
|
|
||||||
]
|
|
||||||
return layoutSection
|
|
||||||
|
|
||||||
case _ where section.isFeaturedAppsSection:
|
|
||||||
let itemHeight: NSCollectionLayoutDimension = if #available(iOS 17, *) { .uniformAcrossSiblings(estimate: 350) } else { .estimated(350) }
|
|
||||||
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: itemHeight)
|
|
||||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
|
||||||
|
|
||||||
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: itemHeight)
|
|
||||||
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
|
|
||||||
group.interItemSpacing = .fixed(spacing)
|
|
||||||
|
|
||||||
let titleHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.sourceHeader.rawValue, alignment: .topLeading)
|
|
||||||
|
|
||||||
let buttonSize = NSCollectionLayoutSize(widthDimension: .estimated(44), heightDimension: .estimated(20))
|
|
||||||
let buttonHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: buttonSize, elementKind: ElementKind.button.rawValue, alignment: .topTrailing)
|
|
||||||
|
|
||||||
let layoutSection = NSCollectionLayoutSection(group: group)
|
|
||||||
layoutSection.interGroupSpacing = spacing
|
|
||||||
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
|
|
||||||
layoutSection.contentInsets.top = 8
|
|
||||||
layoutSection.contentInsets.bottom = interSectionSpacing
|
|
||||||
layoutSection.boundarySupplementaryItems = [titleHeader, buttonHeader]
|
|
||||||
return layoutSection
|
|
||||||
|
|
||||||
default: return nil
|
|
||||||
}
|
|
||||||
}, configuration: config)
|
|
||||||
|
|
||||||
return layout
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
|
||||||
{
|
|
||||||
let featuredHeaderDataSource = RSTDynamicCollectionViewDataSource<StoreApp>()
|
|
||||||
featuredHeaderDataSource.numberOfSectionsHandler = { 1 }
|
|
||||||
featuredHeaderDataSource.numberOfItemsHandler = { _ in 0 }
|
|
||||||
|
|
||||||
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>(dataSources: [self.recentlyUpdatedDataSource, self.categoriesDataSource, featuredHeaderDataSource, self.featuredAppsDataSource])
|
|
||||||
dataSource.predicate = StoreApp.visibleAppsPredicate // Ensure we never accidentally show hidden apps
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeRecentlyUpdatedDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
|
||||||
{
|
|
||||||
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
|
||||||
fetchRequest.returnsObjectsAsFaults = false
|
|
||||||
fetchRequest.sortDescriptors = [
|
|
||||||
NSSortDescriptor(keyPath: \StoreApp.latestSupportedVersion?.date, ascending: false),
|
|
||||||
NSSortDescriptor(keyPath: \StoreApp.name, ascending: true),
|
|
||||||
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true),
|
|
||||||
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true),
|
|
||||||
]
|
|
||||||
|
|
||||||
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
|
||||||
dataSource.cellIdentifierHandler = { _ in ReuseID.recent.rawValue }
|
|
||||||
dataSource.liveFetchLimit = 10 // Show 10 most recently updated apps
|
|
||||||
dataSource.cellConfigurationHandler = { cell, storeApp, indexPath in
|
|
||||||
let cell = cell as! AppBannerCollectionViewCell
|
|
||||||
cell.tintColor = storeApp.tintColor
|
|
||||||
cell.contentView.preservesSuperviewLayoutMargins = false
|
|
||||||
cell.contentView.layoutMargins = .zero
|
|
||||||
|
|
||||||
cell.bannerView.button.isIndicatingActivity = false
|
|
||||||
cell.bannerView.configure(for: storeApp)
|
|
||||||
|
|
||||||
if let versionDate = storeApp.latestSupportedVersion?.date
|
|
||||||
{
|
|
||||||
cell.bannerView.subtitleLabel.text = Date().relativeDateString(since: versionDate, dateFormatter: Date.mediumDateFormatter)
|
|
||||||
}
|
|
||||||
|
|
||||||
cell.bannerView.button.addTarget(self, action: #selector(FeaturedViewController.performAppAction), for: .primaryActionTriggered)
|
|
||||||
|
|
||||||
cell.bannerView.iconImageView.image = nil
|
|
||||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
|
||||||
}
|
|
||||||
dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in
|
|
||||||
return RSTAsyncBlockOperation { (operation) in
|
|
||||||
storeApp.managedObjectContext?.perform {
|
|
||||||
ImagePipeline.shared.loadImage(with: storeApp.iconURL, progress: nil) { result in
|
|
||||||
guard !operation.isCancelled else { return operation.finish() }
|
|
||||||
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .success(let response): completion(response.image, nil)
|
|
||||||
case .failure(let error): completion(nil, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dataSource.prefetchCompletionHandler = { [weak dataSource] (cell, image, indexPath, error) in
|
|
||||||
let cell = cell as! AppBannerCollectionViewCell
|
|
||||||
cell.bannerView.iconImageView.image = image
|
|
||||||
cell.bannerView.iconImageView.isIndicatingActivity = false
|
|
||||||
|
|
||||||
if let error, let dataSource
|
|
||||||
{
|
|
||||||
let app = dataSource.item(at: indexPath)
|
|
||||||
Logger.main.debug("Failed to app icon from \(app.iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeCategoriesDataSource() -> RSTCompositeCollectionViewDataSource<StoreApp>
|
|
||||||
{
|
|
||||||
let knownCategories = StoreCategory.allCases.filter { $0 != .other }.map { $0.rawValue }
|
|
||||||
|
|
||||||
let knownFetchRequest = StoreApp.fetchRequest()
|
|
||||||
knownFetchRequest.predicate = NSPredicate(format: "%K IN %@", #keyPath(StoreApp._category), knownCategories)
|
|
||||||
knownFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp._category, ascending: true),
|
|
||||||
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true),
|
|
||||||
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true)]
|
|
||||||
|
|
||||||
let unknownFetchRequest = StoreApp.fetchRequest()
|
|
||||||
unknownFetchRequest.predicate = StoreApp.otherCategoryPredicate
|
|
||||||
unknownFetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp._category, ascending: true),
|
|
||||||
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true),
|
|
||||||
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true)]
|
|
||||||
|
|
||||||
let knownController = NSFetchedResultsController(fetchRequest: knownFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(StoreApp._category), cacheName: nil)
|
|
||||||
let knownDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: knownController)
|
|
||||||
knownDataSource.liveFetchLimit = 1 // One app per category
|
|
||||||
|
|
||||||
let unknownController = NSFetchedResultsController(fetchRequest: unknownFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: nil, cacheName: nil)
|
|
||||||
let unknownDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: unknownController)
|
|
||||||
unknownDataSource.liveFetchLimit = 1
|
|
||||||
|
|
||||||
// Use composite data source to ensure "Other" category is always last.
|
|
||||||
let dataSource = RSTCompositeCollectionViewDataSource<StoreApp>(dataSources: [knownDataSource, unknownDataSource])
|
|
||||||
dataSource.shouldFlattenSections = true // Combine into single section, with one StoreApp per category.
|
|
||||||
dataSource.cellIdentifierHandler = { _ in ReuseID.category.rawValue }
|
|
||||||
dataSource.cellConfigurationHandler = { cell, storeApp, indexPath in
|
|
||||||
let category = storeApp.category ?? .other
|
|
||||||
|
|
||||||
let cell = cell as! LargeIconCollectionViewCell
|
|
||||||
cell.textLabel.text = category.localizedName
|
|
||||||
cell.imageView.image = UIImage(systemName: category.symbolName)
|
|
||||||
|
|
||||||
var background = UIBackgroundConfiguration.clear()
|
|
||||||
background.backgroundColor = category.tintColor
|
|
||||||
background.cornerRadius = 16
|
|
||||||
cell.backgroundConfiguration = background
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeFeaturedAppsDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>
|
|
||||||
{
|
|
||||||
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
|
||||||
fetchRequest.returnsObjectsAsFaults = false
|
|
||||||
fetchRequest.sortDescriptors = [
|
|
||||||
// Sort by Source first to group into sections.
|
|
||||||
NSSortDescriptor(keyPath: \StoreApp._source?.featuredSortID, ascending: true),
|
|
||||||
|
|
||||||
// Show uninstalled apps first.
|
|
||||||
// Sorting by StoreApp.installedApp crashes because InstalledApp does not respond to compare:
|
|
||||||
// Instead, sort by StoreApp.installedApp.storeApp.source.sourceIdentifier, which will be either nil OR source ID.
|
|
||||||
NSSortDescriptor(keyPath: \StoreApp.installedApp?.storeApp?.sourceIdentifier, ascending: true),
|
|
||||||
|
|
||||||
// Show featured apps first.
|
|
||||||
// Sorting by StoreApp.featuringSource crashes because Source does not respond to compare:
|
|
||||||
// Instead, sort by StoreApp.featuringSource.identifier, which will be either nil OR source ID.
|
|
||||||
NSSortDescriptor(keyPath: \StoreApp.featuringSource?.identifier, ascending: false),
|
|
||||||
|
|
||||||
// Randomize order within sections.
|
|
||||||
NSSortDescriptor(keyPath: \StoreApp.featuredSortID, ascending: true),
|
|
||||||
|
|
||||||
// Sanity check to ensure stable ordering
|
|
||||||
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true)
|
|
||||||
]
|
|
||||||
|
|
||||||
let sourceHasRemainingAppsPredicate = NSPredicate(format:
|
|
||||||
"""
|
|
||||||
SUBQUERY(%K, $app,
|
|
||||||
($app.%K != %@) AND ($app.%K == nil) AND (($app.%K == NO) OR ($app.%K == NO) OR ($app.%K == YES))
|
|
||||||
).@count > 0
|
|
||||||
""",
|
|
||||||
#keyPath(StoreApp._source._apps),
|
|
||||||
#keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID,
|
|
||||||
#keyPath(StoreApp.installedApp),
|
|
||||||
#keyPath(StoreApp.isPledgeRequired), #keyPath(StoreApp.isHiddenWithoutPledge), #keyPath(StoreApp.isPledged)
|
|
||||||
)
|
|
||||||
|
|
||||||
let primaryFetchRequest = fetchRequest.copy() as! NSFetchRequest<StoreApp>
|
|
||||||
primaryFetchRequest.predicate = sourceHasRemainingAppsPredicate
|
|
||||||
|
|
||||||
let primaryController = NSFetchedResultsController(fetchRequest: primaryFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(StoreApp._source.featuredSortID), cacheName: nil)
|
|
||||||
let primaryDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: primaryController)
|
|
||||||
primaryDataSource.liveFetchLimit = 5
|
|
||||||
|
|
||||||
let secondaryFetchRequest = fetchRequest.copy() as! NSFetchRequest<StoreApp>
|
|
||||||
secondaryFetchRequest.predicate = NSCompoundPredicate(notPredicateWithSubpredicate: sourceHasRemainingAppsPredicate)
|
|
||||||
|
|
||||||
let secondaryController = NSFetchedResultsController(fetchRequest: secondaryFetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(StoreApp._source.featuredSortID), cacheName: nil)
|
|
||||||
let secondaryDataSource = RSTFetchedResultsCollectionViewDataSource<StoreApp>(fetchedResultsController: secondaryController)
|
|
||||||
secondaryDataSource.liveFetchLimit = 5
|
|
||||||
|
|
||||||
// Ensure sources with no remaining apps always come last.
|
|
||||||
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource<StoreApp, UIImage>(dataSources: [primaryDataSource, secondaryDataSource])
|
|
||||||
dataSource.cellIdentifierHandler = { _ in ReuseID.featuredApp.rawValue }
|
|
||||||
dataSource.cellConfigurationHandler = { cell, storeApp, indexPath in
|
|
||||||
let cell = cell as! AppCardCollectionViewCell
|
|
||||||
cell.configure(for: storeApp)
|
|
||||||
cell.prefersPagingScreenshots = false
|
|
||||||
|
|
||||||
cell.bannerView.button.addTarget(self, action: #selector(FeaturedViewController.performAppAction), for: .primaryActionTriggered)
|
|
||||||
cell.bannerView.sourceIconImageView.isHidden = true
|
|
||||||
|
|
||||||
cell.bannerView.iconImageView.image = nil
|
|
||||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
|
||||||
}
|
|
||||||
dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in
|
|
||||||
return RSTAsyncBlockOperation { (operation) in
|
|
||||||
storeApp.managedObjectContext?.perform {
|
|
||||||
ImagePipeline.shared.loadImage(with: storeApp.iconURL, progress: nil) { result in
|
|
||||||
guard !operation.isCancelled else { return operation.finish() }
|
|
||||||
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .success(let response): completion(response.image, nil)
|
|
||||||
case .failure(let error): completion(nil, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dataSource.prefetchCompletionHandler = { [weak dataSource] (cell, image, indexPath, error) in
|
|
||||||
let cell = cell as! AppCardCollectionViewCell
|
|
||||||
cell.bannerView.iconImageView.image = image
|
|
||||||
cell.bannerView.iconImageView.isIndicatingActivity = false
|
|
||||||
|
|
||||||
if let error = error, let dataSource
|
|
||||||
{
|
|
||||||
let app = dataSource.item(at: indexPath)
|
|
||||||
Logger.main.debug("Failed to app icon from \(app.iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension FeaturedViewController
|
|
||||||
{
|
|
||||||
@IBSegueAction
|
|
||||||
func makeBrowseViewController(_ coder: NSCoder, sender: Any) -> UIViewController?
|
|
||||||
{
|
|
||||||
if let category = sender as? StoreCategory
|
|
||||||
{
|
|
||||||
let browseViewController = BrowseViewController(category: category, coder: coder)
|
|
||||||
return browseViewController
|
|
||||||
}
|
|
||||||
else if let source = sender as? Source
|
|
||||||
{
|
|
||||||
let browseViewController = BrowseViewController(source: source, coder: coder)
|
|
||||||
return browseViewController
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
let browseViewController = BrowseViewController(coder: coder)
|
|
||||||
return browseViewController
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@IBSegueAction
|
|
||||||
func makeSourceDetailViewController(_ coder: NSCoder, sender: Any?) -> UIViewController?
|
|
||||||
{
|
|
||||||
guard let source = sender as? Source else { return nil }
|
|
||||||
|
|
||||||
let sourceDetailViewController = SourceDetailViewController(source: source, coder: coder)
|
|
||||||
return sourceDetailViewController
|
|
||||||
}
|
|
||||||
|
|
||||||
func showAllApps(for source: Source)
|
|
||||||
{
|
|
||||||
self.performSegue(withIdentifier: "showBrowseViewController", sender: source)
|
|
||||||
}
|
|
||||||
|
|
||||||
func showSourceDetails(for source: Source)
|
|
||||||
{
|
|
||||||
self.performSegue(withIdentifier: "showSourceDetails", sender: source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension FeaturedViewController
|
|
||||||
{
|
|
||||||
@objc func performAppAction(_ sender: PillButton)
|
|
||||||
{
|
|
||||||
let point = self.collectionView.convert(sender.center, from: sender.superview)
|
|
||||||
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
|
|
||||||
|
|
||||||
let storeApp = self.dataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
// if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable
|
|
||||||
if let installedApp = storeApp.installedApp, !installedApp.hasUpdate
|
|
||||||
{
|
|
||||||
self.open(installedApp)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.install(storeApp, at: indexPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func install(_ storeApp: StoreApp, at indexPath: IndexPath)
|
|
||||||
{
|
|
||||||
let previousProgress = AppManager.shared.installationProgress(for: storeApp)
|
|
||||||
guard previousProgress == nil else {
|
|
||||||
previousProgress?.cancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable
|
|
||||||
if let installedApp = storeApp.installedApp, installedApp.hasUpdate
|
|
||||||
{
|
|
||||||
AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
AppManager.shared.install(storeApp, presentingViewController: self, completionHandler: finish(_:))
|
|
||||||
}
|
|
||||||
|
|
||||||
UIView.performWithoutAnimation {
|
|
||||||
self.collectionView.reloadItems(at: [indexPath])
|
|
||||||
}
|
|
||||||
|
|
||||||
func finish(_ result: Result<InstalledApp, Error>)
|
|
||||||
{
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(OperationError.cancelled): break // Ignore
|
|
||||||
case .failure(let error):
|
|
||||||
let toastView = ToastView(error: error)
|
|
||||||
toastView.opensErrorLog = true
|
|
||||||
toastView.show(in: self)
|
|
||||||
|
|
||||||
case .success:
|
|
||||||
Logger.main.info("Installed app \(storeApp.bundleIdentifier, privacy: .public) from FeaturedViewController.")
|
|
||||||
}
|
|
||||||
|
|
||||||
for indexPath in self.collectionView.indexPathsForVisibleItems
|
|
||||||
{
|
|
||||||
// Only need to reload if it's still visible.
|
|
||||||
|
|
||||||
let item = self.dataSource.item(at: indexPath)
|
|
||||||
guard item == storeApp else { continue }
|
|
||||||
|
|
||||||
UIView.performWithoutAnimation {
|
|
||||||
self.collectionView.reloadItems(at: [indexPath])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func open(_ installedApp: InstalledApp)
|
|
||||||
{
|
|
||||||
UIApplication.shared.open(installedApp.openAppURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension FeaturedViewController
|
|
||||||
{
|
|
||||||
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
|
|
||||||
{
|
|
||||||
let section = Section(rawValue: indexPath.section)
|
|
||||||
|
|
||||||
switch kind
|
|
||||||
{
|
|
||||||
case ElementKind.sourceHeader.rawValue:
|
|
||||||
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! IconButtonCollectionReusableView
|
|
||||||
|
|
||||||
let indexPath = IndexPath(item: 0, section: indexPath.section)
|
|
||||||
let storeApp = self.dataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
var content = UIListContentConfiguration.plainHeader()
|
|
||||||
content.text = storeApp.source?.name ?? NSLocalizedString("Unknown Source", comment: "")
|
|
||||||
content.textProperties.numberOfLines = 1
|
|
||||||
|
|
||||||
content.directionalLayoutMargins.leading = 0
|
|
||||||
content.imageToTextPadding = 8
|
|
||||||
content.imageProperties.reservedLayoutSize = CGSize(width: 26, height: 26)
|
|
||||||
content.imageProperties.maximumSize = CGSize(width: 26, height: 26)
|
|
||||||
content.imageProperties.cornerRadius = 13
|
|
||||||
|
|
||||||
UIView.performWithoutAnimation {
|
|
||||||
headerView.titleButton.setTitle(content.text, for: .normal)
|
|
||||||
headerView.titleButton.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
headerView.iconButton.backgroundColor = storeApp.source?.effectiveTintColor?.adjustedForDisplay
|
|
||||||
headerView.iconButton.setImage(nil, for: .normal)
|
|
||||||
|
|
||||||
if let iconURL = storeApp.source?.effectiveIconURL
|
|
||||||
{
|
|
||||||
ImagePipeline.shared.loadImage(with: iconURL) { result in
|
|
||||||
guard case .success(let image) = result else { return }
|
|
||||||
|
|
||||||
headerView.iconButton.backgroundColor = .white
|
|
||||||
headerView.iconButton.setImage(image.image, for: .normal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let buttons = [headerView.iconButton, headerView.titleButton]
|
|
||||||
for button in buttons
|
|
||||||
{
|
|
||||||
button.removeAction(identifiedBy: .showSourceDetails, for: .primaryActionTriggered)
|
|
||||||
|
|
||||||
if let source = storeApp.source
|
|
||||||
{
|
|
||||||
let action = UIAction(identifier: .showSourceDetails) { [weak self] _ in
|
|
||||||
self?.showSourceDetails(for: source)
|
|
||||||
}
|
|
||||||
button.addAction(action, for: .primaryActionTriggered)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return headerView
|
|
||||||
|
|
||||||
case ElementKind.sectionHeader.rawValue:
|
|
||||||
// Regular section header
|
|
||||||
|
|
||||||
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! UICollectionViewListCell
|
|
||||||
|
|
||||||
var content: UIListContentConfiguration = if #available(iOS 15, *) {
|
|
||||||
.prominentInsetGroupedHeader()
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
.groupedHeader()
|
|
||||||
}
|
|
||||||
|
|
||||||
switch section
|
|
||||||
{
|
|
||||||
case .recentlyUpdated: content.text = NSLocalizedString("New & Updated", comment: "")
|
|
||||||
case .categories: content.text = NSLocalizedString("Categories", comment: "")
|
|
||||||
case .featuredHeader: content.text = NSLocalizedString("Featured", comment: "")
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
|
|
||||||
content.directionalLayoutMargins.leading = .zero
|
|
||||||
content.directionalLayoutMargins.trailing = .zero
|
|
||||||
|
|
||||||
headerView.contentConfiguration = content
|
|
||||||
return headerView
|
|
||||||
|
|
||||||
case ElementKind.button.rawValue where section.isFeaturedAppsSection:
|
|
||||||
let buttonView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath) as! ButtonCollectionReusableView
|
|
||||||
|
|
||||||
let indexPath = IndexPath(item: 0, section: indexPath.section)
|
|
||||||
let storeApp = self.dataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
buttonView.tintColor = storeApp.source?.effectiveTintColor?.adjustedForDisplay ?? .altPrimary
|
|
||||||
|
|
||||||
buttonView.button.setTitle(NSLocalizedString("See All", comment: ""), for: .normal)
|
|
||||||
buttonView.button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
|
||||||
buttonView.button.contentEdgeInsets.bottom = 8
|
|
||||||
|
|
||||||
buttonView.button.removeAction(identifiedBy: .showAllApps, for: .primaryActionTriggered)
|
|
||||||
|
|
||||||
if let source = storeApp.source
|
|
||||||
{
|
|
||||||
let action = UIAction(identifier: .showAllApps) { [weak self] _ in
|
|
||||||
self?.showAllApps(for: source)
|
|
||||||
}
|
|
||||||
buttonView.button.addAction(action, for: .primaryActionTriggered)
|
|
||||||
}
|
|
||||||
|
|
||||||
return buttonView
|
|
||||||
|
|
||||||
default: return UICollectionReusableView(frame: .zero)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
|
|
||||||
{
|
|
||||||
let storeApp = self.dataSource.item(at: indexPath)
|
|
||||||
|
|
||||||
let section = Section(rawValue: indexPath.section)
|
|
||||||
switch section
|
|
||||||
{
|
|
||||||
case _ where section.isFeaturedAppsSection: fallthrough
|
|
||||||
case .recentlyUpdated:
|
|
||||||
let appViewController = AppViewController.makeAppViewController(app: storeApp)
|
|
||||||
self.navigationController?.pushViewController(appViewController, animated: true)
|
|
||||||
|
|
||||||
case .categories:
|
|
||||||
let category = storeApp.category ?? .other
|
|
||||||
self.performSegue(withIdentifier: "showBrowseViewController", sender: category)
|
|
||||||
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 17, *)
|
|
||||||
#Preview(traits: .portrait) {
|
|
||||||
DatabaseManager.shared.startForPreview()
|
|
||||||
|
|
||||||
let storyboard = UIStoryboard(name: "Main", bundle: nil)
|
|
||||||
let featuredViewController = storyboard.instantiateViewController(identifier: "featuredViewController")
|
|
||||||
|
|
||||||
let navigationController = UINavigationController(rootViewController: featuredViewController)
|
|
||||||
navigationController.navigationBar.prefersLargeTitles = true
|
|
||||||
navigationController.modalPresentationStyle = .fullScreen
|
|
||||||
|
|
||||||
let viewController = UIViewController()
|
|
||||||
|
|
||||||
AppManager.shared.fetchSources() { (result) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let (_, context) = try result.get()
|
|
||||||
try context.save()
|
|
||||||
}
|
|
||||||
catch let error as NSError
|
|
||||||
{
|
|
||||||
Logger.main.error("Failed to fetch sources for preview. \(error.localizedDescription, privacy: .public)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AppManager.shared.updateKnownSources { result in
|
|
||||||
Task {
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let knownSources = try result.get()
|
|
||||||
|
|
||||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
|
||||||
|
|
||||||
await withThrowingTaskGroup(of: Void.self) { taskGroup in
|
|
||||||
for source in knownSources.0
|
|
||||||
{
|
|
||||||
guard let sourceURL = source.sourceURL else { continue }
|
|
||||||
|
|
||||||
taskGroup.addTask {
|
|
||||||
_ = try await AppManager.shared.fetchSource(sourceURL: sourceURL, managedObjectContext: context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.performAsync {
|
|
||||||
try! context.save()
|
|
||||||
}
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
viewController.present(navigationController, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
Logger.main.error("Failed to fetch known sources for preview. \(error.localizedDescription, privacy: .public)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return viewController
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
//
|
|
||||||
// AppBannerCollectionViewCell.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 3/23/20.
|
|
||||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class AppBannerCollectionViewCell: UICollectionViewListCell
|
|
||||||
{
|
|
||||||
let bannerView = AppBannerView(frame: .zero)
|
|
||||||
|
|
||||||
override init(frame: CGRect)
|
|
||||||
{
|
|
||||||
super.init(frame: frame)
|
|
||||||
|
|
||||||
self.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder)
|
|
||||||
{
|
|
||||||
super.init(coder: coder)
|
|
||||||
|
|
||||||
self.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initialize()
|
|
||||||
{
|
|
||||||
// Prevent content "squishing" when scrolling offscreen.
|
|
||||||
self.insetsLayoutMarginsFromSafeArea = false
|
|
||||||
self.contentView.insetsLayoutMarginsFromSafeArea = false
|
|
||||||
self.bannerView.insetsLayoutMarginsFromSafeArea = false
|
|
||||||
|
|
||||||
self.backgroundView = UIView() // Clear background
|
|
||||||
self.selectedBackgroundView = UIView() // Disable selection highlighting.
|
|
||||||
|
|
||||||
self.contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
||||||
self.contentView.preservesSuperviewLayoutMargins = true
|
|
||||||
|
|
||||||
self.bannerView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
self.contentView.addSubview(self.bannerView)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
self.bannerView.topAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.topAnchor),
|
|
||||||
self.bannerView.bottomAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.bottomAnchor),
|
|
||||||
self.bannerView.leadingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.leadingAnchor),
|
|
||||||
self.bannerView.trailingAnchor.constraint(equalTo: self.contentView.layoutMarginsGuide.trailingAnchor),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,27 +11,6 @@ import UIKit
|
|||||||
import AltStoreCore
|
import AltStoreCore
|
||||||
import Roxas
|
import Roxas
|
||||||
|
|
||||||
import Nuke
|
|
||||||
|
|
||||||
extension AppBannerView
|
|
||||||
{
|
|
||||||
static let standardHeight = 88.0
|
|
||||||
|
|
||||||
enum Style
|
|
||||||
{
|
|
||||||
case app
|
|
||||||
case source
|
|
||||||
}
|
|
||||||
|
|
||||||
enum AppAction
|
|
||||||
{
|
|
||||||
case install
|
|
||||||
case open
|
|
||||||
case update
|
|
||||||
case custom(String)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AppBannerView: RSTNibView
|
class AppBannerView: RSTNibView
|
||||||
{
|
{
|
||||||
override var accessibilityLabel: String? {
|
override var accessibilityLabel: String? {
|
||||||
@@ -59,8 +38,6 @@ class AppBannerView: RSTNibView
|
|||||||
set { self.accessibilityView?.accessibilityTraits = newValue }
|
set { self.accessibilityView?.accessibilityTraits = newValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
var style: Style = .app
|
|
||||||
|
|
||||||
private var originalTintColor: UIColor?
|
private var originalTintColor: UIColor?
|
||||||
|
|
||||||
@IBOutlet var titleLabel: UILabel!
|
@IBOutlet var titleLabel: UILabel!
|
||||||
@@ -69,16 +46,12 @@ class AppBannerView: RSTNibView
|
|||||||
@IBOutlet var button: PillButton!
|
@IBOutlet var button: PillButton!
|
||||||
@IBOutlet var buttonLabel: UILabel!
|
@IBOutlet var buttonLabel: UILabel!
|
||||||
@IBOutlet var betaBadgeView: UIView!
|
@IBOutlet var betaBadgeView: UIView!
|
||||||
@IBOutlet var sourceIconImageView: AppIconImageView!
|
|
||||||
|
|
||||||
@IBOutlet var backgroundEffectView: UIVisualEffectView!
|
@IBOutlet var backgroundEffectView: UIVisualEffectView!
|
||||||
|
|
||||||
@IBOutlet private var vibrancyView: UIVisualEffectView!
|
@IBOutlet private var vibrancyView: UIVisualEffectView!
|
||||||
@IBOutlet private var stackView: UIStackView!
|
|
||||||
@IBOutlet private var accessibilityView: UIView!
|
@IBOutlet private var accessibilityView: UIView!
|
||||||
|
|
||||||
@IBOutlet private var iconImageViewHeightConstraint: NSLayoutConstraint!
|
|
||||||
|
|
||||||
override init(frame: CGRect)
|
override init(frame: CGRect)
|
||||||
{
|
{
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
@@ -101,15 +74,6 @@ class AppBannerView: RSTNibView
|
|||||||
self.accessibilityElements = [self.accessibilityView, self.button].compactMap { $0 }
|
self.accessibilityElements = [self.accessibilityView, self.button].compactMap { $0 }
|
||||||
|
|
||||||
self.betaBadgeView.isHidden = true
|
self.betaBadgeView.isHidden = true
|
||||||
|
|
||||||
self.sourceIconImageView.style = .circular
|
|
||||||
self.sourceIconImageView.isHidden = true
|
|
||||||
|
|
||||||
self.layoutMargins = self.stackView.layoutMargins
|
|
||||||
self.insetsLayoutMarginsFromSafeArea = false
|
|
||||||
|
|
||||||
self.stackView.isLayoutMarginsRelativeArrangement = true
|
|
||||||
self.stackView.preservesSuperviewLayoutMargins = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tintColorDidChange()
|
override func tintColorDidChange()
|
||||||
@@ -127,7 +91,7 @@ class AppBannerView: RSTNibView
|
|||||||
|
|
||||||
extension AppBannerView
|
extension AppBannerView
|
||||||
{
|
{
|
||||||
func configure(for app: AppProtocol, action: AppAction? = nil, showSourceIcon: Bool = true)
|
func configure(for app: AppProtocol)
|
||||||
{
|
{
|
||||||
struct AppValues
|
struct AppValues
|
||||||
{
|
{
|
||||||
@@ -138,20 +102,17 @@ extension AppBannerView
|
|||||||
init(app: AppProtocol)
|
init(app: AppProtocol)
|
||||||
{
|
{
|
||||||
self.name = app.name
|
self.name = app.name
|
||||||
|
|
||||||
guard let storeApp = (app as? StoreApp) ?? (app as? InstalledApp)?.storeApp else { return }
|
guard let storeApp = (app as? StoreApp) ?? (app as? InstalledApp)?.storeApp else { return }
|
||||||
self.developerName = storeApp.developerName
|
self.developerName = storeApp.developerName
|
||||||
|
|
||||||
if let track = storeApp.latestSupportedVersion?.channel,
|
if storeApp.isBeta
|
||||||
ReleaseTracks.betaTracks.contains(track)
|
|
||||||
{
|
{
|
||||||
self.name = String(format: NSLocalizedString("%@ beta", comment: ""), app.name)
|
self.name = String(format: NSLocalizedString("%@ beta", comment: ""), app.name)
|
||||||
self.isBeta = true
|
self.isBeta = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.style = .app
|
|
||||||
|
|
||||||
let values = AppValues(app: app)
|
let values = AppValues(app: app)
|
||||||
self.titleLabel.text = app.name // Don't use values.name since that already includes "beta".
|
self.titleLabel.text = app.name // Don't use values.name since that already includes "beta".
|
||||||
@@ -167,210 +128,6 @@ extension AppBannerView
|
|||||||
self.subtitleLabel.text = NSLocalizedString("Sideloaded", comment: "")
|
self.subtitleLabel.text = NSLocalizedString("Sideloaded", comment: "")
|
||||||
self.accessibilityLabel = values.name
|
self.accessibilityLabel = values.name
|
||||||
}
|
}
|
||||||
|
|
||||||
if let storeApp = app.storeApp, storeApp.isPledgeRequired
|
|
||||||
{
|
|
||||||
// Always show button label for Patreon apps.
|
|
||||||
self.buttonLabel.isHidden = false
|
|
||||||
|
|
||||||
if storeApp.isPledged
|
|
||||||
{
|
|
||||||
self.buttonLabel.text = NSLocalizedString("Pledged", comment: "")
|
|
||||||
}
|
|
||||||
else if storeApp.installedApp != nil
|
|
||||||
{
|
|
||||||
self.buttonLabel.text = NSLocalizedString("Pledge Expired", comment: "")
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.buttonLabel.text = NSLocalizedString("Join Patreon", comment: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.buttonLabel.isHidden = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if let source = app.storeApp?.source, showSourceIcon
|
|
||||||
{
|
|
||||||
self.sourceIconImageView.isHidden = false
|
|
||||||
self.sourceIconImageView.backgroundColor = source.effectiveTintColor?.adjustedForDisplay ?? .altPrimary
|
|
||||||
|
|
||||||
if let iconURL = source.effectiveIconURL
|
|
||||||
{
|
|
||||||
if let image = ImageCache.shared[iconURL]
|
|
||||||
{
|
|
||||||
self.sourceIconImageView.backgroundColor = .white
|
|
||||||
self.sourceIconImageView.image = image.image
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.sourceIconImageView.image = nil
|
|
||||||
|
|
||||||
Nuke.loadImage(with: iconURL, into: self.sourceIconImageView) { result in
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .failure(let error): Logger.main.error("Failed to fetch source icon from \(iconURL, privacy: .public). \(error.localizedDescription, privacy: .public)")
|
|
||||||
case .success: self.sourceIconImageView.backgroundColor = .white // In case icon has transparent background.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.sourceIconImageView.isHidden = true
|
|
||||||
}
|
|
||||||
|
|
||||||
let buttonAction: AppAction
|
|
||||||
|
|
||||||
if let action
|
|
||||||
{
|
|
||||||
buttonAction = action
|
|
||||||
}
|
|
||||||
else if let storeApp = app.storeApp
|
|
||||||
{
|
|
||||||
if let installedApp = storeApp.installedApp
|
|
||||||
{
|
|
||||||
// App is installed
|
|
||||||
|
|
||||||
// if installedApp.isUpdateAvailable
|
|
||||||
if installedApp.hasUpdate
|
|
||||||
{
|
|
||||||
buttonAction = .update
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
buttonAction = .open
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// App is not installed
|
|
||||||
buttonAction = .install
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// App is not from a source, fall back to .open
|
|
||||||
buttonAction = .open
|
|
||||||
}
|
|
||||||
|
|
||||||
UIView.performWithoutAnimation {
|
|
||||||
switch buttonAction
|
|
||||||
{
|
|
||||||
case .open:
|
|
||||||
let buttonTitle = NSLocalizedString("Open", comment: "")
|
|
||||||
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
|
||||||
self.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), values.name)
|
|
||||||
self.button.accessibilityValue = buttonTitle
|
|
||||||
|
|
||||||
self.button.countdownDate = nil
|
|
||||||
|
|
||||||
case .update:
|
|
||||||
let buttonTitle = NSLocalizedString("Update", comment: "")
|
|
||||||
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
|
||||||
self.button.accessibilityLabel = String(format: NSLocalizedString("Update %@", comment: ""), values.name)
|
|
||||||
self.button.accessibilityValue = buttonTitle
|
|
||||||
|
|
||||||
self.button.countdownDate = nil
|
|
||||||
|
|
||||||
case .custom(let buttonTitle):
|
|
||||||
self.button.setTitle(buttonTitle, for: .normal)
|
|
||||||
self.button.accessibilityLabel = buttonTitle
|
|
||||||
self.button.accessibilityValue = buttonTitle
|
|
||||||
|
|
||||||
self.button.countdownDate = nil
|
|
||||||
|
|
||||||
case .install:
|
|
||||||
if let storeApp = app.storeApp, storeApp.isPledgeRequired
|
|
||||||
{
|
|
||||||
// Pledge required
|
|
||||||
|
|
||||||
if storeApp.isPledged
|
|
||||||
{
|
|
||||||
let buttonTitle = NSLocalizedString("Install", comment: "")
|
|
||||||
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
|
||||||
self.button.accessibilityLabel = String(format: NSLocalizedString("Install %@", comment: ""), app.name)
|
|
||||||
self.button.accessibilityValue = buttonTitle
|
|
||||||
}
|
|
||||||
else if let amount = storeApp.pledgeAmount, let currencyCode = storeApp.pledgeCurrency, !storeApp.prefersCustomPledge, #available(iOS 15, *)
|
|
||||||
{
|
|
||||||
let price = amount.formatted(.currency(code: currencyCode).presentation(.narrow).precision(.fractionLength(0...2)))
|
|
||||||
|
|
||||||
let buttonTitle = String(format: NSLocalizedString("%@/mo", comment: ""), price)
|
|
||||||
self.button.setTitle(buttonTitle, for: .normal)
|
|
||||||
self.button.accessibilityLabel = String(format: NSLocalizedString("Pledge %@ a month", comment: ""), price)
|
|
||||||
self.button.accessibilityValue = String(format: NSLocalizedString("%@ a month", comment: ""), price)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
let buttonTitle = NSLocalizedString("Pledge", comment: "")
|
|
||||||
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
|
||||||
self.button.accessibilityLabel = buttonTitle
|
|
||||||
self.button.accessibilityValue = buttonTitle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Free app
|
|
||||||
|
|
||||||
let buttonTitle = NSLocalizedString("Free", comment: "")
|
|
||||||
self.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
|
||||||
self.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name)
|
|
||||||
self.button.accessibilityValue = buttonTitle
|
|
||||||
}
|
|
||||||
|
|
||||||
if let versionDate = app.storeApp?.latestSupportedVersion?.date, versionDate > Date()
|
|
||||||
{
|
|
||||||
self.button.countdownDate = versionDate
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.button.countdownDate = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure PillButton is correct size before assigning progress.
|
|
||||||
self.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
if let progress = AppManager.shared.installationProgress(for: app), progress.fractionCompleted < 1.0
|
|
||||||
{
|
|
||||||
self.button.progress = progress
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.button.progress = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func configure(for source: Source)
|
|
||||||
{
|
|
||||||
self.style = .source
|
|
||||||
|
|
||||||
let subtitle: String
|
|
||||||
if let text = source.subtitle
|
|
||||||
{
|
|
||||||
subtitle = text
|
|
||||||
}
|
|
||||||
else if let scheme = source.sourceURL.scheme
|
|
||||||
{
|
|
||||||
subtitle = source.sourceURL.absoluteString.replacingOccurrences(of: scheme + "://", with: "")
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
subtitle = source.sourceURL.absoluteString
|
|
||||||
}
|
|
||||||
|
|
||||||
self.titleLabel.text = source.name
|
|
||||||
self.subtitleLabel.text = subtitle
|
|
||||||
|
|
||||||
let tintColor = source.effectiveTintColor ?? .altPrimary
|
|
||||||
self.tintColor = tintColor
|
|
||||||
|
|
||||||
let accessibilityLabel = source.name + "\n" + subtitle
|
|
||||||
self.accessibilityLabel = accessibilityLabel
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,48 +138,7 @@ private extension AppBannerView
|
|||||||
self.clipsToBounds = true
|
self.clipsToBounds = true
|
||||||
self.layer.cornerRadius = 22
|
self.layer.cornerRadius = 22
|
||||||
|
|
||||||
let tintColor = self.originalTintColor ?? self.tintColor
|
self.subtitleLabel.textColor = self.originalTintColor ?? self.tintColor
|
||||||
self.subtitleLabel.textColor = tintColor
|
self.backgroundEffectView.backgroundColor = self.originalTintColor ?? self.tintColor
|
||||||
|
|
||||||
switch self.style
|
|
||||||
{
|
|
||||||
case .app:
|
|
||||||
self.directionalLayoutMargins.trailing = self.stackView.directionalLayoutMargins.trailing
|
|
||||||
|
|
||||||
self.iconImageViewHeightConstraint.constant = 60
|
|
||||||
self.iconImageView.style = .icon
|
|
||||||
|
|
||||||
self.titleLabel.textColor = .label
|
|
||||||
|
|
||||||
self.button.style = .pill
|
|
||||||
|
|
||||||
self.backgroundEffectView.contentView.backgroundColor = UIColor(resource: .blurTint)
|
|
||||||
self.backgroundEffectView.backgroundColor = tintColor
|
|
||||||
|
|
||||||
case .source:
|
|
||||||
self.directionalLayoutMargins.trailing = 20
|
|
||||||
|
|
||||||
self.iconImageViewHeightConstraint.constant = 44
|
|
||||||
self.iconImageView.style = .circular
|
|
||||||
|
|
||||||
self.titleLabel.textColor = .white
|
|
||||||
|
|
||||||
self.button.style = .custom
|
|
||||||
|
|
||||||
self.backgroundEffectView.contentView.backgroundColor = tintColor?.adjustedForDisplay
|
|
||||||
self.backgroundEffectView.backgroundColor = nil
|
|
||||||
|
|
||||||
if let tintColor, tintColor.isTooBright
|
|
||||||
{
|
|
||||||
let textVibrancyEffect = UIVibrancyEffect(blurEffect: .init(style: .systemChromeMaterialLight), style: .fill)
|
|
||||||
self.vibrancyView.effect = textVibrancyEffect
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Thinner == more dull
|
|
||||||
let textVibrancyEffect = UIVibrancyEffect(blurEffect: .init(style: .systemThinMaterialDark), style: .secondaryLabel)
|
|
||||||
self.vibrancyView.effect = textVibrancyEffect
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="22155" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17154" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<deployment identifier="iOS"/>
|
<deployment identifier="iOS"/>
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22131"/>
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17124"/>
|
||||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
@@ -17,9 +17,6 @@
|
|||||||
<outlet property="button" destination="tVx-3G-dcu" id="joa-AH-syX"/>
|
<outlet property="button" destination="tVx-3G-dcu" id="joa-AH-syX"/>
|
||||||
<outlet property="buttonLabel" destination="Yd9-jw-faD" id="o7g-Gb-CIt"/>
|
<outlet property="buttonLabel" destination="Yd9-jw-faD" id="o7g-Gb-CIt"/>
|
||||||
<outlet property="iconImageView" destination="avS-dx-4iy" id="TQs-Ej-gin"/>
|
<outlet property="iconImageView" destination="avS-dx-4iy" id="TQs-Ej-gin"/>
|
||||||
<outlet property="iconImageViewHeightConstraint" destination="6lU-H8-nEw" id="PSt-Xa-lQT"/>
|
|
||||||
<outlet property="sourceIconImageView" destination="dku-SJ-aay" id="rA0-y1-dIb"/>
|
|
||||||
<outlet property="stackView" destination="d1T-UD-gWG" id="E7N-Zb-lm1"/>
|
|
||||||
<outlet property="subtitleLabel" destination="oN5-vu-Dnw" id="gA4-iJ-Tix"/>
|
<outlet property="subtitleLabel" destination="oN5-vu-Dnw" id="gA4-iJ-Tix"/>
|
||||||
<outlet property="titleLabel" destination="mFe-zJ-eva" id="2OH-f8-cid"/>
|
<outlet property="titleLabel" destination="mFe-zJ-eva" id="2OH-f8-cid"/>
|
||||||
<outlet property="vibrancyView" destination="fC4-1V-iMn" id="PXE-2B-A7w"/>
|
<outlet property="vibrancyView" destination="fC4-1V-iMn" id="PXE-2B-A7w"/>
|
||||||
@@ -46,38 +43,31 @@
|
|||||||
</view>
|
</view>
|
||||||
<blurEffect style="systemChromeMaterial"/>
|
<blurEffect style="systemChromeMaterial"/>
|
||||||
</visualEffectView>
|
</visualEffectView>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" alignment="center" spacing="11" translatesAutoresizingMaskIntoConstraints="NO" id="d1T-UD-gWG" userLabel="App Info">
|
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" alignment="center" spacing="11" translatesAutoresizingMaskIntoConstraints="NO" id="d1T-UD-gWG" userLabel="App Info">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
|
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="avS-dx-4iy" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="avS-dx-4iy" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
|
||||||
<rect key="frame" x="16" y="14" width="60" height="60"/>
|
<rect key="frame" x="14" y="14" width="60" height="60"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="height" constant="60" id="6lU-H8-nEw"/>
|
<constraint firstAttribute="height" constant="60" id="6lU-H8-nEw"/>
|
||||||
<constraint firstAttribute="width" secondItem="avS-dx-4iy" secondAttribute="height" multiplier="1:1" id="AYT-3g-wcV"/>
|
<constraint firstAttribute="width" secondItem="avS-dx-4iy" secondAttribute="height" multiplier="1:1" id="AYT-3g-wcV"/>
|
||||||
</constraints>
|
</constraints>
|
||||||
</imageView>
|
</imageView>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="100" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" alignment="top" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="caL-vN-Svn">
|
<stackView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="100" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" alignment="top" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="caL-vN-Svn">
|
||||||
<rect key="frame" x="87" y="25.5" width="184" height="37.5"/>
|
<rect key="frame" x="85" y="25.5" width="190" height="37.5"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" horizontalCompressionResistancePriority="500" alignment="center" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="1Es-pv-zwd">
|
<stackView opaque="NO" contentMode="scaleToFill" horizontalCompressionResistancePriority="500" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="1Es-pv-zwd">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="147" height="19.5"/>
|
<rect key="frame" x="0.0" y="0.0" width="126" height="19.5"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="400" verticalHuggingPriority="251" horizontalCompressionResistancePriority="500" text="App Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="mFe-zJ-eva">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" horizontalCompressionResistancePriority="500" text="App Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="mFe-zJ-eva">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="79" height="19.5"/>
|
<rect key="frame" x="0.0" y="0.0" width="79" height="19.5"/>
|
||||||
<accessibility key="accessibilityConfiguration" identifier="NameLabel"/>
|
<accessibility key="accessibilityConfiguration" identifier="NameLabel"/>
|
||||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="16"/>
|
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="16"/>
|
||||||
<nil key="textColor"/>
|
<nil key="textColor"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="dku-SJ-aay" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
|
|
||||||
<rect key="frame" x="84" y="1" width="17" height="17"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="width" secondItem="dku-SJ-aay" secondAttribute="height" id="VKw-lc-8NQ"/>
|
|
||||||
<constraint firstAttribute="width" constant="17" id="hAe-gc-Ehh"/>
|
|
||||||
</constraints>
|
|
||||||
</imageView>
|
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="BetaBadge" translatesAutoresizingMaskIntoConstraints="NO" id="qQl-Ez-zC5">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="BetaBadge" translatesAutoresizingMaskIntoConstraints="NO" id="qQl-Ez-zC5">
|
||||||
<rect key="frame" x="106" y="1" width="41" height="17"/>
|
<rect key="frame" x="85" y="0.0" width="41" height="19.5"/>
|
||||||
<accessibility key="accessibilityConfiguration" identifier="Beta Badge">
|
<accessibility key="accessibilityConfiguration" identifier="Beta Badge">
|
||||||
<accessibilityTraits key="traits" image="YES" notEnabled="YES"/>
|
<accessibilityTraits key="traits" image="YES" notEnabled="YES"/>
|
||||||
<bool key="isElement" value="YES"/>
|
<bool key="isElement" value="YES"/>
|
||||||
@@ -86,13 +76,13 @@
|
|||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</stackView>
|
||||||
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fC4-1V-iMn">
|
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fC4-1V-iMn">
|
||||||
<rect key="frame" x="0.0" y="21.5" width="62" height="16"/>
|
<rect key="frame" x="0.0" y="21.5" width="190" height="16"/>
|
||||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="LQh-pN-ePC">
|
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="LQh-pN-ePC">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="62" height="16"/>
|
<rect key="frame" x="0.0" y="0.0" width="190" height="16"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="750" verticalHuggingPriority="750" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="oN5-vu-Dnw">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="750" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="oN5-vu-Dnw">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="62" height="16"/>
|
<rect key="frame" x="0.0" y="0.0" width="190" height="16"/>
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="13"/>
|
<fontDescription key="fontDescription" type="system" pointSize="13"/>
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
@@ -111,36 +101,39 @@
|
|||||||
</visualEffectView>
|
</visualEffectView>
|
||||||
</subviews>
|
</subviews>
|
||||||
</stackView>
|
</stackView>
|
||||||
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="900" horizontalCompressionResistancePriority="900" placeholderIntrinsicWidth="77" placeholderIntrinsicHeight="31" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="tVx-3G-dcu" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
|
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="3ox-Q2-Rnd" userLabel="Button Stack View">
|
||||||
<rect key="frame" x="282" y="28.5" width="77" height="31"/>
|
<rect key="frame" x="286" y="28.5" width="77" height="31"/>
|
||||||
<color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
|
<subviews>
|
||||||
<constraints>
|
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yd9-jw-faD">
|
||||||
<constraint firstAttribute="width" relation="greaterThanOrEqual" secondItem="tVx-3G-dcu" secondAttribute="height" priority="999" id="Vbk-VH-5eU"/>
|
<rect key="frame" x="0.0" y="0.0" width="77" height="0.0"/>
|
||||||
<constraint firstAttribute="height" constant="31" id="Zwh-yQ-GTu"/>
|
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="10"/>
|
||||||
</constraints>
|
<color key="textColor" systemColor="secondaryLabelColor"/>
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
|
<nil key="highlightedColor"/>
|
||||||
<state key="normal" title="FREE"/>
|
</label>
|
||||||
</button>
|
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="900" horizontalCompressionResistancePriority="900" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="tVx-3G-dcu" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="77" height="31"/>
|
||||||
|
<color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstAttribute="height" constant="31" id="Zwh-yQ-GTu"/>
|
||||||
|
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="77" id="eGc-Dk-QbL"/>
|
||||||
|
</constraints>
|
||||||
|
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
|
||||||
|
<state key="normal" title="FREE"/>
|
||||||
|
</button>
|
||||||
|
</subviews>
|
||||||
|
</stackView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<edgeInsets key="layoutMargins" top="14" left="14" bottom="14" right="12"/>
|
<edgeInsets key="layoutMargins" top="14" left="14" bottom="14" right="12"/>
|
||||||
</stackView>
|
</stackView>
|
||||||
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yd9-jw-faD">
|
|
||||||
<rect key="frame" x="307" y="12.5" width="27" height="12"/>
|
|
||||||
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="10"/>
|
|
||||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
</subviews>
|
</subviews>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="d1T-UD-gWG" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="5tv-QN-ZWU"/>
|
<constraint firstItem="d1T-UD-gWG" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="5tv-QN-ZWU"/>
|
||||||
<constraint firstAttribute="bottom" secondItem="bJL-Yw-i4u" secondAttribute="bottom" id="FRq-ZD-2rE"/>
|
<constraint firstAttribute="bottom" secondItem="bJL-Yw-i4u" secondAttribute="bottom" id="FRq-ZD-2rE"/>
|
||||||
<constraint firstItem="rZk-be-tiI" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="N6R-B2-Rie"/>
|
<constraint firstItem="rZk-be-tiI" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="N6R-B2-Rie"/>
|
||||||
<constraint firstItem="Yd9-jw-faD" firstAttribute="centerX" secondItem="tVx-3G-dcu" secondAttribute="centerX" id="acx-pf-8hH"/>
|
|
||||||
<constraint firstItem="bJL-Yw-i4u" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="h6T-q1-YV9"/>
|
<constraint firstItem="bJL-Yw-i4u" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="h6T-q1-YV9"/>
|
||||||
<constraint firstItem="tVx-3G-dcu" firstAttribute="top" secondItem="Yd9-jw-faD" secondAttribute="bottom" constant="4" id="hTD-wh-KV8"/>
|
|
||||||
<constraint firstAttribute="trailing" secondItem="d1T-UD-gWG" secondAttribute="trailing" id="mlG-w3-Ly6"/>
|
<constraint firstAttribute="trailing" secondItem="d1T-UD-gWG" secondAttribute="trailing" id="mlG-w3-Ly6"/>
|
||||||
<constraint firstAttribute="trailing" secondItem="rZk-be-tiI" secondAttribute="trailing" id="nEy-pm-Fcs"/>
|
<constraint firstAttribute="trailing" secondItem="rZk-be-tiI" secondAttribute="trailing" id="nEy-pm-Fcs"/>
|
||||||
<constraint firstAttribute="bottom" secondItem="d1T-UD-gWG" secondAttribute="bottom" priority="999" id="nJo-To-LmX"/>
|
<constraint firstAttribute="bottom" secondItem="d1T-UD-gWG" secondAttribute="bottom" id="nJo-To-LmX"/>
|
||||||
<constraint firstItem="bJL-Yw-i4u" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="oLt-2z-QoJ"/>
|
<constraint firstItem="bJL-Yw-i4u" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="oLt-2z-QoJ"/>
|
||||||
<constraint firstItem="rZk-be-tiI" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="pGD-Tl-U4c"/>
|
<constraint firstItem="rZk-be-tiI" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="pGD-Tl-U4c"/>
|
||||||
<constraint firstItem="d1T-UD-gWG" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="q2p-0S-Nv5"/>
|
<constraint firstItem="d1T-UD-gWG" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="q2p-0S-Nv5"/>
|
||||||
|
|||||||
@@ -1,388 +0,0 @@
|
|||||||
//
|
|
||||||
// AppCardCollectionViewCell.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 10/13/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
import AltStoreCore
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
import Nuke
|
|
||||||
|
|
||||||
private let minimumItemSpacing = 8.0
|
|
||||||
|
|
||||||
class AppCardCollectionViewCell: UICollectionViewCell
|
|
||||||
{
|
|
||||||
let bannerView: AppBannerView
|
|
||||||
let captionLabel: UILabel
|
|
||||||
|
|
||||||
var prefersPagingScreenshots = true
|
|
||||||
|
|
||||||
private let screenshotsCollectionView: UICollectionView
|
|
||||||
private let stackView: UIStackView
|
|
||||||
|
|
||||||
private let topAreaPanGestureRecognizer: UIPanGestureRecognizer
|
|
||||||
|
|
||||||
private lazy var dataSource = self.makeDataSource()
|
|
||||||
|
|
||||||
private var screenshots: [AppScreenshot] = [] {
|
|
||||||
didSet {
|
|
||||||
self.dataSource.items = self.screenshots
|
|
||||||
|
|
||||||
if self.screenshots.isEmpty
|
|
||||||
{
|
|
||||||
// No screenshots, so hide collection view.
|
|
||||||
self.collectionViewAspectRatioConstraint.isActive = false
|
|
||||||
self.stackView.layoutMargins.bottom = 0
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// At least one screenshot, so show collection view.
|
|
||||||
self.collectionViewAspectRatioConstraint.isActive = true
|
|
||||||
self.stackView.layoutMargins.bottom = self.screenshotsCollectionView.directionalLayoutMargins.leading
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private let collectionViewAspectRatioConstraint: NSLayoutConstraint
|
|
||||||
|
|
||||||
override init(frame: CGRect)
|
|
||||||
{
|
|
||||||
self.bannerView = AppBannerView(frame: .zero)
|
|
||||||
self.bannerView.layoutMargins.bottom = 0
|
|
||||||
|
|
||||||
let vibrancyEffect = UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemChromeMaterial), style: .secondaryLabel)
|
|
||||||
let captionVibrancyView = UIVisualEffectView(effect: vibrancyEffect)
|
|
||||||
|
|
||||||
self.captionLabel = UILabel(frame: .zero)
|
|
||||||
self.captionLabel.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .footnote).bolded(), size: 0)
|
|
||||||
self.captionLabel.textAlignment = .center
|
|
||||||
self.captionLabel.numberOfLines = 2
|
|
||||||
self.captionLabel.minimumScaleFactor = 0.8
|
|
||||||
captionVibrancyView.contentView.addSubview(self.captionLabel, pinningEdgesWith: .zero)
|
|
||||||
|
|
||||||
self.screenshotsCollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
|
|
||||||
self.screenshotsCollectionView.backgroundColor = nil
|
|
||||||
self.screenshotsCollectionView.alwaysBounceVertical = false
|
|
||||||
self.screenshotsCollectionView.alwaysBounceHorizontal = true
|
|
||||||
self.screenshotsCollectionView.showsHorizontalScrollIndicator = false
|
|
||||||
self.screenshotsCollectionView.showsVerticalScrollIndicator = false
|
|
||||||
|
|
||||||
self.stackView = UIStackView(arrangedSubviews: [self.bannerView, captionVibrancyView, self.screenshotsCollectionView])
|
|
||||||
self.stackView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
self.stackView.spacing = 12
|
|
||||||
self.stackView.axis = .vertical
|
|
||||||
self.stackView.alignment = .fill
|
|
||||||
self.stackView.distribution = .equalSpacing
|
|
||||||
|
|
||||||
// Aspect ratio constraint to fit exactly 3 modern portrait iPhone screenshots side-by-side (with spacing).
|
|
||||||
let inset = self.bannerView.layoutMargins.left
|
|
||||||
let multiplier = (AppScreenshot.defaultAspectRatio.width * 3) / AppScreenshot.defaultAspectRatio.height
|
|
||||||
let spacing = (inset * 2) + (minimumItemSpacing * 2)
|
|
||||||
self.collectionViewAspectRatioConstraint = self.screenshotsCollectionView.widthAnchor.constraint(equalTo: self.screenshotsCollectionView.heightAnchor, multiplier: multiplier, constant: spacing)
|
|
||||||
|
|
||||||
// Allows us to ignore swipes in top portion of screenshotsCollectionView.
|
|
||||||
self.topAreaPanGestureRecognizer = UIPanGestureRecognizer(target: nil, action: nil)
|
|
||||||
self.topAreaPanGestureRecognizer.cancelsTouchesInView = false
|
|
||||||
self.topAreaPanGestureRecognizer.delaysTouchesBegan = false
|
|
||||||
self.topAreaPanGestureRecognizer.delaysTouchesEnded = false
|
|
||||||
|
|
||||||
super.init(frame: frame)
|
|
||||||
|
|
||||||
self.contentView.clipsToBounds = true
|
|
||||||
self.contentView.layer.cornerCurve = .continuous
|
|
||||||
|
|
||||||
self.contentView.addSubview(self.bannerView.backgroundEffectView, pinningEdgesWith: .zero)
|
|
||||||
self.contentView.addSubview(self.stackView, pinningEdgesWith: .zero)
|
|
||||||
|
|
||||||
self.screenshotsCollectionView.collectionViewLayout = self.makeLayout()
|
|
||||||
self.screenshotsCollectionView.dataSource = self.dataSource
|
|
||||||
self.screenshotsCollectionView.prefetchDataSource = self.dataSource
|
|
||||||
|
|
||||||
// Adding screenshotsCollectionView's gesture recognizers to self.contentView breaks paging,
|
|
||||||
// so instead we intercept taps and pass them onto delegate.
|
|
||||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(AppCardCollectionViewCell.handleTapGesture(_:)))
|
|
||||||
tapGestureRecognizer.cancelsTouchesInView = false
|
|
||||||
tapGestureRecognizer.delaysTouchesBegan = false
|
|
||||||
tapGestureRecognizer.delaysTouchesEnded = false
|
|
||||||
self.screenshotsCollectionView.addGestureRecognizer(tapGestureRecognizer)
|
|
||||||
|
|
||||||
self.topAreaPanGestureRecognizer.delegate = self
|
|
||||||
self.screenshotsCollectionView.panGestureRecognizer.require(toFail: self.topAreaPanGestureRecognizer)
|
|
||||||
self.screenshotsCollectionView.addGestureRecognizer(self.topAreaPanGestureRecognizer)
|
|
||||||
|
|
||||||
self.screenshotsCollectionView.register(AppScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
|
||||||
|
|
||||||
self.stackView.isLayoutMarginsRelativeArrangement = true
|
|
||||||
self.stackView.layoutMargins.bottom = inset
|
|
||||||
|
|
||||||
self.contentView.preservesSuperviewLayoutMargins = true
|
|
||||||
self.screenshotsCollectionView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: inset, bottom: 0, trailing: inset)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
self.bannerView.heightAnchor.constraint(equalToConstant: AppBannerView.standardHeight - inset)
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews()
|
|
||||||
{
|
|
||||||
super.layoutSubviews()
|
|
||||||
|
|
||||||
self.contentView.layer.cornerRadius = self.bannerView.layer.cornerRadius
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension AppCardCollectionViewCell
|
|
||||||
{
|
|
||||||
func makeLayout() -> UICollectionViewCompositionalLayout
|
|
||||||
{
|
|
||||||
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
|
|
||||||
layoutConfig.contentInsetsReference = .layoutMargins
|
|
||||||
|
|
||||||
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
|
|
||||||
guard let self else { return nil }
|
|
||||||
|
|
||||||
var contentWidth = 0.0
|
|
||||||
var numberOfVisibleScreenshots = 0
|
|
||||||
|
|
||||||
for screenshot in self.screenshots
|
|
||||||
{
|
|
||||||
var aspectRatio = screenshot.aspectRatio
|
|
||||||
if aspectRatio.width > aspectRatio.height
|
|
||||||
{
|
|
||||||
switch screenshot.deviceType
|
|
||||||
{
|
|
||||||
case .iphone:
|
|
||||||
// Always rotate landscape iPhone screenshots
|
|
||||||
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
|
||||||
|
|
||||||
case .ipad:
|
|
||||||
// Never rotate iPad screenshots
|
|
||||||
break
|
|
||||||
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let screenshotWidth = (layoutEnvironment.container.effectiveContentSize.height * (aspectRatio.width / aspectRatio.height)).rounded(.up) // Round to ensure we over-estimate contentWidth.
|
|
||||||
|
|
||||||
let totalContentWidth = contentWidth + (screenshotWidth + minimumItemSpacing)
|
|
||||||
if totalContentWidth > layoutEnvironment.container.effectiveContentSize.width
|
|
||||||
{
|
|
||||||
// totalContentWidth is larger than visible width.
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
contentWidth = totalContentWidth
|
|
||||||
numberOfVisibleScreenshots += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use .estimated(1) to ensure we don't over-estimate widths, which can cause incorrect layouts for the last group.
|
|
||||||
let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(1), heightDimension: .fractionalHeight(1.0))
|
|
||||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
|
||||||
|
|
||||||
if numberOfVisibleScreenshots == 1
|
|
||||||
{
|
|
||||||
// If there's only one screenshot visible initially, we'll (reluctantly) opt-in to flexible spacing on both sides.
|
|
||||||
// This ensures the items are always centered, but may result in larger spacings between items than we'd prefer.
|
|
||||||
item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(0), top: nil, trailing: .flexible(0), bottom: nil)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Otherwise, only have flexible spacing on the leading edge, which will be balanced by trailingGroup's flexible trailing spacing.
|
|
||||||
item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(0), top: nil, trailing: nil, bottom: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
let groupItem = NSCollectionLayoutItem(layoutSize: itemSize)
|
|
||||||
let trailingGroup = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [groupItem])
|
|
||||||
trailingGroup.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: nil, top: nil, trailing: .flexible(0), bottom: nil)
|
|
||||||
|
|
||||||
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
|
|
||||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, trailingGroup])
|
|
||||||
group.interItemSpacing = .fixed(minimumItemSpacing)
|
|
||||||
|
|
||||||
if numberOfVisibleScreenshots < self.screenshots.count
|
|
||||||
{
|
|
||||||
// There are more screenshots than what is displayed, so no need to manually center them.
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// We're showing all screenshots initially, so make sure they're centered.
|
|
||||||
|
|
||||||
let insetWidth = (layoutEnvironment.container.effectiveContentSize.width - contentWidth) / 2.0
|
|
||||||
group.contentInsets.leading = (insetWidth - 1).rounded(.down) // Subtract 1 to avoid overflowing/clipping
|
|
||||||
}
|
|
||||||
|
|
||||||
let layoutSection = NSCollectionLayoutSection(group: group)
|
|
||||||
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
|
|
||||||
layoutSection.interGroupSpacing = self.screenshotsCollectionView.directionalLayoutMargins.leading + self.screenshotsCollectionView.directionalLayoutMargins.trailing
|
|
||||||
return layoutSection
|
|
||||||
}, configuration: layoutConfig)
|
|
||||||
|
|
||||||
return layout
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>
|
|
||||||
{
|
|
||||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>(items: [])
|
|
||||||
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in
|
|
||||||
let cell = cell as! AppScreenshotCollectionViewCell
|
|
||||||
cell.imageView.image = nil
|
|
||||||
cell.imageView.isIndicatingActivity = true
|
|
||||||
|
|
||||||
var aspectRatio = screenshot.aspectRatio
|
|
||||||
if aspectRatio.width > aspectRatio.height
|
|
||||||
{
|
|
||||||
switch screenshot.deviceType
|
|
||||||
{
|
|
||||||
case .iphone:
|
|
||||||
// Always rotate landscape iPhone screenshots
|
|
||||||
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
|
|
||||||
|
|
||||||
case .ipad:
|
|
||||||
// Never rotate iPad screenshots
|
|
||||||
break
|
|
||||||
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cell.aspectRatio = aspectRatio
|
|
||||||
}
|
|
||||||
dataSource.prefetchHandler = { (screenshot, indexPath, completionHandler) in
|
|
||||||
let imageURL = screenshot.imageURL
|
|
||||||
return RSTAsyncBlockOperation() { (operation) in
|
|
||||||
let request = ImageRequest(url: imageURL)
|
|
||||||
ImagePipeline.shared.loadImage(with: request, progress: nil) { result in
|
|
||||||
guard !operation.isCancelled else { return operation.finish() }
|
|
||||||
|
|
||||||
switch result
|
|
||||||
{
|
|
||||||
case .success(let response): completionHandler(response.image, nil)
|
|
||||||
case .failure(let error): completionHandler(nil, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
|
||||||
let cell = cell as! AppScreenshotCollectionViewCell
|
|
||||||
cell.imageView.isIndicatingActivity = false
|
|
||||||
cell.setImage(image)
|
|
||||||
|
|
||||||
if let error = error
|
|
||||||
{
|
|
||||||
print("Error loading image:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataSource
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func handleTapGesture(_ tapGesture: UITapGestureRecognizer)
|
|
||||||
{
|
|
||||||
var superview: UIView? = self.superview
|
|
||||||
var collectionView: UICollectionView? = nil
|
|
||||||
|
|
||||||
while case let view? = superview
|
|
||||||
{
|
|
||||||
if let cv = view as? UICollectionView
|
|
||||||
{
|
|
||||||
collectionView = cv
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
superview = view.superview
|
|
||||||
}
|
|
||||||
|
|
||||||
if let collectionView, let indexPath = collectionView.indexPath(for: self)
|
|
||||||
{
|
|
||||||
collectionView.delegate?.collectionView?(collectionView, didSelectItemAt: indexPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppCardCollectionViewCell
|
|
||||||
{
|
|
||||||
func configure(for storeApp: StoreApp, showSourceIcon: Bool = true)
|
|
||||||
{
|
|
||||||
self.screenshots = storeApp.preferredScreenshots()
|
|
||||||
|
|
||||||
// Explicitly set to false to ensure we're starting from a non-activity indicating state.
|
|
||||||
// Otherwise, cell reuse can mess up some cached values.
|
|
||||||
self.bannerView.button.isIndicatingActivity = false
|
|
||||||
|
|
||||||
self.bannerView.tintColor = storeApp.tintColor
|
|
||||||
self.bannerView.configure(for: storeApp, showSourceIcon: showSourceIcon)
|
|
||||||
|
|
||||||
self.bannerView.subtitleLabel.numberOfLines = 1
|
|
||||||
self.bannerView.subtitleLabel.lineBreakMode = .byTruncatingTail
|
|
||||||
self.bannerView.subtitleLabel.minimumScaleFactor = 0.8
|
|
||||||
self.bannerView.subtitleLabel.text = storeApp.developerName
|
|
||||||
|
|
||||||
if let subtitle = storeApp.subtitle, !subtitle.isEmpty
|
|
||||||
{
|
|
||||||
self.captionLabel.text = subtitle
|
|
||||||
self.captionLabel.isHidden = false
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.captionLabel.isHidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension AppCardCollectionViewCell: UIGestureRecognizerDelegate
|
|
||||||
{
|
|
||||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool
|
|
||||||
{
|
|
||||||
// Never recognize topAreaPanGestureRecognizer unless prefersPagingScreenshots is false.
|
|
||||||
guard !self.prefersPagingScreenshots else { return false }
|
|
||||||
|
|
||||||
let point = gestureRecognizer.location(in: self.screenshotsCollectionView)
|
|
||||||
|
|
||||||
// Top area = Top 3/4
|
|
||||||
let isTopArea = point.y < (self.screenshotsCollectionView.bounds.height / 4) * 3
|
|
||||||
return isTopArea
|
|
||||||
}
|
|
||||||
|
|
||||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool
|
|
||||||
{
|
|
||||||
guard let panGestureRecognizer = otherGestureRecognizer as? UIPanGestureRecognizer, let view = panGestureRecognizer.view else { return false }
|
|
||||||
|
|
||||||
if view.isDescendant(of: self.screenshotsCollectionView)
|
|
||||||
{
|
|
||||||
// Only allow nested gesture recognizers if topAreaPanGestureRecognizer fails.
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Always allow parent gesture recognizers.
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool
|
|
||||||
{
|
|
||||||
guard let panGestureRecognizer = otherGestureRecognizer as? UIPanGestureRecognizer, let view = panGestureRecognizer.view else { return true }
|
|
||||||
|
|
||||||
if view.isDescendant(of: self.screenshotsCollectionView)
|
|
||||||
{
|
|
||||||
// Don't recognize topAreaPanGestureRecognizer alongside nested gesture recognizers.
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Allow recognizing simultaneously with parent gesture recognizers.
|
|
||||||
// This fixes accidentally breaking scrolling in parent.
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,62 +8,36 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
extension AppIconImageView
|
final class AppIconImageView: UIImageView
|
||||||
{
|
{
|
||||||
enum Style
|
override func awakeFromNib()
|
||||||
{
|
{
|
||||||
case icon
|
super.awakeFromNib()
|
||||||
case circular
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AppIconImageView: UIImageView
|
|
||||||
{
|
|
||||||
var style: Style = .icon {
|
|
||||||
didSet {
|
|
||||||
self.setNeedsLayout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init(style: Style)
|
|
||||||
{
|
|
||||||
self.style = style
|
|
||||||
|
|
||||||
super.init(image: nil)
|
|
||||||
|
|
||||||
self.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder)
|
|
||||||
{
|
|
||||||
super.init(coder: coder)
|
|
||||||
|
|
||||||
self.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initialize()
|
|
||||||
{
|
|
||||||
self.contentMode = .scaleAspectFill
|
self.contentMode = .scaleAspectFill
|
||||||
self.clipsToBounds = true
|
self.clipsToBounds = true
|
||||||
self.backgroundColor = .white
|
|
||||||
|
|
||||||
self.layer.cornerCurve = .continuous
|
self.backgroundColor = .white
|
||||||
|
|
||||||
|
if #available(iOS 13, *)
|
||||||
|
{
|
||||||
|
self.layer.cornerCurve = .continuous
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if self.layer.responds(to: Selector(("continuousCorners")))
|
||||||
|
{
|
||||||
|
self.layer.setValue(true, forKey: "continuousCorners")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func layoutSubviews()
|
override func layoutSubviews()
|
||||||
{
|
{
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
|
|
||||||
switch self.style
|
// Based off of 60pt icon having 12pt radius.
|
||||||
{
|
let radius = self.bounds.height / 5
|
||||||
case .icon:
|
self.layer.cornerRadius = radius
|
||||||
// Based off of 60pt icon having 12pt radius.
|
|
||||||
let radius = self.bounds.height / 5
|
|
||||||
self.layer.cornerRadius = radius
|
|
||||||
|
|
||||||
case .circular:
|
|
||||||
let radius = self.bounds.height / 2
|
|
||||||
self.layer.cornerRadius = radius
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
AltStore/Components/BannerCollectionViewCell.swift
Normal file
54
AltStore/Components/BannerCollectionViewCell.swift
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
//
|
||||||
|
// BannerCollectionViewCell.swift
|
||||||
|
// AltStore
|
||||||
|
//
|
||||||
|
// Created by Riley Testut on 3/23/20.
|
||||||
|
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class BannerCollectionViewCell: UICollectionViewCell
|
||||||
|
{
|
||||||
|
private(set) var errorBadge: UIView?
|
||||||
|
@IBOutlet private(set) var bannerView: AppBannerView!
|
||||||
|
|
||||||
|
override func awakeFromNib()
|
||||||
|
{
|
||||||
|
super.awakeFromNib()
|
||||||
|
|
||||||
|
self.contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
self.contentView.preservesSuperviewLayoutMargins = true
|
||||||
|
|
||||||
|
if #available(iOS 13.0, *)
|
||||||
|
{
|
||||||
|
let errorBadge = UIView()
|
||||||
|
errorBadge.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
errorBadge.isHidden = true
|
||||||
|
self.addSubview(errorBadge)
|
||||||
|
|
||||||
|
// Solid background to make the X opaque white.
|
||||||
|
let backgroundView = UIView()
|
||||||
|
backgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
backgroundView.backgroundColor = .white
|
||||||
|
errorBadge.addSubview(backgroundView)
|
||||||
|
|
||||||
|
let badgeView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill"))
|
||||||
|
badgeView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(scale: .large)
|
||||||
|
badgeView.tintColor = .systemRed
|
||||||
|
errorBadge.addSubview(badgeView, pinningEdgesWith: .zero)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
errorBadge.centerXAnchor.constraint(equalTo: self.bannerView.trailingAnchor, constant: -5),
|
||||||
|
errorBadge.centerYAnchor.constraint(equalTo: self.bannerView.topAnchor, constant: 5),
|
||||||
|
|
||||||
|
backgroundView.centerXAnchor.constraint(equalTo: badgeView.centerXAnchor),
|
||||||
|
backgroundView.centerYAnchor.constraint(equalTo: badgeView.centerYAnchor),
|
||||||
|
backgroundView.widthAnchor.constraint(equalTo: badgeView.widthAnchor, multiplier: 0.5),
|
||||||
|
backgroundView.heightAnchor.constraint(equalTo: badgeView.heightAnchor, multiplier: 0.5)
|
||||||
|
])
|
||||||
|
|
||||||
|
self.errorBadge = errorBadge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ final class CollapsingTextView: UITextView
|
|||||||
{
|
{
|
||||||
var isCollapsed = true {
|
var isCollapsed = true {
|
||||||
didSet {
|
didSet {
|
||||||
guard self.isCollapsed != oldValue else { return }
|
|
||||||
self.setNeedsLayout()
|
self.setNeedsLayout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,59 +22,19 @@ final class CollapsingTextView: UITextView
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var lineSpacing: Double = 2 {
|
var lineSpacing: CGFloat = 2 {
|
||||||
didSet {
|
didSet {
|
||||||
|
self.setNeedsLayout()
|
||||||
if #available(iOS 16, *)
|
|
||||||
{
|
|
||||||
self.updateText()
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.setNeedsLayout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override var text: String! {
|
|
||||||
didSet {
|
|
||||||
|
|
||||||
guard #available(iOS 16, *) else { return }
|
|
||||||
self.updateText()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let moreButton = UIButton(type: .system)
|
let moreButton = UIButton(type: .system)
|
||||||
|
|
||||||
override init(frame: CGRect, textContainer: NSTextContainer?)
|
|
||||||
{
|
|
||||||
super.init(frame: frame, textContainer: textContainer)
|
|
||||||
|
|
||||||
self.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder)
|
|
||||||
{
|
|
||||||
super.init(coder: coder)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func awakeFromNib()
|
override func awakeFromNib()
|
||||||
{
|
{
|
||||||
super.awakeFromNib()
|
super.awakeFromNib()
|
||||||
|
|
||||||
self.initialize()
|
self.layoutManager.delegate = self
|
||||||
}
|
|
||||||
|
|
||||||
private func initialize()
|
|
||||||
{
|
|
||||||
if #available(iOS 16, *)
|
|
||||||
{
|
|
||||||
self.updateText()
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.layoutManager.delegate = self
|
|
||||||
}
|
|
||||||
|
|
||||||
self.textContainerInset = .zero
|
self.textContainerInset = .zero
|
||||||
self.textContainer.lineFragmentPadding = 0
|
self.textContainer.lineFragmentPadding = 0
|
||||||
@@ -110,13 +69,13 @@ final class CollapsingTextView: UITextView
|
|||||||
|
|
||||||
if self.isCollapsed
|
if self.isCollapsed
|
||||||
{
|
{
|
||||||
|
self.textContainer.maximumNumberOfLines = self.maximumNumberOfLines
|
||||||
|
|
||||||
let boundingSize = self.attributedText.boundingRect(with: CGSize(width: self.textContainer.size.width, height: .infinity), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
|
let boundingSize = self.attributedText.boundingRect(with: CGSize(width: self.textContainer.size.width, height: .infinity), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
|
||||||
let maximumCollapsedHeight = font.lineHeight * Double(self.maximumNumberOfLines) + self.lineSpacing * Double(self.maximumNumberOfLines - 1)
|
let maximumCollapsedHeight = font.lineHeight * Double(self.maximumNumberOfLines)
|
||||||
|
|
||||||
if boundingSize.height.rounded() > maximumCollapsedHeight.rounded()
|
if boundingSize.height.rounded() > maximumCollapsedHeight.rounded()
|
||||||
{
|
{
|
||||||
self.textContainer.maximumNumberOfLines = self.maximumNumberOfLines
|
|
||||||
|
|
||||||
var exclusionFrame = moreButtonFrame
|
var exclusionFrame = moreButtonFrame
|
||||||
exclusionFrame.origin.y += self.moreButton.bounds.midY
|
exclusionFrame.origin.y += self.moreButton.bounds.midY
|
||||||
exclusionFrame.size.width = self.bounds.width // Extra wide to make sure it wraps to next line.
|
exclusionFrame.size.width = self.bounds.width // Extra wide to make sure it wraps to next line.
|
||||||
@@ -126,7 +85,6 @@ final class CollapsingTextView: UITextView
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
self.textContainer.maximumNumberOfLines = 0 // Fixes last line having slightly smaller line spacing.
|
|
||||||
self.textContainer.exclusionPaths = []
|
self.textContainer.exclusionPaths = []
|
||||||
|
|
||||||
self.moreButton.isHidden = true
|
self.moreButton.isHidden = true
|
||||||
@@ -150,25 +108,6 @@ private extension CollapsingTextView
|
|||||||
{
|
{
|
||||||
self.isCollapsed.toggle()
|
self.isCollapsed.toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 16, *)
|
|
||||||
func updateText()
|
|
||||||
{
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let style = NSMutableParagraphStyle()
|
|
||||||
style.lineSpacing = self.lineSpacing
|
|
||||||
|
|
||||||
var attributedText = try AttributedString(self.attributedText, including: \.uiKit)
|
|
||||||
attributedText[AttributeScopes.UIKitAttributes.ParagraphStyleAttribute.self] = style
|
|
||||||
|
|
||||||
self.attributedText = NSAttributedString(attributedText)
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
print("[ALTLog] Failed to update CollapsingTextView line spacing:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension CollapsingTextView: NSLayoutManagerDelegate
|
extension CollapsingTextView: NSLayoutManagerDelegate
|
||||||
|
|||||||
@@ -1,649 +0,0 @@
|
|||||||
//
|
|
||||||
// HeaderContentViewController.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 3/10/23.
|
|
||||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
import AltStoreCore
|
|
||||||
import Roxas
|
|
||||||
|
|
||||||
import Nuke
|
|
||||||
|
|
||||||
protocol ScrollableContentViewController: UIViewController
|
|
||||||
{
|
|
||||||
var scrollView: UIScrollView { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
class HeaderContentViewController<Header: UIView, Content: ScrollableContentViewController> : UIViewController,
|
|
||||||
UIAdaptivePresentationControllerDelegate,
|
|
||||||
UIScrollViewDelegate,
|
|
||||||
UIGestureRecognizerDelegate
|
|
||||||
{
|
|
||||||
var tintColor: UIColor? {
|
|
||||||
didSet {
|
|
||||||
guard self.isViewLoaded else { return }
|
|
||||||
|
|
||||||
self.view.tintColor = self.tintColor?.adjustedForDisplay
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private(set) var headerView: Header!
|
|
||||||
private(set) var contentViewController: Content!
|
|
||||||
|
|
||||||
private(set) var backButton: VibrantButton!
|
|
||||||
private(set) var backgroundImageView: UIImageView!
|
|
||||||
|
|
||||||
private(set) var navigationBarNameLabel: UILabel!
|
|
||||||
private(set) var navigationBarIconView: UIImageView!
|
|
||||||
private(set) var navigationBarTitleView: UIStackView!
|
|
||||||
private(set) var navigationBarButton: PillButton!
|
|
||||||
|
|
||||||
private var scrollView: UIScrollView!
|
|
||||||
private var headerScrollView: UIScrollView!
|
|
||||||
private var headerContainerView: UIView!
|
|
||||||
private var backgroundBlurView: UIVisualEffectView!
|
|
||||||
private var contentViewControllerShadowView: UIView!
|
|
||||||
|
|
||||||
private var ignoreBackGestureRecognizer: UIPanGestureRecognizer!
|
|
||||||
|
|
||||||
private var blurAnimator: UIViewPropertyAnimator?
|
|
||||||
private var navigationBarAnimator: UIViewPropertyAnimator?
|
|
||||||
private var contentSizeObservation: NSKeyValueObservation?
|
|
||||||
|
|
||||||
private var _shouldResetLayout = false
|
|
||||||
private var _backgroundBlurEffect: UIBlurEffect?
|
|
||||||
private var _backgroundBlurTintColor: UIColor?
|
|
||||||
|
|
||||||
private var isViewingHeader: Bool {
|
|
||||||
let isViewingHeader = (self.headerScrollView.contentOffset.x != self.headerScrollView.contentInset.left)
|
|
||||||
return isViewingHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
|
||||||
if #available(iOS 17, *)
|
|
||||||
{
|
|
||||||
// On iOS 17+, .default will update the status bar automatically.
|
|
||||||
return .default
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return _preferredStatusBarStyle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private var _preferredStatusBarStyle: UIStatusBarStyle = .default
|
|
||||||
|
|
||||||
init()
|
|
||||||
{
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit
|
|
||||||
{
|
|
||||||
self.blurAnimator?.stopAnimation(true)
|
|
||||||
self.navigationBarAnimator?.stopAnimation(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder)
|
|
||||||
{
|
|
||||||
super.init(coder: coder)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeContentViewController() -> Content
|
|
||||||
{
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeHeaderView() -> Header
|
|
||||||
{
|
|
||||||
fatalError()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad()
|
|
||||||
{
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
self.view.backgroundColor = .white
|
|
||||||
self.view.clipsToBounds = true
|
|
||||||
|
|
||||||
self.navigationItem.largeTitleDisplayMode = .never
|
|
||||||
self.navigationController?.presentationController?.delegate = self
|
|
||||||
|
|
||||||
|
|
||||||
// Background
|
|
||||||
self.backgroundImageView = UIImageView(frame: .zero)
|
|
||||||
self.backgroundImageView.contentMode = .scaleAspectFill
|
|
||||||
self.view.addSubview(self.backgroundImageView)
|
|
||||||
|
|
||||||
let blurEffect = UIBlurEffect(style: .regular)
|
|
||||||
self.backgroundBlurView = UIVisualEffectView(effect: blurEffect)
|
|
||||||
self.view.addSubview(self.backgroundBlurView, pinningEdgesWith: .zero)
|
|
||||||
|
|
||||||
|
|
||||||
// Header View
|
|
||||||
self.headerContainerView = UIView(frame: .zero)
|
|
||||||
self.view.addSubview(self.headerContainerView, pinningEdgesWith: .zero)
|
|
||||||
|
|
||||||
self.ignoreBackGestureRecognizer = UIPanGestureRecognizer(target: self, action: nil)
|
|
||||||
self.ignoreBackGestureRecognizer.delegate = self
|
|
||||||
self.headerContainerView.addGestureRecognizer(self.ignoreBackGestureRecognizer)
|
|
||||||
self.navigationController?.interactivePopGestureRecognizer?.require(toFail: self.ignoreBackGestureRecognizer) // So we can disable back gesture when viewing header.
|
|
||||||
|
|
||||||
self.headerScrollView = UIScrollView(frame: .zero)
|
|
||||||
self.headerScrollView.delegate = self
|
|
||||||
self.headerScrollView.isPagingEnabled = true
|
|
||||||
self.headerScrollView.clipsToBounds = false
|
|
||||||
self.headerScrollView.indicatorStyle = .white
|
|
||||||
self.headerScrollView.showsVerticalScrollIndicator = false
|
|
||||||
self.headerContainerView.addSubview(self.headerScrollView)
|
|
||||||
self.headerContainerView.addGestureRecognizer(self.headerScrollView.panGestureRecognizer) // Allow panning outside headerScrollView bounds.
|
|
||||||
|
|
||||||
self.headerView = self.makeHeaderView()
|
|
||||||
self.headerScrollView.addSubview(self.headerView)
|
|
||||||
|
|
||||||
let imageConfiguration = UIImage.SymbolConfiguration(weight: .semibold)
|
|
||||||
let image = UIImage(systemName: "chevron.backward", withConfiguration: imageConfiguration)
|
|
||||||
|
|
||||||
self.backButton = VibrantButton(type: .system)
|
|
||||||
self.backButton.image = image
|
|
||||||
self.backButton.tintColor = self.tintColor
|
|
||||||
self.backButton.sizeToFit()
|
|
||||||
self.backButton.addTarget(self.navigationController, action: #selector(UINavigationController.popViewController(animated:)), for: .primaryActionTriggered)
|
|
||||||
self.view.addSubview(self.backButton)
|
|
||||||
|
|
||||||
|
|
||||||
// Content View Controller
|
|
||||||
self.contentViewController = self.makeContentViewController()
|
|
||||||
self.contentViewController.view.frame = self.view.bounds
|
|
||||||
self.contentViewController.view.layer.cornerRadius = 38
|
|
||||||
self.contentViewController.view.layer.masksToBounds = true
|
|
||||||
|
|
||||||
self.addChild(self.contentViewController)
|
|
||||||
self.view.addSubview(self.contentViewController.view)
|
|
||||||
self.contentViewController.didMove(toParent: self)
|
|
||||||
|
|
||||||
self.contentViewControllerShadowView = UIView()
|
|
||||||
self.contentViewControllerShadowView.backgroundColor = .white
|
|
||||||
self.contentViewControllerShadowView.layer.cornerRadius = 38
|
|
||||||
self.contentViewControllerShadowView.layer.shadowColor = UIColor.black.cgColor
|
|
||||||
self.contentViewControllerShadowView.layer.shadowOffset = CGSize(width: 0, height: -1)
|
|
||||||
self.contentViewControllerShadowView.layer.shadowRadius = 10
|
|
||||||
self.contentViewControllerShadowView.layer.shadowOpacity = 0.3
|
|
||||||
self.view.insertSubview(self.contentViewControllerShadowView, belowSubview: self.contentViewController.view)
|
|
||||||
|
|
||||||
// Add scrollView to front so the scroll indicators are visible, but disable user interaction.
|
|
||||||
self.scrollView = UIScrollView(frame: CGRect(origin: .zero, size: self.view.bounds.size))
|
|
||||||
self.scrollView.delegate = self
|
|
||||||
self.scrollView.isUserInteractionEnabled = false
|
|
||||||
self.scrollView.contentInsetAdjustmentBehavior = .never
|
|
||||||
self.view.addSubview(self.scrollView, pinningEdgesWith: .zero)
|
|
||||||
self.view.addGestureRecognizer(self.scrollView.panGestureRecognizer)
|
|
||||||
|
|
||||||
self.contentViewController.scrollView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer)
|
|
||||||
self.contentViewController.scrollView.showsVerticalScrollIndicator = false
|
|
||||||
self.contentViewController.scrollView.contentInsetAdjustmentBehavior = .never
|
|
||||||
|
|
||||||
|
|
||||||
// Navigation Bar Title View
|
|
||||||
self.navigationBarNameLabel = UILabel(frame: .zero)
|
|
||||||
self.navigationBarNameLabel.font = UIFont.boldSystemFont(ofSize: 17) // We want semibold, which this (apparently) returns.
|
|
||||||
self.navigationBarNameLabel.text = self.title
|
|
||||||
self.navigationBarNameLabel.sizeToFit()
|
|
||||||
|
|
||||||
self.navigationBarIconView = UIImageView(frame: .zero)
|
|
||||||
self.navigationBarIconView.clipsToBounds = true
|
|
||||||
|
|
||||||
self.navigationBarTitleView = UIStackView(arrangedSubviews: [self.navigationBarIconView, self.navigationBarNameLabel])
|
|
||||||
self.navigationBarTitleView.axis = .horizontal
|
|
||||||
self.navigationBarTitleView.spacing = 8
|
|
||||||
|
|
||||||
self.navigationBarButton = PillButton(type: .system)
|
|
||||||
self.navigationBarButton.setContentCompressionResistancePriority(UILayoutPriority(rawValue: 9000), for: .horizontal) // Prioritize over title length.
|
|
||||||
|
|
||||||
// Embed navigationBarButton in container view with Auto Layout to ensure it can automatically update its size.
|
|
||||||
let buttonContainerView = UIView()
|
|
||||||
buttonContainerView.addSubview(self.navigationBarButton, pinningEdgesWith: .zero)
|
|
||||||
self.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: buttonContainerView)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
self.navigationBarIconView.widthAnchor.constraint(equalToConstant: 35),
|
|
||||||
self.navigationBarIconView.heightAnchor.constraint(equalTo: self.navigationBarIconView.widthAnchor)
|
|
||||||
])
|
|
||||||
|
|
||||||
let size = self.navigationBarTitleView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
|
||||||
self.navigationBarTitleView.bounds.size = size
|
|
||||||
self.navigationItem.titleView = self.navigationBarTitleView
|
|
||||||
|
|
||||||
self._backgroundBlurEffect = self.backgroundBlurView.effect as? UIBlurEffect
|
|
||||||
self._backgroundBlurTintColor = self.backgroundBlurView.contentView.backgroundColor
|
|
||||||
|
|
||||||
self.contentSizeObservation = self.contentViewController.scrollView.observe(\.contentSize, options: [.new, .old]) { [weak self] (scrollView, change) in
|
|
||||||
guard let size = change.newValue, let previousSize = change.oldValue, size != previousSize else { return }
|
|
||||||
self?.view.setNeedsLayout()
|
|
||||||
self?.view.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't call update() before subclasses have finished viewDidLoad().
|
|
||||||
// self.update()
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(HeaderContentViewController.willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(HeaderContentViewController.didBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil)
|
|
||||||
|
|
||||||
if #available(iOS 15, *)
|
|
||||||
{
|
|
||||||
// Fix navigation bar + tab bar appearance on iOS 15.
|
|
||||||
self.setContentScrollView(self.scrollView)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start with navigation bar hidden.
|
|
||||||
self.hideNavigationBar()
|
|
||||||
|
|
||||||
self.view.tintColor = self.tintColor?.adjustedForDisplay
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool)
|
|
||||||
{
|
|
||||||
super.viewWillAppear(animated)
|
|
||||||
|
|
||||||
self.prepareBlur()
|
|
||||||
|
|
||||||
// Update blur immediately.
|
|
||||||
self.view.setNeedsLayout()
|
|
||||||
self.view.layoutIfNeeded()
|
|
||||||
|
|
||||||
self.headerScrollView.flashScrollIndicators()
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewIsAppearing(_ animated: Bool)
|
|
||||||
{
|
|
||||||
super.viewIsAppearing(animated)
|
|
||||||
|
|
||||||
// Ensure header view has correct layout dimensions.
|
|
||||||
self.headerView.setNeedsLayout()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool)
|
|
||||||
{
|
|
||||||
super.viewDidAppear(animated)
|
|
||||||
|
|
||||||
self._shouldResetLayout = true
|
|
||||||
self.view.setNeedsLayout()
|
|
||||||
self.view.layoutIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayoutSubviews()
|
|
||||||
{
|
|
||||||
super.viewDidLayoutSubviews()
|
|
||||||
|
|
||||||
if self._shouldResetLayout
|
|
||||||
{
|
|
||||||
// Various events can cause UI to mess up, so reset affected components now.
|
|
||||||
|
|
||||||
self.prepareBlur()
|
|
||||||
|
|
||||||
// Reset navigation bar animation, and create a new one later in this method if necessary.
|
|
||||||
self.resetNavigationBarAnimation()
|
|
||||||
|
|
||||||
self._shouldResetLayout = false
|
|
||||||
}
|
|
||||||
|
|
||||||
let statusBarHeight: Double
|
|
||||||
|
|
||||||
if let navigationController, navigationController.presentingViewController != nil, navigationController.modalPresentationStyle != .fullScreen
|
|
||||||
{
|
|
||||||
statusBarHeight = 20
|
|
||||||
}
|
|
||||||
else if let statusBarManager = (self.view.window ?? self.presentedViewController?.view.window)?.windowScene?.statusBarManager
|
|
||||||
{
|
|
||||||
statusBarHeight = statusBarManager.statusBarFrame.height
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
statusBarHeight = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
let cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius
|
|
||||||
|
|
||||||
let inset = 15 as CGFloat
|
|
||||||
let padding = 20 as CGFloat
|
|
||||||
|
|
||||||
let backButtonSize = self.backButton.sizeThatFits(CGSize(width: Double.infinity, height: .infinity))
|
|
||||||
let largestBackButtonDimension = max(backButtonSize.width, backButtonSize.height) // Enforce 1:1 aspect ratio.
|
|
||||||
var backButtonFrame = CGRect(x: inset, y: statusBarHeight, width: largestBackButtonDimension, height: largestBackButtonDimension)
|
|
||||||
|
|
||||||
var headerFrame = CGRect(x: inset, y: 0, width: self.view.bounds.width - inset * 2, height: self.headerView.bounds.height)
|
|
||||||
var contentFrame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: self.view.bounds.height)
|
|
||||||
var backgroundIconFrame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: self.view.bounds.width)
|
|
||||||
|
|
||||||
let backButtonPadding = 8.0
|
|
||||||
let minimumHeaderY = backButtonFrame.maxY + backButtonPadding
|
|
||||||
|
|
||||||
let minimumContentHeight = minimumHeaderY + headerFrame.height + padding // Minimum height for header + back button + spacing.
|
|
||||||
let maximumContentY = max(self.view.bounds.width * 0.667, minimumContentHeight) // Initial Y-value of content view.
|
|
||||||
|
|
||||||
contentFrame.origin.y = maximumContentY - self.scrollView.contentOffset.y
|
|
||||||
headerFrame.origin.y = contentFrame.origin.y - padding - headerFrame.height
|
|
||||||
|
|
||||||
// Stretch the app icon image to fill additional vertical space if necessary.
|
|
||||||
let height = max(contentFrame.origin.y + cornerRadius * 2, backgroundIconFrame.height)
|
|
||||||
backgroundIconFrame.size.height = height
|
|
||||||
|
|
||||||
// Update blur.
|
|
||||||
self.updateBlur()
|
|
||||||
|
|
||||||
// Animate navigation bar.
|
|
||||||
let showNavigationBarThreshold = (maximumContentY - minimumContentHeight) + backButtonFrame.origin.y
|
|
||||||
if self.scrollView.contentOffset.y > showNavigationBarThreshold
|
|
||||||
{
|
|
||||||
if self.navigationBarAnimator == nil
|
|
||||||
{
|
|
||||||
self.prepareNavigationBarAnimation()
|
|
||||||
}
|
|
||||||
|
|
||||||
let difference = self.scrollView.contentOffset.y - showNavigationBarThreshold
|
|
||||||
|
|
||||||
let range: Double
|
|
||||||
if self.presentingViewController == nil && self.parent?.presentingViewController == nil
|
|
||||||
{
|
|
||||||
// Not presented modally, so rely on safe area + navigation bar height.
|
|
||||||
range = (headerFrame.height + padding) - (self.navigationController?.navigationBar.bounds.height ?? self.view.safeAreaInsets.top)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Presented modally, so rely on maximumContentY.
|
|
||||||
range = maximumContentY - (maximumContentY - padding - headerFrame.height) - inset
|
|
||||||
}
|
|
||||||
|
|
||||||
let fractionComplete = min(difference, range) / range
|
|
||||||
self.navigationBarAnimator?.fractionComplete = fractionComplete
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.navigationBarAnimator?.fractionComplete = 0.0
|
|
||||||
self.resetNavigationBarAnimation()
|
|
||||||
}
|
|
||||||
|
|
||||||
let beginMovingBackButtonThreshold = (maximumContentY - minimumContentHeight)
|
|
||||||
if self.scrollView.contentOffset.y > beginMovingBackButtonThreshold
|
|
||||||
{
|
|
||||||
let difference = self.scrollView.contentOffset.y - beginMovingBackButtonThreshold
|
|
||||||
backButtonFrame.origin.y -= difference
|
|
||||||
}
|
|
||||||
|
|
||||||
let pinContentToTopThreshold = maximumContentY
|
|
||||||
if self.scrollView.contentOffset.y > pinContentToTopThreshold
|
|
||||||
{
|
|
||||||
contentFrame.origin.y = 0
|
|
||||||
backgroundIconFrame.origin.y = 0
|
|
||||||
|
|
||||||
let difference = self.scrollView.contentOffset.y - pinContentToTopThreshold
|
|
||||||
self.contentViewController.scrollView.contentOffset.y = -self.contentViewController.scrollView.contentInset.top + difference
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Keep content table view's content offset at the top.
|
|
||||||
self.contentViewController.scrollView.contentOffset.y = -self.contentViewController.scrollView.contentInset.top
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep background app icon centered in gap between top of content and top of screen.
|
|
||||||
backgroundIconFrame.origin.y = (contentFrame.origin.y / 2) - backgroundIconFrame.height / 2
|
|
||||||
|
|
||||||
// Set frames.
|
|
||||||
self.contentViewController.view.frame = contentFrame
|
|
||||||
self.contentViewControllerShadowView.frame = contentFrame
|
|
||||||
self.backgroundImageView.frame = backgroundIconFrame
|
|
||||||
|
|
||||||
self.backButton.frame = backButtonFrame
|
|
||||||
self.backButton.layer.cornerRadius = backButtonFrame.height / 2
|
|
||||||
|
|
||||||
// Adjust header scroll view content size for paging
|
|
||||||
self.headerView.frame = CGRect(origin: .zero, size: headerFrame.size)
|
|
||||||
self.headerScrollView.frame = headerFrame
|
|
||||||
self.headerScrollView.contentSize = CGSize(width: headerFrame.width * 2, height: headerFrame.height)
|
|
||||||
|
|
||||||
self.scrollView.verticalScrollIndicatorInsets.top = statusBarHeight
|
|
||||||
self.headerScrollView.horizontalScrollIndicatorInsets.bottom = -12
|
|
||||||
|
|
||||||
// Adjust content offset + size.
|
|
||||||
let contentOffset = self.scrollView.contentOffset
|
|
||||||
|
|
||||||
var contentSize = self.contentViewController.scrollView.contentSize
|
|
||||||
contentSize.height += self.contentViewController.scrollView.contentInset.top + self.contentViewController.scrollView.contentInset.bottom
|
|
||||||
contentSize.height += maximumContentY
|
|
||||||
contentSize.height = max(contentSize.height, self.view.bounds.height + maximumContentY - (self.navigationController?.navigationBar.bounds.height ?? 0))
|
|
||||||
self.scrollView.contentSize = contentSize
|
|
||||||
|
|
||||||
self.scrollView.contentOffset = contentOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
func update()
|
|
||||||
{
|
|
||||||
// Overridden by subclasses.
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cannot add @objc functions in extensions of generic types, so include them in main definition instead.
|
|
||||||
|
|
||||||
//MARK: Notifications
|
|
||||||
|
|
||||||
@objc private func willEnterForeground(_ notification: Notification)
|
|
||||||
{
|
|
||||||
guard let navigationController = self.navigationController, navigationController.topViewController == self else { return }
|
|
||||||
|
|
||||||
self._shouldResetLayout = true
|
|
||||||
self.view.setNeedsLayout()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func didBecomeActive(_ notification: Notification)
|
|
||||||
{
|
|
||||||
guard let navigationController = self.navigationController, navigationController.topViewController == self else { return }
|
|
||||||
|
|
||||||
// Fixes incorrect blur after app becomes inactive -> active again.
|
|
||||||
self._shouldResetLayout = true
|
|
||||||
self.view.setNeedsLayout()
|
|
||||||
}
|
|
||||||
|
|
||||||
//MARK: UIAdaptivePresentationControllerDelegate
|
|
||||||
|
|
||||||
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool
|
|
||||||
{
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
//MARK: UIScrollViewDelegate
|
|
||||||
|
|
||||||
func scrollViewDidScroll(_ scrollView: UIScrollView)
|
|
||||||
{
|
|
||||||
switch scrollView
|
|
||||||
{
|
|
||||||
case self.scrollView: self.view.setNeedsLayout()
|
|
||||||
case self.headerScrollView:
|
|
||||||
// Do NOT call setNeedsLayout(), or else it will mess with scrolling.
|
|
||||||
self.headerScrollView.showsHorizontalScrollIndicator = false
|
|
||||||
self.updateBlur()
|
|
||||||
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//MARK: UIGestureRecognizerDelegate
|
|
||||||
|
|
||||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool
|
|
||||||
{
|
|
||||||
// Ignore interactive back gesture when viewing header, which means returning `true` to enable ignoreBackGestureRecognizer.
|
|
||||||
let disableBackGesture = self.isViewingHeader
|
|
||||||
return disableBackGesture
|
|
||||||
}
|
|
||||||
|
|
||||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool
|
|
||||||
{
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension HeaderContentViewController
|
|
||||||
{
|
|
||||||
func showNavigationBar()
|
|
||||||
{
|
|
||||||
self.navigationBarIconView.alpha = 1.0
|
|
||||||
self.navigationBarNameLabel.alpha = 1.0
|
|
||||||
self.navigationBarButton.alpha = 1.0
|
|
||||||
|
|
||||||
self.updateNavigationBarAppearance(isHidden: false)
|
|
||||||
|
|
||||||
if self.traitCollection.userInterfaceStyle == .dark
|
|
||||||
{
|
|
||||||
self._preferredStatusBarStyle = .lightContent
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self._preferredStatusBarStyle = .default
|
|
||||||
}
|
|
||||||
|
|
||||||
if #unavailable(iOS 17)
|
|
||||||
{
|
|
||||||
self.navigationController?.setNeedsStatusBarAppearanceUpdate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func hideNavigationBar()
|
|
||||||
{
|
|
||||||
self.navigationBarIconView.alpha = 0.0
|
|
||||||
self.navigationBarNameLabel.alpha = 0.0
|
|
||||||
self.navigationBarButton.alpha = 0.0
|
|
||||||
|
|
||||||
self.updateNavigationBarAppearance(isHidden: true)
|
|
||||||
|
|
||||||
self._preferredStatusBarStyle = .lightContent
|
|
||||||
|
|
||||||
if #unavailable(iOS 17)
|
|
||||||
{
|
|
||||||
self.navigationController?.setNeedsStatusBarAppearanceUpdate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateNavigationBarAppearance(isHidden: Bool)
|
|
||||||
{
|
|
||||||
let barAppearance = self.navigationItem.standardAppearance as? NavigationBarAppearance ?? NavigationBarAppearance()
|
|
||||||
|
|
||||||
if isHidden
|
|
||||||
{
|
|
||||||
barAppearance.configureWithTransparentBackground()
|
|
||||||
barAppearance.ignoresUserInteraction = true
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
barAppearance.configureWithDefaultBackground()
|
|
||||||
barAppearance.ignoresUserInteraction = false
|
|
||||||
}
|
|
||||||
|
|
||||||
barAppearance.titleTextAttributes = [.foregroundColor: UIColor.clear]
|
|
||||||
|
|
||||||
let dynamicColor = UIColor { traitCollection in
|
|
||||||
var tintColor = self.tintColor ?? .altPrimary
|
|
||||||
|
|
||||||
if traitCollection.userInterfaceStyle == .dark && tintColor.isTooDark
|
|
||||||
{
|
|
||||||
tintColor = .white
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
tintColor = tintColor.adjustedForDisplay
|
|
||||||
}
|
|
||||||
|
|
||||||
return tintColor
|
|
||||||
}
|
|
||||||
|
|
||||||
let tintColor = isHidden ? UIColor.clear : dynamicColor
|
|
||||||
barAppearance.configureWithTintColor(tintColor)
|
|
||||||
|
|
||||||
self.navigationItem.standardAppearance = barAppearance
|
|
||||||
self.navigationItem.scrollEdgeAppearance = barAppearance
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareBlur()
|
|
||||||
{
|
|
||||||
if let animator = self.blurAnimator
|
|
||||||
{
|
|
||||||
animator.stopAnimation(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.backgroundBlurView.effect = self._backgroundBlurEffect
|
|
||||||
self.backgroundBlurView.contentView.backgroundColor = self._backgroundBlurTintColor
|
|
||||||
|
|
||||||
self.blurAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in
|
|
||||||
self?.backgroundBlurView.effect = nil
|
|
||||||
self?.backgroundBlurView.contentView.backgroundColor = .clear
|
|
||||||
}
|
|
||||||
|
|
||||||
self.blurAnimator?.startAnimation()
|
|
||||||
self.blurAnimator?.pauseAnimation()
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateBlur()
|
|
||||||
{
|
|
||||||
// A full blur is too much for header, so we reduce the visible blur by 0.3, resulting in 70% blur.
|
|
||||||
let minimumBlurFraction = 0.3 as CGFloat
|
|
||||||
|
|
||||||
if self.isViewingHeader
|
|
||||||
{
|
|
||||||
let maximumX = self.headerScrollView.bounds.width
|
|
||||||
let fraction = self.headerScrollView.contentOffset.x / maximumX
|
|
||||||
|
|
||||||
let fractionComplete = (fraction * (1.0 - minimumBlurFraction)) + minimumBlurFraction
|
|
||||||
self.blurAnimator?.fractionComplete = fractionComplete
|
|
||||||
}
|
|
||||||
else if self.scrollView.contentOffset.y < 0
|
|
||||||
{
|
|
||||||
// Determine how much to lessen blur by.
|
|
||||||
|
|
||||||
let range = 75 as CGFloat
|
|
||||||
let difference = -self.scrollView.contentOffset.y
|
|
||||||
|
|
||||||
let fraction = min(difference, range) / range
|
|
||||||
|
|
||||||
let fractionComplete = (fraction * (1.0 - minimumBlurFraction)) + minimumBlurFraction
|
|
||||||
self.blurAnimator?.fractionComplete = fractionComplete
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Set blur to default.
|
|
||||||
self.blurAnimator?.fractionComplete = minimumBlurFraction
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareNavigationBarAnimation()
|
|
||||||
{
|
|
||||||
self.resetNavigationBarAnimation()
|
|
||||||
|
|
||||||
self.navigationBarAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in
|
|
||||||
self?.showNavigationBar()
|
|
||||||
|
|
||||||
// Must call layoutIfNeeded() to animate appearance change.
|
|
||||||
self?.navigationController?.navigationBar.layoutIfNeeded()
|
|
||||||
|
|
||||||
self?.contentViewController.view.layer.cornerRadius = 0
|
|
||||||
}
|
|
||||||
self.navigationBarAnimator?.startAnimation()
|
|
||||||
self.navigationBarAnimator?.pauseAnimation()
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
|
|
||||||
func resetNavigationBarAnimation()
|
|
||||||
{
|
|
||||||
guard self.navigationBarAnimator != nil else { return }
|
|
||||||
|
|
||||||
self.navigationBarAnimator?.stopAnimation(true)
|
|
||||||
self.navigationBarAnimator = nil
|
|
||||||
|
|
||||||
self.hideNavigationBar()
|
|
||||||
|
|
||||||
self.contentViewController.view.layer.cornerRadius = self.contentViewControllerShadowView.layer.cornerRadius
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,24 +10,12 @@ import UIKit
|
|||||||
|
|
||||||
import Roxas
|
import Roxas
|
||||||
|
|
||||||
class NavigationBarAppearance: UINavigationBarAppearance
|
final class NavigationBar: UINavigationBar
|
||||||
{
|
{
|
||||||
// We sometimes need to ignore user interaction so
|
|
||||||
// we can tap items underneath the navigation bar.
|
|
||||||
var ignoresUserInteraction: Bool = false
|
|
||||||
|
|
||||||
override func copy(with zone: NSZone? = nil) -> Any
|
|
||||||
{
|
|
||||||
let copy = super.copy(with: zone) as! NavigationBarAppearance
|
|
||||||
copy.ignoresUserInteraction = self.ignoresUserInteraction
|
|
||||||
return copy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class NavigationBar: UINavigationBar
|
|
||||||
{
|
|
||||||
@IBInspectable var automaticallyAdjustsItemPositions: Bool = true
|
@IBInspectable var automaticallyAdjustsItemPositions: Bool = true
|
||||||
|
|
||||||
|
private let backgroundColorView = UIView()
|
||||||
|
|
||||||
override init(frame: CGRect)
|
override init(frame: CGRect)
|
||||||
{
|
{
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
@@ -44,39 +32,64 @@ class NavigationBar: UINavigationBar
|
|||||||
|
|
||||||
private func initialize()
|
private func initialize()
|
||||||
{
|
{
|
||||||
let standardAppearance = UINavigationBarAppearance()
|
if #available(iOS 13, *)
|
||||||
standardAppearance.configureWithDefaultBackground()
|
|
||||||
standardAppearance.shadowColor = nil
|
|
||||||
|
|
||||||
let edgeAppearance = UINavigationBarAppearance()
|
|
||||||
edgeAppearance.configureWithOpaqueBackground()
|
|
||||||
edgeAppearance.backgroundColor = self.barTintColor
|
|
||||||
edgeAppearance.shadowColor = nil
|
|
||||||
|
|
||||||
if let tintColor = self.barTintColor
|
|
||||||
{
|
{
|
||||||
let textAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
|
let standardAppearance = UINavigationBarAppearance()
|
||||||
|
standardAppearance.configureWithDefaultBackground()
|
||||||
|
standardAppearance.shadowColor = nil
|
||||||
|
|
||||||
standardAppearance.backgroundColor = tintColor
|
let edgeAppearance = UINavigationBarAppearance()
|
||||||
standardAppearance.titleTextAttributes = textAttributes
|
edgeAppearance.configureWithOpaqueBackground()
|
||||||
standardAppearance.largeTitleTextAttributes = textAttributes
|
edgeAppearance.backgroundColor = self.barTintColor
|
||||||
|
edgeAppearance.shadowColor = nil
|
||||||
|
|
||||||
edgeAppearance.titleTextAttributes = textAttributes
|
if let tintColor = self.barTintColor
|
||||||
edgeAppearance.largeTitleTextAttributes = textAttributes
|
{
|
||||||
|
let textAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
|
||||||
|
|
||||||
|
standardAppearance.backgroundColor = tintColor
|
||||||
|
standardAppearance.titleTextAttributes = textAttributes
|
||||||
|
standardAppearance.largeTitleTextAttributes = textAttributes
|
||||||
|
|
||||||
|
edgeAppearance.titleTextAttributes = textAttributes
|
||||||
|
edgeAppearance.largeTitleTextAttributes = textAttributes
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
standardAppearance.backgroundColor = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.scrollEdgeAppearance = edgeAppearance
|
||||||
|
self.standardAppearance = standardAppearance
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
standardAppearance.backgroundColor = nil
|
self.shadowImage = UIImage()
|
||||||
|
|
||||||
|
if let tintColor = self.barTintColor
|
||||||
|
{
|
||||||
|
self.backgroundColorView.backgroundColor = tintColor
|
||||||
|
|
||||||
|
// Top = -50 to cover status bar area above navigation bar on any device.
|
||||||
|
// Bottom = -1 to prevent a flickering gray line from appearing.
|
||||||
|
self.addSubview(self.backgroundColorView, pinningEdgesWith: UIEdgeInsets(top: -50, left: 0, bottom: -1, right: 0))
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self.barTintColor = .white
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.scrollEdgeAppearance = edgeAppearance
|
|
||||||
self.standardAppearance = standardAppearance
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func layoutSubviews()
|
override func layoutSubviews()
|
||||||
{
|
{
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
if self.backgroundColorView.superview != nil
|
||||||
|
{
|
||||||
|
self.insertSubview(self.backgroundColorView, at: 1)
|
||||||
|
}
|
||||||
|
|
||||||
if self.automaticallyAdjustsItemPositions
|
if self.automaticallyAdjustsItemPositions
|
||||||
{
|
{
|
||||||
// We can't easily shift just the back button up, so we shift the entire content view slightly.
|
// We can't easily shift just the back button up, so we shift the entire content view slightly.
|
||||||
@@ -87,15 +100,4 @@ class NavigationBar: UINavigationBar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?
|
|
||||||
{
|
|
||||||
if let appearance = self.topItem?.standardAppearance as? NavigationBarAppearance, appearance.ignoresUserInteraction
|
|
||||||
{
|
|
||||||
// Ignore touches.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.hitTest(point, with: event)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,22 +8,7 @@
|
|||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
extension PillButton
|
final class PillButton: UIButton
|
||||||
{
|
|
||||||
static let minimumSize = CGSize(width: 77, height: 31)
|
|
||||||
static let contentInsets = NSDirectionalEdgeInsets(top: 7, leading: 13, bottom: 7, trailing: 13)
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PillButton
|
|
||||||
{
|
|
||||||
enum Style
|
|
||||||
{
|
|
||||||
case pill
|
|
||||||
case custom
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class PillButton: UIButton
|
|
||||||
{
|
{
|
||||||
override var accessibilityValue: String? {
|
override var accessibilityValue: String? {
|
||||||
get {
|
get {
|
||||||
@@ -47,8 +32,11 @@ class PillButton: UIButton
|
|||||||
}
|
}
|
||||||
|
|
||||||
var progressTintColor: UIColor? {
|
var progressTintColor: UIColor? {
|
||||||
didSet {
|
get {
|
||||||
self.update()
|
return self.progressView.progressTintColor
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
self.progressView.progressTintColor = newValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,20 +52,6 @@ class PillButton: UIButton
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var style: Style = .pill {
|
|
||||||
didSet {
|
|
||||||
guard self.style != oldValue else { return }
|
|
||||||
|
|
||||||
if self.style == .custom
|
|
||||||
{
|
|
||||||
// Reset insets for custom style.
|
|
||||||
self.contentEdgeInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private let progressView = UIProgressView(progressViewStyle: .default)
|
private let progressView = UIProgressView(progressViewStyle: .default)
|
||||||
|
|
||||||
private lazy var displayLink: CADisplayLink = {
|
private lazy var displayLink: CADisplayLink = {
|
||||||
@@ -96,7 +70,9 @@ class PillButton: UIButton
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
override var intrinsicContentSize: CGSize {
|
override var intrinsicContentSize: CGSize {
|
||||||
let size = self.sizeThatFits(CGSize(width: Double.infinity, height: .infinity))
|
var size = super.intrinsicContentSize
|
||||||
|
size.width += 26
|
||||||
|
size.height += 3
|
||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,32 +81,14 @@ class PillButton: UIButton
|
|||||||
self.displayLink.remove(from: .main, forMode: RunLoop.Mode.default)
|
self.displayLink.remove(from: .main, forMode: RunLoop.Mode.default)
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: CGRect)
|
|
||||||
{
|
|
||||||
super.init(frame: frame)
|
|
||||||
|
|
||||||
self.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder)
|
|
||||||
{
|
|
||||||
super.init(coder: coder)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func awakeFromNib()
|
override func awakeFromNib()
|
||||||
{
|
{
|
||||||
super.awakeFromNib()
|
super.awakeFromNib()
|
||||||
|
|
||||||
self.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initialize()
|
|
||||||
{
|
|
||||||
self.layer.masksToBounds = true
|
self.layer.masksToBounds = true
|
||||||
self.accessibilityTraits.formUnion([.updatesFrequently, .button])
|
self.accessibilityTraits.formUnion([.updatesFrequently, .button])
|
||||||
|
|
||||||
self.activityIndicatorView.style = .medium
|
self.activityIndicatorView.style = .medium
|
||||||
self.activityIndicatorView.color = .white
|
|
||||||
self.activityIndicatorView.isUserInteractionEnabled = false
|
self.activityIndicatorView.isUserInteractionEnabled = false
|
||||||
|
|
||||||
self.progressView.progress = 0
|
self.progressView.progress = 0
|
||||||
@@ -161,23 +119,6 @@ class PillButton: UIButton
|
|||||||
|
|
||||||
self.update()
|
self.update()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func sizeThatFits(_ size: CGSize) -> CGSize
|
|
||||||
{
|
|
||||||
var size = super.sizeThatFits(size)
|
|
||||||
|
|
||||||
switch self.style
|
|
||||||
{
|
|
||||||
case .pill:
|
|
||||||
// Enforce minimum size for pill style.
|
|
||||||
size.width = max(size.width, PillButton.minimumSize.width)
|
|
||||||
size.height = max(size.height, PillButton.minimumSize.height)
|
|
||||||
|
|
||||||
case .custom: break
|
|
||||||
}
|
|
||||||
|
|
||||||
return size
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension PillButton
|
private extension PillButton
|
||||||
@@ -195,17 +136,7 @@ private extension PillButton
|
|||||||
self.backgroundColor = self.tintColor.withAlphaComponent(0.15)
|
self.backgroundColor = self.tintColor.withAlphaComponent(0.15)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.progressView.progressTintColor = self.progressTintColor ?? self.tintColor
|
self.progressView.progressTintColor = self.tintColor
|
||||||
|
|
||||||
// Update font after init because the original titleLabel is replaced.
|
|
||||||
self.titleLabel?.font = UIFont.boldSystemFont(ofSize: 14)
|
|
||||||
|
|
||||||
switch self.style
|
|
||||||
{
|
|
||||||
case .custom: break // Don't update insets in case client has updated them.
|
|
||||||
case .pill:
|
|
||||||
self.contentEdgeInsets = UIEdgeInsets(top: Self.contentInsets.top, left: Self.contentInsets.leading, bottom: Self.contentInsets.bottom, right: Self.contentInsets.trailing)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func updateCountdown()
|
@objc func updateCountdown()
|
||||||
|
|||||||
@@ -16,22 +16,10 @@ extension TimeInterval
|
|||||||
static let longToastViewDuration = 8.0
|
static let longToastViewDuration = 8.0
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ToastView
|
final class ToastView: RSTToastView
|
||||||
{
|
|
||||||
static let openErrorLogNotification = Notification.Name("ALTOpenErrorLogNotification")
|
|
||||||
}
|
|
||||||
|
|
||||||
class ToastView: RSTToastView
|
|
||||||
{
|
{
|
||||||
var preferredDuration: TimeInterval
|
var preferredDuration: TimeInterval
|
||||||
|
|
||||||
var opensErrorLog: Bool = false
|
|
||||||
|
|
||||||
convenience init(text: String, detailText: String?, opensLog: Bool = false) {
|
|
||||||
self.init(text: text, detailText: detailText)
|
|
||||||
self.opensErrorLog = opensLog
|
|
||||||
}
|
|
||||||
|
|
||||||
override init(text: String, detailText detailedText: String?)
|
override init(text: String, detailText detailedText: String?)
|
||||||
{
|
{
|
||||||
if detailedText == nil
|
if detailedText == nil
|
||||||
@@ -55,34 +43,53 @@ class ToastView: RSTToastView
|
|||||||
// RSTToastView does not expose stack view containing labels,
|
// RSTToastView does not expose stack view containing labels,
|
||||||
// so we access it indirectly as the labels' superview.
|
// so we access it indirectly as the labels' superview.
|
||||||
stackView.spacing = (detailedText != nil) ? 4.0 : 0.0
|
stackView.spacing = (detailedText != nil) ? 4.0 : 0.0
|
||||||
stackView.alignment = .leading
|
|
||||||
}
|
}
|
||||||
self.addTarget(self, action: #selector(ToastView.showErrorLog), for: .touchUpInside)
|
|
||||||
}
|
|
||||||
|
|
||||||
convenience init(error: Error, opensLog: Bool = false) {
|
|
||||||
self.init(error: error)
|
|
||||||
self.opensErrorLog = opensLog
|
|
||||||
}
|
|
||||||
|
|
||||||
enum InfoMode: String {
|
|
||||||
case fullError
|
|
||||||
case localizedDescription
|
|
||||||
}
|
}
|
||||||
|
|
||||||
convenience init(error: Error){
|
convenience init(error: Error)
|
||||||
self.init(error: error, mode: .localizedDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
convenience init(error: Error, mode: InfoMode)
|
|
||||||
{
|
{
|
||||||
let error = error as NSError
|
var error = error as NSError
|
||||||
let mode = mode == .fullError ? ErrorProcessing.InfoMode.fullError : ErrorProcessing.InfoMode.localizedDescription
|
var underlyingError = error.underlyingError
|
||||||
|
|
||||||
let text = error.localizedTitle ?? NSLocalizedString("Operation Failed", comment: "")
|
var preferredDuration: TimeInterval?
|
||||||
let detailText = ErrorProcessing(mode).getDescription(error: error)
|
|
||||||
|
if
|
||||||
|
let unwrappedUnderlyingError = underlyingError,
|
||||||
|
error.domain == AltServerErrorDomain && error.code == ALTServerError.Code.underlyingError.rawValue
|
||||||
|
{
|
||||||
|
// Treat underlyingError as the primary error.
|
||||||
|
|
||||||
|
error = unwrappedUnderlyingError as NSError
|
||||||
|
underlyingError = nil
|
||||||
|
|
||||||
|
preferredDuration = .longToastViewDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
let text: String
|
||||||
|
let detailText: String?
|
||||||
|
|
||||||
|
if let failure = error.localizedFailure
|
||||||
|
{
|
||||||
|
text = failure
|
||||||
|
detailText = error.localizedFailureReason ?? error.localizedRecoverySuggestion ?? underlyingError?.localizedDescription ?? error.localizedDescription
|
||||||
|
}
|
||||||
|
else if let reason = error.localizedFailureReason
|
||||||
|
{
|
||||||
|
text = reason
|
||||||
|
detailText = error.localizedRecoverySuggestion ?? underlyingError?.localizedDescription
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
text = error.localizedDescription
|
||||||
|
detailText = underlyingError?.localizedDescription ?? error.localizedRecoverySuggestion
|
||||||
|
}
|
||||||
|
|
||||||
self.init(text: text, detailText: detailText)
|
self.init(text: text, detailText: detailText)
|
||||||
|
|
||||||
|
if let preferredDuration = preferredDuration
|
||||||
|
{
|
||||||
|
self.preferredDuration = preferredDuration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
required init(coder aDecoder: NSCoder) {
|
required init(coder aDecoder: NSCoder) {
|
||||||
@@ -105,18 +112,6 @@ class ToastView: RSTToastView
|
|||||||
|
|
||||||
override func show(in view: UIView, duration: TimeInterval)
|
override func show(in view: UIView, duration: TimeInterval)
|
||||||
{
|
{
|
||||||
if opensErrorLog, #available(iOS 13.0, *), case let configuration = UIImage.SymbolConfiguration(font: self.textLabel.font),
|
|
||||||
let icon = UIImage(systemName: "chevron.right.circle", withConfiguration: configuration) {
|
|
||||||
let tintedIcon = icon.withTintColor(.white, renderingMode: .alwaysOriginal)
|
|
||||||
let moreIconImageView = UIImageView(image: tintedIcon)
|
|
||||||
moreIconImageView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
self.addSubview(moreIconImageView)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
moreIconImageView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -self.layoutMargins.right),
|
|
||||||
moreIconImageView.centerYAnchor.constraint(equalTo: self.textLabel.centerYAnchor),
|
|
||||||
moreIconImageView.leadingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: self.textLabel.trailingAnchor, multiplier: 1.0)
|
|
||||||
])
|
|
||||||
}
|
|
||||||
super.show(in: view, duration: duration)
|
super.show(in: view, duration: duration)
|
||||||
|
|
||||||
let announcement = (self.textLabel.text ?? "") + ". " + (self.detailTextLabel.text ?? "")
|
let announcement = (self.textLabel.text ?? "") + ". " + (self.detailTextLabel.text ?? "")
|
||||||
@@ -133,13 +128,3 @@ class ToastView: RSTToastView
|
|||||||
self.show(in: view, duration: self.preferredDuration)
|
self.show(in: view, duration: self.preferredDuration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ToastView
|
|
||||||
{
|
|
||||||
@objc func showErrorLog()
|
|
||||||
{
|
|
||||||
guard self.opensErrorLog else { return }
|
|
||||||
|
|
||||||
NotificationCenter.default.post(name: ToastView.openErrorLogNotification, object: self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
//
|
|
||||||
// VibrantButton.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 3/22/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
private let preferredFont = UIFont.boldSystemFont(ofSize: 14)
|
|
||||||
|
|
||||||
class VibrantButton: UIButton
|
|
||||||
{
|
|
||||||
var title: String? {
|
|
||||||
didSet {
|
|
||||||
if #available(iOS 15, *)
|
|
||||||
{
|
|
||||||
self.configuration?.title = self.title
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.setTitle(self.title, for: .normal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var image: UIImage? {
|
|
||||||
didSet {
|
|
||||||
if #available(iOS 15, *)
|
|
||||||
{
|
|
||||||
self.configuration?.image = self.image
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.setImage(self.image, for: .normal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var contentInsets: NSDirectionalEdgeInsets = .zero {
|
|
||||||
didSet {
|
|
||||||
if #available(iOS 15, *)
|
|
||||||
{
|
|
||||||
self.configuration?.contentInsets = self.contentInsets
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.contentEdgeInsets = UIEdgeInsets(top: self.contentInsets.top, left: self.contentInsets.leading, bottom: self.contentInsets.bottom, right: self.contentInsets.trailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override var isIndicatingActivity: Bool {
|
|
||||||
didSet {
|
|
||||||
guard #available(iOS 15, *) else { return }
|
|
||||||
self.updateConfiguration()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private let vibrancyView = UIVisualEffectView(effect: nil)
|
|
||||||
|
|
||||||
override init(frame: CGRect)
|
|
||||||
{
|
|
||||||
super.init(frame: frame)
|
|
||||||
|
|
||||||
self.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder)
|
|
||||||
{
|
|
||||||
super.init(coder: coder)
|
|
||||||
|
|
||||||
self.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initialize()
|
|
||||||
{
|
|
||||||
let blurEffect = UIBlurEffect(style: .systemThinMaterial)
|
|
||||||
let vibrancyEffect = UIVibrancyEffect(blurEffect: blurEffect, style: .fill) // .fill is more vibrant than .secondaryLabel
|
|
||||||
|
|
||||||
if #available(iOS 15, *)
|
|
||||||
{
|
|
||||||
var backgroundConfig = UIBackgroundConfiguration.clear()
|
|
||||||
backgroundConfig.visualEffect = blurEffect
|
|
||||||
|
|
||||||
var config = UIButton.Configuration.plain()
|
|
||||||
config.cornerStyle = .capsule
|
|
||||||
config.background = backgroundConfig
|
|
||||||
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { [weak self] (attributes) in
|
|
||||||
var attributes = attributes
|
|
||||||
attributes.font = preferredFont
|
|
||||||
|
|
||||||
if let self, self.isIndicatingActivity
|
|
||||||
{
|
|
||||||
// Hide title when indicating activity, but without changing intrinsicContentSize.
|
|
||||||
attributes.foregroundColor = UIColor.clear
|
|
||||||
}
|
|
||||||
|
|
||||||
return attributes
|
|
||||||
}
|
|
||||||
|
|
||||||
self.configuration = config
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
self.clipsToBounds = true
|
|
||||||
self.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8) // Add padding.
|
|
||||||
|
|
||||||
let blurView = UIVisualEffectView(effect: blurEffect)
|
|
||||||
blurView.isUserInteractionEnabled = false
|
|
||||||
self.addSubview(blurView, pinningEdgesWith: .zero)
|
|
||||||
self.insertSubview(blurView, at: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.vibrancyView.effect = vibrancyEffect
|
|
||||||
self.vibrancyView.isUserInteractionEnabled = false
|
|
||||||
self.addSubview(self.vibrancyView, pinningEdgesWith: .zero)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews()
|
|
||||||
{
|
|
||||||
super.layoutSubviews()
|
|
||||||
|
|
||||||
self.layer.cornerRadius = self.bounds.midY
|
|
||||||
|
|
||||||
// Make sure content subviews are inside self.vibrancyView.contentView.
|
|
||||||
|
|
||||||
if let titleLabel = self.titleLabel, titleLabel.superview != self.vibrancyView.contentView
|
|
||||||
{
|
|
||||||
self.vibrancyView.contentView.addSubview(titleLabel)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let imageView = self.imageView, imageView.superview != self.vibrancyView.contentView
|
|
||||||
{
|
|
||||||
self.vibrancyView.contentView.addSubview(imageView)
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.activityIndicatorView.superview != self.vibrancyView.contentView
|
|
||||||
{
|
|
||||||
self.vibrancyView.contentView.addSubview(self.activityIndicatorView)
|
|
||||||
}
|
|
||||||
|
|
||||||
if #unavailable(iOS 15)
|
|
||||||
{
|
|
||||||
// Update font after init because the original titleLabel is replaced.
|
|
||||||
self.titleLabel?.font = preferredFont
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
import Intents
|
import Intents
|
||||||
|
|
||||||
|
// Requires iOS 14 in-app intent handling.
|
||||||
|
@available(iOS 14, *)
|
||||||
extension INInteraction
|
extension INInteraction
|
||||||
{
|
{
|
||||||
static func refreshAllApps() -> INInteraction
|
static func refreshAllApps() -> INInteraction
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
//
|
|
||||||
// UIColor+AltStore.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 5/23/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension UIColor
|
|
||||||
{
|
|
||||||
static let altBackground = UIColor(named: "Background")!
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIColor
|
|
||||||
{
|
|
||||||
private static let brightnessMaxThreshold = 0.85
|
|
||||||
private static let brightnessMinThreshold = 0.35
|
|
||||||
|
|
||||||
private static let saturationBrightnessThreshold = 0.5
|
|
||||||
|
|
||||||
var adjustedForDisplay: UIColor {
|
|
||||||
guard self.isTooBright || self.isTooDark else { return self }
|
|
||||||
|
|
||||||
return UIColor { traits in
|
|
||||||
var hue: CGFloat = 0
|
|
||||||
var saturation: CGFloat = 0
|
|
||||||
var brightness: CGFloat = 0
|
|
||||||
guard self.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: nil) else { return self }
|
|
||||||
|
|
||||||
brightness = min(brightness, UIColor.brightnessMaxThreshold)
|
|
||||||
|
|
||||||
if traits.userInterfaceStyle == .dark
|
|
||||||
{
|
|
||||||
// Only raise brightness when in dark mode.
|
|
||||||
brightness = max(brightness, UIColor.brightnessMinThreshold)
|
|
||||||
}
|
|
||||||
|
|
||||||
let color = UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: 1.0)
|
|
||||||
return color
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isTooBright: Bool {
|
|
||||||
var saturation: CGFloat = 0
|
|
||||||
var brightness: CGFloat = 0
|
|
||||||
|
|
||||||
guard self.getHue(nil, saturation: &saturation, brightness: &brightness, alpha: nil) else { return false }
|
|
||||||
|
|
||||||
let isTooBright = (brightness >= UIColor.brightnessMaxThreshold && saturation <= UIColor.saturationBrightnessThreshold)
|
|
||||||
return isTooBright
|
|
||||||
}
|
|
||||||
|
|
||||||
var isTooDark: Bool {
|
|
||||||
var brightness: CGFloat = 0
|
|
||||||
guard self.getHue(nil, saturation: nil, brightness: &brightness, alpha: nil) else { return false }
|
|
||||||
|
|
||||||
let isTooDark = brightness <= UIColor.brightnessMinThreshold
|
|
||||||
return isTooDark
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -29,6 +29,7 @@ extension UIDevice
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(iOS 14, *)
|
||||||
var supportsFugu14: Bool {
|
var supportsFugu14: Bool {
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
return true
|
return true
|
||||||
@@ -39,6 +40,7 @@ extension UIDevice
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(iOS 14, *)
|
||||||
var isUntetheredJailbreakRequired: Bool {
|
var isUntetheredJailbreakRequired: Bool {
|
||||||
let ios14_4 = OperatingSystemVersion(majorVersion: 14, minorVersion: 4, patchVersion: 0)
|
let ios14_4 = OperatingSystemVersion(majorVersion: 14, minorVersion: 4, patchVersion: 0)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ private extension SystemSoundID
|
|||||||
static let tryAgain = SystemSoundID(1102)
|
static let tryAgain = SystemSoundID(1102)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(iOS 13, *)
|
||||||
extension UIDevice
|
extension UIDevice
|
||||||
{
|
{
|
||||||
enum VibrationPattern
|
enum VibrationPattern
|
||||||
@@ -25,6 +26,7 @@ extension UIDevice
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(iOS 13, *)
|
||||||
extension UIDevice
|
extension UIDevice
|
||||||
{
|
{
|
||||||
var isVibrationSupported: Bool {
|
var isVibrationSupported: Bool {
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
//
|
|
||||||
// UIFontDescriptor+Bold.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 10/16/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension UIFontDescriptor
|
|
||||||
{
|
|
||||||
func bolded() -> UIFontDescriptor
|
|
||||||
{
|
|
||||||
guard let descriptor = self.withSymbolicTraits(.traitBold) else { return self }
|
|
||||||
return descriptor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
//
|
|
||||||
// UINavigationBarAppearance+TintColor.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 4/4/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension UINavigationBarAppearance
|
|
||||||
{
|
|
||||||
func configureWithTintColor(_ tintColor: UIColor)
|
|
||||||
{
|
|
||||||
let buttonAppearance = UIBarButtonItemAppearance(style: .plain)
|
|
||||||
buttonAppearance.normal.titleTextAttributes = [.foregroundColor: tintColor]
|
|
||||||
self.buttonAppearance = buttonAppearance
|
|
||||||
|
|
||||||
let backButtonImage = UIImage(systemName: "chevron.backward")?.withTintColor(tintColor, renderingMode: .alwaysOriginal)
|
|
||||||
self.setBackIndicatorImage(backButtonImage, transitionMaskImage: backButtonImage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
//
|
|
||||||
// UTType+AltStore.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 11/3/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UniformTypeIdentifiers
|
|
||||||
|
|
||||||
extension UTType
|
|
||||||
{
|
|
||||||
static let ipa = UTType(importedAs: "com.apple.itunes.ipa")
|
|
||||||
}
|
|
||||||
@@ -2,18 +2,19 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>ALTAnisetteURL</key>
|
|
||||||
<string>https://ani.sidestore.io</string>
|
|
||||||
<key>ALTAppGroups</key>
|
<key>ALTAppGroups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.$(APP_GROUP_IDENTIFIER)</string>
|
<string>group.$(APP_GROUP_IDENTIFIER)</string>
|
||||||
|
<string>group.com.SideStore.SideStore</string>
|
||||||
</array>
|
</array>
|
||||||
<key>ALTDeviceID</key>
|
<key>ALTDeviceID</key>
|
||||||
<string>00008120-001270DA119B401E</string>
|
<string>00008101-000129D63698001E</string>
|
||||||
<key>ALTPairingFile</key>
|
|
||||||
<string><insert pairing file here></string>
|
|
||||||
<key>ALTServerID</key>
|
<key>ALTServerID</key>
|
||||||
<string>1F7D5B55-79CE-4546-A029-D4DDC4AF3B6D</string>
|
<string>1F7D5B55-79CE-4546-A029-D4DDC4AF3B6D</string>
|
||||||
|
<key>ALTPairingFile</key>
|
||||||
|
<string><insert pairing file here></string>
|
||||||
|
<key>ALTAnisetteURL</key>
|
||||||
|
<string>https://sideloadly.io/anisette/irGb3Quww8zrhgqnzmrx</string>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDocumentTypes</key>
|
<key>CFBundleDocumentTypes</key>
|
||||||
@@ -35,17 +36,6 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIcons</key>
|
|
||||||
<dict>
|
|
||||||
<key>CFBundlePrimaryIcon</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSAppIconComplementingColorNames</key>
|
|
||||||
<array>
|
|
||||||
<string>GradientTop</string>
|
|
||||||
<string>GradientBottom</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
@@ -54,6 +44,8 @@
|
|||||||
<string>$(PRODUCT_NAME)</string>
|
<string>$(PRODUCT_NAME)</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
<true/>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>$(MARKETING_VERSION)</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
@@ -62,9 +54,10 @@
|
|||||||
<key>CFBundleTypeRole</key>
|
<key>CFBundleTypeRole</key>
|
||||||
<string>Editor</string>
|
<string>Editor</string>
|
||||||
<key>CFBundleURLName</key>
|
<key>CFBundleURLName</key>
|
||||||
<string>SideStore General</string>
|
<string>AltStore General</string>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
|
<string>altstore</string>
|
||||||
<string>sidestore</string>
|
<string>sidestore</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
@@ -72,15 +65,16 @@
|
|||||||
<key>CFBundleTypeRole</key>
|
<key>CFBundleTypeRole</key>
|
||||||
<string>Editor</string>
|
<string>Editor</string>
|
||||||
<key>CFBundleURLName</key>
|
<key>CFBundleURLName</key>
|
||||||
<string>SideStore Backup</string>
|
<string>AltStore Backup</string>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
|
<string>altstore-com.rileytestut.AltStore</string>
|
||||||
<string>sidestore-com.SideStore.SideStore</string>
|
<string>sidestore-com.SideStore.SideStore</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>1</string>
|
||||||
<key>INIntentsSupported</key>
|
<key>INIntentsSupported</key>
|
||||||
<array>
|
<array>
|
||||||
<string>RefreshAllIntent</string>
|
<string>RefreshAllIntent</string>
|
||||||
@@ -88,18 +82,17 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>LSApplicationQueriesSchemes</key>
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>sidestore-com.SideStore.SideStore</string>
|
<string>altstore-com.rileytestut.AltStore</string>
|
||||||
<string>sidestore-com.SideStore.SideStore.Beta</string>
|
<string>altstore-com.rileytestut.AltStore.Beta</string>
|
||||||
|
<string>altstore-com.rileytestut.Delta</string>
|
||||||
|
<string>altstore-com.rileytestut.Delta.Beta</string>
|
||||||
|
<string>altstore-com.rileytestut.Delta.Lite</string>
|
||||||
|
<string>altstore-com.rileytestut.Delta.Lite.Beta</string>
|
||||||
|
<string>altstore-com.rileytestut.Clip</string>
|
||||||
|
<string>altstore-com.rileytestut.Clip.Beta</string>
|
||||||
</array>
|
</array>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
|
||||||
<true/>
|
|
||||||
<key>NSAppTransportSecurity</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
<key>NSBonjourServices</key>
|
<key>NSBonjourServices</key>
|
||||||
<array>
|
<array>
|
||||||
<string>_altserver._tcp</string>
|
<string>_altserver._tcp</string>
|
||||||
@@ -111,42 +104,6 @@
|
|||||||
<string>RefreshAllIntent</string>
|
<string>RefreshAllIntent</string>
|
||||||
<string>ViewAppIntent</string>
|
<string>ViewAppIntent</string>
|
||||||
</array>
|
</array>
|
||||||
<key>OSLogPreferences</key>
|
|
||||||
<dict>
|
|
||||||
<key>com.SideStore.SideStore</key>
|
|
||||||
<dict>
|
|
||||||
<key>AltJIT</key>
|
|
||||||
<dict>
|
|
||||||
<key>Level</key>
|
|
||||||
<dict>
|
|
||||||
<key>Enable</key>
|
|
||||||
<string>Info</string>
|
|
||||||
<key>Persist</key>
|
|
||||||
<string>Info</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>Main</key>
|
|
||||||
<dict>
|
|
||||||
<key>Level</key>
|
|
||||||
<dict>
|
|
||||||
<key>Enable</key>
|
|
||||||
<string>Info</string>
|
|
||||||
<key>Persist</key>
|
|
||||||
<string>Info</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>Sideload</key>
|
|
||||||
<dict>
|
|
||||||
<key>Level</key>
|
|
||||||
<dict>
|
|
||||||
<key>Enable</key>
|
|
||||||
<string>Info</string>
|
|
||||||
<key>Persist</key>
|
|
||||||
<string>Info</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
@@ -174,10 +131,13 @@
|
|||||||
<string>fetch</string>
|
<string>fetch</string>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIFileSharingEnabled</key>
|
|
||||||
<true/>
|
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
<string>Main</string>
|
<string>Main</string>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
@@ -222,8 +182,6 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>public.filename-extension</key>
|
<key>public.filename-extension</key>
|
||||||
<string>ipa</string>
|
<string>ipa</string>
|
||||||
<key>public.mime-type</key>
|
|
||||||
<string>application/x-ios-app</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
@@ -246,5 +204,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
<key>UIFileSharingEnabled</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
//
|
|
||||||
// AppShortcuts.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 8/23/22.
|
|
||||||
// Copyright © 2022 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import AppIntents
|
|
||||||
|
|
||||||
@available(iOS 17, *)
|
|
||||||
public struct ShortcutsProvider: AppShortcutsProvider
|
|
||||||
{
|
|
||||||
public static var appShortcuts: [AppShortcut] {
|
|
||||||
AppShortcut(intent: RefreshAllAppsIntent(),
|
|
||||||
phrases: [
|
|
||||||
"Refresh \(.applicationName)",
|
|
||||||
"Refresh \(.applicationName) apps",
|
|
||||||
"Refresh my \(.applicationName) apps",
|
|
||||||
"Refresh apps with \(.applicationName)",
|
|
||||||
],
|
|
||||||
shortTitle: "Refresh All Apps",
|
|
||||||
systemImageName: "arrow.triangle.2.circlepath")
|
|
||||||
}
|
|
||||||
|
|
||||||
public static var shortcutTileColor: ShortcutTileColor {
|
|
||||||
return .teal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
//
|
|
||||||
// RefreshAllAppsIntent.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 8/18/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import AppIntents
|
|
||||||
import WidgetKit
|
|
||||||
|
|
||||||
import AltStoreCore
|
|
||||||
|
|
||||||
// Shouldn't conform types we don't own to protocols we don't own, so make custom
|
|
||||||
// NSError subclass that conforms to CustomLocalizedStringResourceConvertible instead.
|
|
||||||
//
|
|
||||||
// Would prefer to just conform ALTLocalizedError to CustomLocalizedStringResourceConvertible,
|
|
||||||
// but that can't be done without raising minimum version for ALTLocalizedError to iOS 16 :/
|
|
||||||
@available(iOS 16, *)
|
|
||||||
class IntentError: NSError, CustomLocalizedStringResourceConvertible
|
|
||||||
{
|
|
||||||
var localizedStringResource: LocalizedStringResource {
|
|
||||||
return "\(self.localizedDescription)"
|
|
||||||
}
|
|
||||||
|
|
||||||
init(_ error: some Error)
|
|
||||||
{
|
|
||||||
let serializedError = (error as NSError).sanitizedForSerialization()
|
|
||||||
super.init(domain: serializedError.domain, code: serializedError.code, userInfo: serializedError.userInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder)
|
|
||||||
{
|
|
||||||
super.init(coder: coder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 17.0, *)
|
|
||||||
extension RefreshAllAppsIntent
|
|
||||||
{
|
|
||||||
private actor OperationActor
|
|
||||||
{
|
|
||||||
private(set) var operation: BackgroundRefreshAppsOperation?
|
|
||||||
|
|
||||||
func set(_ operation: BackgroundRefreshAppsOperation?)
|
|
||||||
{
|
|
||||||
self.operation = operation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 17.0, *)
|
|
||||||
struct RefreshAllAppsIntent: AppIntent, CustomIntentMigratedAppIntent, PredictableIntent, ProgressReportingIntent, ForegroundContinuableIntent
|
|
||||||
{
|
|
||||||
static let intentClassName = "RefreshAllIntent"
|
|
||||||
|
|
||||||
static var title: LocalizedStringResource = "Refresh All Apps"
|
|
||||||
static var description = IntentDescription("Refreshes your sideloaded apps to prevent them from expiring.")
|
|
||||||
|
|
||||||
static var parameterSummary: some ParameterSummary {
|
|
||||||
Summary("Refresh All Apps")
|
|
||||||
}
|
|
||||||
|
|
||||||
static var predictionConfiguration: some IntentPredictionConfiguration {
|
|
||||||
IntentPrediction {
|
|
||||||
DisplayRepresentation(
|
|
||||||
title: "Refresh All Apps",
|
|
||||||
subtitle: ""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let presentsNotifications: Bool
|
|
||||||
|
|
||||||
private let operationActor = OperationActor()
|
|
||||||
|
|
||||||
init(presentsNotifications: Bool)
|
|
||||||
{
|
|
||||||
self.presentsNotifications = presentsNotifications
|
|
||||||
|
|
||||||
self.progress.completedUnitCount = 0
|
|
||||||
self.progress.totalUnitCount = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
init()
|
|
||||||
{
|
|
||||||
self.init(presentsNotifications: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func perform() async throws -> some IntentResult & ProvidesDialog
|
|
||||||
{
|
|
||||||
do
|
|
||||||
{
|
|
||||||
// Request foreground execution at ~27 seconds to gracefully handle timeout.
|
|
||||||
let deadline: ContinuousClock.Instant = .now + .seconds(27)
|
|
||||||
|
|
||||||
try await withThrowingTaskGroup(of: Void.self) { taskGroup in
|
|
||||||
taskGroup.addTask {
|
|
||||||
try await self.refreshAllApps()
|
|
||||||
}
|
|
||||||
|
|
||||||
taskGroup.addTask {
|
|
||||||
try await Task.sleep(until: deadline)
|
|
||||||
throw OperationError.timedOut
|
|
||||||
}
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
for try await _ in taskGroup.prefix(1)
|
|
||||||
{
|
|
||||||
// We only care about the first child task to complete.
|
|
||||||
taskGroup.cancelAll()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch OperationError.timedOut
|
|
||||||
{
|
|
||||||
// We took too long to finish and return the final result,
|
|
||||||
// so we'll now present a normal notification when finished.
|
|
||||||
let operation = await self.operationActor.operation
|
|
||||||
operation?.presentsFinishedNotification = true
|
|
||||||
|
|
||||||
try await self.requestToContinueInForeground()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return .result(dialog: "All apps have been refreshed.")
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
let intentError = IntentError(error)
|
|
||||||
throw intentError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 17.0, *)
|
|
||||||
private extension RefreshAllAppsIntent
|
|
||||||
{
|
|
||||||
func refreshAllApps() async throws
|
|
||||||
{
|
|
||||||
if !DatabaseManager.shared.isStarted
|
|
||||||
{
|
|
||||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
|
||||||
DatabaseManager.shared.start { error in
|
|
||||||
if let error
|
|
||||||
{
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
continuation.resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
|
||||||
let installedApps = await context.perform { InstalledApp.fetchAppsForRefreshingAll(in: context) }
|
|
||||||
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
let operation = AppManager.shared.backgroundRefresh(installedApps, presentsNotifications: self.presentsNotifications) { (result) in
|
|
||||||
do
|
|
||||||
{
|
|
||||||
let results = try result.get()
|
|
||||||
|
|
||||||
for (_, result) in results
|
|
||||||
{
|
|
||||||
guard case let .failure(error) = result else { continue }
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
continuation.resume()
|
|
||||||
}
|
|
||||||
catch ~RefreshErrorCode.noInstalledApps
|
|
||||||
{
|
|
||||||
continuation.resume()
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
operation.ignoresServerNotFoundError = false
|
|
||||||
|
|
||||||
self.progress.addChild(operation.progress, withPendingUnitCount: 1)
|
|
||||||
|
|
||||||
Task {
|
|
||||||
await self.operationActor.set(operation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
//
|
|
||||||
// RefreshAllAppsWidgetIntent.swift
|
|
||||||
// AltStore
|
|
||||||
//
|
|
||||||
// Created by Riley Testut on 8/18/23.
|
|
||||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import AppIntents
|
|
||||||
|
|
||||||
@available(iOS 17, *)
|
|
||||||
struct RefreshAllAppsWidgetIntent: AppIntent, ProgressReportingIntent
|
|
||||||
{
|
|
||||||
static var title: LocalizedStringResource { "Refresh Apps via Widget" }
|
|
||||||
static var isDiscoverable: Bool { false } // Don't show in Shortcuts or Spotlight.
|
|
||||||
|
|
||||||
#if !WIDGET_EXTENSION
|
|
||||||
private let intent = RefreshAllAppsIntent(presentsNotifications: true)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
func perform() async throws -> some IntentResult
|
|
||||||
{
|
|
||||||
#if !WIDGET_EXTENSION
|
|
||||||
do
|
|
||||||
{
|
|
||||||
_ = try await self.intent.perform()
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
print("Failed to refresh apps via widget.", error)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
return .result()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// To ensure this intent is handled by the app itself (and not widget extension)
|
|
||||||
// we need to conform to either `ForegroundContinuableIntent` or `AudioPlaybackIntent`.
|
|
||||||
// https://mastodon.social/@mgorbach/110812347476671807
|
|
||||||
//
|
|
||||||
// Unfortunately `ForegroundContinuableIntent` is marked as unavailable in app extensions,
|
|
||||||
// so we "conform" RefreshAllAppsWidgetIntent to it in an `unavailable` extension ¯\_(ツ)_/¯
|
|
||||||
@available(iOS, unavailable)
|
|
||||||
extension RefreshAllAppsWidgetIntent: ForegroundContinuableIntent {}
|
|
||||||
@@ -8,13 +8,12 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import minimuxer
|
|
||||||
import AltStoreCore
|
import AltStoreCore
|
||||||
|
|
||||||
@available(iOS 14, *)
|
@available(iOS 14, *)
|
||||||
final class IntentHandler: NSObject, RefreshAllIntentHandling
|
final class IntentHandler: NSObject, RefreshAllIntentHandling
|
||||||
{
|
{
|
||||||
private let queue = DispatchQueue(label: "io.sidestore.IntentHandler")
|
private let queue = DispatchQueue(label: "io.altstore.IntentHandler")
|
||||||
|
|
||||||
private var completionHandlers = [RefreshAllIntent: (RefreshAllIntentResponse) -> Void]()
|
private var completionHandlers = [RefreshAllIntent: (RefreshAllIntentResponse) -> Void]()
|
||||||
private var queuedResponses = [RefreshAllIntent: RefreshAllIntentResponse]()
|
private var queuedResponses = [RefreshAllIntent: RefreshAllIntentResponse]()
|
||||||
@@ -40,12 +39,8 @@ final class IntentHandler: NSObject, RefreshAllIntentHandling
|
|||||||
|
|
||||||
// Give ourselves 9 extra seconds before starting handle() timeout timer.
|
// Give ourselves 9 extra seconds before starting handle() timeout timer.
|
||||||
// 10 seconds or longer results in timeout regardless.
|
// 10 seconds or longer results in timeout regardless.
|
||||||
self.queue.asyncAfter(deadline: .now() + 8.0) {
|
self.queue.asyncAfter(deadline: .now() + 9.0) {
|
||||||
if minimuxer.ready() {
|
self.finish(intent, response: RefreshAllIntentResponse(code: .ready, userActivity: nil))
|
||||||
self.finish(intent, response: RefreshAllIntentResponse(code: .success, userActivity: nil))
|
|
||||||
} else {
|
|
||||||
self.finish(intent, response: RefreshAllIntentResponse(code: .failure, userActivity: nil))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !DatabaseManager.shared.isStarted
|
if !DatabaseManager.shared.isStarted
|
||||||
@@ -57,14 +52,12 @@ final class IntentHandler: NSObject, RefreshAllIntentHandling
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
self.finish(intent, response: RefreshAllIntentResponse(code: .ready, userActivity: nil))
|
|
||||||
self.refreshApps(intent: intent)
|
self.refreshApps(intent: intent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
self.finish(intent, response: RefreshAllIntentResponse(code: .ready, userActivity: nil))
|
|
||||||
self.refreshApps(intent: intent)
|
self.refreshApps(intent: intent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,11 +83,6 @@ final class IntentHandler: NSObject, RefreshAllIntentHandling
|
|||||||
// We took too long to finish and return the final result,
|
// We took too long to finish and return the final result,
|
||||||
// so we'll now present a normal notification when finished.
|
// so we'll now present a normal notification when finished.
|
||||||
operation.presentsFinishedNotification = true
|
operation.presentsFinishedNotification = true
|
||||||
if minimuxer.ready() {
|
|
||||||
self.finish(intent, response: RefreshAllIntentResponse(code: .success, userActivity: nil))
|
|
||||||
} else {
|
|
||||||
self.finish(intent, response: RefreshAllIntentResponse(code: .failure, userActivity: nil))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.finish(intent, response: RefreshAllIntentResponse(code: .inProgress, userActivity: nil))
|
self.finish(intent, response: RefreshAllIntentResponse(code: .inProgress, userActivity: nil))
|
||||||
@@ -103,6 +91,7 @@ final class IntentHandler: NSObject, RefreshAllIntentHandling
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(iOS 14, *)
|
||||||
private extension IntentHandler
|
private extension IntentHandler
|
||||||
{
|
{
|
||||||
func finish(_ intent: RefreshAllIntent, response: RefreshAllIntentResponse)
|
func finish(_ intent: RefreshAllIntent, response: RefreshAllIntentResponse)
|
||||||
@@ -117,9 +106,6 @@ private extension IntentHandler
|
|||||||
{
|
{
|
||||||
// Queue response in case refreshing finishes after confirm() but before handle().
|
// Queue response in case refreshing finishes after confirm() but before handle().
|
||||||
self.queuedResponses[intent] = response
|
self.queuedResponses[intent] = response
|
||||||
DispatchQueue.main.async {
|
|
||||||
UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,7 +113,7 @@ private extension IntentHandler
|
|||||||
func refreshApps(intent: RefreshAllIntent)
|
func refreshApps(intent: RefreshAllIntent)
|
||||||
{
|
{
|
||||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
||||||
let installedApps = InstalledApp.fetchAppsForRefreshingAll(in: context)
|
let installedApps = InstalledApp.fetchActiveApps(in: context)
|
||||||
let operation = AppManager.shared.backgroundRefresh(installedApps, presentsNotifications: false) { (result) in
|
let operation = AppManager.shared.backgroundRefresh(installedApps, presentsNotifications: false) { (result) in
|
||||||
do
|
do
|
||||||
{
|
{
|
||||||
@@ -140,12 +126,10 @@ private extension IntentHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.finish(intent, response: RefreshAllIntentResponse(code: .success, userActivity: nil))
|
self.finish(intent, response: RefreshAllIntentResponse(code: .success, userActivity: nil))
|
||||||
UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
|
|
||||||
}
|
}
|
||||||
catch ~RefreshErrorCode.noInstalledApps
|
catch RefreshError.noInstalledApps
|
||||||
{
|
{
|
||||||
self.finish(intent, response: RefreshAllIntentResponse(code: .success, userActivity: nil))
|
self.finish(intent, response: RefreshAllIntentResponse(code: .success, userActivity: nil))
|
||||||
UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
|
|
||||||
}
|
}
|
||||||
catch let error as NSError
|
catch let error as NSError
|
||||||
{
|
{
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user