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"