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)