diff --git a/ControlRoom.xcodeproj/project.pbxproj b/ControlRoom.xcodeproj/project.pbxproj index 16efe7c..41e7206 100644 --- a/ControlRoom.xcodeproj/project.pbxproj +++ b/ControlRoom.xcodeproj/project.pbxproj @@ -94,6 +94,10 @@ C9C203ED2B17FEA80081E1EF /* LocalSearchRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C203EC2B17FEA80081E1EF /* LocalSearchRowView.swift */; }; E04B47322B07B66F00DAE338 /* LocationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E04B47312B07B66F00DAE338 /* LocationsController.swift */; }; E04B47342B07B72C00DAE338 /* Location.swift in Sources */ = {isa = PBXBuildFile; fileRef = E04B47332B07B72C00DAE338 /* Location.swift */; }; + CE01920261019000000000010 /* PosterBoardController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE01920261019000000000000 /* PosterBoardController.swift */; }; + CE01920261019000000000011 /* PosterBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE01920261019000000000001 /* PosterBoardView.swift */; }; + 9AABE0F726EF43318916684D /* PosterBoardArtwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = E532BC1C9BDC44BD94D254DF /* PosterBoardArtwork.swift */; }; + 4B86C454B2DE496D8C080990 /* PosterBoardSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0F4016BC0FC499691C339A1 /* PosterBoardSelection.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -202,6 +206,10 @@ C9C203EC2B17FEA80081E1EF /* LocalSearchRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalSearchRowView.swift; sourceTree = ""; }; E04B47312B07B66F00DAE338 /* LocationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationsController.swift; sourceTree = ""; }; E04B47332B07B72C00DAE338 /* Location.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Location.swift; sourceTree = ""; }; + CE01920261019000000000000 /* PosterBoardController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterBoardController.swift; sourceTree = ""; }; + CE01920261019000000000001 /* PosterBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterBoardView.swift; sourceTree = ""; }; + E532BC1C9BDC44BD94D254DF /* PosterBoardArtwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterBoardArtwork.swift; sourceTree = ""; }; + A0F4016BC0FC499691C339A1 /* PosterBoardSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterBoardSelection.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -354,6 +362,7 @@ isa = PBXGroup; children = ( 511BA59B23F4172400E3E660 /* SystemView.swift */, + CE01920261019000000000001 /* PosterBoardView.swift */, 51AB56E42A13D3F6002B5A67 /* DeepLinkEditorView.swift */, ); path = SystemView; @@ -440,6 +449,9 @@ 5523A7E023F99D7200F25EEC /* Preferences.swift */, ACCD798D240AE82C0004ECE5 /* PushNotification.swift */, 551F8CFD23F5C9EF0006D1BD /* SimCtl.swift */, + A0F4016BC0FC499691C339A1 /* PosterBoardSelection.swift */, + E532BC1C9BDC44BD94D254DF /* PosterBoardArtwork.swift */, + CE01920261019000000000000 /* PosterBoardController.swift */, B07F584823F99A1700256D5D /* SimCtl+SubCommands.swift */, 551F8CFF23F5CB3A0006D1BD /* SimCtl+Types.swift */, 511BA59723F4096800E3E660 /* Simulator.swift */, @@ -664,6 +676,10 @@ 551F8CED23F489A50006D1BD /* SidebarView.swift in Sources */, 551F8CEF23F48B030006D1BD /* SplitLayoutView.swift in Sources */, 551F8CFE23F5C9EF0006D1BD /* SimCtl.swift in Sources */, + 4B86C454B2DE496D8C080990 /* PosterBoardSelection.swift in Sources */, + 9AABE0F726EF43318916684D /* PosterBoardArtwork.swift in Sources */, + CE01920261019000000000011 /* PosterBoardView.swift in Sources */, + CE01920261019000000000010 /* PosterBoardController.swift in Sources */, E04B47322B07B66F00DAE338 /* LocationsController.swift in Sources */, 54C2092B27DBDE2200E47016 /* DocumentPickerConfig.swift in Sources */, ACCD798E240AE82C0004ECE5 /* PushNotification.swift in Sources */, diff --git a/ControlRoom/Controllers/PosterBoardArtwork.swift b/ControlRoom/Controllers/PosterBoardArtwork.swift new file mode 100644 index 0000000..175a0d6 --- /dev/null +++ b/ControlRoom/Controllers/PosterBoardArtwork.swift @@ -0,0 +1,85 @@ +import Foundation +import ImageIO + +extension PosterBoardController { + // Use Collections' Still renderer. A higher contentVersion prevents WallpaperKit + // from silently substituting the built-in preset with the same identifier. + // Commit the manifest only after its referenced image has been encoded. + static func replaceArtwork(in bundleURL: URL, imageURL: URL) throws { + let manager = FileManager.default + let manifestURL = bundleURL.appendingPathComponent("Wallpaper.plist") + let originalURL = bundleURL.appendingPathComponent(".ControlRoomOriginalWallpaper.plist") + let manifest = try readManifest(manifestURL) + guard bundleURL.pathExtension == "wallpaper", manifest["assets"] is [String: Any] else { + throw OperationError(message: "This provider does not contain replaceable wallpaper artwork.") + } + guard let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil), + let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 4096 + ] as CFDictionary) else { + throw OperationError(message: "The selected file could not be read as an image.") + } + let filename = "ControlRoomImage-\(UUID().uuidString).jpg" + let output = bundleURL.appendingPathComponent(filename) + do { + let encoded = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(encoded, "public.jpeg" as CFString, 1, nil) else { + throw OperationError(message: "The wallpaper image could not be prepared.") + } + CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: 0.95] as CFDictionary) + guard CGImageDestinationFinalize(destination) else { throw OperationError(message: "The wallpaper image could not be encoded.") } + try (encoded as Data).write(to: output, options: .atomic) + var updated = manifest + let asset: [String: Any] = ["type": "Still", "fullSizeImageFileName": filename, "thumbnailImageFileName": filename, + "identifier": manifest["identifier"] ?? 0, "name": "Image"] + updated["assets"] = ["lockAndHome": ["default": asset, "dark": asset, "inactive": asset]] + updated["appearanceAware"] = false + updated["logicalScreenClass"] = "any" + updated["disableAdaptiveTime"] = false + updated["preferredProminentColor"] = ["default": "#FFFFFF", "dark": "#FFFFFF"] + updated["contentVersion"] = max((manifest["contentVersion"] as? NSNumber)?.doubleValue ?? 1, 1) + 1 + if !manager.fileExists(atPath: originalURL.path) { + try Data(contentsOf: manifestURL).write(to: originalURL, options: .atomic) + } + try PropertyListSerialization.data(fromPropertyList: updated, format: .xml, options: 0).write(to: manifestURL, options: .atomic) + } catch { + try? manager.removeItem(at: output) + throw error + } + try removeGeneratedArtwork(in: bundleURL, except: filename) + } + + static func restoreArtwork(in bundleURL: URL) throws { + let manager = FileManager.default + let original = bundleURL.appendingPathComponent(".ControlRoomOriginalWallpaper.plist") + let data = try Data(contentsOf: original) + _ = try readManifest(original) + try data.write(to: bundleURL.appendingPathComponent("Wallpaper.plist"), options: .atomic) + try removeGeneratedArtwork(in: bundleURL, except: nil) + try manager.removeItem(at: original) + } + + private static func removeGeneratedArtwork(in bundleURL: URL, except retained: String?) throws { + let manager = FileManager.default + for url in try manager.contentsOfDirectory(at: bundleURL, includingPropertiesForKeys: nil) { + let name = url.deletingPathExtension().lastPathComponent + guard url.pathExtension == "jpg", name.hasPrefix("ControlRoomImage-"), + UUID(uuidString: String(name.dropFirst("ControlRoomImage-".count))) != nil, + url.lastPathComponent != retained else { continue } + try requireContained(url, in: bundleURL) + try manager.removeItem(at: url) + } + } + + static func clearSnapshots(in versionURL: URL) throws { + let manager = FileManager.default + for url in try manager.contentsOfDirectory(at: versionURL, includingPropertiesForKeys: nil) { + guard url.lastPathComponent.hasPrefix("RuntimeSnapshot") || ["scratch", "FallbackSnapshots"].contains(url.lastPathComponent) else { continue } + try requireContained(url, in: versionURL) + try manager.removeItem(at: url) + } + } + +} diff --git a/ControlRoom/Controllers/PosterBoardController.swift b/ControlRoom/Controllers/PosterBoardController.swift new file mode 100644 index 0000000..c921d64 --- /dev/null +++ b/ControlRoom/Controllers/PosterBoardController.swift @@ -0,0 +1,216 @@ +import Foundation + +/// Offline operations on one simulator's PosterBoard store. No private frameworks are linked. +enum PosterBoardController { + struct OperationError: LocalizedError { + let message: String + var errorDescription: String? { message } + } + + private static let queue = DispatchQueue(label: "ControlRoom.PosterBoard", qos: .userInitiated) + private static let storePath = "Library/Application Support/PRBPosterExtensionDataStore" + + struct WallpaperTemplate: Identifiable, Hashable { + let bundleURL: URL + let versionURL: URL + let name: String + let isGallery: Bool + var id: String { bundleURL.path } + var displayName: String { name } + var canRestore: Bool { + FileManager.default.fileExists(atPath: bundleURL.appendingPathComponent(".ControlRoomOriginalWallpaper.plist").path) + } + } + + static func wallpaperTemplates(in dataURL: URL) throws -> [WallpaperTemplate] { + let manager = FileManager.default + let store = dataURL.appendingPathComponent(storePath) + guard manager.fileExists(atPath: store.path) else { return [] } + let structures = try manager.contentsOfDirectory(at: store, includingPropertiesForKeys: nil) + .filter { Int($0.lastPathComponent) != nil } + .sorted { (Int($0.lastPathComponent) ?? 0) > (Int($1.lastPathComponent) ?? 0) } + guard let structure = structures.first else { return [] } + var templates: [WallpaperTemplate] = [] + for category in ["configurations", "descriptors"] { + let directory = structure.appendingPathComponent("Extensions/com.apple.WallpaperKit.CollectionsPoster/\(category)") + guard manager.fileExists(atPath: directory.path) else { continue } + for poster in try manager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) { + guard UUID(uuidString: poster.lastPathComponent) != nil else { continue } + let versions = try manager.contentsOfDirectory(at: poster.appendingPathComponent("versions"), includingPropertiesForKeys: nil) + .filter { Int($0.lastPathComponent) != nil } + .sorted { (Int($0.lastPathComponent) ?? 0) > (Int($1.lastPathComponent) ?? 0) } + guard let version = versions.first else { continue } + let contents = version.appendingPathComponent("contents") + guard manager.fileExists(atPath: contents.path) else { continue } + for bundle in try manager.contentsOfDirectory(at: contents, includingPropertiesForKeys: nil) where bundle.pathExtension == "wallpaper" { + try requireContained(bundle, in: dataURL) + let manifest = try readManifest(bundle.appendingPathComponent("Wallpaper.plist")) + let family = manifest["family"] as? String ?? "Collections" + let name = manifest["name"] as? String ?? "Wallpaper" + templates.append(WallpaperTemplate(bundleURL: bundle, versionURL: version, name: "\(family) · \(name)", + isGallery: category == "descriptors")) + } + } + } + return templates.sorted { $0.displayName < $1.displayName } + } + + static func readManifest(_ url: URL) throws -> [String: Any] { + guard let value = try PropertyListSerialization.propertyList(from: Data(contentsOf: url), format: nil) as? [String: Any] else { + throw OperationError(message: "This wallpaper has an unsupported manifest.") + } + return value + } + + static func loadWallpapers(_ simulator: Simulator, completion: @escaping (Result<[WallpaperTemplate], Error>) -> Void) { + queue.async { + let result = Result { try wallpaperTemplates(in: validatedDataURL(simulator)) } + DispatchQueue.main.async { completion(result) } + } + } + + static func replaceImages(_ simulator: Simulator, wallpaperID: String, imageURL: URL, completion: @escaping (Result) -> Void) { + performOffline(simulator, completion: completion) { dataURL in + guard let template = try wallpaperTemplates(in: dataURL).first(where: { $0.id == wallpaperID }) else { + throw OperationError(message: "This wallpaper has changed. Refresh the list and choose it again.") + } + if template.isGallery { + _ = try createWallpaper(from: template, imageURL: imageURL) + return "A separate image wallpaper was created and selected. The original template and your other wallpapers are unchanged." + } + try replaceArtwork(in: template.bundleURL, imageURL: imageURL) + try clearSnapshots(in: template.versionURL) + return "The saved wallpaper now uses your image. Select it in the wallpaper switcher if it is not already active." + } + } + + static func restoreImages(_ simulator: Simulator, wallpaperID: String, completion: @escaping (Result) -> Void) { + performOffline(simulator, completion: completion) { dataURL in + guard let template = try wallpaperTemplates(in: dataURL).first(where: { $0.id == wallpaperID }) else { + throw OperationError(message: "This wallpaper is no longer available. Refresh and try again.") + } + try restoreArtwork(in: template.bundleURL) + try clearSnapshots(in: template.versionURL) + return "The original wallpaper artwork was restored." + } + } + + static func reset(_ simulator: Simulator, completion: @escaping (Result) -> Void) { + performOffline(simulator, completion: completion) { dataURL in + try removeStore(in: dataURL) + return "PosterBoard was reset. Lock Screen and StandBy layouts will be recreated." + } + } + + /// Refuse missing, default, or mismatched devices before any shutdown or file mutation. + private static func validatedDataURL(_ simulator: Simulator) throws -> URL { + guard UUID(uuidString: simulator.udid) != nil, !simulator.dataPath.isEmpty else { + throw OperationError(message: "Select a specific simulator with a valid data folder.") + } + let dataURL = URL(fileURLWithPath: simulator.dataPath).standardizedFileURL + guard dataURL.lastPathComponent == "data", + dataURL.deletingLastPathComponent().lastPathComponent == simulator.udid else { + throw OperationError(message: "The simulator data folder does not match its device ID.") + } + try checkDevice(dataURL, id: simulator.udid, requiresShutdown: false) + return dataURL + } + + static func checkDevice(_ dataURL: URL, id: String, requiresShutdown: Bool) throws { + let plistURL = dataURL.deletingLastPathComponent().appendingPathComponent("device.plist") + let value = try PropertyListSerialization.propertyList(from: Data(contentsOf: plistURL), format: nil) + guard let device = value as? [String: Any], device["UDID"] as? String == id, + let runtime = device["runtime"] as? String, runtime.contains(".iOS-") else { + throw OperationError(message: "PosterBoard controls require a matching iOS simulator.") + } + if requiresShutdown && device["state"] as? Int != 1 { + throw OperationError(message: "The simulator must finish shutting down before changing PosterBoard.") + } + } + + private static func performOffline(_ simulator: Simulator, completion: @escaping (Result) -> Void, operation: @escaping (URL) throws -> String) { + queue.async { + let result = Result { + let dataURL = try validatedDataURL(simulator) + // Read the actual on-disk state; the sidebar refreshes only periodically. + let plistURL = dataURL.deletingLastPathComponent().appendingPathComponent("device.plist") + let device = try PropertyListSerialization.propertyList(from: Data(contentsOf: plistURL), format: nil) as? [String: Any] + let state = device?["state"] as? Int + guard state == 1 || state == 3 else { + throw OperationError(message: "Wait for the simulator to finish starting or stopping, then try again.") + } + let wasBooted = state == 3 + if wasBooted { try run(["shutdown", simulator.udid]) } + let message: String + do { + try checkDevice(dataURL, id: simulator.udid, requiresShutdown: true) + message = try operation(dataURL) + } catch { + // Restore the device's running state even if image conversion or deletion fails. + if wasBooted { + do { try run(["boot", simulator.udid]) } catch let restartError { + throw OperationError(message: "\(error.localizedDescription)\nThe simulator could not restart: \(restartError.localizedDescription)") + } + } + throw error + } + if wasBooted { + do { try run(["boot", simulator.udid]) } catch { + throw OperationError(message: "\(message)\nThe change succeeded, but the simulator could not restart: \(error.localizedDescription)\nStart it manually; do not repeat the change.") + } + } + return message + } + DispatchQueue.main.async { completion(result) } + } + } + + /// Check exit status and drain both streams; the app's older executor ignores nonzero exits. + private static func run(_ arguments: [String]) throws { + let process = Process() + let output = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["simctl"] + arguments + process.standardOutput = output + process.standardError = output + try process.run() + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let detail = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + throw OperationError(message: detail.isEmpty ? "simctl failed (\(process.terminationStatus))." : detail) + } + } + + /// Only known PosterBoard paths are removed. App data, Photos, and other preferences are preserved. + static func removeStore(in dataURL: URL) throws { + let manager = FileManager.default + let springBoardURL = dataURL.appendingPathComponent("Library/Preferences/com.apple.springboard.plist") + var resetPreferences: Data? + if manager.fileExists(atPath: springBoardURL.path) { + try requireContained(springBoardURL, in: dataURL) + var preferences = try readManifest(springBoardURL) + preferences.removeValue(forKey: "SBLockPoster") + preferences.removeValue(forKey: "SBHomePoster") + resetPreferences = try PropertyListSerialization.data(fromPropertyList: preferences, format: .binary, options: 0) + } + let paths = [storePath, "Library/Preferences/com.apple.PosterBoard.plist", "Library/Preferences/com.apple.PosterBoard.unprotectedUserDefaults.plist"] + for path in paths { + let url = dataURL.appendingPathComponent(path) + if manager.fileExists(atPath: url.path) { + try requireContained(url, in: dataURL) + try manager.removeItem(at: url) + } + } + if let resetPreferences { try resetPreferences.write(to: springBoardURL, options: .atomic) } + } + + static func requireContained(_ url: URL, in root: URL) throws { + let canonical = url.resolvingSymlinksInPath().standardizedFileURL.path + let canonicalRoot = root.resolvingSymlinksInPath().standardizedFileURL.path + "/" + guard canonical.hasPrefix(canonicalRoot), canonical != String(canonicalRoot.dropLast()) else { + throw OperationError(message: "Refusing a PosterBoard path outside the simulator data folder.") + } + } + +} diff --git a/ControlRoom/Controllers/PosterBoardSelection.swift b/ControlRoom/Controllers/PosterBoardSelection.swift new file mode 100644 index 0000000..efec8f3 --- /dev/null +++ b/ControlRoom/Controllers/PosterBoardSelection.swift @@ -0,0 +1,258 @@ +import Foundation +import SQLite3 + +extension PosterBoardController { + private static func useLockWallpaperForHome(at url: URL) throws { + guard var archive = try PropertyListSerialization.propertyList(from: Data(contentsOf: url), format: nil) as? [String: Any], + var objects = archive["$objects"] as? [Any], let top = archive["$top"] as? [String: Any] else { + throw OperationError(message: "Unsupported Home Screen presentation settings.") + } + let index = try archiveIndex(top["root"]) + guard objects.indices.contains(index), var configuration = objects[index] as? [String: Any], + try selfObjectClass(configuration, objects: objects) == "PRPosterHomeScreenConfiguration" else { + throw OperationError(message: "Unsupported Home Screen configuration archive.") + } + configuration["selectedAppearanceType"] = 0 + objects[index] = configuration + archive["$objects"] = objects + try PropertyListSerialization.data(fromPropertyList: archive, format: .binary, options: 0).write(to: url, options: .atomic) + } + + /// Resolve keyed-archive references by their public XML plist representation; + /// never assume object-array indices from one user's SpringBoard preferences. + private static func archiveIndex(_ reference: Any?) throws -> Int { + guard let reference else { throw OperationError(message: "Missing poster archive reference.") } + let data = try PropertyListSerialization.data(fromPropertyList: ["reference": reference], format: .xml, options: 0) + let xml = String(decoding: data, as: UTF8.self) + guard xml.contains("CF$UID"), let start = xml.range(of: ""), + let end = xml.range(of: "", range: start.upperBound.. Data { + guard var preferences = try PropertyListSerialization.propertyList(from: preferencesData, format: nil) as? [String: Any], + let archiveData = preferences["SBLockPoster"] as? Data, + var archive = try PropertyListSerialization.propertyList(from: archiveData, format: nil) as? [String: Any], + var objects = archive["$objects"] as? [Any], let top = archive["$top"] as? [String: Any] else { + throw OperationError(message: "Set up a Lock Screen wallpaper first; its saved poster identity is unavailable.") + } + func object(_ reference: Any?) throws -> [String: Any] { + let index = try archiveIndex(reference) + guard objects.indices.contains(index), let object = objects[index] as? [String: Any] else { + throw OperationError(message: "Unsupported poster archive object.") + } + return object + } + func className(_ object: [String: Any]) throws -> String? { try selfObjectClass(object, objects: objects) } + let root = try object(top["root"]) + guard try className(root) == "PRSPosterConfiguration" else { + throw OperationError(message: "This runtime uses an unsupported poster selection archive.") + } + let pathIndex = try archiveIndex(root["p"]) + let path = try object(root["p"]) + let identityIndex = try archiveIndex(path["i"]) + var identity = try object(path["i"]) + guard try className(path) == "PFServerPosterPath", try className(identity) == "PFServerPosterIdentity", + identity["t"] as? Int == 3 else { + throw OperationError(message: "The saved wallpaper identity is not a supported configuration path.") + } + let roleIndex = try archiveIndex(identity["r"]) + guard objects.indices.contains(roleIndex), objects[roleIndex] as? String == "PRPosterRoleLockScreen" else { + throw OperationError(message: "The saved poster does not identify a Lock Screen wallpaper.") + } + func replaceReferencedString(_ reference: Any?, with value: String) throws { + let index = try archiveIndex(reference) + guard objects.indices.contains(index), objects[index] is String else { throw OperationError(message: "Unsupported poster string reference.") } + objects[index] = value + } + try replaceReferencedString(identity["p"], with: "com.apple.WallpaperKit.CollectionsPoster") + try replaceReferencedString(identity["d"], with: descriptor) + let uuidIndex = try archiveIndex(identity["u"]) + var uuidObject = try object(identity["u"]) + guard try className(uuidObject) == "NSUUID" else { throw OperationError(message: "Unsupported poster UUID encoding.") } + var bytes = identifier.uuid + uuidObject["NS.uuidbytes"] = withUnsafeBytes(of: &bytes) { Data($0) } + objects[uuidIndex] = uuidObject + identity["v"] = version + identity["s"] = 0 + objects[identityIndex] = identity + let urlIndex = try archiveIndex(path["c"]) + let url = try object(path["c"]) + guard try className(url) == "NSURL" else { throw OperationError(message: "Unsupported poster contents URL.") } + try replaceReferencedString(url["NS.relative"], with: contentsURL.absoluteString) + // Preserve URL/base, display pin, role, class hierarchy and unknown archive fields. + objects[urlIndex] = url + objects[pathIndex] = path + archive["$objects"] = objects + preferences["SBLockPoster"] = try PropertyListSerialization.data(fromPropertyList: archive, format: .binary, options: 0) + return try PropertyListSerialization.data(fromPropertyList: preferences, format: .binary, options: 0) + } + + private static func selfObjectClass(_ object: [String: Any], objects: [Any]) throws -> String? { + let index = try archiveIndex(object["$class"]) + guard objects.indices.contains(index) else { throw OperationError(message: "Invalid poster archive class reference.") } + return (objects[index] as? [String: Any])?["$classname"] as? String + } + + // A gallery preset can be hidden by a runtime's gallery policy. Materialize it as a + // separate saved wallpaper instead of modifying the preset or relying on that policy. + // Keep file staging, selection and transaction rollback in one scope. + // swiftlint:disable:next function_body_length cyclomatic_complexity + static func createWallpaper(from template: WallpaperTemplate, imageURL: URL) throws -> String { + let manager = FileManager.default + let posterRoot = template.versionURL.deletingLastPathComponent().deletingLastPathComponent() + let providerRoot = posterRoot.deletingLastPathComponent().deletingLastPathComponent() + let structure = providerRoot.deletingLastPathComponent().deletingLastPathComponent() + guard template.isGallery, posterRoot.deletingLastPathComponent().lastPathComponent == "descriptors", + providerRoot.lastPathComponent == "com.apple.WallpaperKit.CollectionsPoster" else { + throw OperationError(message: "Select a Collections gallery template to create an image wallpaper.") + } + let identifier = UUID().uuidString + let newRoot = providerRoot.appendingPathComponent("configurations/\(identifier)") + try requireContained(providerRoot, in: structure) + try manager.createDirectory(at: newRoot.appendingPathComponent("versions"), withIntermediateDirectories: true) + do { + for name in ["providerInfo.plist", "com.apple.posterkit.role.identifier", "com.apple.posterkit.provider.descriptor.identifier"] { + let source = posterRoot.appendingPathComponent(name) + try requireContained(source, in: structure) + try manager.copyItem(at: source, to: newRoot.appendingPathComponent(name)) + } + let role = try String(contentsOf: newRoot.appendingPathComponent("com.apple.posterkit.role.identifier"), encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard role == "PRPosterRoleLockScreen" else { throw OperationError(message: "This template is not a Lock Screen wallpaper.") } + let version = newRoot.appendingPathComponent("versions/0") + try manager.createDirectory(at: version, withIntermediateDirectories: true) + // Copy authored provider content, never scratch or rendered snapshot caches. + for item in try manager.contentsOfDirectory(at: template.versionURL, includingPropertiesForKeys: nil) { + guard !item.lastPathComponent.hasPrefix("RuntimeSnapshot"), !["scratch", "FallbackSnapshots"].contains(item.lastPathComponent) else { continue } + try requireContained(item, in: structure) + try manager.copyItem(at: item, to: version.appendingPathComponent(item.lastPathComponent)) + } + let database = try PosterDatabase(url: structure.appendingPathComponent("PBFPosterExtensionDataStoreSQLiteDatabase.sqlite3")) + let presentation = try database.presentationVersion(in: structure) + for item in try manager.contentsOfDirectory(at: presentation, includingPropertiesForKeys: nil) { + guard item.lastPathComponent.hasPrefix("com.apple.posterkit.provider.instance.") || item.lastPathComponent == "supplements" else { continue } + try requireContained(item, in: structure) + let destination = version.appendingPathComponent(item.lastPathComponent) + if manager.fileExists(atPath: destination.path) { try manager.removeItem(at: destination) } + try manager.copyItem(at: item, to: destination) + } + let homeURL = version.appendingPathComponent("supplements/0/com.apple.posterkit.provider.supplementURL.homescreenConfiguration.plist") + try useLockWallpaperForHome(at: homeURL) + let bundle = version.appendingPathComponent("contents/\(template.bundleURL.lastPathComponent)") + try replaceArtwork(in: bundle, imageURL: imageURL) + let dataURL = structure.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + try checkDevice(dataURL, id: dataURL.deletingLastPathComponent().lastPathComponent, requiresShutdown: true) + let preferencesURL = dataURL.appendingPathComponent("Library/Preferences/com.apple.springboard.plist") + let oldPreferences = try Data(contentsOf: preferencesURL) + let descriptor = try String(contentsOf: newRoot.appendingPathComponent("com.apple.posterkit.provider.descriptor.identifier"), encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let uuid = UUID(uuidString: identifier), !descriptor.isEmpty else { throw OperationError(message: "The template descriptor identity is missing.") } + let newPreferences = try selectingPoster(in: oldPreferences, identifier: uuid, descriptor: descriptor, version: 0, + contentsURL: version.appendingPathComponent("contents", isDirectory: true)) + try database.execute("BEGIN IMMEDIATE") + do { + try database.execute("INSERT INTO poster (UUID, providerId) VALUES (?, ?)", [identifier, providerRoot.lastPathComponent]) + try database.execute("INSERT INTO posterRoleMembership (posterUUID, roleId, roleSortKey) SELECT ?, ?, COALESCE(MAX(roleSortKey), 0)+1 FROM posterRoleMembership WHERE roleId = ?", [identifier, role, role]) + try database.execute("UPDATE posterAttributes SET attributePayload = '0' WHERE roleId = ? AND attributeIdentifier = 'SELECTED'", [role]) + try database.execute("INSERT INTO posterAttributes (posterUUID, roleId, attributeIdentifier, attributePayload) VALUES (?, ?, 'SELECTED', '1')", [identifier, role]) + let now = Date().timeIntervalSince1970 + let metadata: [String: Any] = ["attributeType": "PRPosterRoleAttributeTypeUsageMetadata", "creationDate": now, + "lastModifiedDate": now, "lastActivatedDate": now, "extensionAvailable": true] + let payload = String(decoding: try JSONSerialization.data(withJSONObject: metadata), as: UTF8.self) + try database.execute("INSERT INTO posterAttributes (posterUUID, roleId, attributeIdentifier, attributePayload) VALUES (?, ?, 'PRPosterRoleAttributeTypeUsageMetadata', ?)", [identifier, role, payload]) + try newPreferences.write(to: preferencesURL, options: .atomic) + try database.execute("COMMIT") + } catch { + try? database.execute("ROLLBACK") + do { try oldPreferences.write(to: preferencesURL, options: .atomic) } catch let restoreError { + throw OperationError(message: "Poster selection could not be restored: \(restoreError.localizedDescription)") + } + throw error + } + } catch { + try? manager.removeItem(at: newRoot) + throw error + } + return identifier + } + +} + +/// Unsupported schemas fail before commit; a missing database is never created. +private final class PosterDatabase { + private let handle: OpaquePointer + + init(url: URL) throws { + var database: OpaquePointer? + let result = sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) + guard result == SQLITE_OK, let database else { + let detail = database.map { String(cString: sqlite3_errmsg($0)) } ?? "Could not open PosterBoard database." + if let database { sqlite3_close(database) } + throw PosterBoardController.OperationError(message: detail) + } + handle = database + sqlite3_busy_timeout(handle, 3000) + do { + try execute("PRAGMA foreign_keys = ON") + } catch { + sqlite3_close(handle) + throw error + } + } + + deinit { sqlite3_close(handle) } + + func presentationVersion(in structure: URL) throws -> URL { + let sql = """ + SELECT p.UUID, p.providerId + FROM poster p + JOIN posterRoleMembership r ON p.UUID = r.posterUUID + LEFT JOIN posterAttributes a ON p.UUID = a.posterUUID AND a.roleId = r.roleId AND a.attributeIdentifier = 'SELECTED' + WHERE r.roleId = 'PRPosterRoleLockScreen' + ORDER BY (a.attributePayload = '1') DESC, p.posterId DESC + """ + var statement: OpaquePointer? + guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + throw PosterBoardController.OperationError(message: "Could not read the existing wallpaper presentation settings.") + } + defer { sqlite3_finalize(statement) } + while sqlite3_step(statement) == SQLITE_ROW { + guard let uuidText = sqlite3_column_text(statement, 0), let providerText = sqlite3_column_text(statement, 1) else { continue } + let identifier = String(cString: uuidText) + let provider = String(cString: providerText) + guard UUID(uuidString: identifier) != nil, provider.hasPrefix("com.apple."), !provider.contains("/") else { continue } + let versions = structure.appendingPathComponent("Extensions/\(provider)/configurations/\(identifier)/versions") + let candidates = (try? FileManager.default.contentsOfDirectory(at: versions, includingPropertiesForKeys: nil)) ?? [] + for version in candidates.filter({ Int($0.lastPathComponent) != nil }).sorted(by: { + (Int($0.lastPathComponent) ?? 0) > (Int($1.lastPathComponent) ?? 0) + }) { + let home = version.appendingPathComponent("supplements/0/com.apple.posterkit.provider.supplementURL.homescreenConfiguration.plist") + if FileManager.default.fileExists(atPath: home.path) { return version } + } + } + throw PosterBoardController.OperationError(message: "Set up a wallpaper on this simulator first so its display and clock settings are available.") + } + + func execute(_ sql: String, _ values: [String] = []) throws { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + throw PosterBoardController.OperationError(message: "Unsupported PosterBoard database: \(String(cString: sqlite3_errmsg(handle)))") + } + defer { sqlite3_finalize(statement) } + for (index, value) in values.enumerated() { + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + guard sqlite3_bind_text(statement, Int32(index + 1), value, -1, transient) == SQLITE_OK else { + throw PosterBoardController.OperationError(message: "Could not bind PosterBoard database values.") + } + } + guard sqlite3_step(statement) == SQLITE_DONE else { + throw PosterBoardController.OperationError(message: "PosterBoard database update failed: \(String(cString: sqlite3_errmsg(handle)))") + } + } +} diff --git a/ControlRoom/Localizable.xcstrings b/ControlRoom/Localizable.xcstrings index 1890873..087f842 100644 --- a/ControlRoom/Localizable.xcstrings +++ b/ControlRoom/Localizable.xcstrings @@ -206,6 +206,15 @@ } } } + }, + "Apply Image" : { + + }, + "Apply Image…" : { + + }, + "Apply Wallpaper Image?" : { + }, "Aps" : { "localizations" : { @@ -376,6 +385,9 @@ } } } + }, + "Choose a wallpaper template and replace its artwork with an image, bypassing the photo wallpaper editor." : { + }, "Choose the device type and operating system for the new simulator" : { "localizations" : { @@ -446,6 +458,9 @@ } } } + }, + "Color templates are not supported. Choose Stripes or another Collections template instead." : { + }, "Colors" : { "localizations" : { @@ -926,6 +941,9 @@ } } } + }, + "Gallery templates create a separate image wallpaper. Saved wallpapers keep their clock settings. The simulator restarts. Editing in iOS can restore Apple’s artwork; apply the image again if needed." : { + }, "Get Logs" : { "localizations" : { @@ -1112,6 +1130,9 @@ } } } + }, + "Method:" : { + }, "Misc" : { @@ -1185,6 +1206,9 @@ } } } + }, + "OK" : { + }, "On/Off Labels" : { "localizations" : { @@ -1315,6 +1339,12 @@ } } } + }, + "PosterBoard" : { + + }, + "PosterBoard:" : { + }, "Power" : { "localizations" : { @@ -1375,6 +1405,9 @@ } } } + }, + "Refresh" : { + }, "Regular" : { "localizations" : { @@ -1475,6 +1508,15 @@ } } } + }, + "Reset PosterBoard" : { + + }, + "Reset PosterBoard?" : { + + }, + "Reset PosterBoard…" : { + }, "Restart" : { "localizations" : { @@ -1505,6 +1547,9 @@ } } } + }, + "Restore Original" : { + }, "Restore Snapshot" : { "localizations" : { @@ -1955,6 +2000,9 @@ } } } + }, + "This applies the image on %@. Gallery templates create a separate wallpaper; saved wallpapers are modified and support Restore Original. A running simulator restarts." : { + }, "This is an experimental feature and may not function properly yet." : { "localizations" : { @@ -1965,6 +2013,9 @@ } } } + }, + "This removes every saved Lock Screen and StandBy layout on %@. The simulator will restart if it is running. App data and Photos are kept. No backup is retained." : { + }, "Time:" : { "localizations" : { @@ -2091,6 +2142,9 @@ } } } + }, + "Wallpaper:" : { + }, "Wi-Fi bars:" : { "localizations" : { diff --git a/ControlRoom/Simulator UI/ControlScreens/SystemView/PosterBoardView.swift b/ControlRoom/Simulator UI/ControlScreens/SystemView/PosterBoardView.swift new file mode 100644 index 0000000..7d4ef45 --- /dev/null +++ b/ControlRoom/Simulator UI/ControlScreens/SystemView/PosterBoardView.swift @@ -0,0 +1,164 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct PosterBoardView: View { + let simulator: Simulator + + @State private var wallpapers: [PosterBoardController.WallpaperTemplate] = [] + @State private var selectedWallpaper = "" + private enum WallpaperSource: String, CaseIterable, Identifiable { + case builtIn = "Built-in templates" + case saved = "Saved wallpapers" + var id: String { rawValue } + } + + @State private var source = WallpaperSource.builtIn + + private var matchingWallpapers: [PosterBoardController.WallpaperTemplate] { wallpapers.filter { $0.isGallery == (source == .builtIn) } } + @State private var isBusy = false + @State private var isImporting = false + @State private var confirmsReset = false + @State private var confirmsReplacement = false + @State private var selectedImage: URL? + @State private var showsResult = false + @State private var resultMessage = "" + + private var supportsPosterBoard: Bool { + simulator.deviceFamily == .iPhone || simulator.deviceFamily == .iPad + } + + private var canOperate: Bool { + supportsPosterBoard && !simulator.isDefault && (simulator.state == .booted || simulator.state == .shutdown) + } + + var body: some View { + Section { + LabeledContent("PosterBoard:") { + VStack(alignment: .leading, spacing: 8) { + Text("Create a wallpaper from a HEIC, JPEG, PNG, or other image, or replace the artwork of a supported saved wallpaper.") + .font(.caption) + .foregroundStyle(.secondary) + + Picker("Source:", selection: $source) { + ForEach(WallpaperSource.allCases) { item in + Text(item.rawValue).tag(item) + } + } + if !matchingWallpapers.isEmpty { + Picker(source == .builtIn ? "Template:" : "Wallpaper:", selection: $selectedWallpaper) { + ForEach(matchingWallpapers) { wallpaper in + Text(wallpaper.displayName).tag(wallpaper.id) + } + } + } else { + Text(source == .builtIn + ? "No compatible built-in templates were found on \(simulator.name)." + : "No compatible saved wallpapers were found on \(simulator.name). Color and photo wallpapers cannot be changed here.") + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack { + Button("Refresh", action: refresh) + Button("Apply Image…") { isImporting = true } + .disabled(selectedWallpaper.isEmpty) + Button("Restore Original") { + isBusy = true + PosterBoardController.restoreImages(simulator, wallpaperID: selectedWallpaper, completion: complete) + } + .disabled(!matchingWallpapers.contains(where: { $0.id == selectedWallpaper && $0.canRestore })) + Button("Reset PosterBoard…", role: .destructive) { confirmsReset = true } + if isBusy { ProgressView().controlSize(.small) } + } + .disabled(!canOperate || isBusy) + + Text(source == .builtIn + ? "These templates ship with \(simulator.name) and may be hidden in iOS’s wallpaper gallery. They are not wallpapers you added. Applying an image creates and selects a new saved wallpaper." + : "These compatible wallpapers are already saved on \(simulator.name). " + + "Applying an image changes the selected wallpaper’s artwork and keeps its clock settings. Restore Original undoes the artwork change.") + .font(.caption) + .foregroundStyle(.secondary) + Text("A running simulator restarts. Editing in iOS can restore Apple’s artwork; apply the image again if needed.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .onChange(of: source) { _ in selectedWallpaper = matchingWallpapers.first?.id ?? "" } + .task(id: simulator.udid) { if canOperate { refresh() } } + .fileImporter(isPresented: $isImporting, allowedContentTypes: [.image, .heic, .heif]) { result in + switch result { + case .success(let url): + selectedImage = url + confirmsReplacement = true + case .failure(let error): + if (error as NSError).code != NSUserCancelledError || (error as NSError).domain != NSCocoaErrorDomain { + show(error.localizedDescription) + } + } + } + .alert("Apply Wallpaper Image?", isPresented: $confirmsReplacement) { + Button("Apply Image") { replaceImages() } + Button("Cancel", role: .cancel) { selectedImage = nil } + } message: { + Text("This applies the image on \(simulator.name). Built-in templates create a new saved wallpaper; saved wallpapers are modified and support Restore Original. A running simulator restarts.") + } + .alert("Reset PosterBoard?", isPresented: $confirmsReset) { + Button("Reset PosterBoard", role: .destructive) { + isBusy = true + PosterBoardController.reset(simulator, completion: complete) + } + Button("Cancel", role: .cancel) { } + } message: { + Text("This removes every saved Lock Screen and StandBy layout on \(simulator.name). The simulator will restart if it is running. App data and Photos are kept. No backup is retained.") + } + .alert("PosterBoard", isPresented: $showsResult) { + Button("OK", role: .cancel) { } + } message: { + Text(resultMessage) + } + } + + private func refresh() { + isBusy = true + PosterBoardController.loadWallpapers(simulator) { result in + isBusy = false + switch result { + case .success(let items): + wallpapers = items + if !matchingWallpapers.contains(where: { $0.id == selectedWallpaper }) { selectedWallpaper = matchingWallpapers.first?.id ?? "" } + case .failure(let error): + wallpapers = [] + selectedWallpaper = "" + show(error.localizedDescription) + } + } + } + + private func replaceImages() { + guard let url = selectedImage else { return } + let scoped = url.startAccessingSecurityScopedResource() + isBusy = true + PosterBoardController.replaceImages(simulator, wallpaperID: selectedWallpaper, imageURL: url) { result in + if scoped { url.stopAccessingSecurityScopedResource() } + selectedImage = nil + complete(result) + } + } + + private func complete(_ result: Result) { + isBusy = false + switch result { + case .success(let message): + refresh() + show(message) + case .failure(let error): + show(error.localizedDescription) + } + } + + private func show(_ message: String) { + resultMessage = message + showsResult = true + } +} diff --git a/ControlRoom/Simulator UI/ControlScreens/SystemView/SystemView.swift b/ControlRoom/Simulator UI/ControlScreens/SystemView/SystemView.swift index aba31ef..ea08482 100644 --- a/ControlRoom/Simulator UI/ControlScreens/SystemView/SystemView.swift +++ b/ControlRoom/Simulator UI/ControlScreens/SystemView/SystemView.swift @@ -106,6 +106,9 @@ struct SystemView: View { Spacer() .frame(height: 40) + PosterBoardView(simulator: simulator) + .id(simulator.udid) + LabeledContent("Data:") { HStack { Button("Trigger iCloud Sync", action: triggerSync) diff --git a/README.md b/README.md index 32144c2..9ab67f1 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ Control Room is packed with features to help you develop apps more effectively, - Overriding dark or light mode, language, accessibility options, and Dynamic Type content size. - Picking a custom user location from anywhere in the world. - Starting, stopping, installing, and removing apps. +- Resetting iOS simulator PosterBoard state to recover from wallpaper crashes. +- Replacing Stripes and other Collections wallpaper artwork with an image, with a template picker and Restore Original. - Sending test push notifications or triggering deep links. - Selecting colors from the simulator, converting them to UIKit or SwiftUI code, or even dragging directly into your asset catalog.