From 069d5cf1c336677c2e96a85aa44fc9b3f1560464 Mon Sep 17 00:00:00 2001 From: leminlimez <59540996+leminlimez@users.noreply.github.com> Date: Thu, 19 Jun 2025 04:23:26 -0400 Subject: [PATCH 01/22] specific carplay ui --- Pocket Poster.xcodeproj/project.pbxproj | 21 +- .../xcshareddata/swiftpm/Package.resolved | 11 +- .../Controllers/CPBitmapHandler.swift | 28 +++ .../Controllers/PosterBoardManager.swift | 26 +++ Pocket Poster/Localizable.xcstrings | 19 ++ .../Views/NavigatorViews/CarPlayView.swift | 190 ++++++++++++++++++ Pocket Poster/Views/RootView.swift | 4 + Pocket Poster/Views/SettingsView.swift | 13 +- 8 files changed, 306 insertions(+), 6 deletions(-) create mode 100644 Pocket Poster/Controllers/CPBitmapHandler.swift create mode 100644 Pocket Poster/Views/NavigatorViews/CarPlayView.swift diff --git a/Pocket Poster.xcodeproj/project.pbxproj b/Pocket Poster.xcodeproj/project.pbxproj index 07e4182..4f157c5 100644 --- a/Pocket Poster.xcodeproj/project.pbxproj +++ b/Pocket Poster.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */; }; + 6F09BA332E00808300CDE89C /* Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09BA322E00808300CDE89C /* Dynamic */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -30,6 +31,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 6F09BA332E00808300CDE89C /* Dynamic in Frameworks */, 6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -74,6 +76,7 @@ name = "Pocket Poster"; packageProductDependencies = ( 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */, + 6F09BA322E00808300CDE89C /* Dynamic */, ); productName = "Pocket Poster"; productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */; @@ -105,6 +108,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */, + 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */; @@ -276,7 +280,7 @@ 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", @@ -308,7 +312,7 @@ 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", @@ -354,6 +358,14 @@ minimumVersion = 0.9.19; }; }; + 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/mhdhejazi/Dynamic"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -362,6 +374,11 @@ package = 6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */; productName = ZIPFoundation; }; + 6F09BA322E00808300CDE89C /* Dynamic */ = { + isa = XCSwiftPackageProductDependency; + package = 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */; + productName = Dynamic; + }; /* 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..d2be016 100644 --- a/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "d0d4edfdf2bf3cd05b3ba2dec0af1a9c271c93f944cbba8677cc647f74a6b323", + "originHash" : "b781b75a76813cee1292db0b540a9eecd3df93961ced8a0063095503735dadb2", "pins" : [ + { + "identity" : "dynamic", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mhdhejazi/Dynamic", + "state" : { + "revision" : "ab9a2570862d54aed2663691bb767f881226a12f", + "version" : "1.2.0" + } + }, { "identity" : "zipfoundation", "kind" : "remoteSourceControl", 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/PosterBoardManager.swift b/Pocket Poster/Controllers/PosterBoardManager.swift index 4260e12..0596504 100644 --- a/Pocket Poster/Controllers/PosterBoardManager.swift +++ b/Pocket Poster/Controllers/PosterBoardManager.swift @@ -273,6 +273,32 @@ class PosterBoardManager: ObservableObject { } } + func applyCarPlay(appHash: String, wallpapers: [CarPlayWallpaper]) throws { + // write the image + var toRemove: [URL] = [] + for wallpaper in wallpapers { + if let data = wallpaper.selectedImageDataLight, let img = UIImage(data: data) { + let imgURL = SymHandler.getDocumentsDirectory().appendingPathComponent("CAR\(wallpaper.name)Dynamic-Light-11.cpbitmap") + img.writeToCPBitmapFile(to: imgURL.path() as NSString) + toRemove.append(imgURL) + } + if let data = wallpaper.selectedImageDataDark, let img = UIImage(data: data) { + let imgURL = SymHandler.getDocumentsDirectory().appendingPathComponent("CAR\(wallpaper.name)Dynamic-Dark-11.cpbitmap") + img.writeToCPBitmapFile(to: imgURL.path() as NSString) + toRemove.append(imgURL) + } + } + + // 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) + } + } + static func clearCache() throws { SymHandler.cleanup() let docDir = SymHandler.getDocumentsDirectory() diff --git a/Pocket Poster/Localizable.xcstrings b/Pocket Poster/Localizable.xcstrings index edfbefb..c4780a1 100644 --- a/Pocket Poster/Localizable.xcstrings +++ b/Pocket Poster/Localizable.xcstrings @@ -88,6 +88,9 @@ } } } + }, + "Apply CarPlay Wallpapers" : { + }, "Applying Wallpapers..." : { "localizations" : { @@ -132,6 +135,12 @@ } } } + }, + "CarPlay" : { + + }, + "CarPlay Wallpapers" : { + }, "Clear App Cache" : { "localizations" : { @@ -400,6 +409,7 @@ } }, "Enter App Hash" : { + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -420,6 +430,15 @@ } } } + }, + "Enter CarPlayWallpaper App Hash" : { + + }, + "Enter PosterBoard App Hash" : { + + }, + "Enter your CarPlayWallpaper app hash in Settings." : { + }, "Enter your PosterBoard app hash in Settings." : { "localizations" : { diff --git a/Pocket Poster/Views/NavigatorViews/CarPlayView.swift b/Pocket Poster/Views/NavigatorViews/CarPlayView.swift new file mode 100644 index 0000000..60b9588 --- /dev/null +++ b/Pocket Poster/Views/NavigatorViews/CarPlayView.swift @@ -0,0 +1,190 @@ +// +// CarPlayView.swift +// Pocket Poster +// +// Created by lemin on 6/19/25. +// + +import SwiftUI +import PhotosUI + +struct CarPlayWallpaper: Identifiable { + var id = UUID() + var name: String + var lightImage: UIImage + var darkImage: UIImage + var selectedImageDataLight: Data? + var selectedImageDataDark: Data? + var changed: Bool +} + +struct CarPlayView: View { + @State var wallpapers: [CarPlayWallpaper] = [] + @State var didChange: Bool = false + @State var showDark: Bool = false + + @AppStorage("cpHash") var cpHash: String = "" + @ObservedObject var pbManager = PosterBoardManager.shared + + var body: some View { + NavigationStack { + ScrollView { + if wallpapers.isEmpty { + ProgressView() + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150, maximum: 250))]) { + ForEach($wallpapers) { wallpaper in + WallpaperView(wallpaper: wallpaper, didChange: $didChange, showDark: $showDark) + } + } + .padding() + } + + if cpHash == "" { + Text("Enter your CarPlayWallpaper app hash in Settings.") + .padding() + } else 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 pbManager.applyCarPlay(appHash: cpHash, wallpapers: wallpapers) + SymHandler.cleanup() // just to be extra sure + try? FileManager.default.removeItem(at: pbManager.getTendiesStoreURL()) + UIApplication.shared.dismissAlert(animated: false) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: { + pbManager.selectedTendies.removeAll() + Haptic.shared.notify(.success) + UIApplication.shared.confirmAlert(title: NSLocalizedString("Success!", comment: ""), body: NSLocalizedString("The PosterBoard app will now open. Please close it from the app switcher.", comment: ""), onOK: { + if !pbManager.openPosterBoard() { + UIApplication.shared.confirmAlert(title: NSLocalizedString("Falling Back to Shortcut", comment: ""), body: NSLocalizedString("PosterBoard failed to open directly. The fallback shortcut will now be opened.", comment: ""), onOK: { + pbManager.runShortcut(named: "PosterBoard") + }, noCancel: true) + } + }, noCancel: true) + }) + } catch CocoaError.fileWriteUnknown { + presentError(ApplyError.wrongAppHash) + } catch CocoaError.fileWriteFileExists { + presentError(ApplyError.collectionsNeedsReset) + } catch { + print(error.localizedDescription) + presentError(ApplyError.unexpected(info: error.localizedDescription)) + } + } + }) { + Label("Apply CarPlay Wallpapers", systemImage: "checkmark.circle") + } + .buttonStyle(TintedButton(color: .blue, fullwidth: true)) + .padding() + } + } + .navigationTitle("CarPlay Wallpapers") + .toolbar { + ToolbarItem(placement: .topBarTrailing, content: { + Button(action: { + withAnimation { + showDark.toggle() + } + }) { + Image(systemName: showDark ? "moon" : "sun.max") + } + }) + } + } + .onAppear { + if wallpapers.isEmpty { + // load the wallpapers + 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: "") + wallpapers.append(.init(name: name, lightImage: wpImg, darkImage: darkImg, changed: false)) + } + } + } + } catch { + print(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 + 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 + } + didChange = true + } + } + } + } +} + +struct WallpaperImageView: View { + var img: UIImage + + var body: some View { + Image(uiImage: img) + .resizable() + .aspectRatio(1.25, contentMode: .fill) + .cornerRadius(8) + .clipped() + } +} diff --git a/Pocket Poster/Views/RootView.swift b/Pocket Poster/Views/RootView.swift index 19bf41d..b12f681 100644 --- a/Pocket Poster/Views/RootView.swift +++ b/Pocket Poster/Views/RootView.swift @@ -18,6 +18,10 @@ struct RootView: View { .tabItem { Label("Videos", systemImage: "camera") } + CarPlayView() + .tabItem { + Label("CarPlay", systemImage: "car") + } } } } diff --git a/Pocket Poster/Views/SettingsView.swift b/Pocket Poster/Views/SettingsView.swift index 3cde281..6c4538c 100644 --- a/Pocket Poster/Views/SettingsView.swift +++ b/Pocket Poster/Views/SettingsView.swift @@ -10,6 +10,7 @@ import SwiftUI struct SettingsView: View { // Prefs @AppStorage("pbHash") var pbHash: String = "" + @AppStorage("cpHash") var cpHash: String = "" @State var checkingForHash: Bool = false @State var hashCheckTask: Task? = nil @@ -18,14 +19,17 @@ 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)) + 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 Button(action: { - UIApplication.shared.confirmAlert(title: "Waiting for app hash...", body: "Connect your device to Nugget and click the \"Pocket Poster Helper\" button.", confirmTitle: "Cancel", onOK: { + UIApplication.shared.confirmAlert(title: "Waiting for app hashes...", body: "Connect your device to Nugget and click the \"Pocket Poster Helper\" button.", confirmTitle: "Cancel", onOK: { cancelWaitForHash() }, noCancel: true) startWaitForHash() @@ -33,7 +37,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) @@ -137,6 +141,9 @@ struct SettingsView: View { if let pb = items.first { pbHash = String(pb) } + if items.count >= 2 { + cpHash = String(items[1]) + } } } catch { print(error.localizedDescription) From f305e4b2c6a4d0317977d4257cac07095fd31655 Mon Sep 17 00:00:00 2001 From: leminlimez <59540996+leminlimez@users.noreply.github.com> Date: Thu, 19 Jun 2025 04:39:31 -0400 Subject: [PATCH 02/22] active wallpapers tracking --- .../Controllers/PosterBoardManager.swift | 8 +++++ Pocket Poster/Localizable.xcstrings | 9 ++++++ .../Views/NavigatorViews/CarPlayView.swift | 29 ++++++++++++------- Pocket Poster/Views/SettingsView.swift | 10 +++++++ 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/Pocket Poster/Controllers/PosterBoardManager.swift b/Pocket Poster/Controllers/PosterBoardManager.swift index 0596504..c7290a2 100644 --- a/Pocket Poster/Controllers/PosterBoardManager.swift +++ b/Pocket Poster/Controllers/PosterBoardManager.swift @@ -276,16 +276,23 @@ class PosterBoardManager: ObservableObject { func applyCarPlay(appHash: String, wallpapers: [CarPlayWallpaper]) throws { // write the image var toRemove: [URL] = [] + var activeWP: [String] = UserDefaults.standard.array(forKey: "ActiveCarPlayWallpapers") as? [String] ?? [] for wallpaper in wallpapers { if let data = wallpaper.selectedImageDataLight, let img = UIImage(data: data) { let imgURL = SymHandler.getDocumentsDirectory().appendingPathComponent("CAR\(wallpaper.name)Dynamic-Light-11.cpbitmap") img.writeToCPBitmapFile(to: imgURL.path() as NSString) 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-11.cpbitmap") img.writeToCPBitmapFile(to: imgURL.path() as NSString) toRemove.append(imgURL) + if !activeWP.contains(wallpaper.name) { + activeWP.append(wallpaper.name) + } } } @@ -297,6 +304,7 @@ class PosterBoardManager: ObservableObject { for imgURL in toRemove { try FileManager.default.trashItem(at: imgURL, resultingItemURL: nil) } + UserDefaults.standard.set(activeWP, forKey: "ActiveCarPlayWallpapers") } static func clearCache() throws { diff --git a/Pocket Poster/Localizable.xcstrings b/Pocket Poster/Localizable.xcstrings index c4780a1..de58906 100644 --- a/Pocket Poster/Localizable.xcstrings +++ b/Pocket Poster/Localizable.xcstrings @@ -138,6 +138,9 @@ }, "CarPlay" : { + }, + "CarPlay Applied Wallpapers Successfully Cleared!" : { + }, "CarPlay Wallpapers" : { @@ -969,6 +972,9 @@ } } } + }, + "Reset CarPlay Applied Wallpapers" : { + }, "Reset Collections" : { "localizations" : { @@ -1305,6 +1311,9 @@ } } } + }, + "You can now choose your wallpapers in the CarPlay settings in your car." : { + }, "You can only apply %@ descriptors." : { "localizations" : { diff --git a/Pocket Poster/Views/NavigatorViews/CarPlayView.swift b/Pocket Poster/Views/NavigatorViews/CarPlayView.swift index 60b9588..ca2fa73 100644 --- a/Pocket Poster/Views/NavigatorViews/CarPlayView.swift +++ b/Pocket Poster/Views/NavigatorViews/CarPlayView.swift @@ -24,6 +24,7 @@ struct CarPlayView: View { @State var showDark: Bool = false @AppStorage("cpHash") var cpHash: String = "" + @State var activeWallpapers: [String] = [] @ObservedObject var pbManager = PosterBoardManager.shared var body: some View { @@ -34,7 +35,18 @@ struct CarPlayView: View { } else { LazyVGrid(columns: [GridItem(.adaptive(minimum: 150, maximum: 250))]) { ForEach($wallpapers) { wallpaper in - WallpaperView(wallpaper: wallpaper, didChange: $didChange, showDark: $showDark) + 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() @@ -52,18 +64,11 @@ struct CarPlayView: View { do { try pbManager.applyCarPlay(appHash: cpHash, wallpapers: wallpapers) SymHandler.cleanup() // just to be extra sure - try? FileManager.default.removeItem(at: pbManager.getTendiesStoreURL()) UIApplication.shared.dismissAlert(animated: false) DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: { - pbManager.selectedTendies.removeAll() + activeWallpapers = UserDefaults.standard.array(forKey: "ActiveCarPlayWallpapers") as? [String] ?? [] Haptic.shared.notify(.success) - UIApplication.shared.confirmAlert(title: NSLocalizedString("Success!", comment: ""), body: NSLocalizedString("The PosterBoard app will now open. Please close it from the app switcher.", comment: ""), onOK: { - if !pbManager.openPosterBoard() { - UIApplication.shared.confirmAlert(title: NSLocalizedString("Falling Back to Shortcut", comment: ""), body: NSLocalizedString("PosterBoard failed to open directly. The fallback shortcut will now be opened.", comment: ""), onOK: { - pbManager.runShortcut(named: "PosterBoard") - }, noCancel: true) - } - }, noCancel: true) + 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) @@ -96,6 +101,8 @@ struct CarPlayView: View { } .onAppear { if wallpapers.isEmpty { + // load active wallpapers from user defaults + activeWallpapers = UserDefaults.standard.array(forKey: "ActiveCarPlayWallpapers") as? [String] ?? [] // load the wallpapers let frameworkPath = "/System/Library/PrivateFrameworks/CarPlayUIServices.framework" do { @@ -183,7 +190,7 @@ struct WallpaperImageView: View { var body: some View { Image(uiImage: img) .resizable() - .aspectRatio(1.25, contentMode: .fill) + .aspectRatio(1.35, contentMode: .fill) .cornerRadius(8) .clipped() } diff --git a/Pocket Poster/Views/SettingsView.swift b/Pocket Poster/Views/SettingsView.swift index 6c4538c..3c1e8f0 100644 --- a/Pocket Poster/Views/SettingsView.swift +++ b/Pocket Poster/Views/SettingsView.swift @@ -71,6 +71,16 @@ struct SettingsView: View { Label("Clear App Cache", systemImage: "trash.circle") } .foregroundStyle(.red) + + Button(action: { + UIImpactFeedbackGenerator(style: .soft).impactOccurred() + UserDefaults.standard.set(nil, forKey: "ActiveCarPlayWallpapers") + 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") } From f9ba2dee0dc6fb9cbb0e6d96e1eb4ba7bf20c493 Mon Sep 17 00:00:00 2001 From: leminlimez <59540996+leminlimez@users.noreply.github.com> Date: Sat, 21 Jun 2025 14:04:45 -0400 Subject: [PATCH 03/22] improve carplay apply button ui --- .../Views/Buttons/OpaqueButton.swift | 39 +++++++ .../{ => Views/Buttons}/TintedButton.swift | 0 .../{ => Views}/LoopingPlayerUIView.swift | 0 .../Extensions/{ => Views}/MaterialView.swift | 0 .../Extensions/{ => Views}/PlayerView.swift | 0 Pocket Poster/Localizable.xcstrings | 28 +++++ .../Views/NavigatorViews/CarPlayView.swift | 107 ++++++++++-------- 7 files changed, 126 insertions(+), 48 deletions(-) create mode 100644 Pocket Poster/Extensions/Views/Buttons/OpaqueButton.swift rename Pocket Poster/Extensions/{ => Views/Buttons}/TintedButton.swift (100%) rename Pocket Poster/Extensions/{ => Views}/LoopingPlayerUIView.swift (100%) rename Pocket Poster/Extensions/{ => Views}/MaterialView.swift (100%) rename Pocket Poster/Extensions/{ => Views}/PlayerView.swift (100%) 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/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 2219868..ebab764 100644 --- a/Pocket Poster/Localizable.xcstrings +++ b/Pocket Poster/Localizable.xcstrings @@ -418,6 +418,9 @@ } } } + }, + "Apply CarPlay Wallpapers" : { + }, "Applying Wallpapers..." : { "localizations" : { @@ -630,6 +633,15 @@ } } } + }, + "CarPlay" : { + + }, + "CarPlay Applied Wallpapers Successfully Cleared!" : { + + }, + "CarPlay Wallpapers" : { + }, "Clear App Cache" : { "localizations" : { @@ -1977,6 +1989,7 @@ } }, "Enter App Hash" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -2075,6 +2088,15 @@ } } } + }, + "Enter CarPlayWallpaper App Hash" : { + + }, + "Enter PosterBoard App Hash" : { + + }, + "Enter your CarPlayWallpaper app hash in Settings." : { + }, "Enter your PosterBoard app hash in Settings." : { "localizations" : { @@ -5136,6 +5158,9 @@ } } } + }, + "Reset CarPlay Applied Wallpapers" : { + }, "Reset Collections" : { "localizations" : { @@ -7008,6 +7033,9 @@ } } } + }, + "You can now choose your wallpapers in the CarPlay settings in your car." : { + }, "You can only apply %@ descriptors." : { "localizations" : { diff --git a/Pocket Poster/Views/NavigatorViews/CarPlayView.swift b/Pocket Poster/Views/NavigatorViews/CarPlayView.swift index ca2fa73..e57fcbe 100644 --- a/Pocket Poster/Views/NavigatorViews/CarPlayView.swift +++ b/Pocket Poster/Views/NavigatorViews/CarPlayView.swift @@ -29,61 +29,70 @@ struct CarPlayView: View { var body: some View { NavigationStack { - 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) + 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() - } - - if cpHash == "" { - Text("Enter your CarPlayWallpaper app hash in Settings.") .padding() - } else 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) + } + } + 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 pbManager.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)) + DispatchQueue.global(qos: .userInitiated).async { + do { + try pbManager.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") } } - }) { - 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) } - .buttonStyle(TintedButton(color: .blue, fullwidth: true)) - .padding() } } .navigationTitle("CarPlay Wallpapers") @@ -177,7 +186,9 @@ struct WallpaperView: View { } else { wallpaper.selectedImageDataLight = data } - didChange = true + withAnimation { + didChange = true + } } } } From 6e95f9336ab7e301f06ddf32b21037353cb8fb64 Mon Sep 17 00:00:00 2001 From: leminlimez <59540996+leminlimez@users.noreply.github.com> Date: Sun, 22 Jun 2025 10:29:25 -0400 Subject: [PATCH 04/22] add ios 17 + ios 26 support for carplay cache --- .../Controllers/PosterBoardManager.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Pocket Poster/Controllers/PosterBoardManager.swift b/Pocket Poster/Controllers/PosterBoardManager.swift index 38d0239..fecf196 100644 --- a/Pocket Poster/Controllers/PosterBoardManager.swift +++ b/Pocket Poster/Controllers/PosterBoardManager.swift @@ -235,13 +235,24 @@ class PosterBoardManager: ObservableObject { } } + 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 "" + } + func applyCarPlay(appHash: String, wallpapers: [CarPlayWallpaper]) throws { // write the image var toRemove: [URL] = [] var activeWP: [String] = UserDefaults.standard.array(forKey: "ActiveCarPlayWallpapers") as? [String] ?? [] + 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-11.cpbitmap") + let imgURL = SymHandler.getDocumentsDirectory().appendingPathComponent("CAR\(wallpaper.name)Dynamic-Light\(cacheVer).cpbitmap") img.writeToCPBitmapFile(to: imgURL.path() as NSString) toRemove.append(imgURL) if !activeWP.contains(wallpaper.name) { @@ -249,7 +260,7 @@ class PosterBoardManager: ObservableObject { } } if let data = wallpaper.selectedImageDataDark, let img = UIImage(data: data) { - let imgURL = SymHandler.getDocumentsDirectory().appendingPathComponent("CAR\(wallpaper.name)Dynamic-Dark-11.cpbitmap") + let imgURL = SymHandler.getDocumentsDirectory().appendingPathComponent("CAR\(wallpaper.name)Dynamic-Dark\(cacheVer).cpbitmap") img.writeToCPBitmapFile(to: imgURL.path() as NSString) toRemove.append(imgURL) if !activeWP.contains(wallpaper.name) { From 918af3baa35b98736dd961dcca9a7b9385688aea Mon Sep 17 00:00:00 2001 From: leminlimez <59540996+leminlimez@users.noreply.github.com> Date: Sun, 22 Jun 2025 15:59:01 -0400 Subject: [PATCH 05/22] get wallpapers in order --- .../Controllers/CarPlayWallpaper.swift | 23 +++++++++ .../Controllers/PosterBoardManager.swift | 50 +++++++++++++++++-- Pocket Poster/Extensions/Reorderable.swift | 30 +++++++++++ .../Extensions/{ => Views}/Alert++.swift | 0 Pocket Poster/Localizable.xcstrings | 3 ++ .../Views/NavigatorViews/CarPlayView.swift | 18 +++---- 6 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 Pocket Poster/Controllers/CarPlayWallpaper.swift create mode 100644 Pocket Poster/Extensions/Reorderable.swift rename Pocket Poster/Extensions/{ => Views}/Alert++.swift (100%) diff --git a/Pocket Poster/Controllers/CarPlayWallpaper.swift b/Pocket Poster/Controllers/CarPlayWallpaper.swift new file mode 100644 index 0000000..c1d90de --- /dev/null +++ b/Pocket Poster/Controllers/CarPlayWallpaper.swift @@ -0,0 +1,23 @@ +// +// 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? + var changed: Bool +} + +extension CarPlayWallpaper: Reorderable { + typealias OrderElement = String + var orderElement: OrderElement { name } +} diff --git a/Pocket Poster/Controllers/PosterBoardManager.swift b/Pocket Poster/Controllers/PosterBoardManager.swift index fecf196..1ff5fcc 100644 --- a/Pocket Poster/Controllers/PosterBoardManager.swift +++ b/Pocket Poster/Controllers/PosterBoardManager.swift @@ -245,6 +245,39 @@ class PosterBoardManager: ObservableObject { return "" } + 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 + } + func applyCarPlay(appHash: String, wallpapers: [CarPlayWallpaper]) throws { // write the image var toRemove: [URL] = [] @@ -270,14 +303,21 @@ class PosterBoardManager: ObservableObject { } // symlink and apply - let _ = try SymHandler.createAppSymlink(for: "\(appHash)/Library/Caches/MappedImageCache/com.apple.CarPlayApp.wallpaper-images") +// 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) + let hmm = try SymHandler.createAppSymlink(for: "\(appHash)/Library") defer { SymHandler.cleanup() } - for imgURL in toRemove { - try FileManager.default.trashItem(at: imgURL, resultingItemURL: nil) - } - UserDefaults.standard.set(activeWP, forKey: "ActiveCarPlayWallpapers") + try FileManager.default.removeItem(at: hmm.appendingPathComponent("Caches")) } static func clearCache() throws { 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/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/Localizable.xcstrings b/Pocket Poster/Localizable.xcstrings index b6cdaa3..602e384 100644 --- a/Pocket Poster/Localizable.xcstrings +++ b/Pocket Poster/Localizable.xcstrings @@ -3421,6 +3421,9 @@ } } } + }, + "Failed to fetch CarPlay wallpapers" : { + }, "Failed to import tendies" : { "localizations" : { diff --git a/Pocket Poster/Views/NavigatorViews/CarPlayView.swift b/Pocket Poster/Views/NavigatorViews/CarPlayView.swift index e57fcbe..16b2d51 100644 --- a/Pocket Poster/Views/NavigatorViews/CarPlayView.swift +++ b/Pocket Poster/Views/NavigatorViews/CarPlayView.swift @@ -8,16 +8,6 @@ import SwiftUI import PhotosUI -struct CarPlayWallpaper: Identifiable { - var id = UUID() - var name: String - var lightImage: UIImage - var darkImage: UIImage - var selectedImageDataLight: Data? - var selectedImageDataDark: Data? - var changed: Bool -} - struct CarPlayView: View { @State var wallpapers: [CarPlayWallpaper] = [] @State var didChange: Bool = false @@ -128,8 +118,12 @@ struct CarPlayView: View { } } } + // sort them + if let sortedWallpapers = PosterBoardManager.shared.getCarPlayWallpaperNames() { + wallpapers = wallpapers.reorder(by: sortedWallpapers) + } } catch { - print(error.localizedDescription) + UIApplication.shared.alert(body: NSLocalizedString("Failed to fetch CarPlay wallpapers", comment: "") + ":\n\(error.localizedDescription)") } } } @@ -178,6 +172,7 @@ struct WallpaperView: View { .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) { @@ -186,6 +181,7 @@ struct WallpaperView: View { } else { wallpaper.selectedImageDataLight = data } + selectedItem = nil withAnimation { didChange = true } From 4062d084dbba0a8bac3a4bf3b7a078c59a2e0f65 Mon Sep 17 00:00:00 2001 From: leminlimez <59540996+leminlimez@users.noreply.github.com> Date: Sun, 22 Jun 2025 17:18:38 -0400 Subject: [PATCH 06/22] ios 16 poterboard probably --- .../xcschemes/xcschememanagement.plist | 2 +- Pocket Poster/Controllers/PosterBoardManager.swift | 2 +- Pocket Poster/Controllers/SymHandler.swift | 12 ++++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) 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/Controllers/PosterBoardManager.swift b/Pocket Poster/Controllers/PosterBoardManager.swift index 1ff5fcc..2daf7d1 100644 --- a/Pocket Poster/Controllers/PosterBoardManager.swift +++ b/Pocket Poster/Controllers/PosterBoardManager.swift @@ -101,7 +101,7 @@ class PosterBoardManager: ObservableObject { let fileName = dir.lastPathComponent if fileName.lowercased() == "container" { // container support, find the extensions - let extDir = dir.appending(path: "Library/Application Support/PRBPosterExtensionDataStore/61/Extensions") + let extDir = dir.appending(path: "Library/Application Support/PRBPosterExtensionDataStore/\(SymHandler.getExtensionVersion())/Extensions") print(extDir.absoluteString) var retList: [String: [URL]] = [:] for ext in try FileManager.default.contentsOfDirectory(at: extDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) { diff --git a/Pocket Poster/Controllers/SymHandler.swift b/Pocket Poster/Controllers/SymHandler.swift index a38d391..a792c4c 100644 --- a/Pocket Poster/Controllers/SymHandler.swift +++ b/Pocket Poster/Controllers/SymHandler.swift @@ -43,10 +43,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() { From d5833dfb81d9c612280592f786466513660c6de8 Mon Sep 17 00:00:00 2001 From: leminlimez Date: Mon, 23 Jun 2025 14:25:54 -0400 Subject: [PATCH 07/22] save changed images --- Pocket Poster.xcodeproj/project.pbxproj | 8 ++--- .../Controllers/CarPlayWallpaper.swift | 1 - .../Controllers/PosterBoardManager.swift | 35 ++++++++++++------- .../Views/NavigatorViews/CarPlayView.swift | 10 +++++- Pocket Poster/Views/SettingsView.swift | 1 + 5 files changed, 36 insertions(+), 19 deletions(-) diff --git a/Pocket Poster.xcodeproj/project.pbxproj b/Pocket Poster.xcodeproj/project.pbxproj index 3af708c..37ccaeb 100644 --- a/Pocket Poster.xcodeproj/project.pbxproj +++ b/Pocket Poster.xcodeproj/project.pbxproj @@ -268,7 +268,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; @@ -285,7 +285,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0; + MARKETING_VERSION = 3.0; PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -300,7 +300,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; @@ -317,7 +317,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0; + MARKETING_VERSION = 3.0; PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/Pocket Poster/Controllers/CarPlayWallpaper.swift b/Pocket Poster/Controllers/CarPlayWallpaper.swift index c1d90de..c636249 100644 --- a/Pocket Poster/Controllers/CarPlayWallpaper.swift +++ b/Pocket Poster/Controllers/CarPlayWallpaper.swift @@ -14,7 +14,6 @@ struct CarPlayWallpaper: Identifiable { var darkImage: UIImage var selectedImageDataLight: Data? var selectedImageDataDark: Data? - var changed: Bool } extension CarPlayWallpaper: Reorderable { diff --git a/Pocket Poster/Controllers/PosterBoardManager.swift b/Pocket Poster/Controllers/PosterBoardManager.swift index 2daf7d1..9752f32 100644 --- a/Pocket Poster/Controllers/PosterBoardManager.swift +++ b/Pocket Poster/Controllers/PosterBoardManager.swift @@ -278,15 +278,26 @@ class PosterBoardManager: ObservableObject { return nil } + 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 + } + 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) @@ -295,6 +306,7 @@ class PosterBoardManager: ObservableObject { 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) @@ -303,28 +315,25 @@ class PosterBoardManager: ObservableObject { } // 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) - let hmm = try SymHandler.createAppSymlink(for: "\(appHash)/Library") + let _ = try SymHandler.createAppSymlink(for: "\(appHash)/Library/Caches/MappedImageCache/com.apple.CarPlayApp.wallpaper-images") defer { SymHandler.cleanup() } - try FileManager.default.removeItem(at: hmm.appendingPathComponent("Caches")) + 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) } static func clearCache() throws { SymHandler.cleanup() let docDir = SymHandler.getDocumentsDirectory() for file in try FileManager.default.contentsOfDirectory(at: docDir, includingPropertiesForKeys: nil) { - try FileManager.default.removeItem(at: file) + if file.lastPathComponent != "CarPlayPhotos" { + try FileManager.default.removeItem(at: file) + } } } } diff --git a/Pocket Poster/Views/NavigatorViews/CarPlayView.swift b/Pocket Poster/Views/NavigatorViews/CarPlayView.swift index 16b2d51..952a97f 100644 --- a/Pocket Poster/Views/NavigatorViews/CarPlayView.swift +++ b/Pocket Poster/Views/NavigatorViews/CarPlayView.swift @@ -103,6 +103,7 @@ struct CarPlayView: View { // load active wallpapers from user defaults activeWallpapers = UserDefaults.standard.array(forKey: "ActiveCarPlayWallpapers") as? [String] ?? [] // load the wallpapers + let cppURL = PosterBoardManager.shared.getCarPlayPhotosURL() let frameworkPath = "/System/Library/PrivateFrameworks/CarPlayUIServices.framework" do { for file in try FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: frameworkPath), includingPropertiesForKeys: nil, options: .skipsHiddenFiles) { @@ -114,7 +115,14 @@ struct CarPlayView: View { darkImg = UIImage(data: darkData) ?? wpImg } let name = file.lastPathComponent.replacingOccurrences(of: "-Light.heic", with: "") - wallpapers.append(.init(name: name, lightImage: wpImg, darkImage: darkImg, changed: false)) + // 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 + )) } } } diff --git a/Pocket Poster/Views/SettingsView.swift b/Pocket Poster/Views/SettingsView.swift index 66ccce4..fd6e9ea 100644 --- a/Pocket Poster/Views/SettingsView.swift +++ b/Pocket Poster/Views/SettingsView.swift @@ -84,6 +84,7 @@ struct SettingsView: View { Button(action: { UIImpactFeedbackGenerator(style: .soft).impactOccurred() UserDefaults.standard.set(nil, forKey: "ActiveCarPlayWallpapers") + try? FileManager.default.removeItem(at: PosterBoardManager.shared.getCarPlayPhotosURL()) Haptic.shared.notify(.success) UIApplication.shared.alert(title: NSLocalizedString("CarPlay Applied Wallpapers Successfully Cleared!", comment: ""), body: "") }) { From c04b863efacae1e3dd19af1e0a8050ff5601fda7 Mon Sep 17 00:00:00 2001 From: leminlimez Date: Mon, 23 Jun 2025 15:51:39 -0400 Subject: [PATCH 08/22] dont do container number 59 on ios 16 --- Pocket Poster.xcodeproj/project.pbxproj | 4 ++-- Pocket Poster/Controllers/PosterBoardManager.swift | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Pocket Poster.xcodeproj/project.pbxproj b/Pocket Poster.xcodeproj/project.pbxproj index 37ccaeb..f3054f6 100644 --- a/Pocket Poster.xcodeproj/project.pbxproj +++ b/Pocket Poster.xcodeproj/project.pbxproj @@ -268,7 +268,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; @@ -300,7 +300,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; diff --git a/Pocket Poster/Controllers/PosterBoardManager.swift b/Pocket Poster/Controllers/PosterBoardManager.swift index 9752f32..3b7ae6a 100644 --- a/Pocket Poster/Controllers/PosterBoardManager.swift +++ b/Pocket Poster/Controllers/PosterBoardManager.swift @@ -101,8 +101,7 @@ class PosterBoardManager: ObservableObject { let fileName = dir.lastPathComponent if fileName.lowercased() == "container" { // container support, find the extensions - let extDir = dir.appending(path: "Library/Application Support/PRBPosterExtensionDataStore/\(SymHandler.getExtensionVersion())/Extensions") - print(extDir.absoluteString) + let extDir = dir.appending(path: "Library/Application Support/PRBPosterExtensionDataStore/61/Extensions") var retList: [String: [URL]] = [:] for ext in try FileManager.default.contentsOfDirectory(at: extDir, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) { let descrDir = ext.appendingPathComponent("descriptors") From edde7ddb0c83c87dfdc668e555056052698ee188 Mon Sep 17 00:00:00 2001 From: leminlimez Date: Sun, 6 Jul 2025 19:02:03 -0400 Subject: [PATCH 09/22] explore page still needs fixed for no wifi and freezing --- Pocket Poster.xcodeproj/project.pbxproj | 4 +- .../Controllers/DownloadManager.swift | 54 +++++++++++++++++-- Pocket Poster/Localizable.xcstrings | 3 ++ Pocket Poster/Pocket_PosterApp.swift | 36 ++----------- .../Views/Accessory Views/WebView.swift | 46 ++++++++++++++++ .../Views/NavigatorViews/ExploreView.swift | 14 +++++ Pocket Poster/Views/RootView.swift | 4 ++ 7 files changed, 122 insertions(+), 39 deletions(-) create mode 100644 Pocket Poster/Views/Accessory Views/WebView.swift create mode 100644 Pocket Poster/Views/NavigatorViews/ExploreView.swift diff --git a/Pocket Poster.xcodeproj/project.pbxproj b/Pocket Poster.xcodeproj/project.pbxproj index f3054f6..5d45780 100644 --- a/Pocket Poster.xcodeproj/project.pbxproj +++ b/Pocket Poster.xcodeproj/project.pbxproj @@ -268,7 +268,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; @@ -300,7 +300,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; diff --git a/Pocket Poster/Controllers/DownloadManager.swift b/Pocket Poster/Controllers/DownloadManager.swift index a560d5f..9b53338 100644 --- a/Pocket Poster/Controllers/DownloadManager.swift +++ b/Pocket Poster/Controllers/DownloadManager.swift @@ -6,13 +6,57 @@ // import Foundation +import UIKit -class DownloadManager { - static func getWallpaperNameFromURL(string url: String) -> String { +class DownloadManager: ObservableObject { + static let shared = DownloadManager() + + @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: false) + 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: false) + 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 +68,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/Localizable.xcstrings b/Pocket Poster/Localizable.xcstrings index 8b562ed..f1359c0 100644 --- a/Pocket Poster/Localizable.xcstrings +++ b/Pocket Poster/Localizable.xcstrings @@ -3256,6 +3256,9 @@ } } } + }, + "Explore" : { + }, "Extracting tendies..." : { "comment" : "happens when unzipping the files", diff --git a/Pocket Poster/Pocket_PosterApp.swift b/Pocket Poster/Pocket_PosterApp.swift index 8e54693..c30dc7e 100644 --- a/Pocket Poster/Pocket_PosterApp.swift +++ b/Pocket Poster/Pocket_PosterApp.swift @@ -10,11 +10,11 @@ import SwiftUI @main struct Pocket_PosterApp: App { // Prefs + @ObservedObject var dlManager = DownloadManager.shared + @AppStorage("finishedTutorial") var finishedTutorial: Bool = false @AppStorage("pbHash") var pbHash: String = "" - @State var downloadURL: String? = nil - var body: some Scene { WindowGroup { Group { @@ -29,17 +29,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=") { @@ -51,7 +41,7 @@ struct Pocket_PosterApp: App { } else { // copy it over to the KFC bucket do { - let newURL = try DownloadManager.copyTendies(from: url) + let newURL = try DownloadManager.shared.copyTendies(from: url) PosterBoardManager.shared.selectedTendies.append(newURL) Haptic.shared.notify(.success) UIApplication.shared.alert(title: String(format: NSLocalizedString("Successfully imported %@", comment: ""), "\(url.lastPathComponent)"), body: "") @@ -65,24 +55,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..a403cab --- /dev/null +++ b/Pocket Poster/Views/Accessory Views/WebView.swift @@ -0,0 +1,46 @@ +// +// WebView.swift +// Pocket Poster +// +// Created by lemin on 7/6/25. +// + +import Foundation +import SwiftUI +import WebKit + +struct WallpaperWebView: UIViewRepresentable { + let url: URL + + init(_ url: URL) { + self.url = url + } + + func makeUIView(context: Context) -> some UIView { + let configuration = WKWebViewConfiguration() + configuration.setURLSchemeHandler(AssetsSchemeHandler(), forURLScheme: "pocketposter") + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.load(URLRequest(url: url)) + + return webView + } + + func updateUIView(_ uiView: UIViewType, context: Context) {} +} + + +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/ExploreView.swift b/Pocket Poster/Views/NavigatorViews/ExploreView.swift new file mode 100644 index 0000000..6f5f33f --- /dev/null +++ b/Pocket Poster/Views/NavigatorViews/ExploreView.swift @@ -0,0 +1,14 @@ +// +// ExploreView.swift +// Pocket Poster +// +// Created by lemin on 7/6/25. +// + +import SwiftUI + +struct ExploreView: View { + var body: some View { + WallpaperWebView(URL(string: "https://cowabun.ga/wallpapers?pocketposter=true")!) + } +} diff --git a/Pocket Poster/Views/RootView.swift b/Pocket Poster/Views/RootView.swift index b12f681..c0af04c 100644 --- a/Pocket Poster/Views/RootView.swift +++ b/Pocket Poster/Views/RootView.swift @@ -22,6 +22,10 @@ struct RootView: View { .tabItem { Label("CarPlay", systemImage: "car") } + ExploreView() + .tabItem { + Label("Explore", systemImage: "safari") + } } } } From e932b82515a284b2385ee378440f4187bc3ed1d5 Mon Sep 17 00:00:00 2001 From: leminlimez Date: Fri, 11 Jul 2025 11:15:52 -0400 Subject: [PATCH 10/22] loading and error handling --- .../Controllers/DownloadManager.swift | 4 +-- Pocket Poster/Localizable.xcstrings | 12 +++++++ .../Views/Accessory Views/WebView.swift | 31 ++++++++++++++++++- .../Views/NavigatorViews/ExploreView.swift | 25 ++++++++++++++- 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/Pocket Poster/Controllers/DownloadManager.swift b/Pocket Poster/Controllers/DownloadManager.swift index 9b53338..1071b48 100644 --- a/Pocket Poster/Controllers/DownloadManager.swift +++ b/Pocket Poster/Controllers/DownloadManager.swift @@ -43,13 +43,13 @@ class DownloadManager: ObservableObject { DispatchQueue.main.async { PosterBoardManager.shared.selectedTendies.append(newURL) Haptic.shared.notify(.success) - UIApplication.shared.dismissAlert(animated: false) + UIApplication.shared.dismissAlert(animated: true) 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: false) + UIApplication.shared.dismissAlert(animated: true) UIApplication.shared.alert(title: NSLocalizedString("Could not download wallpaper!", comment: ""), body: error.localizedDescription) } } diff --git a/Pocket Poster/Localizable.xcstrings b/Pocket Poster/Localizable.xcstrings index f1359c0..6b1c1dd 100644 --- a/Pocket Poster/Localizable.xcstrings +++ b/Pocket Poster/Localizable.xcstrings @@ -3777,6 +3777,9 @@ } } } + }, + "Failed to load explore page." : { + }, "Fallback Shortcut Creator" : { "localizations" : { @@ -7219,6 +7222,9 @@ } } } + }, + "Retry" : { + }, "Select Tendies" : { "localizations" : { @@ -7839,6 +7845,9 @@ } } } + }, + "Successfully downloaded wallpaper!" : { + }, "Successfully imported %@" : { "localizations" : { @@ -9957,6 +9966,9 @@ } } }, + "Your downloaded .tendies will be on the Home page." : { + "comment" : "after successfully downloading tendies" + }, "Your PosterBoard will refresh automatically." : { "localizations" : { "ar" : { diff --git a/Pocket Poster/Views/Accessory Views/WebView.swift b/Pocket Poster/Views/Accessory Views/WebView.swift index a403cab..28e4b28 100644 --- a/Pocket Poster/Views/Accessory Views/WebView.swift +++ b/Pocket Poster/Views/Accessory Views/WebView.swift @@ -11,21 +11,50 @@ import WebKit struct WallpaperWebView: UIViewRepresentable { let url: URL + @Binding var isLoading: Bool + @Binding var error: Error? - init(_ url: URL) { + 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 + } + } } diff --git a/Pocket Poster/Views/NavigatorViews/ExploreView.swift b/Pocket Poster/Views/NavigatorViews/ExploreView.swift index 6f5f33f..b891fc9 100644 --- a/Pocket Poster/Views/NavigatorViews/ExploreView.swift +++ b/Pocket Poster/Views/NavigatorViews/ExploreView.swift @@ -8,7 +8,30 @@ import SwiftUI struct ExploreView: View { + @State private var isLoading = true + @State private var error: Error? = nil + var body: some View { - WallpaperWebView(URL(string: "https://cowabun.ga/wallpapers?pocketposter=true")!) + ZStack { + if let error = error { + VStack { + Text(error.localizedDescription) + .foregroundStyle(.red) + Button(action: { + isLoading = true + self.error = nil + }) { + Text("Retry") + } + } + } else if let url = URL(string: "https://cowabun.ga/wallpapers?pocketposter=true") { + WallpaperWebView(url, isLoading: $isLoading, error: $error) + if isLoading { + ProgressView() + } + } else { + Text("Failed to load explore page.") + } + } } } From 5a94ea73d9604eea17717c275c7075d7dc798e4b Mon Sep 17 00:00:00 2001 From: leminlimez Date: Tue, 15 Jul 2025 11:30:47 -0400 Subject: [PATCH 11/22] DEBUG: explore link changing --- .../Controllers/DownloadManager.swift | 1 + .../Views/NavigatorViews/ExploreView.swift | 11 ++++++++++- Pocket Poster/Views/SettingsView.swift | 18 ++++++++++++++++++ ipabuild.sh | 10 ++++++++-- 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/Pocket Poster/Controllers/DownloadManager.swift b/Pocket Poster/Controllers/DownloadManager.swift index 1071b48..15a862f 100644 --- a/Pocket Poster/Controllers/DownloadManager.swift +++ b/Pocket Poster/Controllers/DownloadManager.swift @@ -10,6 +10,7 @@ import UIKit class DownloadManager: ObservableObject { static let shared = DownloadManager() + static let exploreLink = "https://cowabun.ga/wallpapers?pocketposter=true" @Published var downloadURL: String? = nil diff --git a/Pocket Poster/Views/NavigatorViews/ExploreView.swift b/Pocket Poster/Views/NavigatorViews/ExploreView.swift index b891fc9..f2bc682 100644 --- a/Pocket Poster/Views/NavigatorViews/ExploreView.swift +++ b/Pocket Poster/Views/NavigatorViews/ExploreView.swift @@ -11,8 +11,17 @@ struct ExploreView: View { @State private var isLoading = true @State private var error: Error? = nil +#if DEBUG + @AppStorage("exploreLink") var exploreLink: String = DownloadManager.exploreLink +#endif + var body: some View { ZStack { +#if DEBUG + let websiteURL = URL(string: exploreLink) +#else + let websiteURL = URL(string: DownloadManager.exploreLink) +#endif if let error = error { VStack { Text(error.localizedDescription) @@ -24,7 +33,7 @@ struct ExploreView: View { Text("Retry") } } - } else if let url = URL(string: "https://cowabun.ga/wallpapers?pocketposter=true") { + } else if let url = websiteURL { WallpaperWebView(url, isLoading: $isLoading, error: $error) if isLoading { ProgressView() diff --git a/Pocket Poster/Views/SettingsView.swift b/Pocket Poster/Views/SettingsView.swift index d87ee9d..9e1abc0 100644 --- a/Pocket Poster/Views/SettingsView.swift +++ b/Pocket Poster/Views/SettingsView.swift @@ -13,6 +13,11 @@ struct SettingsView: View { @AppStorage("cpHash") var cpHash: String = "" // CarPlay hash @AppStorage("ignoreDurationLimit") var ignoreDurationLimit: Bool = false + // DEBUG +#if DEBUG + @AppStorage("exploreLink") var exploreLink: String = DownloadManager.exploreLink +#endif + @State var checkingForHash: Bool = false @State var hashCheckTask: Task? = nil @@ -53,6 +58,19 @@ struct SettingsView: View { Label("App Hash", systemImage: "lock.app.dashed") } +#if DEBUG + Section { + VStack { + Text(String("Explore Page URL")) + TextField(String("Enter Explore Page URL"), text: $exploreLink) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .font(.system(.body, design: .monospaced)) + } + } header: { + Label(String("Debug"), systemImage: "ant") + } +#endif + Section { Toggle(isOn: $ignoreDurationLimit, label: { Label("Disable Video Duration Limit", systemImage: "ruler") 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" From 4d6b093ffdd63789fc4e1dc371f988223b530bad Mon Sep 17 00:00:00 2001 From: leminlimez Date: Tue, 15 Jul 2025 15:00:42 -0400 Subject: [PATCH 12/22] custom explore page + api --- Pocket Poster.xcodeproj/project.pbxproj | 17 ++ .../xcshareddata/swiftpm/Package.resolved | 11 +- Pocket Poster/API/CowabungaAPI.swift | 79 +++++++ Pocket Poster/API/DownloadableWallpaper.swift | 27 +++ Pocket Poster/Error Codes/APIError.swift | 30 +++ .../Extensions/URLCache+imageCache.swift | 14 ++ .../Extensions/Views/CornerRadius++.swift | 25 ++ Pocket Poster/Localizable.xcstrings | 27 ++- .../Views/NavigatorViews/ExploreView.swift | 216 ++++++++++++++++-- Pocket Poster/Views/SettingsView.swift | 18 -- 10 files changed, 415 insertions(+), 49 deletions(-) create mode 100644 Pocket Poster/API/CowabungaAPI.swift create mode 100644 Pocket Poster/API/DownloadableWallpaper.swift create mode 100644 Pocket Poster/Error Codes/APIError.swift create mode 100644 Pocket Poster/Extensions/URLCache+imageCache.swift create mode 100644 Pocket Poster/Extensions/Views/CornerRadius++.swift diff --git a/Pocket Poster.xcodeproj/project.pbxproj b/Pocket Poster.xcodeproj/project.pbxproj index 5d45780..cc78c52 100644 --- a/Pocket Poster.xcodeproj/project.pbxproj +++ b/Pocket Poster.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* 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 */ @@ -31,6 +32,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */, 6F09BA332E00808300CDE89C /* Dynamic in Frameworks */, 6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */, ); @@ -77,6 +79,7 @@ packageProductDependencies = ( 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */, 6F09BA322E00808300CDE89C /* Dynamic */, + 6FB105542E26D3EC00185A5C /* CachedAsyncImage */, ); productName = "Pocket Poster"; productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */; @@ -109,6 +112,7 @@ packageReferences = ( 6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */, + 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */; @@ -366,6 +370,14 @@ 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 */ @@ -379,6 +391,11 @@ 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 d2be016..8c6e3da 100644 --- a/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b781b75a76813cee1292db0b540a9eecd3df93961ced8a0063095503735dadb2", + "originHash" : "73a600cf677d3bb2b929c60349923a2a4e20e6a9ef10509bf043d2ad2229ca95", "pins" : [ { "identity" : "dynamic", @@ -10,6 +10,15 @@ "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/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..9b070ee --- /dev/null +++ b/Pocket Poster/API/DownloadableWallpaper.swift @@ -0,0 +1,27 @@ +// +// 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 + } +} 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/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/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/Localizable.xcstrings b/Pocket Poster/Localizable.xcstrings index b1d33db..faafe0b 100644 --- a/Pocket Poster/Localizable.xcstrings +++ b/Pocket Poster/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + + }, "Actions" : { "localizations" : { "ar" : { @@ -462,6 +465,9 @@ } } } + }, + "Apple" : { + }, "Apply" : { "localizations" : { @@ -1438,6 +1444,9 @@ } } } + }, + "Could not connect to server" : { + }, "Could not download wallpaper!" : { "localizations" : { @@ -2210,6 +2219,9 @@ } } } + }, + "Custom" : { + }, "Detect" : { "localizations" : { @@ -4229,6 +4241,9 @@ }, "Failed to fetch CarPlay wallpapers" : { + }, + "Failed to fetch wallpapers" : { + }, "Failed to import tendies" : { "localizations" : { @@ -4537,9 +4552,6 @@ } } } - }, - "Failed to load explore page." : { - }, "Fallback Shortcut Creator" : { "localizations" : { @@ -4848,6 +4860,9 @@ } } } + }, + "Filter Wallpapers" : { + }, "Find Wallpapers" : { "localizations" : { @@ -8749,9 +8764,6 @@ } } } - }, - "Retry" : { - }, "Select Tendies" : { "localizations" : { @@ -10603,6 +10615,9 @@ } } } + }, + "Unable to obtain repo hash. Maybe update to the latest version?" : { + }, "Update Available" : { "comment" : "app update available on GitHub", diff --git a/Pocket Poster/Views/NavigatorViews/ExploreView.swift b/Pocket Poster/Views/NavigatorViews/ExploreView.swift index f2bc682..4f4385d 100644 --- a/Pocket Poster/Views/NavigatorViews/ExploreView.swift +++ b/Pocket Poster/Views/NavigatorViews/ExploreView.swift @@ -6,41 +6,209 @@ // import SwiftUI +import CachedAsyncImage + +let MIN_SIZE: CGFloat = 165 struct ExploreView: View { - @State private var isLoading = true - @State private var error: Error? = nil + @ObservedObject var cowabungaAPI = CowabungaAPI.shared -#if DEBUG - @AppStorage("exploreLink") var exploreLink: String = DownloadManager.exploreLink -#endif + // 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 { - ZStack { -#if DEBUG - let websiteURL = URL(string: exploreLink) -#else - let websiteURL = URL(string: DownloadManager.exploreLink) -#endif - if let error = error { + NavigationView { + ScrollView { + PullToRefresh(coordinateSpaceName: "pullToRefresh") { + // refresh + wallpapers.removeAll() + Haptic.shared.play(.light) + //URLCache.imageCache.removeAllCachedResponses() + loadWallpapers() + } VStack { - Text(error.localizedDescription) - .foregroundStyle(.red) + 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) { + CachedAsyncImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(maxWidth: .infinity) + .cornerRadius(10, corners: .topLeft) + .cornerRadius(10, corners: .topRight) + } placeholder: { + Color.gray + .frame(height: MIN_SIZE) + } + HStack { + VStack(spacing: 4) { + HStack { + Text(wallpaper.name) + .foregroundStyle(Color(uiColor: .label)) + .minimumScaleFactor(0.5) + Spacer() + } + HStack { + Text(wallpaper.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(10) + .padding(4) + } + } + } + .padding() + } + } + .searchable(text: $searchTerm) + .coordinateSpace(name: "pullToRefresh") + .navigationTitle("Explore") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { Button(action: { - isLoading = true - self.error = nil + showFilterChangerPopup() }) { - Text("Retry") + Image(systemName: "line.3.horizontal.decrease.circle") } } - } else if let url = websiteURL { - WallpaperWebView(url, isLoading: $isLoading, error: $error) - if isLoading { - ProgressView() - } - } else { - Text("Failed to load explore page.") + } + } + .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/SettingsView.swift b/Pocket Poster/Views/SettingsView.swift index 9e1abc0..d87ee9d 100644 --- a/Pocket Poster/Views/SettingsView.swift +++ b/Pocket Poster/Views/SettingsView.swift @@ -13,11 +13,6 @@ struct SettingsView: View { @AppStorage("cpHash") var cpHash: String = "" // CarPlay hash @AppStorage("ignoreDurationLimit") var ignoreDurationLimit: Bool = false - // DEBUG -#if DEBUG - @AppStorage("exploreLink") var exploreLink: String = DownloadManager.exploreLink -#endif - @State var checkingForHash: Bool = false @State var hashCheckTask: Task? = nil @@ -58,19 +53,6 @@ struct SettingsView: View { Label("App Hash", systemImage: "lock.app.dashed") } -#if DEBUG - Section { - VStack { - Text(String("Explore Page URL")) - TextField(String("Enter Explore Page URL"), text: $exploreLink) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .font(.system(.body, design: .monospaced)) - } - } header: { - Label(String("Debug"), systemImage: "ant") - } -#endif - Section { Toggle(isOn: $ignoreDurationLimit, label: { Label("Disable Video Duration Limit", systemImage: "ruler") From ad280b1a6b5d043497d16755a5e81c43bd56707c Mon Sep 17 00:00:00 2001 From: leminlimez Date: Tue, 15 Jul 2025 15:13:28 -0400 Subject: [PATCH 13/22] gif previews + handle apple wallpapers --- Pocket Poster.xcodeproj/project.pbxproj | 17 ++++++ .../xcshareddata/swiftpm/Package.resolved | 20 ++++++- Pocket Poster/API/DownloadableWallpaper.swift | 10 +++- .../Views/NavigatorViews/ExploreView.swift | 56 +++++++++++++------ 4 files changed, 82 insertions(+), 21 deletions(-) diff --git a/Pocket Poster.xcodeproj/project.pbxproj b/Pocket Poster.xcodeproj/project.pbxproj index cc78c52..2ff02b4 100644 --- a/Pocket Poster.xcodeproj/project.pbxproj +++ b/Pocket Poster.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 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 */; }; + 6FB105602E26DD0A00185A5C /* Giffy in Frameworks */ = {isa = PBXBuildFile; productRef = 6FB1055F2E26DD0A00185A5C /* Giffy */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -34,6 +35,7 @@ files = ( 6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */, 6F09BA332E00808300CDE89C /* Dynamic in Frameworks */, + 6FB105602E26DD0A00185A5C /* Giffy in Frameworks */, 6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -80,6 +82,7 @@ 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */, 6F09BA322E00808300CDE89C /* Dynamic */, 6FB105542E26D3EC00185A5C /* CachedAsyncImage */, + 6FB1055F2E26DD0A00185A5C /* Giffy */, ); productName = "Pocket Poster"; productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */; @@ -113,6 +116,7 @@ 6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */, 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */, + 6FB1055E2E26DD0A00185A5C /* XCRemoteSwiftPackageReference "Giffy" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */; @@ -378,6 +382,14 @@ minimumVersion = 2.1.1; }; }; + 6FB1055E2E26DD0A00185A5C /* XCRemoteSwiftPackageReference "Giffy" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/tfmart/Giffy.git"; + requirement = { + branch = main; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -396,6 +408,11 @@ package = 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */; productName = CachedAsyncImage; }; + 6FB1055F2E26DD0A00185A5C /* Giffy */ = { + isa = XCSwiftPackageProductDependency; + package = 6FB1055E2E26DD0A00185A5C /* XCRemoteSwiftPackageReference "Giffy" */; + productName = Giffy; + }; /* 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 8c6e3da..450dfbe 100644 --- a/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "73a600cf677d3bb2b929c60349923a2a4e20e6a9ef10509bf043d2ad2229ca95", + "originHash" : "9d3a39dff9bda7be6921bb553210388e3a68059324656c0ace5f2eb2378819b1", "pins" : [ { "identity" : "dynamic", @@ -10,6 +10,24 @@ "version" : "1.2.0" } }, + { + "identity" : "flanimatedimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Flipboard/FLAnimatedImage.git", + "state" : { + "revision" : "d4f07b6f164d53c1212c3e54d6460738b1981e9f", + "version" : "1.0.17" + } + }, + { + "identity" : "giffy", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tfmart/Giffy.git", + "state" : { + "branch" : "main", + "revision" : "595bd0a0bc292d32da4b26b8bca4347235ab2556" + } + }, { "identity" : "swiftui-cached-async-image", "kind" : "remoteSourceControl", diff --git a/Pocket Poster/API/DownloadableWallpaper.swift b/Pocket Poster/API/DownloadableWallpaper.swift index 9b070ee..e6438d3 100644 --- a/Pocket Poster/API/DownloadableWallpaper.swift +++ b/Pocket Poster/API/DownloadableWallpaper.swift @@ -7,13 +7,13 @@ class DownloadableWallpaper: Identifiable, Codable { var name: String - var description: String + var description: String? var url: String var preview: String - var authors: String + var authors: String? var type: WallpaperType? - init(name: String, description: String, authors: String, preview: String, url: String, version: String) { + init(name: String, description: String?, authors: String?, preview: String, url: String, version: String) { self.name = name self.description = description self.authors = authors @@ -24,4 +24,8 @@ class DownloadableWallpaper: Identifiable, Codable { enum WallpaperType: String, Codable { case custom, apple, template } + + func previewIsGif() -> Bool { + return preview.hasSuffix(".gif") + } } diff --git a/Pocket Poster/Views/NavigatorViews/ExploreView.swift b/Pocket Poster/Views/NavigatorViews/ExploreView.swift index 4f4385d..04f879f 100644 --- a/Pocket Poster/Views/NavigatorViews/ExploreView.swift +++ b/Pocket Poster/Views/NavigatorViews/ExploreView.swift @@ -7,6 +7,7 @@ import SwiftUI import CachedAsyncImage +import Giffy let MIN_SIZE: CGFloat = 165 @@ -50,21 +51,40 @@ struct ExploreView: View { } else { LazyVGrid(columns: gridItemLayout) { ForEach(wallpapers) { wallpaper in - if searchTerm == "" || wallpaper.name.lowercased().contains(searchTerm.lowercased()) || wallpaper.authors.lowercased().contains(searchTerm.lowercased()) { + 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) { - CachedAsyncImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(maxWidth: .infinity) - .cornerRadius(10, corners: .topLeft) - .cornerRadius(10, corners: .topRight) - } placeholder: { - Color.gray - .frame(height: MIN_SIZE) + if wallpaper.previewIsGif() { + AsyncGiffy(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper)) { phase in + switch phase { + case .loading: + Color.gray + .frame(height: MIN_SIZE) + case .error: + Color.red + .frame(height: MIN_SIZE) + case .success(let giffy): + giffy + .aspectRatio(contentMode: .fill) + .frame(maxWidth: .infinity) + .cornerRadius(10, corners: .topLeft) + .cornerRadius(10, corners: .topRight) + } + } + } else { + CachedAsyncImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(maxWidth: .infinity) + .cornerRadius(10, corners: .topLeft) + .cornerRadius(10, corners: .topRight) + } placeholder: { + Color.gray + .frame(height: MIN_SIZE) + } } HStack { VStack(spacing: 4) { @@ -74,12 +94,14 @@ struct ExploreView: View { .minimumScaleFactor(0.5) Spacer() } - HStack { - Text(wallpaper.authors) - .foregroundColor(.secondary) - .font(.caption) - .minimumScaleFactor(0.5) - Spacer() + if let authors = wallpaper.authors { + HStack { + Text(authors) + .foregroundColor(.secondary) + .font(.caption) + .minimumScaleFactor(0.5) + Spacer() + } } } .lineLimit(1) From 22a74d5fb8c986b322b7a3bd7e05b1a8f96f7c79 Mon Sep 17 00:00:00 2001 From: leminlimez Date: Fri, 18 Jul 2025 14:48:43 -0400 Subject: [PATCH 14/22] fix confirmation popup not appearing --- Pocket Poster/Controllers/DownloadManager.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Pocket Poster/Controllers/DownloadManager.swift b/Pocket Poster/Controllers/DownloadManager.swift index 15a862f..35c69ba 100644 --- a/Pocket Poster/Controllers/DownloadManager.swift +++ b/Pocket Poster/Controllers/DownloadManager.swift @@ -45,13 +45,17 @@ class DownloadManager: ObservableObject { PosterBoardManager.shared.selectedTendies.append(newURL) Haptic.shared.notify(.success) UIApplication.shared.dismissAlert(animated: true) - 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")) + 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) - UIApplication.shared.alert(title: NSLocalizedString("Could not download wallpaper!", comment: ""), body: error.localizedDescription) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: { + UIApplication.shared.alert(title: NSLocalizedString("Could not download wallpaper!", comment: ""), body: error.localizedDescription) + }) } } } From 71b1d0a21d8388056ece4aae11337b12b35f1cfb Mon Sep 17 00:00:00 2001 From: leminlimez Date: Fri, 18 Jul 2025 16:45:34 -0400 Subject: [PATCH 15/22] cached async giffy --- .../Extensions/CachedAsyncGiffy.swift | 408 ++++++++++++++++++ .../Views/NavigatorViews/ExploreView.swift | 41 +- 2 files changed, 434 insertions(+), 15 deletions(-) create mode 100644 Pocket Poster/Extensions/CachedAsyncGiffy.swift diff --git a/Pocket Poster/Extensions/CachedAsyncGiffy.swift b/Pocket Poster/Extensions/CachedAsyncGiffy.swift new file mode 100644 index 0000000..85ceb48 --- /dev/null +++ b/Pocket Poster/Extensions/CachedAsyncGiffy.swift @@ -0,0 +1,408 @@ +// +// CachedAsyncGiffy.swift +// Pocket Poster +// +// Created by lemin on 7/18/25. +// + + +// +// Created by Lorenzo Fiamingo on 04/11/20. +// + +import SwiftUI +import Giffy + +/// A view that asynchronously loads, cache and displays an image. +/// +/// This view uses a custom default +/// +/// instance to load an image from the specified URL, and then display it. +/// For example, you can display an icon that's stored on a server: +/// +/// CachedAsyncGiffy(url: URL(string: "https://example.com/icon.gif")) +/// .frame(width: 200, height: 200) +/// +/// Until the image loads, the view displays a standard placeholder that +/// fills the available space. After the load completes successfully, the view +/// updates to display the image. In the example above, the icon is smaller +/// than the frame, and so appears smaller than the placeholder. +/// +/// ![A diagram that shows a grey box on the left, the SwiftUI icon on the +/// right, and an arrow pointing from the first to the second. The icon +/// is about half the size of the grey box.](AsyncImage-1) +/// +/// You can specify a custom placeholder using +/// ``init(url:urlCache:scale:content:placeholder:)``. With this initializer, you can +/// also use the `content` parameter to manipulate the loaded image. +/// For example, you can add a modifier to make the loaded image resizable: +/// +/// CachedAsyncGiffy(url: URL(string: "https://example.com/icon.gif")) { image in +/// image.resizable() +/// } placeholder: { +/// ProgressView() +/// } +/// .frame(width: 50, height: 50) +/// +/// For this example, SwiftUI shows a ``ProgressView`` first, and then the +/// image scaled to fit in the specified frame: +/// +/// ![A diagram that shows a progress view on the left, the SwiftUI icon on the +/// right, and an arrow pointing from the first to the second.](AsyncImage-2) +/// +/// > Important: You can't apply image-specific modifiers, like +/// ``Image/resizable(capInsets:resizingMode:)``, directly to a `CachedAsyncImage`. +/// Instead, apply them to the ``Image`` instance that your `content` +/// closure gets when defining the view's appearance. +/// +/// To gain more control over the loading process, use the +/// ``init(url:urlCache:scale:transaction:content:)`` initializer, which takes a +/// `content` closure that receives an ``AsyncImagePhase`` to indicate +/// the state of the loading operation. Return a view that's appropriate +/// for the current phase: +/// +/// CachedAsyncGiffy(url: URL(string: "https://example.com/icon.gif")) { phase in +/// if let image = phase.image { +/// image // Displays the loaded image. +/// } else if phase.error != nil { +/// Color.red // Indicates an error. +/// } else { +/// Color.blue // Acts as a placeholder. +/// } +/// } +/// +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct CachedAsyncGiffy: View where Content: View { + + @State private var phase: AsyncGiffyPhase + + private let urlRequest: URLRequest? + + private let urlSession: URLSession + + private let scale: CGFloat + + private let transaction: Transaction + + private let content: (AsyncGiffyPhase) -> Content + + public var body: some View { + content(phase) + .task(id: urlRequest, load) + } + + /// Loads and displays an image from the specified URL. + /// + /// Until the image loads, SwiftUI displays a default placeholder. When + /// the load operation completes successfully, SwiftUI updates the + /// view to show the loaded image. If the operation fails, SwiftUI + /// continues to display the placeholder. The following example loads + /// and displays an icon from an example server: + /// + /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) + /// + /// If you want to customize the placeholder or apply image-specific + /// modifiers --- like ``Image/resizable(capInsets:resizingMode:)`` --- + /// to the loaded image, use the ``init(url:scale:content:placeholder:)`` + /// initializer instead. + /// + /// - Parameters: + /// - url: The URL of the image to display. + /// - urlCache: The URL cache for providing cached responses to requests within the session. + /// - scale: The scale to use for the image. The default is `1`. Set a + /// different value when loading images designed for higher resolution + /// displays. For example, set a value of `2` for an image that you + /// would name with the `@2x` suffix if stored in a file on disk. + public init(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1) where Content == Giffy { + let urlRequest = url == nil ? nil : URLRequest(url: url!) + self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale) + } + + /// Loads and displays an image from the specified URL. + /// + /// Until the image loads, SwiftUI displays a default placeholder. When + /// the load operation completes successfully, SwiftUI updates the + /// view to show the loaded image. If the operation fails, SwiftUI + /// continues to display the placeholder. The following example loads + /// and displays an icon from an example server: + /// + /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) + /// + /// If you want to customize the placeholder or apply image-specific + /// modifiers --- like ``Image/resizable(capInsets:resizingMode:)`` --- + /// to the loaded image, use the ``init(url:scale:content:placeholder:)`` + /// initializer instead. + /// + /// - Parameters: + /// - urlRequest: The URL request of the image to display. + /// - urlCache: The URL cache for providing cached responses to requests within the session. + /// - scale: The scale to use for the image. The default is `1`. Set a + /// different value when loading images designed for higher resolution + /// displays. For example, set a value of `2` for an image that you + /// would name with the `@2x` suffix if stored in a file on disk. + public init(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1) where Content == Giffy { + self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale) { phase in +#if os(macOS) + phase.image ?? Image(nsImage: .init()) +#else + switch phase { + case .success(let giffy): + return giffy + default: + return Giffy("") + } +#endif + } + } + + /// Loads and displays a modifiable image from the specified URL using + /// a custom placeholder until the image loads. + /// + /// Until the image loads, SwiftUI displays the placeholder view that + /// you specify. When the load operation completes successfully, SwiftUI + /// updates the view to show content that you specify, which you + /// create using the loaded image. For example, you can show a green + /// placeholder, followed by a tiled version of the loaded image: + /// + /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { image in + /// image.resizable(resizingMode: .tile) + /// } placeholder: { + /// Color.green + /// } + /// + /// If the load operation fails, SwiftUI continues to display the + /// placeholder. To be able to display a different view on a load error, + /// use the ``init(url:scale:transaction:content:)`` initializer instead. + /// + /// - Parameters: + /// - url: The URL of the image to display. + /// - urlCache: The URL cache for providing cached responses to requests within the session. + /// - scale: The scale to use for the image. The default is `1`. Set a + /// different value when loading images designed for higher resolution + /// displays. For example, set a value of `2` for an image that you + /// would name with the `@2x` suffix if stored in a file on disk. + /// - content: A closure that takes the loaded image as an input, and + /// returns the view to show. You can return the image directly, or + /// modify it as needed before returning it. + /// - placeholder: A closure that returns the view to show until the + /// load operation completes successfully. + public init(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1, @ViewBuilder content: @escaping (Giffy) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent, I : View, P : View { + let urlRequest = url == nil ? nil : URLRequest(url: url!) + self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale, content: content, placeholder: placeholder) + } + + /// Loads and displays a modifiable image from the specified URL using + /// a custom placeholder until the image loads. + /// + /// Until the image loads, SwiftUI displays the placeholder view that + /// you specify. When the load operation completes successfully, SwiftUI + /// updates the view to show content that you specify, which you + /// create using the loaded image. For example, you can show a green + /// placeholder, followed by a tiled version of the loaded image: + /// + /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { image in + /// image.resizable(resizingMode: .tile) + /// } placeholder: { + /// Color.green + /// } + /// + /// If the load operation fails, SwiftUI continues to display the + /// placeholder. To be able to display a different view on a load error, + /// use the ``init(url:scale:transaction:content:)`` initializer instead. + /// + /// - Parameters: + /// - urlRequest: The URL request of the image to display. + /// - urlCache: The URL cache for providing cached responses to requests within the session. + /// - scale: The scale to use for the image. The default is `1`. Set a + /// different value when loading images designed for higher resolution + /// displays. For example, set a value of `2` for an image that you + /// would name with the `@2x` suffix if stored in a file on disk. + /// - content: A closure that takes the loaded image as an input, and + /// returns the view to show. You can return the image directly, or + /// modify it as needed before returning it. + /// - placeholder: A closure that returns the view to show until the + /// load operation completes successfully. + public init(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1, @ViewBuilder content: @escaping (Giffy) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent, I : View, P : View { + self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale) { phase in + switch phase { + case .success(let image): + content(image) + default: + placeholder() + } + } + } + + /// Loads and displays a modifiable image from the specified URL in phases. + /// + /// If you set the asynchronous image's URL to `nil`, or after you set the + /// URL to a value but before the load operation completes, the phase is + /// ``AsyncImagePhase/empty``. After the operation completes, the phase + /// becomes either ``AsyncImagePhase/failure(_:)`` or + /// ``AsyncImagePhase/success(_:)``. In the first case, the phase's + /// ``AsyncImagePhase/error`` value indicates the reason for failure. + /// In the second case, the phase's ``AsyncImagePhase/image`` property + /// contains the loaded image. Use the phase to drive the output of the + /// `content` closure, which defines the view's appearance: + /// + /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { phase in + /// if let image = phase.image { + /// image // Displays the loaded image. + /// } else if phase.error != nil { + /// Color.red // Indicates an error. + /// } else { + /// Color.blue // Acts as a placeholder. + /// } + /// } + /// + /// To add transitions when you change the URL, apply an identifier to the + /// ``CachedAsyncImage``. + /// + /// - Parameters: + /// - url: The URL of the image to display. + /// - urlCache: The URL cache for providing cached responses to requests within the session. + /// - scale: The scale to use for the image. The default is `1`. Set a + /// different value when loading images designed for higher resolution + /// displays. For example, set a value of `2` for an image that you + /// would name with the `@2x` suffix if stored in a file on disk. + /// - transaction: The transaction to use when the phase changes. + /// - content: A closure that takes the load phase as an input, and + /// returns the view to display for the specified phase. + public init(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (AsyncGiffyPhase) -> Content) { + let urlRequest = url == nil ? nil : URLRequest(url: url!) + self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale, transaction: transaction, content: content) + } + + /// Loads and displays a modifiable image from the specified URL in phases. + /// + /// If you set the asynchronous image's URL to `nil`, or after you set the + /// URL to a value but before the load operation completes, the phase is + /// ``AsyncImagePhase/empty``. After the operation completes, the phase + /// becomes either ``AsyncImagePhase/failure(_:)`` or + /// ``AsyncImagePhase/success(_:)``. In the first case, the phase's + /// ``AsyncImagePhase/error`` value indicates the reason for failure. + /// In the second case, the phase's ``AsyncImagePhase/image`` property + /// contains the loaded image. Use the phase to drive the output of the + /// `content` closure, which defines the view's appearance: + /// + /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { phase in + /// if let image = phase.image { + /// image // Displays the loaded image. + /// } else if phase.error != nil { + /// Color.red // Indicates an error. + /// } else { + /// Color.blue // Acts as a placeholder. + /// } + /// } + /// + /// To add transitions when you change the URL, apply an identifier to the + /// ``CachedAsyncImage``. + /// + /// - Parameters: + /// - urlRequest: The URL request of the image to display. + /// - urlCache: The URL cache for providing cached responses to requests within the session. + /// - scale: The scale to use for the image. The default is `1`. Set a + /// different value when loading images designed for higher resolution + /// displays. For example, set a value of `2` for an image that you + /// would name with the `@2x` suffix if stored in a file on disk. + /// - transaction: The transaction to use when the phase changes. + /// - content: A closure that takes the load phase as an input, and + /// returns the view to display for the specified phase. + public init(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (AsyncGiffyPhase) -> Content) { + let configuration = URLSessionConfiguration.default + configuration.urlCache = urlCache + self.urlRequest = urlRequest + self.urlSession = URLSession(configuration: configuration) + self.scale = scale + self.transaction = transaction + self.content = content + + self._phase = State(wrappedValue: .loading) + do { + if let urlRequest = urlRequest, let image = try cachedGif(from: urlRequest, cache: urlCache) { + self._phase = State(wrappedValue: .success(image)) + } + } catch { + self._phase = State(wrappedValue: .error) + } + } + + @Sendable + private func load() async { + do { + if let urlRequest = urlRequest { + let (image, metrics) = try await remoteGif(from: urlRequest, session: urlSession) + if metrics.transactionMetrics.last?.resourceFetchType == .localCache { + // WARNING: This does not behave well when the url is changed with another + phase = .success(image) + } else { + withAnimation(transaction.animation) { + phase = .success(image) + } + } + } else { + withAnimation(transaction.animation) { + phase = .loading + } + } + } catch { + withAnimation(transaction.animation) { + phase = .error + } + } + } +} + +// MARK: - Helpers + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private extension CachedAsyncGiffy { + private func remoteGif(from request: URLRequest, session: URLSession) async throws -> (Giffy, URLSessionTaskMetrics) { + let (data, _, metrics) = try await session.data(for: request) + if metrics.redirectCount > 0, let lastResponse = metrics.transactionMetrics.last?.response { + let requests = metrics.transactionMetrics.map { $0.request } + requests.forEach(session.configuration.urlCache!.removeCachedResponse) + let lastCachedResponse = CachedURLResponse(response: lastResponse, data: data) + session.configuration.urlCache!.storeCachedResponse(lastCachedResponse, for: request) + } + return (try gif(from: data), metrics) + } + + private func cachedGif(from request: URLRequest, cache: URLCache) throws -> Giffy? { + guard let cachedResponse = cache.cachedResponse(for: request) else { return nil } + return try gif(from: cachedResponse.data) + } + + private func gif(from data: Data) throws -> Giffy { +#if os(macOS) + if let nsImage = NSImage(data: data) { + return Giffy(nsImage: nsImage) + } else { + throw AsyncGiffy.LoadingError() + } +#else + return Giffy(imageData: data) +#endif + } +} + +// MARK: - AsyncImageURLSession + +private class URLSessionTaskController: NSObject, URLSessionTaskDelegate { + + var metrics: URLSessionTaskMetrics? + + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + self.metrics = metrics + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private extension URLSession { + + func data(for request: URLRequest) async throws -> (Data, URLResponse, URLSessionTaskMetrics) { + let controller = URLSessionTaskController() + let (data, response) = try await data(for: request, delegate: controller) + return (data, response, controller.metrics!) + } +} diff --git a/Pocket Poster/Views/NavigatorViews/ExploreView.swift b/Pocket Poster/Views/NavigatorViews/ExploreView.swift index 04f879f..10ddd3d 100644 --- a/Pocket Poster/Views/NavigatorViews/ExploreView.swift +++ b/Pocket Poster/Views/NavigatorViews/ExploreView.swift @@ -57,22 +57,33 @@ struct ExploreView: View { }) { VStack(spacing: 0) { if wallpaper.previewIsGif() { - AsyncGiffy(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper)) { phase in - switch phase { - case .loading: - Color.gray - .frame(height: MIN_SIZE) - case .error: - Color.red - .frame(height: MIN_SIZE) - case .success(let giffy): - giffy - .aspectRatio(contentMode: .fill) - .frame(maxWidth: .infinity) - .cornerRadius(10, corners: .topLeft) - .cornerRadius(10, corners: .topRight) - } + CachedAsyncGiffy(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { giffy in + giffy + .aspectRatio(contentMode: .fill) + .frame(maxWidth: .infinity) + .cornerRadius(10, corners: .topLeft) + .cornerRadius(10, corners: .topRight) + } placeholder: { + Color.gray + .frame(height: MIN_SIZE) } + // Non-cached code: +// AsyncGiffy(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper)) { phase in +// switch phase { +// case .loading: +// Color.gray +// .frame(height: MIN_SIZE) +// case .error: +// Color.red +// .frame(height: MIN_SIZE) +// case .success(let giffy): +// giffy +// .aspectRatio(contentMode: .fill) +// .frame(maxWidth: .infinity) +// .cornerRadius(10, corners: .topLeft) +// .cornerRadius(10, corners: .topRight) +// } +// } } else { CachedAsyncImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { image in image From 395cbe5e131b69c3862fce05f659798ee2d04d8c Mon Sep 17 00:00:00 2001 From: leminlimez Date: Sat, 19 Jul 2025 08:58:08 -0400 Subject: [PATCH 16/22] fix gif caching --- Pocket Poster.xcodeproj/project.pbxproj | 21 +- .../xcshareddata/swiftpm/Package.resolved | 20 +- .../xcschemes/xcschememanagement.plist | 2 +- .../Extensions/CachedAsyncGiffy.swift | 408 ------------------ .../GIFImage/Sources/GIFImage+Init.swift | 169 ++++++++ .../Packages/GIFImage/Sources/GIFImage.swift | 137 ++++++ .../domain/CGImageSourceFrameSequence.swift | 46 ++ .../domain/CGImageSourceIterator.swift | 36 ++ .../GIFImage/Sources/domain/ImageLoader.swift | 80 ++++ .../domain/PresentationController.swift | 65 +++ .../Sources/extension/CFString+GIFImage.swift | 15 + .../extension/CGImageSource+GIFImage.swift | 39 ++ .../Sources/extension/Data+GIFImage.swift | 15 + .../extension/Environment+GIFImage.swift | 28 ++ .../GIFImage/Sources/model/FrameRate.swift | 14 + .../GIFImage/Sources/model/GIFSource.swift | 17 + .../GIFImage/Sources/model/ImageFrame.swift | 14 + .../GIFImage/Sources/model/RawImage.swift | 44 ++ .../Views/NavigatorViews/ExploreView.swift | 33 +- 19 files changed, 728 insertions(+), 475 deletions(-) delete mode 100644 Pocket Poster/Extensions/CachedAsyncGiffy.swift create mode 100644 Pocket Poster/Packages/GIFImage/Sources/GIFImage+Init.swift create mode 100644 Pocket Poster/Packages/GIFImage/Sources/GIFImage.swift create mode 100644 Pocket Poster/Packages/GIFImage/Sources/domain/CGImageSourceFrameSequence.swift create mode 100644 Pocket Poster/Packages/GIFImage/Sources/domain/CGImageSourceIterator.swift create mode 100644 Pocket Poster/Packages/GIFImage/Sources/domain/ImageLoader.swift create mode 100644 Pocket Poster/Packages/GIFImage/Sources/domain/PresentationController.swift create mode 100644 Pocket Poster/Packages/GIFImage/Sources/extension/CFString+GIFImage.swift create mode 100644 Pocket Poster/Packages/GIFImage/Sources/extension/CGImageSource+GIFImage.swift create mode 100644 Pocket Poster/Packages/GIFImage/Sources/extension/Data+GIFImage.swift create mode 100644 Pocket Poster/Packages/GIFImage/Sources/extension/Environment+GIFImage.swift create mode 100644 Pocket Poster/Packages/GIFImage/Sources/model/FrameRate.swift create mode 100644 Pocket Poster/Packages/GIFImage/Sources/model/GIFSource.swift create mode 100644 Pocket Poster/Packages/GIFImage/Sources/model/ImageFrame.swift create mode 100644 Pocket Poster/Packages/GIFImage/Sources/model/RawImage.swift diff --git a/Pocket Poster.xcodeproj/project.pbxproj b/Pocket Poster.xcodeproj/project.pbxproj index 2ff02b4..ac2668e 100644 --- a/Pocket Poster.xcodeproj/project.pbxproj +++ b/Pocket Poster.xcodeproj/project.pbxproj @@ -10,7 +10,6 @@ 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 */; }; - 6FB105602E26DD0A00185A5C /* Giffy in Frameworks */ = {isa = PBXBuildFile; productRef = 6FB1055F2E26DD0A00185A5C /* Giffy */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -35,7 +34,6 @@ files = ( 6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */, 6F09BA332E00808300CDE89C /* Dynamic in Frameworks */, - 6FB105602E26DD0A00185A5C /* Giffy in Frameworks */, 6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -82,7 +80,6 @@ 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */, 6F09BA322E00808300CDE89C /* Dynamic */, 6FB105542E26D3EC00185A5C /* CachedAsyncImage */, - 6FB1055F2E26DD0A00185A5C /* Giffy */, ); productName = "Pocket Poster"; productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */; @@ -116,7 +113,6 @@ 6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */, 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */, - 6FB1055E2E26DD0A00185A5C /* XCRemoteSwiftPackageReference "Giffy" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */; @@ -276,7 +272,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; @@ -308,7 +304,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; @@ -382,14 +378,6 @@ minimumVersion = 2.1.1; }; }; - 6FB1055E2E26DD0A00185A5C /* XCRemoteSwiftPackageReference "Giffy" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/tfmart/Giffy.git"; - requirement = { - branch = main; - kind = branch; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -408,11 +396,6 @@ package = 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */; productName = CachedAsyncImage; }; - 6FB1055F2E26DD0A00185A5C /* Giffy */ = { - isa = XCSwiftPackageProductDependency; - package = 6FB1055E2E26DD0A00185A5C /* XCRemoteSwiftPackageReference "Giffy" */; - productName = Giffy; - }; /* 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 450dfbe..d3b1c82 100644 --- a/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Pocket Poster.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "9d3a39dff9bda7be6921bb553210388e3a68059324656c0ace5f2eb2378819b1", + "originHash" : "7f2d604f1affb7dc07ec1898cef3c0eaf130e2b55b5c441f445ab59c3285c1bb", "pins" : [ { "identity" : "dynamic", @@ -10,24 +10,6 @@ "version" : "1.2.0" } }, - { - "identity" : "flanimatedimage", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Flipboard/FLAnimatedImage.git", - "state" : { - "revision" : "d4f07b6f164d53c1212c3e54d6460738b1981e9f", - "version" : "1.0.17" - } - }, - { - "identity" : "giffy", - "kind" : "remoteSourceControl", - "location" : "https://github.com/tfmart/Giffy.git", - "state" : { - "branch" : "main", - "revision" : "595bd0a0bc292d32da4b26b8bca4347235ab2556" - } - }, { "identity" : "swiftui-cached-async-image", "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 f3372ed..d7a896f 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 - 0 + 1 diff --git a/Pocket Poster/Extensions/CachedAsyncGiffy.swift b/Pocket Poster/Extensions/CachedAsyncGiffy.swift deleted file mode 100644 index 85ceb48..0000000 --- a/Pocket Poster/Extensions/CachedAsyncGiffy.swift +++ /dev/null @@ -1,408 +0,0 @@ -// -// CachedAsyncGiffy.swift -// Pocket Poster -// -// Created by lemin on 7/18/25. -// - - -// -// Created by Lorenzo Fiamingo on 04/11/20. -// - -import SwiftUI -import Giffy - -/// A view that asynchronously loads, cache and displays an image. -/// -/// This view uses a custom default -/// -/// instance to load an image from the specified URL, and then display it. -/// For example, you can display an icon that's stored on a server: -/// -/// CachedAsyncGiffy(url: URL(string: "https://example.com/icon.gif")) -/// .frame(width: 200, height: 200) -/// -/// Until the image loads, the view displays a standard placeholder that -/// fills the available space. After the load completes successfully, the view -/// updates to display the image. In the example above, the icon is smaller -/// than the frame, and so appears smaller than the placeholder. -/// -/// ![A diagram that shows a grey box on the left, the SwiftUI icon on the -/// right, and an arrow pointing from the first to the second. The icon -/// is about half the size of the grey box.](AsyncImage-1) -/// -/// You can specify a custom placeholder using -/// ``init(url:urlCache:scale:content:placeholder:)``. With this initializer, you can -/// also use the `content` parameter to manipulate the loaded image. -/// For example, you can add a modifier to make the loaded image resizable: -/// -/// CachedAsyncGiffy(url: URL(string: "https://example.com/icon.gif")) { image in -/// image.resizable() -/// } placeholder: { -/// ProgressView() -/// } -/// .frame(width: 50, height: 50) -/// -/// For this example, SwiftUI shows a ``ProgressView`` first, and then the -/// image scaled to fit in the specified frame: -/// -/// ![A diagram that shows a progress view on the left, the SwiftUI icon on the -/// right, and an arrow pointing from the first to the second.](AsyncImage-2) -/// -/// > Important: You can't apply image-specific modifiers, like -/// ``Image/resizable(capInsets:resizingMode:)``, directly to a `CachedAsyncImage`. -/// Instead, apply them to the ``Image`` instance that your `content` -/// closure gets when defining the view's appearance. -/// -/// To gain more control over the loading process, use the -/// ``init(url:urlCache:scale:transaction:content:)`` initializer, which takes a -/// `content` closure that receives an ``AsyncImagePhase`` to indicate -/// the state of the loading operation. Return a view that's appropriate -/// for the current phase: -/// -/// CachedAsyncGiffy(url: URL(string: "https://example.com/icon.gif")) { phase in -/// if let image = phase.image { -/// image // Displays the loaded image. -/// } else if phase.error != nil { -/// Color.red // Indicates an error. -/// } else { -/// Color.blue // Acts as a placeholder. -/// } -/// } -/// -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct CachedAsyncGiffy: View where Content: View { - - @State private var phase: AsyncGiffyPhase - - private let urlRequest: URLRequest? - - private let urlSession: URLSession - - private let scale: CGFloat - - private let transaction: Transaction - - private let content: (AsyncGiffyPhase) -> Content - - public var body: some View { - content(phase) - .task(id: urlRequest, load) - } - - /// Loads and displays an image from the specified URL. - /// - /// Until the image loads, SwiftUI displays a default placeholder. When - /// the load operation completes successfully, SwiftUI updates the - /// view to show the loaded image. If the operation fails, SwiftUI - /// continues to display the placeholder. The following example loads - /// and displays an icon from an example server: - /// - /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) - /// - /// If you want to customize the placeholder or apply image-specific - /// modifiers --- like ``Image/resizable(capInsets:resizingMode:)`` --- - /// to the loaded image, use the ``init(url:scale:content:placeholder:)`` - /// initializer instead. - /// - /// - Parameters: - /// - url: The URL of the image to display. - /// - urlCache: The URL cache for providing cached responses to requests within the session. - /// - scale: The scale to use for the image. The default is `1`. Set a - /// different value when loading images designed for higher resolution - /// displays. For example, set a value of `2` for an image that you - /// would name with the `@2x` suffix if stored in a file on disk. - public init(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1) where Content == Giffy { - let urlRequest = url == nil ? nil : URLRequest(url: url!) - self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale) - } - - /// Loads and displays an image from the specified URL. - /// - /// Until the image loads, SwiftUI displays a default placeholder. When - /// the load operation completes successfully, SwiftUI updates the - /// view to show the loaded image. If the operation fails, SwiftUI - /// continues to display the placeholder. The following example loads - /// and displays an icon from an example server: - /// - /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) - /// - /// If you want to customize the placeholder or apply image-specific - /// modifiers --- like ``Image/resizable(capInsets:resizingMode:)`` --- - /// to the loaded image, use the ``init(url:scale:content:placeholder:)`` - /// initializer instead. - /// - /// - Parameters: - /// - urlRequest: The URL request of the image to display. - /// - urlCache: The URL cache for providing cached responses to requests within the session. - /// - scale: The scale to use for the image. The default is `1`. Set a - /// different value when loading images designed for higher resolution - /// displays. For example, set a value of `2` for an image that you - /// would name with the `@2x` suffix if stored in a file on disk. - public init(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1) where Content == Giffy { - self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale) { phase in -#if os(macOS) - phase.image ?? Image(nsImage: .init()) -#else - switch phase { - case .success(let giffy): - return giffy - default: - return Giffy("") - } -#endif - } - } - - /// Loads and displays a modifiable image from the specified URL using - /// a custom placeholder until the image loads. - /// - /// Until the image loads, SwiftUI displays the placeholder view that - /// you specify. When the load operation completes successfully, SwiftUI - /// updates the view to show content that you specify, which you - /// create using the loaded image. For example, you can show a green - /// placeholder, followed by a tiled version of the loaded image: - /// - /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { image in - /// image.resizable(resizingMode: .tile) - /// } placeholder: { - /// Color.green - /// } - /// - /// If the load operation fails, SwiftUI continues to display the - /// placeholder. To be able to display a different view on a load error, - /// use the ``init(url:scale:transaction:content:)`` initializer instead. - /// - /// - Parameters: - /// - url: The URL of the image to display. - /// - urlCache: The URL cache for providing cached responses to requests within the session. - /// - scale: The scale to use for the image. The default is `1`. Set a - /// different value when loading images designed for higher resolution - /// displays. For example, set a value of `2` for an image that you - /// would name with the `@2x` suffix if stored in a file on disk. - /// - content: A closure that takes the loaded image as an input, and - /// returns the view to show. You can return the image directly, or - /// modify it as needed before returning it. - /// - placeholder: A closure that returns the view to show until the - /// load operation completes successfully. - public init(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1, @ViewBuilder content: @escaping (Giffy) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent, I : View, P : View { - let urlRequest = url == nil ? nil : URLRequest(url: url!) - self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale, content: content, placeholder: placeholder) - } - - /// Loads and displays a modifiable image from the specified URL using - /// a custom placeholder until the image loads. - /// - /// Until the image loads, SwiftUI displays the placeholder view that - /// you specify. When the load operation completes successfully, SwiftUI - /// updates the view to show content that you specify, which you - /// create using the loaded image. For example, you can show a green - /// placeholder, followed by a tiled version of the loaded image: - /// - /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { image in - /// image.resizable(resizingMode: .tile) - /// } placeholder: { - /// Color.green - /// } - /// - /// If the load operation fails, SwiftUI continues to display the - /// placeholder. To be able to display a different view on a load error, - /// use the ``init(url:scale:transaction:content:)`` initializer instead. - /// - /// - Parameters: - /// - urlRequest: The URL request of the image to display. - /// - urlCache: The URL cache for providing cached responses to requests within the session. - /// - scale: The scale to use for the image. The default is `1`. Set a - /// different value when loading images designed for higher resolution - /// displays. For example, set a value of `2` for an image that you - /// would name with the `@2x` suffix if stored in a file on disk. - /// - content: A closure that takes the loaded image as an input, and - /// returns the view to show. You can return the image directly, or - /// modify it as needed before returning it. - /// - placeholder: A closure that returns the view to show until the - /// load operation completes successfully. - public init(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1, @ViewBuilder content: @escaping (Giffy) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent, I : View, P : View { - self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale) { phase in - switch phase { - case .success(let image): - content(image) - default: - placeholder() - } - } - } - - /// Loads and displays a modifiable image from the specified URL in phases. - /// - /// If you set the asynchronous image's URL to `nil`, or after you set the - /// URL to a value but before the load operation completes, the phase is - /// ``AsyncImagePhase/empty``. After the operation completes, the phase - /// becomes either ``AsyncImagePhase/failure(_:)`` or - /// ``AsyncImagePhase/success(_:)``. In the first case, the phase's - /// ``AsyncImagePhase/error`` value indicates the reason for failure. - /// In the second case, the phase's ``AsyncImagePhase/image`` property - /// contains the loaded image. Use the phase to drive the output of the - /// `content` closure, which defines the view's appearance: - /// - /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { phase in - /// if let image = phase.image { - /// image // Displays the loaded image. - /// } else if phase.error != nil { - /// Color.red // Indicates an error. - /// } else { - /// Color.blue // Acts as a placeholder. - /// } - /// } - /// - /// To add transitions when you change the URL, apply an identifier to the - /// ``CachedAsyncImage``. - /// - /// - Parameters: - /// - url: The URL of the image to display. - /// - urlCache: The URL cache for providing cached responses to requests within the session. - /// - scale: The scale to use for the image. The default is `1`. Set a - /// different value when loading images designed for higher resolution - /// displays. For example, set a value of `2` for an image that you - /// would name with the `@2x` suffix if stored in a file on disk. - /// - transaction: The transaction to use when the phase changes. - /// - content: A closure that takes the load phase as an input, and - /// returns the view to display for the specified phase. - public init(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (AsyncGiffyPhase) -> Content) { - let urlRequest = url == nil ? nil : URLRequest(url: url!) - self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale, transaction: transaction, content: content) - } - - /// Loads and displays a modifiable image from the specified URL in phases. - /// - /// If you set the asynchronous image's URL to `nil`, or after you set the - /// URL to a value but before the load operation completes, the phase is - /// ``AsyncImagePhase/empty``. After the operation completes, the phase - /// becomes either ``AsyncImagePhase/failure(_:)`` or - /// ``AsyncImagePhase/success(_:)``. In the first case, the phase's - /// ``AsyncImagePhase/error`` value indicates the reason for failure. - /// In the second case, the phase's ``AsyncImagePhase/image`` property - /// contains the loaded image. Use the phase to drive the output of the - /// `content` closure, which defines the view's appearance: - /// - /// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { phase in - /// if let image = phase.image { - /// image // Displays the loaded image. - /// } else if phase.error != nil { - /// Color.red // Indicates an error. - /// } else { - /// Color.blue // Acts as a placeholder. - /// } - /// } - /// - /// To add transitions when you change the URL, apply an identifier to the - /// ``CachedAsyncImage``. - /// - /// - Parameters: - /// - urlRequest: The URL request of the image to display. - /// - urlCache: The URL cache for providing cached responses to requests within the session. - /// - scale: The scale to use for the image. The default is `1`. Set a - /// different value when loading images designed for higher resolution - /// displays. For example, set a value of `2` for an image that you - /// would name with the `@2x` suffix if stored in a file on disk. - /// - transaction: The transaction to use when the phase changes. - /// - content: A closure that takes the load phase as an input, and - /// returns the view to display for the specified phase. - public init(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (AsyncGiffyPhase) -> Content) { - let configuration = URLSessionConfiguration.default - configuration.urlCache = urlCache - self.urlRequest = urlRequest - self.urlSession = URLSession(configuration: configuration) - self.scale = scale - self.transaction = transaction - self.content = content - - self._phase = State(wrappedValue: .loading) - do { - if let urlRequest = urlRequest, let image = try cachedGif(from: urlRequest, cache: urlCache) { - self._phase = State(wrappedValue: .success(image)) - } - } catch { - self._phase = State(wrappedValue: .error) - } - } - - @Sendable - private func load() async { - do { - if let urlRequest = urlRequest { - let (image, metrics) = try await remoteGif(from: urlRequest, session: urlSession) - if metrics.transactionMetrics.last?.resourceFetchType == .localCache { - // WARNING: This does not behave well when the url is changed with another - phase = .success(image) - } else { - withAnimation(transaction.animation) { - phase = .success(image) - } - } - } else { - withAnimation(transaction.animation) { - phase = .loading - } - } - } catch { - withAnimation(transaction.animation) { - phase = .error - } - } - } -} - -// MARK: - Helpers - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -private extension CachedAsyncGiffy { - private func remoteGif(from request: URLRequest, session: URLSession) async throws -> (Giffy, URLSessionTaskMetrics) { - let (data, _, metrics) = try await session.data(for: request) - if metrics.redirectCount > 0, let lastResponse = metrics.transactionMetrics.last?.response { - let requests = metrics.transactionMetrics.map { $0.request } - requests.forEach(session.configuration.urlCache!.removeCachedResponse) - let lastCachedResponse = CachedURLResponse(response: lastResponse, data: data) - session.configuration.urlCache!.storeCachedResponse(lastCachedResponse, for: request) - } - return (try gif(from: data), metrics) - } - - private func cachedGif(from request: URLRequest, cache: URLCache) throws -> Giffy? { - guard let cachedResponse = cache.cachedResponse(for: request) else { return nil } - return try gif(from: cachedResponse.data) - } - - private func gif(from data: Data) throws -> Giffy { -#if os(macOS) - if let nsImage = NSImage(data: data) { - return Giffy(nsImage: nsImage) - } else { - throw AsyncGiffy.LoadingError() - } -#else - return Giffy(imageData: data) -#endif - } -} - -// MARK: - AsyncImageURLSession - -private class URLSessionTaskController: NSObject, URLSessionTaskDelegate { - - var metrics: URLSessionTaskMetrics? - - func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { - self.metrics = metrics - } -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -private extension URLSession { - - func data(for request: URLRequest) async throws -> (Data, URLResponse, URLSessionTaskMetrics) { - let controller = URLSessionTaskController() - let (data, response) = try await data(for: request, delegate: controller) - return (data, response, controller.metrics!) - } -} 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/Views/NavigatorViews/ExploreView.swift b/Pocket Poster/Views/NavigatorViews/ExploreView.swift index 10ddd3d..9603dcf 100644 --- a/Pocket Poster/Views/NavigatorViews/ExploreView.swift +++ b/Pocket Poster/Views/NavigatorViews/ExploreView.swift @@ -7,7 +7,6 @@ import SwiftUI import CachedAsyncImage -import Giffy let MIN_SIZE: CGFloat = 165 @@ -57,33 +56,11 @@ struct ExploreView: View { }) { VStack(spacing: 0) { if wallpaper.previewIsGif() { - CachedAsyncGiffy(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { giffy in - giffy - .aspectRatio(contentMode: .fill) - .frame(maxWidth: .infinity) - .cornerRadius(10, corners: .topLeft) - .cornerRadius(10, corners: .topRight) - } placeholder: { - Color.gray - .frame(height: MIN_SIZE) - } - // Non-cached code: -// AsyncGiffy(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper)) { phase in -// switch phase { -// case .loading: -// Color.gray -// .frame(height: MIN_SIZE) -// case .error: -// Color.red -// .frame(height: MIN_SIZE) -// case .success(let giffy): -// giffy -// .aspectRatio(contentMode: .fill) -// .frame(maxWidth: .infinity) -// .cornerRadius(10, corners: .topLeft) -// .cornerRadius(10, corners: .topRight) -// } -// } + GIFImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), animate: true, loop: true) + .aspectRatio(contentMode: .fill) + .frame(maxWidth: .infinity) + .cornerRadius(10, corners: .topLeft) + .cornerRadius(10, corners: .topRight) } else { CachedAsyncImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { image in image From 7434897eace9c927e6b558634be491a721a1fa0d Mon Sep 17 00:00:00 2001 From: leminlimez Date: Sat, 19 Jul 2025 09:02:08 -0400 Subject: [PATCH 17/22] dont show carplay menu on ipads --- Pocket Poster/Views/RootView.swift | 10 ++++++---- Pocket Poster/Views/SettingsView.swift | 22 +++++++++++++--------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/Pocket Poster/Views/RootView.swift b/Pocket Poster/Views/RootView.swift index c0af04c..ff3a114 100644 --- a/Pocket Poster/Views/RootView.swift +++ b/Pocket Poster/Views/RootView.swift @@ -18,10 +18,12 @@ struct RootView: View { .tabItem { Label("Videos", systemImage: "camera") } - CarPlayView() - .tabItem { - Label("CarPlay", systemImage: "car") - } + if UIDevice.current.userInterfaceIdiom == .phone { + 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 d87ee9d..a7e39e8 100644 --- a/Pocket Poster/Views/SettingsView.swift +++ b/Pocket Poster/Views/SettingsView.swift @@ -23,9 +23,11 @@ struct SettingsView: View { TextField("Enter PosterBoard App Hash", text: $pbHash) .textFieldStyle(RoundedBorderTextFieldStyle()) .font(.system(.body, design: .monospaced)) - TextField("Enter CarPlayWallpaper App Hash", text: $cpHash) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .font(.system(.body, design: .monospaced)) + if UIDevice.current.userInterfaceIdiom == .phone { + 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 @@ -164,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 { From d35636c0be44d0bc0404ceb01bb311ce7a616076 Mon Sep 17 00:00:00 2001 From: leminlimez Date: Sat, 19 Jul 2025 10:22:01 -0400 Subject: [PATCH 18/22] change explore page fit to fill --- Pocket Poster.xcodeproj/project.pbxproj | 4 +-- .../Views/NavigatorViews/ExploreView.swift | 36 ++++++++++--------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/Pocket Poster.xcodeproj/project.pbxproj b/Pocket Poster.xcodeproj/project.pbxproj index ac2668e..634d3bf 100644 --- a/Pocket Poster.xcodeproj/project.pbxproj +++ b/Pocket Poster.xcodeproj/project.pbxproj @@ -272,7 +272,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; @@ -304,7 +304,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; diff --git a/Pocket Poster/Views/NavigatorViews/ExploreView.swift b/Pocket Poster/Views/NavigatorViews/ExploreView.swift index 9603dcf..76f60d6 100644 --- a/Pocket Poster/Views/NavigatorViews/ExploreView.swift +++ b/Pocket Poster/Views/NavigatorViews/ExploreView.swift @@ -9,6 +9,7 @@ import SwiftUI import CachedAsyncImage let MIN_SIZE: CGFloat = 165 +let CORNER_RADIUS: CGFloat = 12 struct ExploreView: View { @ObservedObject var cowabungaAPI = CowabungaAPI.shared @@ -55,25 +56,26 @@ struct ExploreView: View { DownloadManager.shared.startTendiesDownload(for: cowabungaAPI.getDownloadURLForWallpaper(wallpaper: wallpaper)) }) { VStack(spacing: 0) { - if wallpaper.previewIsGif() { - GIFImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), animate: true, loop: true) - .aspectRatio(contentMode: .fill) - .frame(maxWidth: .infinity) - .cornerRadius(10, corners: .topLeft) - .cornerRadius(10, corners: .topRight) - } else { - CachedAsyncImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) + 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) - .cornerRadius(10, corners: .topLeft) - .cornerRadius(10, corners: .topRight) - } placeholder: { - Color.gray - .frame(height: MIN_SIZE) + } 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 { @@ -104,7 +106,7 @@ struct ExploreView: View { } .frame(minWidth: MIN_SIZE) .background(Color(uiColor: .secondarySystemBackground)) - .cornerRadius(10) + .cornerRadius(CORNER_RADIUS) .padding(4) } } From dd902d2c3008782acf3c337400026ed79b12966d Mon Sep 17 00:00:00 2001 From: leminlimez Date: Tue, 12 Aug 2025 14:45:49 -0400 Subject: [PATCH 19/22] disable carplay on ios 26db4+ and make CarPlayManager --- Pocket Poster.xcodeproj/project.pbxproj | 4 +- .../xcschemes/xcschememanagement.plist | 2 +- .../CarPlayWallpaper+CarPlayManager.swift | 149 ++++++++++++++++++ .../Controllers/CarPlayWallpaper.swift | 22 --- .../Controllers/PosterBoardManager.swift | 92 ----------- .../Views/NavigatorViews/CarPlayView.swift | 6 +- Pocket Poster/Views/RootView.swift | 2 +- Pocket Poster/Views/SettingsView.swift | 4 +- 8 files changed, 158 insertions(+), 123 deletions(-) create mode 100644 Pocket Poster/Controllers/CarPlayWallpaper+CarPlayManager.swift delete mode 100644 Pocket Poster/Controllers/CarPlayWallpaper.swift diff --git a/Pocket Poster.xcodeproj/project.pbxproj b/Pocket Poster.xcodeproj/project.pbxproj index 634d3bf..b189ef4 100644 --- a/Pocket Poster.xcodeproj/project.pbxproj +++ b/Pocket Poster.xcodeproj/project.pbxproj @@ -272,7 +272,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; @@ -304,7 +304,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; 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/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/CarPlayWallpaper.swift b/Pocket Poster/Controllers/CarPlayWallpaper.swift deleted file mode 100644 index c636249..0000000 --- a/Pocket Poster/Controllers/CarPlayWallpaper.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// 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 } -} diff --git a/Pocket Poster/Controllers/PosterBoardManager.swift b/Pocket Poster/Controllers/PosterBoardManager.swift index 7b925c1..5ee7025 100644 --- a/Pocket Poster/Controllers/PosterBoardManager.swift +++ b/Pocket Poster/Controllers/PosterBoardManager.swift @@ -235,98 +235,6 @@ class PosterBoardManager: ObservableObject { } } - 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 "" - } - - 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 - } - - 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 - } - - 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) - } - static func clearCache() throws { SymHandler.cleanup() let docDir = SymHandler.getDocumentsDirectory() diff --git a/Pocket Poster/Views/NavigatorViews/CarPlayView.swift b/Pocket Poster/Views/NavigatorViews/CarPlayView.swift index 952a97f..81e4a58 100644 --- a/Pocket Poster/Views/NavigatorViews/CarPlayView.swift +++ b/Pocket Poster/Views/NavigatorViews/CarPlayView.swift @@ -52,7 +52,7 @@ struct CarPlayView: View { DispatchQueue.global(qos: .userInitiated).async { do { - try pbManager.applyCarPlay(appHash: cpHash, wallpapers: wallpapers) + 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: { @@ -103,7 +103,7 @@ struct CarPlayView: View { // load active wallpapers from user defaults activeWallpapers = UserDefaults.standard.array(forKey: "ActiveCarPlayWallpapers") as? [String] ?? [] // load the wallpapers - let cppURL = PosterBoardManager.shared.getCarPlayPhotosURL() + 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) { @@ -127,7 +127,7 @@ struct CarPlayView: View { } } // sort them - if let sortedWallpapers = PosterBoardManager.shared.getCarPlayWallpaperNames() { + if let sortedWallpapers = CarPlayManager.getCarPlayWallpaperNames() { wallpapers = wallpapers.reorder(by: sortedWallpapers) } } catch { diff --git a/Pocket Poster/Views/RootView.swift b/Pocket Poster/Views/RootView.swift index ff3a114..7a85a46 100644 --- a/Pocket Poster/Views/RootView.swift +++ b/Pocket Poster/Views/RootView.swift @@ -18,7 +18,7 @@ struct RootView: View { .tabItem { Label("Videos", systemImage: "camera") } - if UIDevice.current.userInterfaceIdiom == .phone { + if CarPlayManager.supportsCarPlay() { CarPlayView() .tabItem { Label("CarPlay", systemImage: "car") diff --git a/Pocket Poster/Views/SettingsView.swift b/Pocket Poster/Views/SettingsView.swift index a7e39e8..f10275d 100644 --- a/Pocket Poster/Views/SettingsView.swift +++ b/Pocket Poster/Views/SettingsView.swift @@ -23,7 +23,7 @@ struct SettingsView: View { TextField("Enter PosterBoard App Hash", text: $pbHash) .textFieldStyle(RoundedBorderTextFieldStyle()) .font(.system(.body, design: .monospaced)) - if UIDevice.current.userInterfaceIdiom == .phone { + if CarPlayManager.supportsCarPlay() { TextField("Enter CarPlayWallpaper App Hash", text: $cpHash) .textFieldStyle(RoundedBorderTextFieldStyle()) .font(.system(.body, design: .monospaced)) @@ -89,7 +89,7 @@ struct SettingsView: View { Button(action: { UIImpactFeedbackGenerator(style: .soft).impactOccurred() UserDefaults.standard.set(nil, forKey: "ActiveCarPlayWallpapers") - try? FileManager.default.removeItem(at: PosterBoardManager.shared.getCarPlayPhotosURL()) + try? FileManager.default.removeItem(at: CarPlayManager.getCarPlayPhotosURL()) Haptic.shared.notify(.success) UIApplication.shared.alert(title: NSLocalizedString("CarPlay Applied Wallpapers Successfully Cleared!", comment: ""), body: "") }) { From fb8bd7f26ed54571b391a31f9004ef8cc58df332 Mon Sep 17 00:00:00 2001 From: leminlimez Date: Fri, 22 Aug 2025 16:36:44 -0400 Subject: [PATCH 20/22] fix rotation of generated video --- Pocket Poster/Controllers/VideoHandler.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 { From 454c63d2cac4dd1138292a3f186cebbd02f6b7e2 Mon Sep 17 00:00:00 2001 From: leminlimez Date: Fri, 29 Aug 2025 17:26:19 -0400 Subject: [PATCH 21/22] update translations --- Pocket Poster/Localizable.xcstrings | 674 +++++++++++++++++++++++++++- 1 file changed, 653 insertions(+), 21 deletions(-) diff --git a/Pocket Poster/Localizable.xcstrings b/Pocket Poster/Localizable.xcstrings index faafe0b..33bce34 100644 --- a/Pocket Poster/Localizable.xcstrings +++ b/Pocket Poster/Localizable.xcstrings @@ -467,7 +467,38 @@ } }, "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" : { @@ -624,7 +655,38 @@ } }, "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" : { @@ -935,13 +997,106 @@ } }, "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" : { @@ -1407,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", @@ -1419,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", @@ -1446,7 +1613,38 @@ } }, "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" : { @@ -2221,7 +2419,32 @@ } }, "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" : { @@ -2958,7 +3181,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Скачать Tendies файл" + "value" : "Скачать файл Tendies" } }, "th" : { @@ -3457,13 +3680,106 @@ } }, "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" : { @@ -3928,7 +4244,38 @@ } }, "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", @@ -4240,10 +4587,72 @@ } }, "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" : { @@ -4862,7 +5271,38 @@ } }, "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" : { @@ -7448,7 +7888,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Только .tendies файлы могут быть загружены!" + "value" : "Только файлы .tendies могут быть загружены!" } }, "th" : { @@ -7954,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", @@ -7966,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", @@ -8609,7 +9061,38 @@ } }, "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" : { @@ -9536,7 +10019,38 @@ } }, "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" : { @@ -10617,7 +11131,38 @@ } }, "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", @@ -10628,6 +11173,12 @@ "value" : "Je K Dispozici Aktualizace" } }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update verfügbar" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -10640,6 +11191,12 @@ "value" : "Mise à jour disponible" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступно обновление" + } + }, "th" : { "stringUnit" : { "state" : "translated", @@ -11136,6 +11693,12 @@ }, "Waiting for app hash..." : { "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Warte auf App hash..." + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -11148,6 +11711,12 @@ "value" : "En attente de l’app hash..." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ожидание App Hash…" + } + }, "th" : { "stringUnit" : { "state" : "translated", @@ -11945,7 +12514,38 @@ } }, "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" : { @@ -12256,7 +12856,39 @@ } }, "Your downloaded .tendies will be on the Home page." : { - "comment" : "after successfully downloading tendies" + "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" : { From e288b1f2e71f46b752931b30dfcd789196345659 Mon Sep 17 00:00:00 2001 From: leminlimez Date: Fri, 29 Aug 2025 17:29:48 -0400 Subject: [PATCH 22/22] fix update api --- Pocket Poster.xcodeproj/project.pbxproj | 4 ++-- Pocket Poster/Pocket_PosterApp.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Pocket Poster.xcodeproj/project.pbxproj b/Pocket Poster.xcodeproj/project.pbxproj index b189ef4..60e4615 100644 --- a/Pocket Poster.xcodeproj/project.pbxproj +++ b/Pocket Poster.xcodeproj/project.pbxproj @@ -272,7 +272,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; @@ -304,7 +304,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 0; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; diff --git a/Pocket Poster/Pocket_PosterApp.swift b/Pocket Poster/Pocket_PosterApp.swift index 20f8077..be1b987 100644 --- a/Pocket Poster/Pocket_PosterApp.swift +++ b/Pocket Poster/Pocket_PosterApp.swift @@ -33,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 }