diff --git a/Pocket Poster.xcodeproj/project.pbxproj b/Pocket Poster.xcodeproj/project.pbxproj
index 297575c..60e4615 100644
--- a/Pocket Poster.xcodeproj/project.pbxproj
+++ b/Pocket Poster.xcodeproj/project.pbxproj
@@ -8,6 +8,8 @@
/* Begin PBXBuildFile section */
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */; };
+ 6F09BA332E00808300CDE89C /* Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09BA322E00808300CDE89C /* Dynamic */; };
+ 6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 6FB105542E26D3EC00185A5C /* CachedAsyncImage */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -30,6 +32,8 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ 6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */,
+ 6F09BA332E00808300CDE89C /* Dynamic in Frameworks */,
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -74,6 +78,8 @@
name = "Pocket Poster";
packageProductDependencies = (
6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */,
+ 6F09BA322E00808300CDE89C /* Dynamic */,
+ 6FB105542E26D3EC00185A5C /* CachedAsyncImage */,
);
productName = "Pocket Poster";
productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */;
@@ -105,6 +111,8 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
+ 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */,
+ 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */;
@@ -276,12 +284,12 @@
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
- IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.0.2;
+ MARKETING_VERSION = 3.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -308,12 +316,12 @@
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
- IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.0.2;
+ MARKETING_VERSION = 3.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -354,6 +362,22 @@
minimumVersion = 0.9.19;
};
};
+ 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/mhdhejazi/Dynamic";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 1.2.0;
+ };
+ };
+ 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/lorenzofiamingo/swiftui-cached-async-image";
+ requirement = {
+ kind = upToNextMajorVersion;
+ minimumVersion = 2.1.1;
+ };
+ };
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -362,6 +386,16 @@
package = 6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */;
productName = ZIPFoundation;
};
+ 6F09BA322E00808300CDE89C /* Dynamic */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */;
+ productName = Dynamic;
+ };
+ 6FB105542E26D3EC00185A5C /* CachedAsyncImage */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */;
+ productName = CachedAsyncImage;
+ };
/* End XCSwiftPackageProductDependency section */
};
rootObject = 6F09B7952DEB694B00CDE89C /* Project object */;
diff --git a/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 70ae973..d3b1c82 100644
--- a/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,6 +1,24 @@
{
- "originHash" : "d0d4edfdf2bf3cd05b3ba2dec0af1a9c271c93f944cbba8677cc647f74a6b323",
+ "originHash" : "7f2d604f1affb7dc07ec1898cef3c0eaf130e2b55b5c441f445ab59c3285c1bb",
"pins" : [
+ {
+ "identity" : "dynamic",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/mhdhejazi/Dynamic",
+ "state" : {
+ "revision" : "ab9a2570862d54aed2663691bb767f881226a12f",
+ "version" : "1.2.0"
+ }
+ },
+ {
+ "identity" : "swiftui-cached-async-image",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/lorenzofiamingo/swiftui-cached-async-image",
+ "state" : {
+ "revision" : "467a3d17479887943ab917a379e62bbaff60ac8a",
+ "version" : "2.1.1"
+ }
+ },
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
diff --git a/Pocket Poster.xcodeproj/xcuserdata/lemin.xcuserdatad/xcschemes/xcschememanagement.plist b/Pocket Poster.xcodeproj/xcuserdata/lemin.xcuserdatad/xcschemes/xcschememanagement.plist
index d7a896f..f3372ed 100644
--- a/Pocket Poster.xcodeproj/xcuserdata/lemin.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/Pocket Poster.xcodeproj/xcuserdata/lemin.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -7,7 +7,7 @@
Pocket Poster.xcscheme_^#shared#^_
orderHint
- 1
+ 0
diff --git a/Pocket Poster/API/CowabungaAPI.swift b/Pocket Poster/API/CowabungaAPI.swift
new file mode 100644
index 0000000..07bb1a0
--- /dev/null
+++ b/Pocket Poster/API/CowabungaAPI.swift
@@ -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)
+ }
+ }
+ }
+}
diff --git a/Pocket Poster/API/DownloadableWallpaper.swift b/Pocket Poster/API/DownloadableWallpaper.swift
new file mode 100644
index 0000000..e6438d3
--- /dev/null
+++ b/Pocket Poster/API/DownloadableWallpaper.swift
@@ -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")
+ }
+}
diff --git a/Pocket Poster/Controllers/CPBitmapHandler.swift b/Pocket Poster/Controllers/CPBitmapHandler.swift
new file mode 100644
index 0000000..1bdb396
--- /dev/null
+++ b/Pocket Poster/Controllers/CPBitmapHandler.swift
@@ -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)
+ }
+}
diff --git a/Pocket Poster/Controllers/CarPlayWallpaper+CarPlayManager.swift b/Pocket Poster/Controllers/CarPlayWallpaper+CarPlayManager.swift
new file mode 100644
index 0000000..6a094f5
--- /dev/null
+++ b/Pocket Poster/Controllers/CarPlayWallpaper+CarPlayManager.swift
@@ -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)
+ }
+}
diff --git a/Pocket Poster/Controllers/DownloadManager.swift b/Pocket Poster/Controllers/DownloadManager.swift
index a560d5f..35c69ba 100644
--- a/Pocket Poster/Controllers/DownloadManager.swift
+++ b/Pocket Poster/Controllers/DownloadManager.swift
@@ -6,13 +6,62 @@
//
import Foundation
+import UIKit
-class DownloadManager {
- static func getWallpaperNameFromURL(string url: String) -> String {
+class DownloadManager: ObservableObject {
+ static let shared = DownloadManager()
+ static let exploreLink = "https://cowabun.ga/wallpapers?pocketposter=true"
+
+ @Published var downloadURL: String? = nil
+
+ func getWallpaperNameFromURL(string url: String) -> String {
return String(url.split(separator: "/").last ?? "Unknown")
}
- static func downloadFromURL(_ url: URL) async throws -> URL {
+ func startTendiesDownload(for url: URL) {
+ // prohibit to only tendies files
+ if !url.absoluteString.hasSuffix(".tendies") {
+ UIApplication.shared.alert(body: NSLocalizedString("Only .tendies files can be downloaded!", comment: ""))
+ } else if PosterBoardManager.shared.selectedTendies.count >= PosterBoardManager.MaxTendies {
+ UIApplication.shared.alert(title: NSLocalizedString("Max Tendies Reached", comment: ""), body: String(format: NSLocalizedString("You can only apply %@ descriptors.", comment: ""), "\(PosterBoardManager.MaxTendies)"))
+ } else {
+ DispatchQueue.main.async {
+ self.downloadURL = url.absoluteString.replacingOccurrences(of: "pocketposter://download?url=", with: "")
+ UIApplication.shared.confirmAlert(title: NSLocalizedString("Download Tendies File", comment: ""), body: String(format: NSLocalizedString("Would you like to download the file %@?", comment: ""), "\(self.getWallpaperNameFromURL(string: self.downloadURL ?? "/Unknown"))"), onOK: {
+ self.downloadWallpaper()
+ }, noCancel: false)
+ }
+ }
+ }
+
+ func downloadWallpaper() {
+ UIImpactFeedbackGenerator(style: .soft).impactOccurred()
+ UIApplication.shared.alert(title: NSLocalizedString("Downloading", comment: "") + " \(getWallpaperNameFromURL(string: downloadURL ?? "/Unknown"))...", body: NSLocalizedString("Please wait", comment: ""), animated: false, withButton: false)
+
+ Task {
+ do {
+ let newURL = try await downloadFromURL(string: downloadURL!)
+ DispatchQueue.main.async {
+ PosterBoardManager.shared.selectedTendies.append(newURL)
+ Haptic.shared.notify(.success)
+ UIApplication.shared.dismissAlert(animated: true)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: {
+ UIApplication.shared.alert(title: NSLocalizedString("Successfully downloaded wallpaper!", comment: ""), body: NSLocalizedString("Your downloaded .tendies will be on the Home page.", comment: "after successfully downloading tendies"))
+ })
+ }
+ } catch {
+ DispatchQueue.main.async {
+ Haptic.shared.notify(.error)
+ UIApplication.shared.dismissAlert(animated: true)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: {
+ UIApplication.shared.alert(title: NSLocalizedString("Could not download wallpaper!", comment: ""), body: error.localizedDescription)
+ })
+ }
+ }
+ }
+ }
+
+ func downloadFromURL(_ url: URL) async throws -> URL {
print("Downloading from \(url.absoluteString)")
let request = URLRequest(url: url)
@@ -24,12 +73,12 @@ class DownloadManager {
return newURL
}
- static func downloadFromURL(string path: String) async throws -> URL {
+ func downloadFromURL(string path: String) async throws -> URL {
guard let url = URL(string: path) else { throw URLError(.unknown) }
return try await downloadFromURL(url)
}
- static func copyTendies(from url: URL) throws -> URL {
+ func copyTendies(from url: URL) throws -> URL {
// scope the resource
let accessing = url.startAccessingSecurityScopedResource()
defer {
diff --git a/Pocket Poster/Controllers/PosterBoardManager.swift b/Pocket Poster/Controllers/PosterBoardManager.swift
index 8b45a4d..5ee7025 100644
--- a/Pocket Poster/Controllers/PosterBoardManager.swift
+++ b/Pocket Poster/Controllers/PosterBoardManager.swift
@@ -103,7 +103,6 @@ class PosterBoardManager: ObservableObject {
if fileName.lowercased() == "container" {
// container support, find the extensions
let extDir = dir.appending(path: "Library/Application Support/PRBPosterExtensionDataStore/61/Extensions")
- print(extDir.absoluteString)
var retList: [String: [URL]] = [:]
for ext in try FileManager.default.contentsOfDirectory(at: extDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
let descrDir = ext.appendingPathComponent("descriptors")
@@ -240,7 +239,9 @@ class PosterBoardManager: ObservableObject {
SymHandler.cleanup()
let docDir = SymHandler.getDocumentsDirectory()
for file in try FileManager.default.contentsOfDirectory(at: docDir, includingPropertiesForKeys: nil) {
- try FileManager.default.removeItem(at: file)
+ if file.lastPathComponent != "CarPlayPhotos" {
+ try FileManager.default.removeItem(at: file)
+ }
}
}
}
diff --git a/Pocket Poster/Controllers/SymHandler.swift b/Pocket Poster/Controllers/SymHandler.swift
index ea09214..417e590 100644
--- a/Pocket Poster/Controllers/SymHandler.swift
+++ b/Pocket Poster/Controllers/SymHandler.swift
@@ -50,10 +50,18 @@ class SymHandler {
return try createSymlink(to: "/var/mobile/Containers/Data/Application/\(appHash)")
}
+ static func getExtensionVersion() -> String {
+ if #available(iOS 17.0, *) {
+ return "61"
+ }
+ return "59"
+ }
+
static func createDescriptorsSymlink(appHash: String, ext: String) throws -> URL {
// create a symlink directly to the descriptors
- print("linking to \(appHash)/Library/Application Support/PRBPosterExtensionDataStore/61/Extensions/\(ext)/descriptors")
- return try createAppSymlink(for: "\(appHash)/Library/Application Support/PRBPosterExtensionDataStore/61/Extensions/\(ext)/descriptors")
+ let extVer = SymHandler.getExtensionVersion()
+ print("linking to \(appHash)/Library/Application Support/PRBPosterExtensionDataStore/\(extVer)/Extensions/\(ext)/descriptors")
+ return try createAppSymlink(for: "\(appHash)/Library/Application Support/PRBPosterExtensionDataStore/\(extVer)/Extensions/\(ext)/descriptors")
}
static func cleanup() {
diff --git a/Pocket Poster/Controllers/VideoHandler.swift b/Pocket Poster/Controllers/VideoHandler.swift
index 1484f76..dfd404a 100644
--- a/Pocket Poster/Controllers/VideoHandler.swift
+++ b/Pocket Poster/Controllers/VideoHandler.swift
@@ -102,7 +102,8 @@ class VideoHandler {
print("Failed to get video track")
throw AVError(.decodeFailed)
}
- let size = track.naturalSize.applying(track.preferredTransform)
+ let preferredTransform = track.preferredTransform
+ let size = track.naturalSize.applying(preferredTransform)
let width = Int(abs(size.width))
let height = Int(abs(size.height))
let fps = track.nominalFrameRate
@@ -145,7 +146,7 @@ class VideoHandler {
let filePathName = "assets/\(name)"
UIApplication.shared.change(title: NSLocalizedString("Generating Video...", comment: ""), body: String(format: NSLocalizedString("Creating %@...", comment: "the message for the current image that is being generated"), filePathName))
- let ciImage = CIImage(cvPixelBuffer: imageBuffer)
+ let ciImage = CIImage(cvPixelBuffer: imageBuffer).transformed(by: preferredTransform)
let context = CIContext()
if let cgImage = context.createCGImage(ciImage, from: ciImage.extent) {
if let img = cgImage.jpg {
diff --git a/Pocket Poster/Error Codes/APIError.swift b/Pocket Poster/Error Codes/APIError.swift
new file mode 100644
index 0000000..044f9b1
--- /dev/null
+++ b/Pocket Poster/Error Codes/APIError.swift
@@ -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
+ }
+ }
+}
diff --git a/Pocket Poster/Extensions/Reorderable.swift b/Pocket Poster/Extensions/Reorderable.swift
new file mode 100644
index 0000000..3984298
--- /dev/null
+++ b/Pocket Poster/Extensions/Reorderable.swift
@@ -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
+ }
+ }
+}
diff --git a/Pocket Poster/Extensions/URLCache+imageCache.swift b/Pocket Poster/Extensions/URLCache+imageCache.swift
new file mode 100644
index 0000000..a48257e
--- /dev/null
+++ b/Pocket Poster/Extensions/URLCache+imageCache.swift
@@ -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)
+}
diff --git a/Pocket Poster/Extensions/Alert++.swift b/Pocket Poster/Extensions/Views/Alert++.swift
similarity index 100%
rename from Pocket Poster/Extensions/Alert++.swift
rename to Pocket Poster/Extensions/Views/Alert++.swift
diff --git a/Pocket Poster/Extensions/Views/Buttons/OpaqueButton.swift b/Pocket Poster/Extensions/Views/Buttons/OpaqueButton.swift
new file mode 100644
index 0000000..bdbfe00
--- /dev/null
+++ b/Pocket Poster/Extensions/Views/Buttons/OpaqueButton.swift
@@ -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
+ }
+}
diff --git a/Pocket Poster/Extensions/TintedButton.swift b/Pocket Poster/Extensions/Views/Buttons/TintedButton.swift
similarity index 100%
rename from Pocket Poster/Extensions/TintedButton.swift
rename to Pocket Poster/Extensions/Views/Buttons/TintedButton.swift
diff --git a/Pocket Poster/Extensions/Views/CornerRadius++.swift b/Pocket Poster/Extensions/Views/CornerRadius++.swift
new file mode 100644
index 0000000..4aaa8da
--- /dev/null
+++ b/Pocket Poster/Extensions/Views/CornerRadius++.swift
@@ -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)
+ }
+}
diff --git a/Pocket Poster/Extensions/LoopingPlayerUIView.swift b/Pocket Poster/Extensions/Views/LoopingPlayerUIView.swift
similarity index 100%
rename from Pocket Poster/Extensions/LoopingPlayerUIView.swift
rename to Pocket Poster/Extensions/Views/LoopingPlayerUIView.swift
diff --git a/Pocket Poster/Extensions/MaterialView.swift b/Pocket Poster/Extensions/Views/MaterialView.swift
similarity index 100%
rename from Pocket Poster/Extensions/MaterialView.swift
rename to Pocket Poster/Extensions/Views/MaterialView.swift
diff --git a/Pocket Poster/Extensions/PlayerView.swift b/Pocket Poster/Extensions/Views/PlayerView.swift
similarity index 100%
rename from Pocket Poster/Extensions/PlayerView.swift
rename to Pocket Poster/Extensions/Views/PlayerView.swift
diff --git a/Pocket Poster/Localizable.xcstrings b/Pocket Poster/Localizable.xcstrings
index 9714bdf..33bce34 100644
--- a/Pocket Poster/Localizable.xcstrings
+++ b/Pocket Poster/Localizable.xcstrings
@@ -1,6 +1,9 @@
{
"sourceLanguage" : "en",
"strings" : {
+ "" : {
+
+ },
"Actions" : {
"localizations" : {
"ar" : {
@@ -463,6 +466,40 @@
}
}
},
+ "Apple" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Apple"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Apple"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Apple"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Apple"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Apple"
+ }
+ }
+ }
+ },
"Apply" : {
"localizations" : {
"ar" : {
@@ -617,6 +654,40 @@
}
}
},
+ "Apply CarPlay Wallpapers" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "CarPlay Hintergrundbilder anwenden"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fonds d'écran Apple CarPlay"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Подтвердить обои CarPlay"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ใช้งานวอลเปเปอร์ CarPlay"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Áp dụng hình nền CarPlay"
+ }
+ }
+ }
+ },
"Applying Wallpapers..." : {
"localizations" : {
"ar" : {
@@ -925,6 +996,108 @@
}
}
},
+ "CarPlay" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "CarPlay"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "CarPlay"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "CarPlay"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "CarPlay"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "CarPlay"
+ }
+ }
+ }
+ },
+ "CarPlay Applied Wallpapers Successfully Cleared!" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Angewendete CarPlay Hintergrundbilder erfolgreich gelöscht!"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Les Fonds D'Écran Appliqués À CarPlay Ont Été Effacés Avec Succès!"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Обои для CarPlay успешно очищены!"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "เคลียร์วอลเปเปอร์ CarPlay ที่กำลังใช้งานสำเร็จ!"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Đã xóa hình nền CarPlay thành công!"
+ }
+ }
+ }
+ },
+ "CarPlay Wallpapers" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Carplay Hintergrundbilder"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fond D'écran CarPlay"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Обои CarPlay"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "วอลเปเปอร์ CarPlay"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hình nền CarPlay"
+ }
+ }
+ }
+ },
"Clear App Cache" : {
"localizations" : {
"ar" : {
@@ -1389,6 +1562,12 @@
},
"Connect your device to Nugget and click the \"Pocket Poster Helper\" button." : {
"localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verbinde dein Gerät zur Nugget und drücke den \"Pocket Poster Helper\" Knopf."
+ }
+ },
"es" : {
"stringUnit" : {
"state" : "translated",
@@ -1401,6 +1580,12 @@
"value" : "Connectez votre appareil à Nugget et cliquez sur le bouton \"Pocket Poster Helper\"."
}
},
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Подключите ваше устройство к ПК с Nugget и нажмите «Pocket Poster Helper»"
+ }
+ },
"th" : {
"stringUnit" : {
"state" : "translated",
@@ -1427,6 +1612,40 @@
}
}
},
+ "Could not connect to server" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Konnte nicht zum Server verbinden"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ne pouvais pas se connecter au la seurver"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Невозможно подключиться к серверу"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "เชื่อมต่อกับซอเวอร์ไม่สำเร็จ"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Không thể kết nối đến máy chủ"
+ }
+ }
+ }
+ },
"Could not download wallpaper!" : {
"localizations" : {
"ar" : {
@@ -2199,6 +2418,34 @@
}
}
},
+ "Custom" : {
+ "localizations" : {
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Coutume"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Кастом"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "กำหนดเอง"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tùy chỉnh"
+ }
+ }
+ }
+ },
"Detect" : {
"localizations" : {
"ar" : {
@@ -2934,7 +3181,7 @@
"ru" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Скачать Tendies файл"
+ "value" : "Скачать файл Tendies"
}
},
"th" : {
@@ -3278,6 +3525,7 @@
}
},
"Enter App Hash" : {
+ "extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -3431,6 +3679,108 @@
}
}
},
+ "Enter CarPlayWallpaper App Hash" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gib CarPlay-Hintergrund App Hash ein"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrez le hachage de l'application de fond d'écran CarPlay"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Введите App Hash CarPlayWallpaper"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ใส่แฮชแอป CarPlayWallpaper"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nhập mã định danh ứng dụng hình nền CarPlay"
+ }
+ }
+ }
+ },
+ "Enter PosterBoard App Hash" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "PosterBoard App Hash eingeben"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrez le hachage de l'application PosterBoard"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Введите App Hash PosterBoard"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ใส่แฮชแอป PosterBoard"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nhập mã định danh ứng dụng Posterboard"
+ }
+ }
+ }
+ },
+ "Enter your CarPlayWallpaper app hash in Settings." : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gib deine CarPlay-Hintergrund App Hash in den Einstellungen ein."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Saisissez le hachage de votre application CarPlay Wallpaper dans Paramètres."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Введите свой App Hash для CarPlayWallpaper в Настройках"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "กรอกแฮชแอป CarPlayWallpaper ในการตั้งค่า"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nhập mã định danh ứng dụng Hình nền CarPlay ở trong cài đặt."
+ }
+ }
+ }
+ },
"Enter your PosterBoard app hash in Settings." : {
"localizations" : {
"ar" : {
@@ -3893,6 +4243,40 @@
}
}
},
+ "Explore" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erkunden"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Explorer"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Исследуйте"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "สำรวจ"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Khám phá"
+ }
+ }
+ }
+ },
"Extracting tendies..." : {
"comment" : "happens when unzipping the files",
"localizations" : {
@@ -4202,6 +4586,74 @@
}
}
},
+ "Failed to fetch CarPlay wallpapers" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Abrufen der CarPlay-Hintergrundbilder fehlgeschlagen"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Échec de la récupération des fonds d'écran CarPlay"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка при извлечении обоев CarPlay"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ดึงข้อมูลวอลเปเปอร์ CarPlay ไม่สำเร็จ"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Không tải được hình nền CarPlay"
+ }
+ }
+ }
+ },
+ "Failed to fetch wallpapers" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Abrufen der Hintergrundbilder fehlgeschlagen"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossible de récupérer des fonds d'écran"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка при извлечении обоев"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ดึงข้อมูลวอลเปเปอร์ไม่สำเร็จ"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Không tải được hình nền"
+ }
+ }
+ }
+ },
"Failed to import tendies" : {
"localizations" : {
"ar" : {
@@ -4818,6 +5270,40 @@
}
}
},
+ "Filter Wallpapers" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hintergrundbilder filtern"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Filtrer les fonds d'écran"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Фильтр обоев"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "กรองวอลเปเปอร์"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bộ lọc hình nền"
+ }
+ }
+ }
+ },
"Find Wallpapers" : {
"localizations" : {
"ar" : {
@@ -7402,7 +7888,7 @@
"ru" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Только .tendies файлы могут быть загружены!"
+ "value" : "Только файлы .tendies могут быть загружены!"
}
},
"th" : {
@@ -7908,6 +8394,12 @@
"value" : "Pocket Poster %@ je k dispozici, přejete si ho stáhnout z GitHubu?"
}
},
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pocket Poster %@ ist verfügbar, willst du die Releases-Seite besuchen?"
+ }
+ },
"es" : {
"stringUnit" : {
"state" : "translated",
@@ -7920,6 +8412,12 @@
"value" : "Pocket Poster %@ est disponible, voulez-vous visiter la page des communiqués?"
}
},
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Доступен Pocket Poster %@, хотите ли вы перейти на веб-сайт релизов?"
+ }
+ },
"th" : {
"stringUnit" : {
"state" : "translated",
@@ -8562,6 +9060,40 @@
}
}
},
+ "Reset CarPlay Applied Wallpapers" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Angewendete CarPlay Hintergrundbilder zurücksetzen"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Réinitialiser les fonds d'écran appliqués par CarPlay"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Сбросить примененные обои CarPlay"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "รีเซ็ตวอลเปเปอร์ CarPlay ที่ใช้งาน"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Khôi phục lại các thay đổi hình nền CarPlay"
+ }
+ }
+ }
+ },
"Reset Collections" : {
"localizations" : {
"ar" : {
@@ -9486,6 +10018,40 @@
}
}
},
+ "Successfully downloaded wallpaper!" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hintergrund erfolgreich heruntergeladen!"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fond d'écran téléchargé avec succès !"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Обои загружены успешно!"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ดาวน์โหลดวอลเปเปอร์สำเร็จ!"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tải xuống hình nền thành công!"
+ }
+ }
+ }
+ },
"Successfully imported %@" : {
"localizations" : {
"ar" : {
@@ -10564,6 +11130,40 @@
}
}
},
+ "Unable to obtain repo hash. Maybe update to the latest version?" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "könte keinen Repo-Hash abrufen. Aktualisiere vielleicht auf die neuste Version?"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Incapable d'obtenir le hachage repo. Peut-être mettre à jour vers la dernière version ?"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ошибка при получении хэша репозитория. Может обновить до последней версии?"
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ไม่สามารถดึงแฮชรีโป้ข้อมูลได้ ลองอัพเดทแอปให้เป็นเวอร์ชั่นล่าสุด"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Không lấy được mã định danh của repo. Hay là cập nhật lên phiên bản mới nhất đi?"
+ }
+ }
+ }
+ },
"Update Available" : {
"comment" : "app update available on GitHub",
"localizations" : {
@@ -10573,6 +11173,12 @@
"value" : "Je K Dispozici Aktualizace"
}
},
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Update verfügbar"
+ }
+ },
"es" : {
"stringUnit" : {
"state" : "translated",
@@ -10585,6 +11191,12 @@
"value" : "Mise à jour disponible"
}
},
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Доступно обновление"
+ }
+ },
"th" : {
"stringUnit" : {
"state" : "translated",
@@ -11081,6 +11693,12 @@
},
"Waiting for app hash..." : {
"localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Warte auf App hash..."
+ }
+ },
"es" : {
"stringUnit" : {
"state" : "translated",
@@ -11093,6 +11711,12 @@
"value" : "En attente de l’app hash..."
}
},
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ожидание App Hash…"
+ }
+ },
"th" : {
"stringUnit" : {
"state" : "translated",
@@ -11889,6 +12513,40 @@
}
}
},
+ "You can now choose your wallpapers in the CarPlay settings in your car." : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Du kannst jetzt deine Hintergründe in den CarPlay Einstellungen in deinem Auto auswählen."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vous pouvez maintenant choisir vos fonds d'écran dans les paramètres CarPlay de votre voiture."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Теперь Вы можете выбрать обои в настройках CarPlay в машине."
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "คุณสามารถเลือกวอลเปเปอร์ในการตั้งค่า CarPlay ได้แล้ว"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bây giờ bạn đã có thể chọn hình nền trong cài đặt CarPlay của xe."
+ }
+ }
+ }
+ },
"You can only apply %@ descriptors." : {
"localizations" : {
"ar" : {
@@ -12197,6 +12855,41 @@
}
}
},
+ "Your downloaded .tendies will be on the Home page." : {
+ "comment" : "after successfully downloading tendies",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Deine heruntergeladenen .tendies wird auf der Startseite sein."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vos .tendies téléchargés seront sur la page d'accueil."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ваш загруженный .tendies будет на домашнем экране."
+ }
+ },
+ "th" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ไฟล์ .tendies ที่คุณดาวน์โหลดจะอยู่บนหน้าหลัก"
+ }
+ },
+ "vi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tệp .tendies mà bạn đã tải xuống sẽ nằm ở Trang chủ."
+ }
+ }
+ }
+ },
"Your PosterBoard will refresh automatically." : {
"localizations" : {
"ar" : {
diff --git a/Pocket Poster/Packages/GIFImage/Sources/GIFImage+Init.swift b/Pocket Poster/Packages/GIFImage/Sources/GIFImage+Init.swift
new file mode 100644
index 0000000..bc937c5
--- /dev/null
+++ b/Pocket Poster/Packages/GIFImage/Sources/GIFImage+Init.swift
@@ -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 = Binding.constant(true),
+ loop: Binding = 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 = Binding.constant(true),
+ loop: Binding = 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 = Binding.constant(true),
+ loop: Binding = 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
+ )
+ }
+}
diff --git a/Pocket Poster/Packages/GIFImage/Sources/GIFImage.swift b/Pocket Poster/Packages/GIFImage/Sources/GIFImage.swift
new file mode 100644
index 0000000..44ed5a7
--- /dev/null
+++ b/Pocket Poster/Packages/GIFImage/Sources/GIFImage.swift
@@ -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 = Binding.constant(true),
+ loop: Binding = 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
+
diff --git a/Pocket Poster/Packages/GIFImage/Sources/domain/CGImageSourceFrameSequence.swift b/Pocket Poster/Packages/GIFImage/Sources/domain/CGImageSourceFrameSequence.swift
new file mode 100644
index 0000000..ac7b04c
--- /dev/null
+++ b/Pocket Poster/Packages/GIFImage/Sources/domain/CGImageSourceFrameSequence.swift
@@ -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)
+ }
+}
diff --git a/Pocket Poster/Packages/GIFImage/Sources/domain/CGImageSourceIterator.swift b/Pocket Poster/Packages/GIFImage/Sources/domain/CGImageSourceIterator.swift
new file mode 100644
index 0000000..4cb67a7
--- /dev/null
+++ b/Pocket Poster/Packages/GIFImage/Sources/domain/CGImageSourceIterator.swift
@@ -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
+ }
+}
diff --git a/Pocket Poster/Packages/GIFImage/Sources/domain/ImageLoader.swift b/Pocket Poster/Packages/GIFImage/Sources/domain/ImageLoader.swift
new file mode 100644
index 0000000..db7a575
--- /dev/null
+++ b/Pocket Poster/Packages/GIFImage/Sources/domain/ImageLoader.swift
@@ -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
+ }
+}
diff --git a/Pocket Poster/Packages/GIFImage/Sources/domain/PresentationController.swift b/Pocket Poster/Packages/GIFImage/Sources/domain/PresentationController.swift
new file mode 100644
index 0000000..4de122d
--- /dev/null
+++ b/Pocket Poster/Packages/GIFImage/Sources/domain/PresentationController.swift
@@ -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,
+ loop: Binding,
+ 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))
+ }
+}
diff --git a/Pocket Poster/Packages/GIFImage/Sources/extension/CFString+GIFImage.swift b/Pocket Poster/Packages/GIFImage/Sources/extension/CFString+GIFImage.swift
new file mode 100644
index 0000000..1c26073
--- /dev/null
+++ b/Pocket Poster/Packages/GIFImage/Sources/extension/CFString+GIFImage.swift
@@ -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()
+ }
+}
diff --git a/Pocket Poster/Packages/GIFImage/Sources/extension/CGImageSource+GIFImage.swift b/Pocket Poster/Packages/GIFImage/Sources/extension/CGImageSource+GIFImage.swift
new file mode 100644
index 0000000..41034b9
--- /dev/null
+++ b/Pocket Poster/Packages/GIFImage/Sources/extension/CGImageSource+GIFImage.swift
@@ -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
+ }
+ }
+}
diff --git a/Pocket Poster/Packages/GIFImage/Sources/extension/Data+GIFImage.swift b/Pocket Poster/Packages/GIFImage/Sources/extension/Data+GIFImage.swift
new file mode 100644
index 0000000..982adaf
--- /dev/null
+++ b/Pocket Poster/Packages/GIFImage/Sources/extension/Data+GIFImage.swift
@@ -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)
+ }
+}
diff --git a/Pocket Poster/Packages/GIFImage/Sources/extension/Environment+GIFImage.swift b/Pocket Poster/Packages/GIFImage/Sources/extension/Environment+GIFImage.swift
new file mode 100644
index 0000000..506d781
--- /dev/null
+++ b/Pocket Poster/Packages/GIFImage/Sources/extension/Environment+GIFImage.swift
@@ -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)
+ }
+}
diff --git a/Pocket Poster/Packages/GIFImage/Sources/model/FrameRate.swift b/Pocket Poster/Packages/GIFImage/Sources/model/FrameRate.swift
new file mode 100644
index 0000000..96e1fb5
--- /dev/null
+++ b/Pocket Poster/Packages/GIFImage/Sources/model/FrameRate.swift
@@ -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)
+}
diff --git a/Pocket Poster/Packages/GIFImage/Sources/model/GIFSource.swift b/Pocket Poster/Packages/GIFImage/Sources/model/GIFSource.swift
new file mode 100644
index 0000000..fe66dfb
--- /dev/null
+++ b/Pocket Poster/Packages/GIFImage/Sources/model/GIFSource.swift
@@ -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)
+}
diff --git a/Pocket Poster/Packages/GIFImage/Sources/model/ImageFrame.swift b/Pocket Poster/Packages/GIFImage/Sources/model/ImageFrame.swift
new file mode 100644
index 0000000..05cafa5
--- /dev/null
+++ b/Pocket Poster/Packages/GIFImage/Sources/model/ImageFrame.swift
@@ -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?
+}
diff --git a/Pocket Poster/Packages/GIFImage/Sources/model/RawImage.swift b/Pocket Poster/Packages/GIFImage/Sources/model/RawImage.swift
new file mode 100644
index 0000000..36928cd
--- /dev/null
+++ b/Pocket Poster/Packages/GIFImage/Sources/model/RawImage.swift
@@ -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
+ }
+}
diff --git a/Pocket Poster/Pocket_PosterApp.swift b/Pocket Poster/Pocket_PosterApp.swift
index 2667f6f..be1b987 100644
--- a/Pocket Poster/Pocket_PosterApp.swift
+++ b/Pocket Poster/Pocket_PosterApp.swift
@@ -10,6 +10,8 @@ import SwiftUI
@main
struct Pocket_PosterApp: App {
// Prefs
+ @ObservedObject var dlManager = DownloadManager.shared
+
@AppStorage("finishedTutorial") var finishedTutorial: Bool = false
@AppStorage("pbHash") var pbHash: String = ""
@@ -31,7 +33,7 @@ struct Pocket_PosterApp: App {
// check for update
if !checkedForUpdate {
checkedForUpdate = true
- if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, let url = URL(string: "https://api.github.com/leminlimez/Pocket-Poster/releases/latest") {
+ 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 }
@@ -53,17 +55,7 @@ struct Pocket_PosterApp: App {
.onOpenURL(perform: { url in
// Download URL
if url.absoluteString.starts(with: "pocketposter://download") {
- // prohibit to only tendies files
- if !url.absoluteString.hasSuffix(".tendies") {
- UIApplication.shared.alert(body: NSLocalizedString("Only .tendies files can be downloaded!", comment: ""))
- } else if PosterBoardManager.shared.selectedTendies.count >= PosterBoardManager.MaxTendies {
- UIApplication.shared.alert(title: NSLocalizedString("Max Tendies Reached", comment: ""), body: String(format: NSLocalizedString("You can only apply %@ descriptors.", comment: ""), "\(PosterBoardManager.MaxTendies)"))
- } else {
- downloadURL = url.absoluteString.replacingOccurrences(of: "pocketposter://download?url=", with: "")
- UIApplication.shared.confirmAlert(title: NSLocalizedString("Download Tendies File", comment: ""), body: String(format: NSLocalizedString("Would you like to download the file %@?", comment: ""), "\(DownloadManager.getWallpaperNameFromURL(string: downloadURL ?? "/Unknown"))"), onOK: {
- downloadWallpaper()
- }, noCancel: false)
- }
+ dlManager.startTendiesDownload(for: url)
}
// App Hash URL
else if url.absoluteString.starts(with: "pocketposter://app-hash?uuid=") {
@@ -75,7 +67,7 @@ struct Pocket_PosterApp: App {
} else {
// copy it over to the KFC bucket
do {
- let newURL = try DownloadManager.copyTendies(from: url)
+ let newURL = try DownloadManager.shared.copyTendies(from: url)
PosterBoardManager.shared.selectedTendies.append(newURL)
Haptic.shared.notify(.success)
UIApplication.shared.alert(title: String(format: NSLocalizedString("Successfully imported %@", comment: ""), "\(url.lastPathComponent)"), body: "")
@@ -89,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() {
// clear the videos cache
try? FileManager.default.removeItem(at: SymHandler.getDocumentsDirectory().appendingPathComponent("Videos", conformingTo: .directory))
diff --git a/Pocket Poster/Views/Accessory Views/WebView.swift b/Pocket Poster/Views/Accessory Views/WebView.swift
new file mode 100644
index 0000000..28e4b28
--- /dev/null
+++ b/Pocket Poster/Views/Accessory Views/WebView.swift
@@ -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, error: Binding) {
+ 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) {
+
+ }
+}
diff --git a/Pocket Poster/Views/NavigatorViews/CarPlayView.swift b/Pocket Poster/Views/NavigatorViews/CarPlayView.swift
new file mode 100644
index 0000000..81e4a58
--- /dev/null
+++ b/Pocket Poster/Views/NavigatorViews/CarPlayView.swift
@@ -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()
+ }
+}
diff --git a/Pocket Poster/Views/NavigatorViews/ExploreView.swift b/Pocket Poster/Views/NavigatorViews/ExploreView.swift
new file mode 100644
index 0000000..76f60d6
--- /dev/null
+++ b/Pocket Poster/Views/NavigatorViews/ExploreView.swift
@@ -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)
+ }
+}
diff --git a/Pocket Poster/Views/RootView.swift b/Pocket Poster/Views/RootView.swift
index 19bf41d..7a85a46 100644
--- a/Pocket Poster/Views/RootView.swift
+++ b/Pocket Poster/Views/RootView.swift
@@ -18,6 +18,16 @@ struct RootView: View {
.tabItem {
Label("Videos", systemImage: "camera")
}
+ if CarPlayManager.supportsCarPlay() {
+ CarPlayView()
+ .tabItem {
+ Label("CarPlay", systemImage: "car")
+ }
+ }
+ ExploreView()
+ .tabItem {
+ Label("Explore", systemImage: "safari")
+ }
}
}
}
diff --git a/Pocket Poster/Views/SettingsView.swift b/Pocket Poster/Views/SettingsView.swift
index 4c16418..f10275d 100644
--- a/Pocket Poster/Views/SettingsView.swift
+++ b/Pocket Poster/Views/SettingsView.swift
@@ -20,9 +20,14 @@ struct SettingsView: View {
List {
Section {
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())
+ .font(.system(.body, design: .monospaced))
+ }
HStack {
Spacer()
// Run task to check until file exists from Nugget pc over AFC
@@ -38,7 +43,7 @@ struct SettingsView: View {
Text("Detect")
}
.foregroundStyle(.green)
- .onChange(of: checkingForHash) {
+ .onChange(of: checkingForHash) { _ in
if !checkingForHash {
// hide ui alert
UIApplication.shared.dismissAlert(animated: true)
@@ -80,6 +85,17 @@ struct SettingsView: View {
Label("Clear App Cache", systemImage: "trash.circle")
}
.foregroundStyle(.red)
+
+ Button(action: {
+ UIImpactFeedbackGenerator(style: .soft).impactOccurred()
+ UserDefaults.standard.set(nil, forKey: "ActiveCarPlayWallpapers")
+ try? FileManager.default.removeItem(at: CarPlayManager.getCarPlayPhotosURL())
+ Haptic.shared.notify(.success)
+ UIApplication.shared.alert(title: NSLocalizedString("CarPlay Applied Wallpapers Successfully Cleared!", comment: ""), body: "")
+ }) {
+ Label("Reset CarPlay Applied Wallpapers", systemImage: "trash.circle")
+ }
+ .foregroundStyle(.red)
} header: {
Label("Actions", systemImage: "gear")
}
@@ -150,12 +166,14 @@ struct SettingsView: View {
pbHash = contents
}
// check for carplay hash
- 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 UIDevice.current.userInterfaceIdiom == .phone {
+ let carplayPath = SymHandler.getCarPlayHashURL()
+ if FileManager.default.fileExists(atPath: carplayPath.path()) {
+ let carplayContents = try String(contentsOf: carplayPath)
+ try? FileManager.default.removeItem(at: carplayPath)
+ await MainActor.run {
+ cpHash = carplayContents
+ }
}
}
} catch {
diff --git a/ipabuild.sh b/ipabuild.sh
index ca50099..39671f8 100755
--- a/ipabuild.sh
+++ b/ipabuild.sh
@@ -1,5 +1,11 @@
#!/bin/bash
+CONFIG="Release"
+
+if [ "$1" == "-d" ]; then
+ CONFIG="Debug"
+fi
+
set -e
cd "$(dirname "$0")"
@@ -15,13 +21,13 @@ cd build
xcodebuild -project "$WORKING_LOCATION/$APPLICATION_NAME.xcodeproj" \
-scheme "$APPLICATION_NAME" \
- -configuration Release \
+ -configuration "$CONFIG" \
-derivedDataPath "$WORKING_LOCATION/build/DerivedDataApp" \
-destination 'generic/platform=iOS' \
clean build \
CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" CODE_SIGNING_ALLOWED="NO"
-DD_APP_PATH="$WORKING_LOCATION/build/DerivedDataApp/Build/Products/Release-iphoneos/$APPLICATION_NAME.app"
+DD_APP_PATH="$WORKING_LOCATION/build/DerivedDataApp/Build/Products/$CONFIG-iphoneos/$APPLICATION_NAME.app"
TARGET_APP="$WORKING_LOCATION/build/$APPLICATION_NAME.app"
cp -r "$DD_APP_PATH" "$TARGET_APP"