35 Commits
Author SHA1 Message Date
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
45 changed files with 4980 additions and 126 deletions

No files matched your search

+38 -4
View File
@@ -8,6 +8,8 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */; }; 6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */; };
6F09BA332E00808300CDE89C /* Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09BA322E00808300CDE89C /* Dynamic */; };
6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 6FB105542E26D3EC00185A5C /* CachedAsyncImage */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -30,6 +32,8 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */,
6F09BA332E00808300CDE89C /* Dynamic in Frameworks */,
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */, 6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -74,6 +78,8 @@
name = "Pocket Poster"; name = "Pocket Poster";
packageProductDependencies = ( packageProductDependencies = (
6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */, 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */,
6F09BA322E00808300CDE89C /* Dynamic */,
6FB105542E26D3EC00185A5C /* CachedAsyncImage */,
); );
productName = "Pocket Poster"; productName = "Pocket Poster";
productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */; productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */;
@@ -105,6 +111,8 @@
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */,
6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */; productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */;
@@ -276,12 +284,12 @@
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2.0.1; MARKETING_VERSION = 3.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster"; PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -308,12 +316,12 @@
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2.0.1; MARKETING_VERSION = 3.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster"; PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -354,6 +362,22 @@
minimumVersion = 0.9.19; minimumVersion = 0.9.19;
}; };
}; };
6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/mhdhejazi/Dynamic";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.2.0;
};
};
6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/lorenzofiamingo/swiftui-cached-async-image";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.1.1;
};
};
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
@@ -362,6 +386,16 @@
package = 6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */; package = 6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */;
productName = ZIPFoundation; productName = ZIPFoundation;
}; };
6F09BA322E00808300CDE89C /* Dynamic */ = {
isa = XCSwiftPackageProductDependency;
package = 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */;
productName = Dynamic;
};
6FB105542E26D3EC00185A5C /* CachedAsyncImage */ = {
isa = XCSwiftPackageProductDependency;
package = 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */;
productName = CachedAsyncImage;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = 6F09B7952DEB694B00CDE89C /* Project object */; rootObject = 6F09B7952DEB694B00CDE89C /* Project object */;
@@ -1,6 +1,24 @@
{ {
"originHash" : "d0d4edfdf2bf3cd05b3ba2dec0af1a9c271c93f944cbba8677cc647f74a6b323", "originHash" : "7f2d604f1affb7dc07ec1898cef3c0eaf130e2b55b5c441f445ab59c3285c1bb",
"pins" : [ "pins" : [
{
"identity" : "dynamic",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mhdhejazi/Dynamic",
"state" : {
"revision" : "ab9a2570862d54aed2663691bb767f881226a12f",
"version" : "1.2.0"
}
},
{
"identity" : "swiftui-cached-async-image",
"kind" : "remoteSourceControl",
"location" : "https://github.com/lorenzofiamingo/swiftui-cached-async-image",
"state" : {
"revision" : "467a3d17479887943ab917a379e62bbaff60ac8a",
"version" : "2.1.1"
}
},
{ {
"identity" : "zipfoundation", "identity" : "zipfoundation",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -7,7 +7,7 @@
<key>Pocket Poster.xcscheme_^#shared#^_</key> <key>Pocket Poster.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>1</integer> <integer>0</integer>
</dict> </dict>
</dict> </dict>
</dict> </dict>
+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,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,149 @@
//
// 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)
}
}
}
// 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)
}
UserDefaults.standard.set(activeWP, forKey: "ActiveCarPlayWallpapers")
let test = SymHandler.getDocumentsDirectory().appendingPathComponent("Caches")
try Data(count: 0).write(to: test)
}
}
@@ -6,13 +6,62 @@
// //
import Foundation import Foundation
import UIKit
class DownloadManager { class DownloadManager: ObservableObject {
static func getWallpaperNameFromURL(string url: String) -> String { static let shared = DownloadManager()
static let exploreLink = "https://cowabun.ga/wallpapers?pocketposter=true"
@Published var downloadURL: String? = nil
func getWallpaperNameFromURL(string url: String) -> String {
return String(url.split(separator: "/").last ?? "Unknown") return String(url.split(separator: "/").last ?? "Unknown")
} }
static func downloadFromURL(_ url: URL) async throws -> URL { func startTendiesDownload(for url: URL) {
// prohibit to only tendies files
if !url.absoluteString.hasSuffix(".tendies") {
UIApplication.shared.alert(body: NSLocalizedString("Only .tendies files can be downloaded!", comment: ""))
} else if PosterBoardManager.shared.selectedTendies.count >= PosterBoardManager.MaxTendies {
UIApplication.shared.alert(title: NSLocalizedString("Max Tendies Reached", comment: ""), body: String(format: NSLocalizedString("You can only apply %@ descriptors.", comment: ""), "\(PosterBoardManager.MaxTendies)"))
} else {
DispatchQueue.main.async {
self.downloadURL = url.absoluteString.replacingOccurrences(of: "pocketposter://download?url=", with: "")
UIApplication.shared.confirmAlert(title: NSLocalizedString("Download Tendies File", comment: ""), body: String(format: NSLocalizedString("Would you like to download the file %@?", comment: ""), "\(self.getWallpaperNameFromURL(string: self.downloadURL ?? "/Unknown"))"), onOK: {
self.downloadWallpaper()
}, noCancel: false)
}
}
}
func downloadWallpaper() {
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
UIApplication.shared.alert(title: NSLocalizedString("Downloading", comment: "") + " \(getWallpaperNameFromURL(string: downloadURL ?? "/Unknown"))...", body: NSLocalizedString("Please wait", comment: ""), animated: false, withButton: false)
Task {
do {
let newURL = try await downloadFromURL(string: downloadURL!)
DispatchQueue.main.async {
PosterBoardManager.shared.selectedTendies.append(newURL)
Haptic.shared.notify(.success)
UIApplication.shared.dismissAlert(animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: {
UIApplication.shared.alert(title: NSLocalizedString("Successfully downloaded wallpaper!", comment: ""), body: NSLocalizedString("Your downloaded .tendies will be on the Home page.", comment: "after successfully downloading tendies"))
})
}
} catch {
DispatchQueue.main.async {
Haptic.shared.notify(.error)
UIApplication.shared.dismissAlert(animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: {
UIApplication.shared.alert(title: NSLocalizedString("Could not download wallpaper!", comment: ""), body: error.localizedDescription)
})
}
}
}
}
func downloadFromURL(_ url: URL) async throws -> URL {
print("Downloading from \(url.absoluteString)") print("Downloading from \(url.absoluteString)")
let request = URLRequest(url: url) let request = URLRequest(url: url)
@@ -24,12 +73,12 @@ class DownloadManager {
return newURL return newURL
} }
static func downloadFromURL(string path: String) async throws -> URL { func downloadFromURL(string path: String) async throws -> URL {
guard let url = URL(string: path) else { throw URLError(.unknown) } guard let url = URL(string: path) else { throw URLError(.unknown) }
return try await downloadFromURL(url) return try await downloadFromURL(url)
} }
static func copyTendies(from url: URL) throws -> URL { func copyTendies(from url: URL) throws -> URL {
// scope the resource // scope the resource
let accessing = url.startAccessingSecurityScopedResource() let accessing = url.startAccessingSecurityScopedResource()
defer { defer {
@@ -35,6 +35,7 @@ class PosterBoardManager: ObservableObject {
guard let obj = objc_getClass("IPSettingsUtilities") as? NSObject else { return false } guard let obj = objc_getClass("IPSettingsUtilities") as? NSObject else { return false }
langManager = obj langManager = obj
} else { } else {
// TODO: Need to find the correct class for this on iOS 17
guard let obj = objc_getClass("PSLanguageSelector") as? NSObject else { return false } guard let obj = objc_getClass("PSLanguageSelector") as? NSObject else { return false }
langManager = obj langManager = obj
} }
@@ -102,7 +103,6 @@ class PosterBoardManager: ObservableObject {
if fileName.lowercased() == "container" { if fileName.lowercased() == "container" {
// container support, find the extensions // container support, find the extensions
let extDir = dir.appending(path: "Library/Application Support/PRBPosterExtensionDataStore/61/Extensions") let extDir = dir.appending(path: "Library/Application Support/PRBPosterExtensionDataStore/61/Extensions")
print(extDir.absoluteString)
var retList: [String: [URL]] = [:] var retList: [String: [URL]] = [:]
for ext in try FileManager.default.contentsOfDirectory(at: extDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) { for ext in try FileManager.default.contentsOfDirectory(at: extDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
let descrDir = ext.appendingPathComponent("descriptors") let descrDir = ext.appendingPathComponent("descriptors")
@@ -239,7 +239,9 @@ class PosterBoardManager: ObservableObject {
SymHandler.cleanup() SymHandler.cleanup()
let docDir = SymHandler.getDocumentsDirectory() let docDir = SymHandler.getDocumentsDirectory()
for file in try FileManager.default.contentsOfDirectory(at: docDir, includingPropertiesForKeys: nil) { for file in try FileManager.default.contentsOfDirectory(at: docDir, includingPropertiesForKeys: nil) {
if file.lastPathComponent != "CarPlayPhotos" {
try FileManager.default.removeItem(at: file) try FileManager.default.removeItem(at: file)
} }
} }
}
} }
+17 -2
View File
@@ -23,6 +23,13 @@ class SymHandler {
return getDocumentsDirectory() return getDocumentsDirectory()
} }
static func getPosterBoardHashURL() -> URL {
return getLCDocumentsDirectory().appendingPathComponent("NuggetPosterBoardHash")
}
static func getCarPlayHashURL() -> URL {
return getLCDocumentsDirectory().appendingPathComponent("NuggetCarPlayWallpaperHash")
}
private static func getSymlinkURL() -> URL { private static func getSymlinkURL() -> URL {
return getLCDocumentsDirectory().appendingPathComponent(".Trash", conformingTo: .symbolicLink) return getLCDocumentsDirectory().appendingPathComponent(".Trash", conformingTo: .symbolicLink)
} }
@@ -43,10 +50,18 @@ class SymHandler {
return try createSymlink(to: "/var/mobile/Containers/Data/Application/\(appHash)") return try createSymlink(to: "/var/mobile/Containers/Data/Application/\(appHash)")
} }
static func getExtensionVersion() -> String {
if #available(iOS 17.0, *) {
return "61"
}
return "59"
}
static func createDescriptorsSymlink(appHash: String, ext: String) throws -> URL { static func createDescriptorsSymlink(appHash: String, ext: String) throws -> URL {
// create a symlink directly to the descriptors // create a symlink directly to the descriptors
print("linking to \(appHash)/Library/Application Support/PRBPosterExtensionDataStore/61/Extensions/\(ext)/descriptors") let extVer = SymHandler.getExtensionVersion()
return try createAppSymlink(for: "\(appHash)/Library/Application Support/PRBPosterExtensionDataStore/61/Extensions/\(ext)/descriptors") print("linking to \(appHash)/Library/Application Support/PRBPosterExtensionDataStore/\(extVer)/Extensions/\(ext)/descriptors")
return try createAppSymlink(for: "\(appHash)/Library/Application Support/PRBPosterExtensionDataStore/\(extVer)/Extensions/\(ext)/descriptors")
} }
static func cleanup() { static func cleanup() {
+3 -2
View File
@@ -102,7 +102,8 @@ class VideoHandler {
print("Failed to get video track") print("Failed to get video track")
throw AVError(.decodeFailed) throw AVError(.decodeFailed)
} }
let size = track.naturalSize.applying(track.preferredTransform) let preferredTransform = track.preferredTransform
let size = track.naturalSize.applying(preferredTransform)
let width = Int(abs(size.width)) let width = Int(abs(size.width))
let height = Int(abs(size.height)) let height = Int(abs(size.height))
let fps = track.nominalFrameRate let fps = track.nominalFrameRate
@@ -145,7 +146,7 @@ class VideoHandler {
let filePathName = "assets/\(name)" let filePathName = "assets/\(name)"
UIApplication.shared.change(title: NSLocalizedString("Generating Video...", comment: ""), body: String(format: NSLocalizedString("Creating %@...", comment: "the message for the current image that is being generated"), filePathName)) UIApplication.shared.change(title: NSLocalizedString("Generating Video...", comment: ""), body: String(format: NSLocalizedString("Creating %@...", comment: "the message for the current image that is being generated"), filePathName))
let ciImage = CIImage(cvPixelBuffer: imageBuffer) let ciImage = CIImage(cvPixelBuffer: imageBuffer).transformed(by: preferredTransform)
let context = CIContext() let context = CIContext()
if let cgImage = context.createCGImage(ciImage, from: ciImage.extent) { if let cgImage = context.createCGImage(ciImage, from: ciImage.extent) {
if let img = cgImage.jpg { if let img = cgImage.jpg {
+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,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,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" "value" : "Pocket Poster"
} }
}, },
"bg" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pocket Poster"
}
},
"cs" : { "cs" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
@@ -71,12 +77,30 @@
"value" : "Pocket Poster" "value" : "Pocket Poster"
} }
}, },
"fr-CH" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pocket Poster"
}
},
"fr-LU" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pocket Poster"
}
},
"hu" : { "hu" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Pocket Poster" "value" : "Pocket Poster"
} }
}, },
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pocket Poster"
}
},
"ja" : { "ja" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
@@ -125,6 +149,12 @@
"value" : "Pocket Poster" "value" : "Pocket Poster"
} }
}, },
"zh-CN" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pocket Poster"
}
},
"zh-Hant" : { "zh-Hant" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
@@ -153,6 +183,12 @@
"value" : "خلفيات Nugget" "value" : "خلفيات Nugget"
} }
}, },
"bg" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nugget Wallpaper"
}
},
"cs" : { "cs" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
@@ -195,12 +231,30 @@
"value" : "Fond d'écran Nugget" "value" : "Fond d'écran Nugget"
} }
}, },
"fr-CH" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nugget Wallpaper"
}
},
"fr-LU" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nugget Wallpaper"
}
},
"hu" : { "hu" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Nugget Háttérkép" "value" : "Nugget Háttérkép"
} }
}, },
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sfondo di Nugget"
}
},
"ja" : { "ja" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
@@ -249,6 +303,12 @@
"value" : "Hình nền Nugget" "value" : "Hình nền Nugget"
} }
}, },
"zh-CN" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nugget 墙纸"
}
},
"zh-Hant" : { "zh-Hant" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
+28 -30
View File
@@ -10,10 +10,13 @@ import SwiftUI
@main @main
struct Pocket_PosterApp: App { struct Pocket_PosterApp: App {
// Prefs // Prefs
@ObservedObject var dlManager = DownloadManager.shared
@AppStorage("finishedTutorial") var finishedTutorial: Bool = false @AppStorage("finishedTutorial") var finishedTutorial: Bool = false
@AppStorage("pbHash") var pbHash: String = "" @AppStorage("pbHash") var pbHash: String = ""
@State var downloadURL: String? = nil @State var downloadURL: String? = nil
@State var checkedForUpdate: Bool = false
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
@@ -26,20 +29,33 @@ struct Pocket_PosterApp: App {
} }
.transition(.opacity) .transition(.opacity)
.animation(.easeOut(duration: 0.5), value: finishedTutorial) .animation(.easeOut(duration: 0.5), value: finishedTutorial)
.onAppear {
// check for update
if !checkedForUpdate {
checkedForUpdate = true
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, let url = URL(string: "https://api.github.com/repos/leminlimez/Pocket-Poster/releases/latest") {
let task = URLSession.shared.dataTask(with: url) {(data, response, error) in
guard let data = data else { return }
if let json = try? JSONSerialization.jsonObject(with: data, options: .mutableContainers) as? [String: Any] {
if (json["tag_name"] as? String)?.replacingOccurrences(of: "v", with: "").compare(version, options: .numeric) == .orderedDescending {
UIApplication.shared.confirmAlert(
title: NSLocalizedString("Update Available", comment: "app update available on GitHub"),
body: String(format: NSLocalizedString("Pocket Poster %@ is available, do you want to visit releases page?", comment: "app update available on GitHub"), json["tag_name"] as? String ?? "update"),
onOK: {
UIApplication.shared.open(URL(string: "https://github.com/leminlimez/Pocket-Poster/releases/latest")!)
}, noCancel: false)
}
}
}
task.resume()
}
}
}
.onOpenURL(perform: { url in .onOpenURL(perform: { url in
// Download URL // Download URL
if url.absoluteString.starts(with: "pocketposter://download") { if url.absoluteString.starts(with: "pocketposter://download") {
// prohibit to only tendies files dlManager.startTendiesDownload(for: url)
if !url.absoluteString.hasSuffix(".tendies") {
UIApplication.shared.alert(body: NSLocalizedString("Only .tendies files can be downloaded!", comment: ""))
} else if PosterBoardManager.shared.selectedTendies.count >= PosterBoardManager.MaxTendies {
UIApplication.shared.alert(title: NSLocalizedString("Max Tendies Reached", comment: ""), body: String(format: NSLocalizedString("You can only apply %@ descriptors.", comment: ""), "\(PosterBoardManager.MaxTendies)"))
} else {
downloadURL = url.absoluteString.replacingOccurrences(of: "pocketposter://download?url=", with: "")
UIApplication.shared.confirmAlert(title: NSLocalizedString("Download Tendies File", comment: ""), body: String(format: NSLocalizedString("Would you like to download the file %@?", comment: ""), "\(DownloadManager.getWallpaperNameFromURL(string: downloadURL ?? "/Unknown"))"), onOK: {
downloadWallpaper()
}, noCancel: false)
}
} }
// App Hash URL // App Hash URL
else if url.absoluteString.starts(with: "pocketposter://app-hash?uuid=") { else if url.absoluteString.starts(with: "pocketposter://app-hash?uuid=") {
@@ -51,7 +67,7 @@ struct Pocket_PosterApp: App {
} else { } else {
// copy it over to the KFC bucket // copy it over to the KFC bucket
do { do {
let newURL = try DownloadManager.copyTendies(from: url) let newURL = try DownloadManager.shared.copyTendies(from: url)
PosterBoardManager.shared.selectedTendies.append(newURL) PosterBoardManager.shared.selectedTendies.append(newURL)
Haptic.shared.notify(.success) Haptic.shared.notify(.success)
UIApplication.shared.alert(title: String(format: NSLocalizedString("Successfully imported %@", comment: ""), "\(url.lastPathComponent)"), body: "") UIApplication.shared.alert(title: String(format: NSLocalizedString("Successfully imported %@", comment: ""), "\(url.lastPathComponent)"), body: "")
@@ -65,24 +81,6 @@ struct Pocket_PosterApp: App {
} }
} }
func downloadWallpaper() {
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
UIApplication.shared.alert(title: NSLocalizedString("Downloading", comment: "") + " \(DownloadManager.getWallpaperNameFromURL(string: downloadURL ?? "/Unknown"))...", body: NSLocalizedString("Please wait", comment: ""), animated: false, withButton: false)
Task {
do {
let newURL = try await DownloadManager.downloadFromURL(string: downloadURL!)
PosterBoardManager.shared.selectedTendies.append(newURL)
Haptic.shared.notify(.success)
UIApplication.shared.dismissAlert(animated: true)
} catch {
Haptic.shared.notify(.error)
UIApplication.shared.dismissAlert(animated: true)
UIApplication.shared.alert(title: NSLocalizedString("Could not download wallpaper!", comment: ""), body: error.localizedDescription)
}
}
}
init() { init() {
// clear the videos cache // clear the videos cache
try? FileManager.default.removeItem(at: SymHandler.getDocumentsDirectory().appendingPathComponent("Videos", conformingTo: .directory)) try? FileManager.default.removeItem(at: SymHandler.getDocumentsDirectory().appendingPathComponent("Videos", conformingTo: .directory))
@@ -0,0 +1,75 @@
//
// WebView.swift
// Pocket Poster
//
// Created by lemin on 7/6/25.
//
import Foundation
import SwiftUI
import WebKit
struct WallpaperWebView: UIViewRepresentable {
let url: URL
@Binding var isLoading: Bool
@Binding var error: Error?
init(_ url: URL, isLoading: Binding<Bool>, error: Binding<Error?>) {
self.url = url
self._isLoading = isLoading
self._error = error
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> some UIView {
let configuration = WKWebViewConfiguration()
configuration.setURLSchemeHandler(AssetsSchemeHandler(), forURLScheme: "pocketposter")
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = context.coordinator
webView.load(URLRequest(url: url))
return webView
}
func updateUIView(_ uiView: UIViewType, context: Context) {}
class Coordinator: NSObject, WKNavigationDelegate {
var parent: WallpaperWebView
init(_ parent: WallpaperWebView) {
self.parent = parent
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
parent.isLoading = true
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
parent.isLoading = false
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
return
let nsError = error as NSError
if nsError.code == NSURLErrorCancelled { return }
print("loading error: \(error)")
parent.isLoading = false
parent.error = error
}
}
}
class AssetsSchemeHandler: NSObject, WKURLSchemeHandler {
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
DispatchQueue.global().async {
print("handling scheme")
guard let url = urlSchemeTask.request.url, url.absoluteString.starts(with: "pocketposter://download") else { return }
print("url: \(url.absoluteString)")
DownloadManager.shared.startTendiesDownload(for: url)
}
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
}
}
+4
View File
@@ -99,6 +99,7 @@ struct ContentView: View {
.buttonStyle(TintedButton(color: .blue, fullwidth: true)) .buttonStyle(TintedButton(color: .blue, fullwidth: true))
} }
Button(action: { Button(action: {
if #available(iOS 18.0, *) {
guard let lang = UserDefaults.standard.stringArray(forKey: "AppleLanguages")?.first else { guard let lang = UserDefaults.standard.stringArray(forKey: "AppleLanguages")?.first else {
hideResetHelp = false // fallback to tutorial hideResetHelp = false // fallback to tutorial
return return
@@ -110,6 +111,9 @@ struct ContentView: View {
UIApplication.shared.alert(body: "The API failed to call correctly.\nSystem Locale Code: \(lang)") UIApplication.shared.alert(body: "The API failed to call correctly.\nSystem Locale Code: \(lang)")
} }
}, noCancel: false) }, noCancel: false)
} else {
hideResetHelp = false
}
}) { }) {
Label("Reset Collections", systemImage: "arrow.clockwise.circle") Label("Reset Collections", systemImage: "arrow.clockwise.circle")
} }
@@ -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)
}
}
+10
View File
@@ -18,6 +18,16 @@ struct RootView: View {
.tabItem { .tabItem {
Label("Videos", systemImage: "camera") Label("Videos", systemImage: "camera")
} }
if CarPlayManager.supportsCarPlay() {
CarPlayView()
.tabItem {
Label("CarPlay", systemImage: "car")
}
}
ExploreView()
.tabItem {
Label("Explore", systemImage: "safari")
}
} }
} }
} }
+31 -28
View File
@@ -20,39 +20,30 @@ struct SettingsView: View {
List { List {
Section { Section {
VStack { VStack {
TextField("Enter App Hash", text: $pbHash) TextField("Enter PosterBoard App Hash", text: $pbHash)
.textFieldStyle(RoundedBorderTextFieldStyle())
.font(.system(.body, design: .monospaced))
if CarPlayManager.supportsCarPlay() {
TextField("Enter CarPlayWallpaper App Hash", text: $cpHash)
.textFieldStyle(RoundedBorderTextFieldStyle()) .textFieldStyle(RoundedBorderTextFieldStyle())
.font(.system(.body, design: .monospaced)) .font(.system(.body, design: .monospaced))
.onChange(of: pbHash) { _ in
var separator: String? = nil
if pbHash.contains(" ") {
separator = " "
} else if pbHash.contains("\n") {
separator = "\n"
}
if let separator = separator {
let items = pbHash.split(separator: separator)
if let pb = items.first {
pbHash = String(pb)
}
if items.count >= 2 {
cpHash = String(items[1])
}
}
} }
HStack { HStack {
Spacer() Spacer()
// Run task to check until file exists from Nugget pc over AFC // Run task to check until file exists from Nugget pc over AFC
Button(action: { Button(action: {
UIApplication.shared.confirmAlert(title: "Waiting for app hash...", body: "Connect your device to Nugget and click the \"Pocket Poster Helper\" button.", confirmTitle: "Cancel", onOK: { if !FileManager.default.fileExists(atPath: SymHandler.getPosterBoardHashURL().path()) {
// don't show the alert because it is already there
UIApplication.shared.confirmAlert(title: NSLocalizedString("Waiting for app hash...", comment: ""), body: NSLocalizedString("Connect your device to Nugget and click the \"Pocket Poster Helper\" button.", comment: ""), confirmTitle: NSLocalizedString("Cancel", comment: ""), onOK: {
cancelWaitForHash() cancelWaitForHash()
}, noCancel: true) }, noCancel: true)
}
startWaitForHash() startWaitForHash()
}) { }) {
Text("Detect") Text("Detect")
} }
.foregroundStyle(.green) .foregroundStyle(.green)
.onChange(of: checkingForHash) { .onChange(of: checkingForHash) { _ in
if !checkingForHash { if !checkingForHash {
// hide ui alert // hide ui alert
UIApplication.shared.dismissAlert(animated: true) UIApplication.shared.dismissAlert(animated: true)
@@ -94,6 +85,17 @@ struct SettingsView: View {
Label("Clear App Cache", systemImage: "trash.circle") Label("Clear App Cache", systemImage: "trash.circle")
} }
.foregroundStyle(.red) .foregroundStyle(.red)
Button(action: {
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
UserDefaults.standard.set(nil, forKey: "ActiveCarPlayWallpapers")
try? FileManager.default.removeItem(at: CarPlayManager.getCarPlayPhotosURL())
Haptic.shared.notify(.success)
UIApplication.shared.alert(title: NSLocalizedString("CarPlay Applied Wallpapers Successfully Cleared!", comment: ""), body: "")
}) {
Label("Reset CarPlay Applied Wallpapers", systemImage: "trash.circle")
}
.foregroundStyle(.red)
} header: { } header: {
Label("Actions", systemImage: "gear") Label("Actions", systemImage: "gear")
} }
@@ -151,7 +153,7 @@ struct SettingsView: View {
func startWaitForHash() { func startWaitForHash() {
checkingForHash = true checkingForHash = true
hashCheckTask = Task { hashCheckTask = Task {
let filePath = SymHandler.getLCDocumentsDirectory().appendingPathComponent("NuggetAppHash") let filePath = SymHandler.getPosterBoardHashURL()
while !FileManager.default.fileExists(atPath: filePath.path()) { while !FileManager.default.fileExists(atPath: filePath.path()) {
try? await Task.sleep(nanoseconds: 500_000_000) // Sleep 0.5s try? await Task.sleep(nanoseconds: 500_000_000) // Sleep 0.5s
try Task.checkCancellation() try Task.checkCancellation()
@@ -161,16 +163,17 @@ struct SettingsView: View {
let contents = try String(contentsOf: filePath) let contents = try String(contentsOf: filePath)
try? FileManager.default.removeItem(at: filePath) try? FileManager.default.removeItem(at: filePath)
await MainActor.run { await MainActor.run {
var separator = " " pbHash = contents
if contents.contains("\n") {
separator = "\n"
} }
let items = contents.split(separator: separator) // check for carplay hash
if let pb = items.first { if UIDevice.current.userInterfaceIdiom == .phone {
pbHash = String(pb) let carplayPath = SymHandler.getCarPlayHashURL()
if FileManager.default.fileExists(atPath: carplayPath.path()) {
let carplayContents = try String(contentsOf: carplayPath)
try? FileManager.default.removeItem(at: carplayPath)
await MainActor.run {
cpHash = carplayContents
} }
if items.count >= 2 {
cpHash = String(items[1])
} }
} }
} catch { } catch {
+8 -2
View File
@@ -1,5 +1,11 @@
#!/bin/bash #!/bin/bash
CONFIG="Release"
if [ "$1" == "-d" ]; then
CONFIG="Debug"
fi
set -e set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
@@ -15,13 +21,13 @@ cd build
xcodebuild -project "$WORKING_LOCATION/$APPLICATION_NAME.xcodeproj" \ xcodebuild -project "$WORKING_LOCATION/$APPLICATION_NAME.xcodeproj" \
-scheme "$APPLICATION_NAME" \ -scheme "$APPLICATION_NAME" \
-configuration Release \ -configuration "$CONFIG" \
-derivedDataPath "$WORKING_LOCATION/build/DerivedDataApp" \ -derivedDataPath "$WORKING_LOCATION/build/DerivedDataApp" \
-destination 'generic/platform=iOS' \ -destination 'generic/platform=iOS' \
clean build \ clean build \
CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" CODE_SIGNING_ALLOWED="NO" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" CODE_SIGNING_ALLOWED="NO"
DD_APP_PATH="$WORKING_LOCATION/build/DerivedDataApp/Build/Products/Release-iphoneos/$APPLICATION_NAME.app" DD_APP_PATH="$WORKING_LOCATION/build/DerivedDataApp/Build/Products/$CONFIG-iphoneos/$APPLICATION_NAME.app"
TARGET_APP="$WORKING_LOCATION/build/$APPLICATION_NAME.app" TARGET_APP="$WORKING_LOCATION/build/$APPLICATION_NAME.app"
cp -r "$DD_APP_PATH" "$TARGET_APP" cp -r "$DD_APP_PATH" "$TARGET_APP"