1170 lines
53 KiB
Objective-C
1170 lines
53 KiB
Objective-C
#import "PosterBoardFeature.h"
|
|
|
|
#import "MCMFilzaIntegration.h"
|
|
|
|
#import <CommonCrypto/CommonDigest.h>
|
|
#import <dirent.h>
|
|
#import <errno.h>
|
|
#import <fcntl.h>
|
|
#import <sys/stat.h>
|
|
#import <sys/stdio.h>
|
|
#import <unistd.h>
|
|
|
|
static NSString *const PBErrorDomain = @"local.filzamod.posterboard";
|
|
static NSString *const PBPosterBoardIdentifier = @"com.apple.PosterBoard";
|
|
static const NSUInteger PBMaximumDescriptors = 10;
|
|
static const NSUInteger PBMaximumFiles = 4096;
|
|
static const unsigned long long PBMaximumBytes = 256ULL * 1024ULL * 1024ULL;
|
|
static const NSInteger PBButtonTag = 0x50424C42;
|
|
|
|
typedef struct {
|
|
NSUInteger files;
|
|
NSUInteger directories;
|
|
unsigned long long bytes;
|
|
} PBTreeStats;
|
|
|
|
static BOOL PBSetError(NSError **error, NSInteger code, NSString *format, ...)
|
|
{
|
|
if (error) {
|
|
va_list arguments;
|
|
va_start(arguments, format);
|
|
NSString *message = [[NSString alloc] initWithFormat:format arguments:arguments];
|
|
va_end(arguments);
|
|
*error = [NSError errorWithDomain:PBErrorDomain code:code
|
|
userInfo:@{NSLocalizedDescriptionKey: message ?: @"PosterBoard operation failed"}];
|
|
}
|
|
return NO;
|
|
}
|
|
|
|
static NSString *PBWorkspace(void)
|
|
{
|
|
return [MCMFilzaVirtualRoot()
|
|
stringByAppendingPathComponent:MCMFilzaWallpaperLabName()];
|
|
}
|
|
|
|
static NSString *PBImportsDirectory(void)
|
|
{
|
|
return [PBWorkspace() stringByAppendingPathComponent:@"Imports"];
|
|
}
|
|
|
|
static NSString *PBBackupsDirectory(void)
|
|
{
|
|
return [PBWorkspace() stringByAppendingPathComponent:@"Backups"];
|
|
}
|
|
|
|
static NSString *PBReportsDirectory(void)
|
|
{
|
|
return [PBWorkspace() stringByAppendingPathComponent:@"Reports"];
|
|
}
|
|
|
|
static NSString *PBCanonicalPath(NSString *path)
|
|
{
|
|
NSString *result = path.stringByStandardizingPath;
|
|
if ([result isEqualToString:@"/var"] || [result hasPrefix:@"/var/"])
|
|
result = [@"/private" stringByAppendingString:result];
|
|
return result;
|
|
}
|
|
|
|
static BOOL PBSkippedName(NSString *name)
|
|
{
|
|
return [name hasPrefix:@"."] || [name caseInsensitiveCompare:@"__MACOSX"] == NSOrderedSame;
|
|
}
|
|
|
|
static BOOL PBSafeName(NSString *name)
|
|
{
|
|
return name.length > 0 && ![name isEqualToString:@"."] &&
|
|
![name isEqualToString:@".."] && [name rangeOfString:@"/"].location == NSNotFound &&
|
|
[name rangeOfString:@"\0"].location == NSNotFound;
|
|
}
|
|
|
|
static BOOL PBDirectoryNoSymlink(NSString *path, BOOL requireExisting, NSError **error)
|
|
{
|
|
struct stat status = {0};
|
|
if (lstat(path.fileSystemRepresentation, &status) != 0) {
|
|
if (errno == ENOENT && !requireExisting) return YES;
|
|
return PBSetError(error, errno, @"Cannot inspect directory %@ (errno %d)", path, errno);
|
|
}
|
|
if (!S_ISDIR(status.st_mode))
|
|
return PBSetError(error, EINVAL, @"Expected a real directory at %@", path);
|
|
return YES;
|
|
}
|
|
|
|
static NSArray<NSString *> *PBDirectoryNames(NSString *path, NSError **error)
|
|
{
|
|
DIR *directory = opendir(path.fileSystemRepresentation);
|
|
if (!directory) {
|
|
PBSetError(error, errno, @"Cannot open %@ (errno %d)", path, errno);
|
|
return nil;
|
|
}
|
|
NSMutableArray<NSString *> *names = [NSMutableArray array];
|
|
struct dirent *entry = NULL;
|
|
errno = 0;
|
|
while ((entry = readdir(directory))) {
|
|
if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue;
|
|
NSString *name = [NSString stringWithUTF8String:entry->d_name];
|
|
if (!name || !PBSafeName(name)) {
|
|
closedir(directory);
|
|
PBSetError(error, EINVAL, @"Directory contains an unsupported file name: %@", path);
|
|
return nil;
|
|
}
|
|
[names addObject:name];
|
|
}
|
|
int readError = errno;
|
|
closedir(directory);
|
|
if (readError) {
|
|
PBSetError(error, readError, @"Failed while reading %@ (errno %d)", path, readError);
|
|
return nil;
|
|
}
|
|
return [names sortedArrayUsingSelector:@selector(localizedStandardCompare:)];
|
|
}
|
|
|
|
static BOOL PBScanTreeRecursive(NSString *base, NSString *relative, NSUInteger depth,
|
|
NSMutableArray<NSDictionary *> *entries,
|
|
PBTreeStats *stats, NSError **error)
|
|
{
|
|
if (depth > 32) return PBSetError(error, ELOOP, @"Package tree is deeper than 32 levels");
|
|
NSString *directory = relative.length ? [base stringByAppendingPathComponent:relative] : base;
|
|
NSArray<NSString *> *names = PBDirectoryNames(directory, error);
|
|
if (!names) return NO;
|
|
for (NSString *name in names) {
|
|
if (PBSkippedName(name)) continue;
|
|
NSString *childRelative = relative.length
|
|
? [relative stringByAppendingPathComponent:name] : name;
|
|
NSString *child = [base stringByAppendingPathComponent:childRelative];
|
|
struct stat status = {0};
|
|
if (lstat(child.fileSystemRepresentation, &status) != 0)
|
|
return PBSetError(error, errno, @"Cannot inspect %@ (errno %d)", child, errno);
|
|
if (S_ISDIR(status.st_mode)) {
|
|
stats->directories++;
|
|
[entries addObject:@{@"Path": childRelative, @"Directory": @YES}];
|
|
if (!PBScanTreeRecursive(base, childRelative, depth + 1, entries, stats, error))
|
|
return NO;
|
|
} else if (S_ISREG(status.st_mode)) {
|
|
if (status.st_size < 0) return PBSetError(error, EINVAL, @"Invalid file size at %@", child);
|
|
stats->files++;
|
|
stats->bytes += (unsigned long long)status.st_size;
|
|
if (stats->files > PBMaximumFiles || stats->bytes > PBMaximumBytes)
|
|
return PBSetError(error, EFBIG,
|
|
@"Package exceeds the %lu-file or 256 MiB safety limit",
|
|
(unsigned long)PBMaximumFiles);
|
|
[entries addObject:@{@"Path": childRelative, @"Directory": @NO,
|
|
@"Size": @((unsigned long long)status.st_size)}];
|
|
} else {
|
|
return PBSetError(error, EINVAL,
|
|
@"Package contains a link or special file, which is not allowed: %@", childRelative);
|
|
}
|
|
}
|
|
return YES;
|
|
}
|
|
|
|
static NSArray<NSDictionary *> *PBScanTree(NSString *root, PBTreeStats *stats,
|
|
NSError **error)
|
|
{
|
|
memset(stats, 0, sizeof(*stats));
|
|
if (!PBDirectoryNoSymlink(root, YES, error)) return nil;
|
|
NSMutableArray<NSDictionary *> *entries = [NSMutableArray array];
|
|
if (!PBScanTreeRecursive(root, @"", 0, entries, stats, error)) return nil;
|
|
[entries sortUsingComparator:^NSComparisonResult(NSDictionary *left, NSDictionary *right) {
|
|
return [left[@"Path"] compare:right[@"Path"] options:NSLiteralSearch];
|
|
}];
|
|
return entries;
|
|
}
|
|
|
|
static BOOL PBCopyRegularFile(NSString *source, NSString *destination, NSError **error)
|
|
{
|
|
int input = open(source.fileSystemRepresentation, O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
|
|
if (input < 0) return PBSetError(error, errno, @"Cannot open %@ (errno %d)", source, errno);
|
|
struct stat status = {0};
|
|
if (fstat(input, &status) != 0 || !S_ISREG(status.st_mode)) {
|
|
int saved = errno ?: EINVAL;
|
|
close(input);
|
|
return PBSetError(error, saved, @"Source changed during import: %@", source);
|
|
}
|
|
int output = open(destination.fileSystemRepresentation,
|
|
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600);
|
|
if (output < 0) {
|
|
int saved = errno;
|
|
close(input);
|
|
return PBSetError(error, saved, @"Cannot create %@ (errno %d)", destination, saved);
|
|
}
|
|
BOOL ok = YES;
|
|
uint8_t buffer[64 * 1024];
|
|
for (;;) {
|
|
ssize_t count = read(input, buffer, sizeof(buffer));
|
|
if (count == 0) break;
|
|
if (count < 0) {
|
|
if (errno == EINTR) continue;
|
|
ok = PBSetError(error, errno, @"Read failed for %@ (errno %d)", source, errno);
|
|
break;
|
|
}
|
|
ssize_t offset = 0;
|
|
while (offset < count) {
|
|
ssize_t written = write(output, buffer + offset, (size_t)(count - offset));
|
|
if (written < 0 && errno == EINTR) continue;
|
|
if (written <= 0) {
|
|
ok = PBSetError(error, errno ?: EIO, @"Write failed for %@ (errno %d)",
|
|
destination, errno ?: EIO);
|
|
break;
|
|
}
|
|
offset += written;
|
|
}
|
|
if (!ok) break;
|
|
}
|
|
if (ok && fsync(output) != 0)
|
|
ok = PBSetError(error, errno, @"Could not flush %@ (errno %d)", destination, errno);
|
|
close(output);
|
|
close(input);
|
|
if (!ok) unlink(destination.fileSystemRepresentation);
|
|
return ok;
|
|
}
|
|
|
|
static BOOL PBCopyTree(NSString *source, NSString *destination,
|
|
NSArray<NSDictionary *> *entries, NSError **error)
|
|
{
|
|
if (mkdir(destination.fileSystemRepresentation, 0700) != 0)
|
|
return PBSetError(error, errno, @"Cannot create staging directory %@ (errno %d)",
|
|
destination, errno);
|
|
for (NSDictionary *entry in entries) {
|
|
if (![entry[@"Directory"] boolValue]) continue;
|
|
NSString *path = [destination stringByAppendingPathComponent:entry[@"Path"]];
|
|
if (mkdir(path.fileSystemRepresentation, 0700) != 0)
|
|
return PBSetError(error, errno, @"Cannot create directory %@ (errno %d)", path, errno);
|
|
}
|
|
for (NSDictionary *entry in entries) {
|
|
if ([entry[@"Directory"] boolValue]) continue;
|
|
NSString *relative = entry[@"Path"];
|
|
if (!PBCopyRegularFile([source stringByAppendingPathComponent:relative],
|
|
[destination stringByAppendingPathComponent:relative], error))
|
|
return NO;
|
|
}
|
|
return YES;
|
|
}
|
|
|
|
static BOOL PBWritePlist(id plist, NSPropertyListFormat format, NSString *path,
|
|
NSError **error)
|
|
{
|
|
NSError *serializationError = nil;
|
|
NSData *data = [NSPropertyListSerialization dataWithPropertyList:plist
|
|
format:format options:0 error:&serializationError];
|
|
if (!data) {
|
|
if (error) *error = serializationError;
|
|
return NO;
|
|
}
|
|
NSError *writeError = nil;
|
|
if (![data writeToFile:path options:NSDataWritingAtomic error:&writeError]) {
|
|
if (error) *error = writeError;
|
|
return NO;
|
|
}
|
|
return YES;
|
|
}
|
|
|
|
static BOOL PBRewriteDescriptor(NSString *root, NSInteger wallpaperIdentifier,
|
|
NSError **error)
|
|
{
|
|
PBTreeStats stats = {0};
|
|
NSArray<NSDictionary *> *entries = PBScanTree(root, &stats, error);
|
|
if (!entries) return NO;
|
|
for (NSDictionary *entry in entries) {
|
|
if ([entry[@"Directory"] boolValue]) continue;
|
|
NSString *relative = entry[@"Path"];
|
|
NSString *name = relative.lastPathComponent;
|
|
NSString *path = [root stringByAppendingPathComponent:relative];
|
|
if ([name isEqualToString:@"com.apple.posterkit.provider.descriptor.identifier"]) {
|
|
NSData *identifierData = [[NSString stringWithFormat:@"%ld", (long)wallpaperIdentifier]
|
|
dataUsingEncoding:NSUTF8StringEncoding];
|
|
if (![identifierData writeToFile:path options:NSDataWritingAtomic error:error]) return NO;
|
|
continue;
|
|
}
|
|
BOOL userInfo = [name isEqualToString:@"com.apple.posterkit.provider.contents.userInfo"];
|
|
BOOL wallpaperPlist = [name hasSuffix:@"Wallpaper.plist"];
|
|
if (!userInfo && !wallpaperPlist) continue;
|
|
NSData *data = [NSData dataWithContentsOfFile:path options:NSDataReadingMappedIfSafe error:error];
|
|
if (!data) return NO;
|
|
NSPropertyListFormat format = NSPropertyListBinaryFormat_v1_0;
|
|
id plist = [NSPropertyListSerialization propertyListWithData:data
|
|
options:NSPropertyListMutableContainersAndLeaves format:&format error:error];
|
|
if (![plist isKindOfClass:NSMutableDictionary.class])
|
|
return PBSetError(error, EINVAL, @"Expected a dictionary plist at %@", relative);
|
|
NSMutableDictionary *dictionary = plist;
|
|
if (userInfo) {
|
|
dictionary[@"wallpaperRepresentingIdentifier"] = @(wallpaperIdentifier);
|
|
} else {
|
|
dictionary[@"identifier"] = @(wallpaperIdentifier);
|
|
dictionary[@"family"] = @"Marble";
|
|
dictionary[@"name"] = @"Lavender";
|
|
id assets = dictionary[@"assets"];
|
|
id lockAndHome = [assets isKindOfClass:NSMutableDictionary.class]
|
|
? assets[@"lockAndHome"] : nil;
|
|
id defaultAsset = [lockAndHome isKindOfClass:NSMutableDictionary.class]
|
|
? lockAndHome[@"default"] : nil;
|
|
if ([defaultAsset isKindOfClass:NSMutableDictionary.class])
|
|
defaultAsset[@"name"] = @"Lavender";
|
|
}
|
|
if (!PBWritePlist(dictionary, format, path, error)) return NO;
|
|
}
|
|
return YES;
|
|
}
|
|
|
|
static NSString *PBHexDigest(const unsigned char digest[CC_SHA256_DIGEST_LENGTH])
|
|
{
|
|
NSMutableString *result = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
|
|
for (NSUInteger index = 0; index < CC_SHA256_DIGEST_LENGTH; index++)
|
|
[result appendFormat:@"%02x", digest[index]];
|
|
return result;
|
|
}
|
|
|
|
static NSString *PBDataDigest(NSData *data)
|
|
{
|
|
unsigned char digest[CC_SHA256_DIGEST_LENGTH] = {0};
|
|
CC_SHA256(data.bytes, (CC_LONG)data.length, digest);
|
|
return PBHexDigest(digest);
|
|
}
|
|
|
|
static NSString *PBTreeDigest(NSString *root, PBTreeStats *outStats, NSError **error)
|
|
{
|
|
PBTreeStats stats = {0};
|
|
NSArray<NSDictionary *> *entries = PBScanTree(root, &stats, error);
|
|
if (!entries) return nil;
|
|
CC_SHA256_CTX context;
|
|
CC_SHA256_Init(&context);
|
|
for (NSDictionary *entry in entries) {
|
|
BOOL directory = [entry[@"Directory"] boolValue];
|
|
uint8_t type = directory ? 'D' : 'F';
|
|
NSData *pathData = [entry[@"Path"] dataUsingEncoding:NSUTF8StringEncoding];
|
|
uint32_t length = CFSwapInt32HostToBig((uint32_t)pathData.length);
|
|
CC_SHA256_Update(&context, &type, sizeof(type));
|
|
CC_SHA256_Update(&context, &length, sizeof(length));
|
|
CC_SHA256_Update(&context, pathData.bytes, (CC_LONG)pathData.length);
|
|
if (directory) continue;
|
|
NSString *path = [root stringByAppendingPathComponent:entry[@"Path"]];
|
|
int descriptor = open(path.fileSystemRepresentation, O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
|
|
if (descriptor < 0) {
|
|
PBSetError(error, errno, @"Cannot hash %@ (errno %d)", path, errno);
|
|
return nil;
|
|
}
|
|
uint8_t buffer[64 * 1024];
|
|
BOOL ok = YES;
|
|
for (;;) {
|
|
ssize_t count = read(descriptor, buffer, sizeof(buffer));
|
|
if (count == 0) break;
|
|
if (count < 0 && errno == EINTR) continue;
|
|
if (count < 0) {
|
|
ok = PBSetError(error, errno, @"Cannot hash %@ (errno %d)", path, errno);
|
|
break;
|
|
}
|
|
CC_SHA256_Update(&context, buffer, (CC_LONG)count);
|
|
}
|
|
close(descriptor);
|
|
if (!ok) return nil;
|
|
}
|
|
unsigned char digest[CC_SHA256_DIGEST_LENGTH] = {0};
|
|
CC_SHA256_Final(digest, &context);
|
|
if (outStats) *outStats = stats;
|
|
return PBHexDigest(digest);
|
|
}
|
|
|
|
static BOOL PBFindDescriptorContainers(NSString *directory, NSUInteger depth,
|
|
NSMutableArray<NSString *> *containers,
|
|
NSError **error)
|
|
{
|
|
if (depth > 8) return PBSetError(error, ELOOP, @"Package search exceeded 8 levels");
|
|
NSString *lowerName = directory.lastPathComponent.lowercaseString;
|
|
if ([lowerName containsString:@"descriptor"]) {
|
|
[containers addObject:directory];
|
|
return YES;
|
|
}
|
|
NSArray<NSString *> *names = PBDirectoryNames(directory, error);
|
|
if (!names) return NO;
|
|
for (NSString *name in names) {
|
|
if (PBSkippedName(name)) continue;
|
|
NSString *path = [directory stringByAppendingPathComponent:name];
|
|
struct stat status = {0};
|
|
if (lstat(path.fileSystemRepresentation, &status) != 0)
|
|
return PBSetError(error, errno, @"Cannot inspect %@ (errno %d)", path, errno);
|
|
if (S_ISLNK(status.st_mode))
|
|
return PBSetError(error, EINVAL, @"Package links are not allowed: %@", path);
|
|
if (!S_ISDIR(status.st_mode)) continue;
|
|
if ([name caseInsensitiveCompare:@"container"] == NSOrderedSame)
|
|
return PBSetError(error, EPERM,
|
|
@"Unsafe container-style tendies are not supported. Use a descriptor package.");
|
|
if (!PBFindDescriptorContainers(path, depth + 1, containers, error)) return NO;
|
|
}
|
|
return YES;
|
|
}
|
|
|
|
static NSString *PBProviderForContainer(NSString *container)
|
|
{
|
|
NSString *name = container.lastPathComponent.lowercaseString;
|
|
if ([name containsString:@"video"] || [name containsString:@"photos"])
|
|
return @"com.apple.PhotosUIPrivate.PhotosPosterProvider";
|
|
if ([name containsString:@"mercury"])
|
|
return @"com.apple.MercuryPoster";
|
|
return @"com.apple.WallpaperKit.CollectionsPoster";
|
|
}
|
|
|
|
static NSArray<NSDictionary *> *PBDiscoverDescriptors(NSString *package,
|
|
NSError **error)
|
|
{
|
|
NSMutableArray<NSString *> *containers = [NSMutableArray array];
|
|
if (!PBFindDescriptorContainers(package, 0, containers, error)) return nil;
|
|
if (containers.count == 0) {
|
|
PBSetError(error, ENOENT,
|
|
@"No descriptor or descriptors folder was found in %@", package.lastPathComponent);
|
|
return nil;
|
|
}
|
|
NSMutableArray<NSDictionary *> *descriptors = [NSMutableArray array];
|
|
for (NSString *container in containers) {
|
|
NSString *provider = PBProviderForContainer(container);
|
|
NSArray<NSString *> *names = PBDirectoryNames(container, error);
|
|
if (!names) return nil;
|
|
for (NSString *name in names) {
|
|
if (PBSkippedName(name)) continue;
|
|
NSString *source = [container stringByAppendingPathComponent:name];
|
|
struct stat status = {0};
|
|
if (lstat(source.fileSystemRepresentation, &status) != 0) {
|
|
PBSetError(error, errno, @"Cannot inspect %@ (errno %d)", source, errno);
|
|
return nil;
|
|
}
|
|
if (!S_ISDIR(status.st_mode)) {
|
|
PBSetError(error, EINVAL,
|
|
@"Descriptor folder contains an unexpected file: %@", source);
|
|
return nil;
|
|
}
|
|
PBTreeStats stats = {0};
|
|
NSArray *entries = PBScanTree(source, &stats, error);
|
|
if (!entries) return nil;
|
|
if (stats.files == 0) {
|
|
PBSetError(error, EINVAL, @"Descriptor is empty: %@", source);
|
|
return nil;
|
|
}
|
|
[descriptors addObject:@{@"Source": source, @"SourceName": name,
|
|
@"Provider": provider, @"Entries": entries,
|
|
@"FileCount": @(stats.files), @"ByteCount": @(stats.bytes)}];
|
|
if (descriptors.count > PBMaximumDescriptors) {
|
|
PBSetError(error, E2BIG, @"A package may contain at most %lu descriptors",
|
|
(unsigned long)PBMaximumDescriptors);
|
|
return nil;
|
|
}
|
|
}
|
|
}
|
|
if (descriptors.count == 0) {
|
|
PBSetError(error, ENOENT, @"The package contains no descriptor directories");
|
|
return nil;
|
|
}
|
|
return descriptors;
|
|
}
|
|
|
|
static NSDictionary *PBPosterBoardContext(NSError **error)
|
|
{
|
|
NSString *activationError = nil;
|
|
NSString *root = MCMFilzaDataContainerPath(PBPosterBoardIdentifier, &activationError);
|
|
if (!root) {
|
|
PBSetError(error, EPERM, @"PosterBoard container activation failed: %@",
|
|
activationError ?: @"unknown error");
|
|
return nil;
|
|
}
|
|
NSString *store = [root stringByAppendingPathComponent:
|
|
@"Library/Application Support/PRBPosterExtensionDataStore"];
|
|
if (!PBDirectoryNoSymlink(store, YES, error)) return nil;
|
|
NSArray<NSString *> *names = PBDirectoryNames(store, error);
|
|
if (!names) return nil;
|
|
NSString *selected = nil;
|
|
long long selectedValue = LLONG_MIN;
|
|
NSCharacterSet *notDigits = NSCharacterSet.decimalDigitCharacterSet.invertedSet;
|
|
for (NSString *name in names) {
|
|
if (name.length == 0 || [name rangeOfCharacterFromSet:notDigits].location != NSNotFound)
|
|
continue;
|
|
NSString *structure = [store stringByAppendingPathComponent:name];
|
|
NSString *extensions = [structure stringByAppendingPathComponent:@"Extensions"];
|
|
if (!PBDirectoryNoSymlink(structure, YES, nil) ||
|
|
!PBDirectoryNoSymlink(extensions, YES, nil)) continue;
|
|
long long value = name.longLongValue;
|
|
if (value > selectedValue) {
|
|
selectedValue = value;
|
|
selected = name;
|
|
}
|
|
}
|
|
if (!selected)
|
|
return PBSetError(error, ENOENT,
|
|
@"No numeric PosterBoard structure with an Extensions directory was found"), nil;
|
|
NSString *extensions = [[store stringByAppendingPathComponent:selected]
|
|
stringByAppendingPathComponent:@"Extensions"];
|
|
return @{@"Root": root, @"Store": store, @"Structure": selected,
|
|
@"Extensions": extensions};
|
|
}
|
|
|
|
static BOOL PBEnsureTargetDirectory(NSString *path, NSMutableArray<NSString *> *created,
|
|
NSError **error)
|
|
{
|
|
struct stat status = {0};
|
|
if (lstat(path.fileSystemRepresentation, &status) == 0) {
|
|
if (!S_ISDIR(status.st_mode))
|
|
return PBSetError(error, EINVAL, @"Target is not a real directory: %@", path);
|
|
return YES;
|
|
}
|
|
if (errno != ENOENT)
|
|
return PBSetError(error, errno, @"Cannot inspect target %@ (errno %d)", path, errno);
|
|
if (mkdir(path.fileSystemRepresentation, 0700) != 0)
|
|
return PBSetError(error, errno, @"Cannot create target %@ (errno %d)", path, errno);
|
|
[created addObject:path];
|
|
return YES;
|
|
}
|
|
|
|
static void PBRemoveTree(NSString *path)
|
|
{
|
|
if (path.length == 0) return;
|
|
[[NSFileManager defaultManager] removeItemAtPath:path error:nil];
|
|
}
|
|
|
|
static void PBCleanupEmptyDirectories(NSArray<NSString *> *directories)
|
|
{
|
|
for (NSString *path in directories.reverseObjectEnumerator) {
|
|
NSArray *contents = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:path error:nil];
|
|
if (contents.count == 0) rmdir(path.fileSystemRepresentation);
|
|
}
|
|
}
|
|
|
|
static BOOL PBWriteManifest(NSDictionary *manifest, NSString *path, NSError **error)
|
|
{
|
|
return [manifest writeToURL:[NSURL fileURLWithPath:path]
|
|
error:error];
|
|
}
|
|
|
|
static NSDictionary *PBApplyRefreshPreferences(NSDictionary *context,
|
|
NSString *transaction,
|
|
NSError **error)
|
|
{
|
|
NSString *preferencePath = [context[@"Root"] stringByAppendingPathComponent:
|
|
@"Library/Preferences/com.apple.PosterBoard.unprotectedUserDefaults.plist"];
|
|
struct stat status = {0};
|
|
int inspectResult = lstat(preferencePath.fileSystemRepresentation, &status);
|
|
if (inspectResult != 0 && errno != ENOENT) {
|
|
PBSetError(error, errno, @"Cannot inspect PosterBoard refresh preferences (errno %d)", errno);
|
|
return nil;
|
|
}
|
|
BOOL originalExisted = inspectResult == 0;
|
|
if (originalExisted && !S_ISREG(status.st_mode)) {
|
|
PBSetError(error, EINVAL, @"PosterBoard refresh preference is not a regular file");
|
|
return nil;
|
|
}
|
|
NSData *original = nil;
|
|
if (originalExisted) {
|
|
original = [NSData dataWithContentsOfFile:preferencePath
|
|
options:NSDataReadingMappedIfSafe error:error];
|
|
if (!original) return nil;
|
|
}
|
|
|
|
NSMutableDictionary *preferences = nil;
|
|
if (original.length > 0) {
|
|
NSPropertyListFormat format = NSPropertyListBinaryFormat_v1_0;
|
|
id plist = [NSPropertyListSerialization propertyListWithData:original
|
|
options:NSPropertyListMutableContainersAndLeaves format:&format error:error];
|
|
if (![plist isKindOfClass:NSMutableDictionary.class]) {
|
|
PBSetError(error, EINVAL, @"PosterBoard refresh preference is not a dictionary plist");
|
|
return nil;
|
|
}
|
|
preferences = plist;
|
|
} else {
|
|
preferences = [NSMutableDictionary dictionary];
|
|
}
|
|
|
|
// These are Nugget's force-PosterBoard-refresh values for iOS 26.4+.
|
|
preferences[@"PBF_LOCALE_DID_CHANGE"] = @NO;
|
|
preferences[@"PBF_RESET_FILE_PROTECTIONS"] = @YES;
|
|
preferences[@"PersistedPosterContainerBundleIdentifiers"] = @[
|
|
@"com.apple.Posters.CollectionsPosterApp",
|
|
];
|
|
preferences[@"CompletedPosterBundleIdentifierMigrations"] = @[
|
|
@"com.apple.Posters.UnityPosterApp.ExtragalacticPoster",
|
|
@"com.apple.Posters.WeatherPosterApp.WeatherPoster",
|
|
@"com.apple.Posters.UnityPosterApp.Unity2025Poster",
|
|
@"com.apple.Posters.UnityPosterApp.UnityPosterExtension",
|
|
@"com.apple.Posters.UnityPosterApp.RhizomePoster",
|
|
@"com.apple.Posters.KaleidoscopePosterApp.KaleidoscopePoster",
|
|
];
|
|
NSData *installed = [NSPropertyListSerialization dataWithPropertyList:preferences
|
|
format:NSPropertyListBinaryFormat_v1_0 options:0 error:error];
|
|
if (!installed) return nil;
|
|
|
|
NSString *backupPath = @"";
|
|
if (originalExisted) {
|
|
backupPath = [PBBackupsDirectory() stringByAppendingPathComponent:
|
|
[transaction stringByAppendingString:@"-PosterBoardPrefs.bin"]];
|
|
if (![original writeToFile:backupPath options:NSDataWritingAtomic error:error]) return nil;
|
|
}
|
|
if (![installed writeToFile:preferencePath options:NSDataWritingAtomic error:error]) {
|
|
if (backupPath.length) [[NSFileManager defaultManager] removeItemAtPath:backupPath error:nil];
|
|
return nil;
|
|
}
|
|
NSData *roundTrip = [NSData dataWithContentsOfFile:preferencePath
|
|
options:NSDataReadingMappedIfSafe error:error];
|
|
if (!roundTrip || ![roundTrip isEqualToData:installed]) {
|
|
if (originalExisted)
|
|
[original writeToFile:preferencePath options:NSDataWritingAtomic error:nil];
|
|
else
|
|
[[NSFileManager defaultManager] removeItemAtPath:preferencePath error:nil];
|
|
if (backupPath.length) [[NSFileManager defaultManager] removeItemAtPath:backupPath error:nil];
|
|
if (!error || !*error)
|
|
PBSetError(error, EIO, @"PosterBoard refresh preference verification failed");
|
|
return nil;
|
|
}
|
|
return @{
|
|
@"Path": preferencePath,
|
|
@"OriginalExisted": @(originalExisted),
|
|
@"OriginalSHA256": originalExisted ? PBDataDigest(original) : @"",
|
|
@"BackupPath": backupPath,
|
|
@"InstalledSHA256": PBDataDigest(installed),
|
|
};
|
|
}
|
|
|
|
static BOOL PBRestoreRefreshPreferences(NSDictionary *record,
|
|
NSDictionary *context,
|
|
NSError **error)
|
|
{
|
|
if (![record isKindOfClass:NSDictionary.class]) return YES;
|
|
NSString *expectedPath = [context[@"Root"] stringByAppendingPathComponent:
|
|
@"Library/Preferences/com.apple.PosterBoard.unprotectedUserDefaults.plist"];
|
|
NSString *path = [record[@"Path"] isKindOfClass:NSString.class] ? record[@"Path"] : nil;
|
|
if (![path.stringByStandardizingPath isEqualToString:expectedPath.stringByStandardizingPath])
|
|
return PBSetError(error, EPERM, @"Refresh rollback contains an out-of-scope preference path");
|
|
NSData *current = [NSData dataWithContentsOfFile:path
|
|
options:NSDataReadingMappedIfSafe error:error];
|
|
if (!current || ![PBDataDigest(current) isEqualToString:record[@"InstalledSHA256"]])
|
|
return PBSetError(error, EBUSY,
|
|
@"Refusing preference rollback because PosterBoard changed the refresh file");
|
|
|
|
if ([record[@"OriginalExisted"] boolValue]) {
|
|
NSString *backupPath = [record[@"BackupPath"] isKindOfClass:NSString.class]
|
|
? record[@"BackupPath"] : nil;
|
|
NSData *backup = [NSData dataWithContentsOfFile:backupPath
|
|
options:NSDataReadingMappedIfSafe error:error];
|
|
if (!backup || ![PBDataDigest(backup) isEqualToString:record[@"OriginalSHA256"]])
|
|
return PBSetError(error, EINVAL, @"PosterBoard preference backup failed verification");
|
|
if (![backup writeToFile:path options:NSDataWritingAtomic error:error]) return NO;
|
|
} else if (![[NSFileManager defaultManager] removeItemAtPath:path error:error]) {
|
|
return NO;
|
|
}
|
|
return YES;
|
|
}
|
|
|
|
static NSString *PBImportPackage(NSString *package, NSError **outError)
|
|
{
|
|
NSError *error = nil;
|
|
NSArray<NSDictionary *> *descriptors = PBDiscoverDescriptors(package, &error);
|
|
if (!descriptors) {
|
|
if (outError) *outError = error;
|
|
return nil;
|
|
}
|
|
NSDictionary *context = PBPosterBoardContext(&error);
|
|
if (!context) {
|
|
if (outError) *outError = error;
|
|
return nil;
|
|
}
|
|
|
|
NSMutableArray<NSString *> *createdParents = [NSMutableArray array];
|
|
NSMutableArray<NSString *> *stagingPaths = [NSMutableArray array];
|
|
NSMutableArray<NSString *> *installedPaths = [NSMutableArray array];
|
|
NSMutableArray<NSDictionary *> *manifestEntries = [NSMutableArray array];
|
|
NSMutableSet<NSNumber *> *identifiers = [NSMutableSet set];
|
|
NSString *transaction = NSUUID.UUID.UUIDString.uppercaseString;
|
|
NSDictionary *refreshRecord = nil;
|
|
BOOL success = NO;
|
|
|
|
for (NSDictionary *descriptor in descriptors) {
|
|
NSString *providerRoot = [context[@"Extensions"]
|
|
stringByAppendingPathComponent:descriptor[@"Provider"]];
|
|
NSString *descriptorRoot = [providerRoot stringByAppendingPathComponent:@"descriptors"];
|
|
if (!PBEnsureTargetDirectory(providerRoot, createdParents, &error) ||
|
|
!PBEnsureTargetDirectory(descriptorRoot, createdParents, &error)) break;
|
|
|
|
NSString *uuid = NSUUID.UUID.UUIDString.uppercaseString;
|
|
NSString *target = [descriptorRoot stringByAppendingPathComponent:uuid];
|
|
NSString *staging = [descriptorRoot stringByAppendingPathComponent:
|
|
[NSString stringWithFormat:@".%@.partial-%@", uuid, NSUUID.UUID.UUIDString]];
|
|
NSInteger wallpaperIdentifier = 0;
|
|
do {
|
|
wallpaperIdentifier = (NSInteger)arc4random_uniform(90001) + 9999;
|
|
} while ([identifiers containsObject:@(wallpaperIdentifier)]);
|
|
[identifiers addObject:@(wallpaperIdentifier)];
|
|
[stagingPaths addObject:staging];
|
|
|
|
if (!PBCopyTree(descriptor[@"Source"], staging, descriptor[@"Entries"], &error) ||
|
|
!PBRewriteDescriptor(staging, wallpaperIdentifier, &error)) break;
|
|
PBTreeStats finalStats = {0};
|
|
NSString *digest = PBTreeDigest(staging, &finalStats, &error);
|
|
if (!digest) break;
|
|
if (renameatx_np(AT_FDCWD, staging.fileSystemRepresentation,
|
|
AT_FDCWD, target.fileSystemRepresentation, RENAME_EXCL) != 0) {
|
|
PBSetError(&error, errno, @"Could not commit descriptor %@ (errno %d)", uuid, errno);
|
|
break;
|
|
}
|
|
[stagingPaths removeObject:staging];
|
|
[installedPaths addObject:target];
|
|
[manifestEntries addObject:@{
|
|
@"SourceName": descriptor[@"SourceName"],
|
|
@"Provider": descriptor[@"Provider"],
|
|
@"DescriptorRoot": descriptorRoot,
|
|
@"UUID": uuid,
|
|
@"WallpaperIdentifier": @(wallpaperIdentifier),
|
|
@"Target": target,
|
|
@"SHA256": digest,
|
|
@"FileCount": @(finalStats.files),
|
|
@"ByteCount": @(finalStats.bytes),
|
|
}];
|
|
}
|
|
|
|
if (manifestEntries.count == descriptors.count)
|
|
refreshRecord = PBApplyRefreshPreferences(context, transaction, &error);
|
|
|
|
if (manifestEntries.count == descriptors.count && refreshRecord) {
|
|
NSDictionary *manifest = @{
|
|
@"Version": @2,
|
|
@"Status": @"installed",
|
|
@"Transaction": transaction,
|
|
@"CreatedAt": NSDate.date,
|
|
@"Package": package.lastPathComponent ?: @"",
|
|
@"PosterBoardRoot": context[@"Root"],
|
|
@"Structure": context[@"Structure"],
|
|
@"CreatedParents": createdParents,
|
|
@"Entries": manifestEntries,
|
|
@"RefreshPreference": refreshRecord,
|
|
};
|
|
NSString *manifestPath = [PBBackupsDirectory() stringByAppendingPathComponent:
|
|
[transaction stringByAppendingPathExtension:@"plist"]];
|
|
if (PBWriteManifest(manifest, manifestPath, &error)) success = YES;
|
|
}
|
|
|
|
if (!success) {
|
|
if (refreshRecord) PBRestoreRefreshPreferences(refreshRecord, context, nil);
|
|
NSString *backupPath = refreshRecord[@"BackupPath"];
|
|
if (backupPath.length) [[NSFileManager defaultManager] removeItemAtPath:backupPath error:nil];
|
|
for (NSString *path in stagingPaths) PBRemoveTree(path);
|
|
for (NSString *path in installedPaths.reverseObjectEnumerator) PBRemoveTree(path);
|
|
PBCleanupEmptyDirectories(createdParents);
|
|
if (!error) PBSetError(&error, EIO, @"Import transaction did not complete");
|
|
if (outError) *outError = error;
|
|
return nil;
|
|
}
|
|
return [NSString stringWithFormat:
|
|
@"Imported %lu descriptor%@ into PosterBoard structure %@ and applied the backed-up refresh preferences. Open Settings > Wallpaper.",
|
|
(unsigned long)manifestEntries.count, manifestEntries.count == 1 ? @"" : @"s",
|
|
context[@"Structure"]];
|
|
}
|
|
|
|
static NSArray<NSString *> *PBManifestPaths(void)
|
|
{
|
|
NSArray<NSString *> *names = [[NSFileManager defaultManager]
|
|
contentsOfDirectoryAtPath:PBBackupsDirectory() error:nil] ?: @[];
|
|
NSMutableArray<NSString *> *paths = [NSMutableArray array];
|
|
for (NSString *name in names) {
|
|
if (![name.pathExtension.lowercaseString isEqualToString:@"plist"]) continue;
|
|
[paths addObject:[PBBackupsDirectory() stringByAppendingPathComponent:name]];
|
|
}
|
|
[paths sortUsingComparator:^NSComparisonResult(NSString *left, NSString *right) {
|
|
NSDictionary *leftAttributes = [[NSFileManager defaultManager] attributesOfItemAtPath:left error:nil];
|
|
NSDictionary *rightAttributes = [[NSFileManager defaultManager] attributesOfItemAtPath:right error:nil];
|
|
return [rightAttributes[NSFileModificationDate]
|
|
compare:leftAttributes[NSFileModificationDate]];
|
|
}];
|
|
return paths;
|
|
}
|
|
|
|
static BOOL PBPathIsInside(NSString *path, NSString *root)
|
|
{
|
|
NSString *candidate = path.stringByStandardizingPath;
|
|
NSString *base = root.stringByStandardizingPath;
|
|
return [candidate hasPrefix:[base stringByAppendingString:@"/"]];
|
|
}
|
|
|
|
static NSString *PBRollBackLatest(NSError **outError)
|
|
{
|
|
NSString *manifestPath = PBManifestPaths().firstObject;
|
|
if (!manifestPath) {
|
|
PBSetError(outError, ENOENT, @"There is no installed wallpaper transaction to roll back");
|
|
return nil;
|
|
}
|
|
NSDictionary *manifest = [NSDictionary dictionaryWithContentsOfFile:manifestPath];
|
|
NSArray<NSDictionary *> *entries = [manifest[@"Entries"] isKindOfClass:NSArray.class]
|
|
? manifest[@"Entries"] : nil;
|
|
if ((![[manifest objectForKey:@"Version"] isEqual:@1] &&
|
|
![[manifest objectForKey:@"Version"] isEqual:@2]) || entries.count == 0) {
|
|
PBSetError(outError, EINVAL, @"The newest rollback manifest is invalid");
|
|
return nil;
|
|
}
|
|
NSError *error = nil;
|
|
NSDictionary *context = PBPosterBoardContext(&error);
|
|
if (!context) {
|
|
if (outError) *outError = error;
|
|
return nil;
|
|
}
|
|
for (NSDictionary *entry in entries) {
|
|
NSString *target = [entry[@"Target"] isKindOfClass:NSString.class] ? entry[@"Target"] : nil;
|
|
NSString *descriptorRoot = [entry[@"DescriptorRoot"] isKindOfClass:NSString.class]
|
|
? entry[@"DescriptorRoot"] : nil;
|
|
NSString *uuid = [entry[@"UUID"] isKindOfClass:NSString.class] ? entry[@"UUID"] : nil;
|
|
NSUUID *parsedUUID = [[NSUUID alloc] initWithUUIDString:uuid];
|
|
if (!target || !descriptorRoot || !parsedUUID ||
|
|
![descriptorRoot.lastPathComponent isEqualToString:@"descriptors"] ||
|
|
!PBPathIsInside(descriptorRoot, context[@"Extensions"]) ||
|
|
![target.stringByStandardizingPath isEqualToString:
|
|
[descriptorRoot stringByAppendingPathComponent:uuid].stringByStandardizingPath]) {
|
|
PBSetError(outError, EPERM, @"Rollback manifest contains an out-of-scope target");
|
|
return nil;
|
|
}
|
|
PBTreeStats stats = {0};
|
|
NSString *digest = PBTreeDigest(target, &stats, &error);
|
|
if (!digest || ![digest isEqualToString:entry[@"SHA256"]]) {
|
|
if (!error) PBSetError(&error, EBUSY,
|
|
@"Refusing rollback because %@ changed after import", target.lastPathComponent);
|
|
if (outError) *outError = error;
|
|
return nil;
|
|
}
|
|
}
|
|
|
|
NSString *reportPath = [PBReportsDirectory() stringByAppendingPathComponent:
|
|
[NSString stringWithFormat:@"Rollback-%@", manifestPath.lastPathComponent]];
|
|
NSMutableDictionary *report = [manifest mutableCopy];
|
|
report[@"Status"] = @"rollback-pending";
|
|
report[@"RollbackStartedAt"] = NSDate.date;
|
|
if (!PBWriteManifest(report, reportPath, &error)) {
|
|
if (outError) *outError = error;
|
|
return nil;
|
|
}
|
|
NSDictionary *refreshRecord = [manifest[@"RefreshPreference"]
|
|
isKindOfClass:NSDictionary.class] ? manifest[@"RefreshPreference"] : nil;
|
|
if (refreshRecord && !PBRestoreRefreshPreferences(refreshRecord, context, &error)) {
|
|
if (outError) *outError = error;
|
|
return nil;
|
|
}
|
|
NSUInteger removed = 0;
|
|
for (NSDictionary *entry in entries.reverseObjectEnumerator) {
|
|
if (![[NSFileManager defaultManager] removeItemAtPath:entry[@"Target"] error:&error]) break;
|
|
removed++;
|
|
}
|
|
if (removed != entries.count) {
|
|
if (outError) *outError = error ?: [NSError errorWithDomain:PBErrorDomain code:EIO
|
|
userInfo:@{NSLocalizedDescriptionKey: @"Rollback stopped before all descriptors were removed"}];
|
|
return nil;
|
|
}
|
|
PBCleanupEmptyDirectories(manifest[@"CreatedParents"] ?: @[]);
|
|
NSString *preferenceBackup = refreshRecord[@"BackupPath"];
|
|
if (preferenceBackup.length)
|
|
[[NSFileManager defaultManager] removeItemAtPath:preferenceBackup error:nil];
|
|
report[@"Status"] = @"rolled-back";
|
|
report[@"RolledBackAt"] = NSDate.date;
|
|
PBWriteManifest(report, reportPath, nil);
|
|
[[NSFileManager defaultManager] removeItemAtPath:manifestPath error:nil];
|
|
return [NSString stringWithFormat:@"Removed %lu imported descriptor%@ and restored the refresh preferences.",
|
|
(unsigned long)removed, removed == 1 ? @"" : @"s"];
|
|
}
|
|
|
|
static NSString *PBInspection(NSError **outError)
|
|
{
|
|
NSError *error = nil;
|
|
NSDictionary *context = PBPosterBoardContext(&error);
|
|
if (!context) {
|
|
if (outError) *outError = error;
|
|
return nil;
|
|
}
|
|
NSArray *providers = @[
|
|
@"com.apple.WallpaperKit.CollectionsPoster",
|
|
@"com.apple.PhotosUIPrivate.PhotosPosterProvider",
|
|
@"com.apple.MercuryPoster",
|
|
];
|
|
NSMutableArray<NSString *> *lines = [NSMutableArray arrayWithObject:
|
|
[NSString stringWithFormat:@"Active structure: %@", context[@"Structure"]]];
|
|
for (NSString *provider in providers) {
|
|
NSString *path = [[context[@"Extensions"] stringByAppendingPathComponent:provider]
|
|
stringByAppendingPathComponent:@"descriptors"];
|
|
NSArray<NSString *> *names = PBDirectoryNames(path, nil) ?: @[];
|
|
NSUInteger count = 0;
|
|
for (NSString *name in names) {
|
|
struct stat status = {0};
|
|
NSString *child = [path stringByAppendingPathComponent:name];
|
|
if (lstat(child.fileSystemRepresentation, &status) == 0 && S_ISDIR(status.st_mode))
|
|
count++;
|
|
}
|
|
[lines addObject:[NSString stringWithFormat:@"%@: %lu descriptors",
|
|
provider.lastPathComponent, (unsigned long)count]];
|
|
}
|
|
[lines addObject:@"Inspection is read-only."];
|
|
return [lines componentsJoinedByString:@"\n"];
|
|
}
|
|
|
|
static NSArray<NSString *> *PBPackageDirectories(void)
|
|
{
|
|
NSArray<NSString *> *names = PBDirectoryNames(PBImportsDirectory(), nil) ?: @[];
|
|
NSMutableArray<NSString *> *packages = [NSMutableArray array];
|
|
for (NSString *name in names) {
|
|
if (PBSkippedName(name)) continue;
|
|
NSString *path = [PBImportsDirectory() stringByAppendingPathComponent:name];
|
|
struct stat status = {0};
|
|
if (lstat(path.fileSystemRepresentation, &status) == 0 && S_ISDIR(status.st_mode))
|
|
[packages addObject:path];
|
|
if (packages.count == 10) break;
|
|
}
|
|
return packages;
|
|
}
|
|
|
|
static void PBInstallBundledSamples(void)
|
|
{
|
|
NSString *sampleRoot = [NSBundle.mainBundle.resourcePath
|
|
stringByAppendingPathComponent:@"WallpaperSamples"];
|
|
if (!PBDirectoryNoSymlink(sampleRoot, YES, nil)) return;
|
|
NSArray<NSString *> *names = PBDirectoryNames(sampleRoot, nil) ?: @[];
|
|
NSFileManager *manager = NSFileManager.defaultManager;
|
|
for (NSString *name in names) {
|
|
if (PBSkippedName(name)) continue;
|
|
NSString *source = [sampleRoot stringByAppendingPathComponent:name];
|
|
NSString *destination = [PBImportsDirectory() stringByAppendingPathComponent:name];
|
|
if (![manager fileExistsAtPath:destination]) {
|
|
NSError *error = nil;
|
|
if ([manager copyItemAtPath:source toPath:destination error:&error])
|
|
NSLog(@"[WallpaperLab] installed bundled sample %@", name);
|
|
else
|
|
NSLog(@"[WallpaperLab] bundled sample copy failed %@ detail=%@", name, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
static void PBStartAutomaticCipherImport(void)
|
|
{
|
|
static dispatch_once_t onceToken;
|
|
dispatch_once(&onceToken, ^{
|
|
NSString *package = [PBImportsDirectory()
|
|
stringByAppendingPathComponent:@"Cipher by mightycooldude12"];
|
|
NSString *reportPath = [PBReportsDirectory()
|
|
stringByAppendingPathComponent:@"Automatic-Cipher-Import.plist"];
|
|
NSDictionary *existing = [NSDictionary dictionaryWithContentsOfFile:reportPath];
|
|
if ([existing[@"Status"] isEqualToString:@"success"]) return;
|
|
if (!PBDirectoryNoSymlink(package, YES, nil)) return;
|
|
for (NSString *manifestPath in PBManifestPaths()) {
|
|
NSDictionary *manifest = [NSDictionary dictionaryWithContentsOfFile:manifestPath];
|
|
if ([manifest[@"Status"] isEqualToString:@"installed"] &&
|
|
[manifest[@"Version"] isEqual:@2] &&
|
|
[manifest[@"Package"] isEqualToString:package.lastPathComponent]) {
|
|
NSString *message = @"Cipher was already imported with refresh preferences. Open Settings > Wallpaper.";
|
|
NSDictionary *report = @{@"Version": @1, @"Package": package.lastPathComponent,
|
|
@"Status": @"success", @"AttemptedAt": NSDate.date, @"Message": message};
|
|
PBWriteManifest(report, reportPath, nil);
|
|
[message writeToFile:[PBWorkspace() stringByAppendingPathComponent:
|
|
@"CIPHER READY - OPEN SETTINGS.txt"] atomically:YES
|
|
encoding:NSUTF8StringEncoding error:nil];
|
|
return;
|
|
}
|
|
}
|
|
|
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 500 * NSEC_PER_MSEC),
|
|
dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
|
|
NSError *error = nil;
|
|
NSString *result = PBImportPackage(package, &error);
|
|
BOOL success = result != nil;
|
|
NSDictionary *report = @{
|
|
@"Version": @1,
|
|
@"Package": package.lastPathComponent,
|
|
@"Status": success ? @"success" : @"failed",
|
|
@"AttemptedAt": NSDate.date,
|
|
@"Message": result ?: error.localizedDescription ?: @"Unknown failure",
|
|
};
|
|
PBWriteManifest(report, reportPath, nil);
|
|
NSString *readyPath = [PBWorkspace()
|
|
stringByAppendingPathComponent:@"CIPHER READY - OPEN SETTINGS.txt"];
|
|
NSString *failurePath = [PBWorkspace()
|
|
stringByAppendingPathComponent:@"CIPHER IMPORT FAILED.txt"];
|
|
NSFileManager *manager = NSFileManager.defaultManager;
|
|
if (success) {
|
|
[manager removeItemAtPath:failurePath error:nil];
|
|
[result writeToFile:readyPath atomically:YES
|
|
encoding:NSUTF8StringEncoding error:nil];
|
|
NSLog(@"[WallpaperLab] automatic Cipher import succeeded: %@", result);
|
|
} else {
|
|
[manager removeItemAtPath:readyPath error:nil];
|
|
[error.localizedDescription writeToFile:failurePath atomically:YES
|
|
encoding:NSUTF8StringEncoding error:nil];
|
|
NSLog(@"[WallpaperLab] automatic Cipher import failed: %@", error);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
static UIViewController *PBTopController(UIViewController *controller)
|
|
{
|
|
while (controller) {
|
|
UIViewController *next = controller.presentedViewController;
|
|
if (!next && [controller isKindOfClass:UINavigationController.class])
|
|
next = ((UINavigationController *)controller).visibleViewController;
|
|
if (!next && [controller isKindOfClass:UITabBarController.class])
|
|
next = ((UITabBarController *)controller).selectedViewController;
|
|
if (!next || next == controller) break;
|
|
controller = next;
|
|
}
|
|
return controller;
|
|
}
|
|
|
|
@interface PBWallpaperFeatureController : NSObject
|
|
@property(nonatomic, weak) UIViewController *presenter;
|
|
@property(nonatomic) dispatch_queue_t operationQueue;
|
|
+ (instancetype)sharedController;
|
|
- (void)showMenu:(UIBarButtonItem *)sender;
|
|
@end
|
|
|
|
@implementation PBWallpaperFeatureController
|
|
|
|
+ (instancetype)sharedController
|
|
{
|
|
static PBWallpaperFeatureController *controller;
|
|
static dispatch_once_t onceToken;
|
|
dispatch_once(&onceToken, ^{
|
|
controller = [PBWallpaperFeatureController new];
|
|
controller.operationQueue = dispatch_queue_create(
|
|
"local.filzamod.posterboard", DISPATCH_QUEUE_SERIAL);
|
|
});
|
|
return controller;
|
|
}
|
|
|
|
- (void)showAlertWithTitle:(NSString *)title message:(NSString *)message
|
|
{
|
|
UIViewController *presenter = PBTopController(self.presenter);
|
|
if (!presenter) return;
|
|
UIAlertController *alert = [UIAlertController alertControllerWithTitle:title
|
|
message:message preferredStyle:UIAlertControllerStyleAlert];
|
|
[alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
|
|
[presenter presentViewController:alert animated:YES completion:nil];
|
|
}
|
|
|
|
- (void)runOperationWithTitle:(NSString *)title block:(NSString *(^)(NSError **error))block
|
|
{
|
|
dispatch_async(self.operationQueue, ^{
|
|
NSError *error = nil;
|
|
NSString *message = block(&error);
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
[self showAlertWithTitle:message ? title : @"Wallpaper operation failed"
|
|
message:message ?: error.localizedDescription ?: @"Unknown error"];
|
|
});
|
|
});
|
|
}
|
|
|
|
- (void)confirmImport:(NSString *)package
|
|
{
|
|
UIViewController *presenter = PBTopController(self.presenter);
|
|
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Import wallpaper package?"
|
|
message:[NSString stringWithFormat:
|
|
@"%@ will add only new descriptor directories. Existing PosterBoard files are not overwritten.",
|
|
package.lastPathComponent]
|
|
preferredStyle:UIAlertControllerStyleAlert];
|
|
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
|
|
__weak typeof(self) weakSelf = self;
|
|
[alert addAction:[UIAlertAction actionWithTitle:@"Import" style:UIAlertActionStyleDefault
|
|
handler:^(__unused UIAlertAction *action) {
|
|
[weakSelf runOperationWithTitle:@"Wallpaper imported"
|
|
block:^NSString *(NSError **error) { return PBImportPackage(package, error); }];
|
|
}]];
|
|
[presenter presentViewController:alert animated:YES completion:nil];
|
|
}
|
|
|
|
- (void)confirmRollback
|
|
{
|
|
UIViewController *presenter = PBTopController(self.presenter);
|
|
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Roll back last import?"
|
|
message:@"Rollback removes only descriptors from the newest Filza Mod manifest, and refuses if their hashes changed."
|
|
preferredStyle:UIAlertControllerStyleAlert];
|
|
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
|
|
__weak typeof(self) weakSelf = self;
|
|
[alert addAction:[UIAlertAction actionWithTitle:@"Roll Back" style:UIAlertActionStyleDestructive
|
|
handler:^(__unused UIAlertAction *action) {
|
|
[weakSelf runOperationWithTitle:@"Wallpaper rollback complete"
|
|
block:^NSString *(NSError **error) { return PBRollBackLatest(error); }];
|
|
}]];
|
|
[presenter presentViewController:alert animated:YES completion:nil];
|
|
}
|
|
|
|
- (void)showMenu:(UIBarButtonItem *)sender
|
|
{
|
|
UIViewController *presenter = PBTopController(self.presenter);
|
|
if (!presenter) return;
|
|
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Wallpaper Lab"
|
|
message:@"Bounded Nugget-style PosterBoard descriptor import"
|
|
preferredStyle:UIAlertControllerStyleActionSheet];
|
|
__weak typeof(self) weakSelf = self;
|
|
[sheet addAction:[UIAlertAction actionWithTitle:@"Inspect PosterBoard"
|
|
style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *action) {
|
|
[weakSelf runOperationWithTitle:@"PosterBoard inspection"
|
|
block:^NSString *(NSError **error) { return PBInspection(error); }];
|
|
}]];
|
|
for (NSString *package in PBPackageDirectories()) {
|
|
[sheet addAction:[UIAlertAction actionWithTitle:
|
|
[@"Import " stringByAppendingString:package.lastPathComponent]
|
|
style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *action) {
|
|
[weakSelf confirmImport:package];
|
|
}]];
|
|
}
|
|
if (PBManifestPaths().count > 0) {
|
|
[sheet addAction:[UIAlertAction actionWithTitle:@"Roll Back Last Import"
|
|
style:UIAlertActionStyleDestructive handler:^(__unused UIAlertAction *action) {
|
|
[weakSelf confirmRollback];
|
|
}]];
|
|
}
|
|
[sheet addAction:[UIAlertAction actionWithTitle:@"Help" style:UIAlertActionStyleDefault
|
|
handler:^(__unused UIAlertAction *action) {
|
|
[weakSelf showAlertWithTitle:@"Wallpaper Lab"
|
|
message:@"Cipher imports automatically on first launch. Other extracted .tendies packages can be placed in [MHA-C2] Wallpaper Lab/Imports. Container-style packages are rejected. Imports are capped at 10 descriptors and 256 MiB. The refresh preference is backed up for rollback; the PosterBoard database is not changed."];
|
|
}]];
|
|
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
|
|
sheet.popoverPresentationController.barButtonItem = sender;
|
|
[presenter presentViewController:sheet animated:YES completion:nil];
|
|
}
|
|
|
|
@end
|
|
|
|
void PBWallpaperFeatureStart(void)
|
|
{
|
|
NSFileManager *manager = NSFileManager.defaultManager;
|
|
for (NSString *directory in @[PBWorkspace(), PBImportsDirectory(), PBBackupsDirectory(),
|
|
PBReportsDirectory()]) {
|
|
[manager createDirectoryAtPath:directory withIntermediateDirectories:YES
|
|
attributes:@{NSFilePosixPermissions: @0700} error:nil];
|
|
}
|
|
PBInstallBundledSamples();
|
|
NSString *readme = [PBWorkspace() stringByAppendingPathComponent:@"README.txt"];
|
|
NSString *text = @"Wallpaper Lab\n\n"
|
|
@"Primitive: MHA-MCM class 2 application-data lookup for com.apple.PosterBoard.\n"
|
|
@"This folder is local staging. PosterBoard access starts only during a Wallpaper Lab action.\n\n"
|
|
@"The bundled GPL-licensed Cipher wallpaper imports automatically on first launch.\n"
|
|
@"When CIPHER READY appears here, open Settings > Wallpaper.\n\n"
|
|
@"Other descriptor-style .tendies packages may be extracted into Imports.\n"
|
|
@"Only descriptor-style packages are supported. Container-style packages are rejected.\n"
|
|
@"Imports add new UUID directories and never overwrite the PosterBoard database or an existing descriptor.\n"
|
|
@"Nugget's refresh keys are merged into the preference file after an exact backup is saved.\n"
|
|
@"Rollback verifies SHA-256 hashes before restoring the preference and removing the descriptor.\n";
|
|
[text writeToFile:readme atomically:YES encoding:NSUTF8StringEncoding error:nil];
|
|
PBStartAutomaticCipherImport();
|
|
}
|
|
|
|
void PBWallpaperConfigureBrowser(UIViewController *controller, NSString *currentPath)
|
|
{
|
|
if (!controller) return;
|
|
PBWallpaperFeatureController *feature = [PBWallpaperFeatureController sharedController];
|
|
NSString *workspace = PBCanonicalPath(PBWorkspace());
|
|
BOOL visible = [PBCanonicalPath(currentPath) isEqualToString:workspace];
|
|
NSMutableArray<UIBarButtonItem *> *items =
|
|
[NSMutableArray arrayWithArray:controller.navigationItem.rightBarButtonItems ?: @[]];
|
|
NSIndexSet *ours = [items indexesOfObjectsPassingTest:
|
|
^BOOL(UIBarButtonItem *item, __unused NSUInteger index, __unused BOOL *stop) {
|
|
return item.tag == PBButtonTag;
|
|
}];
|
|
[items removeObjectsAtIndexes:ours];
|
|
if (visible) {
|
|
feature.presenter = controller;
|
|
UIBarButtonItem *button = [[UIBarButtonItem alloc] initWithTitle:@"Wallpaper"
|
|
style:UIBarButtonItemStylePlain target:feature action:@selector(showMenu:)];
|
|
button.tag = PBButtonTag;
|
|
// Filza continuously restores its Edit item. At the lab root the
|
|
// wallpaper action replaces it; Edit returns in child directories.
|
|
[items removeAllObjects];
|
|
[items addObject:button];
|
|
NSLog(@"[WallpaperLab] installed navigation action class=%@ path=%@ canonical=%@",
|
|
NSStringFromClass(controller.class), currentPath, PBCanonicalPath(currentPath));
|
|
}
|
|
controller.navigationItem.rightBarButtonItems = items;
|
|
}
|