From bcc9248f6b1076f602db9e9940e3af3bb1827d84 Mon Sep 17 00:00:00 2001 From: relikd Date: Sat, 20 Jan 2024 22:33:35 +0100 Subject: [PATCH 01/32] ref: extract unzip into helper function --- ProvisionQL/GeneratePreviewForURL.m | 19 +++----------- ProvisionQL/GenerateThumbnailForURL.m | 12 +-------- ProvisionQL/Shared.h | 3 +++ ProvisionQL/Shared.m | 38 +++++++++++++++++++-------- 4 files changed, 34 insertions(+), 38 deletions(-) diff --git a/ProvisionQL/GeneratePreviewForURL.m b/ProvisionQL/GeneratePreviewForURL.m index 357d56b..0f1b94e 100644 --- a/ProvisionQL/GeneratePreviewForURL.m +++ b/ProvisionQL/GeneratePreviewForURL.m @@ -282,27 +282,14 @@ OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, NSImage *appIcon = nil; if ([dataType isEqualToString:kDataType_ipa]) { - // get the embedded provisioning & plist from an app archive using: unzip -u -j -d - NSTask *unzipTask = [NSTask new]; - [unzipTask setLaunchPath:@"/usr/bin/unzip"]; - [unzipTask setArguments:@[@"-u", @"-j", @"-d", currentTempDirFolder, [URL path], @"Payload/*.app/embedded.mobileprovision", @"Payload/*.app/Info.plist", @"-x", @"*/*/*/*"]]; - [unzipTask launch]; - [unzipTask waitUntilExit]; - - NSString *provisionPath = [currentTempDirFolder stringByAppendingPathComponent:@"embedded.mobileprovision"]; - provisionData = [NSData dataWithContentsOfFile:provisionPath]; - NSString *plistPath = [currentTempDirFolder stringByAppendingPathComponent:@"Info.plist"]; - appPlist = [NSData dataWithContentsOfFile:plistPath]; + provisionData = unzipFile(URL, @"Payload/*.app/embedded.mobileprovision"); + appPlist = unzipFile(URL, @"Payload/*.app/Info.plist"); // read codesigning entitlements from application binary (extract it first) NSDictionary *appPropertyList = [NSPropertyListSerialization propertyListWithData:appPlist options:0 format:NULL error:NULL]; NSString *bundleExecutable = [appPropertyList objectForKey:@"CFBundleExecutable"]; - NSTask *unzipAppTask = [NSTask new]; - [unzipAppTask setLaunchPath:@"/usr/bin/unzip"]; - [unzipAppTask setArguments:@[@"-u", @"-j", @"-d", currentTempDirFolder, [URL path], [@"Payload/*.app/" stringByAppendingPathComponent:bundleExecutable], @"-x", @"*/*/*/*"]]; - [unzipAppTask launch]; - [unzipAppTask waitUntilExit]; + unzipFileToDir(URL, currentTempDirFolder, [@"Payload/*.app/" stringByAppendingPathComponent:bundleExecutable]); codesignEntitlementsData = codesignEntitlementsDataFromApp(appPlist, currentTempDirFolder); diff --git a/ProvisionQL/GenerateThumbnailForURL.m b/ProvisionQL/GenerateThumbnailForURL.m index f1334c2..c2e7c6f 100644 --- a/ProvisionQL/GenerateThumbnailForURL.m +++ b/ProvisionQL/GenerateThumbnailForURL.m @@ -46,17 +46,7 @@ OSStatus GenerateThumbnailForURL(void *thisInterface, QLThumbnailRequestRef thum } } } else if([dataType isEqualToString:kDataType_ipa]) { - // get the embedded plist from an app archive using: unzip -p 'Payload/*.app/Info.plist' (piped to standard output) - NSTask *unzipTask = [NSTask new]; - [unzipTask setLaunchPath:@"/usr/bin/unzip"]; - [unzipTask setStandardOutput:[NSPipe pipe]]; - [unzipTask setArguments:@[@"-p", [URL path], @"Payload/*.app/Info.plist", @"-x", @"*/*/*/*"]]; - [unzipTask launch]; - - NSData *pipeData = [[[unzipTask standardOutput] fileHandleForReading] readDataToEndOfFile]; - [unzipTask waitUntilExit]; - - appPlist = pipeData; + appPlist = unzipFile(URL, @"Payload/*.app/Info.plist"); } else { // use provisioning directly provisionData = [NSData dataWithContentsOfURL:URL]; diff --git a/ProvisionQL/Shared.h b/ProvisionQL/Shared.h index ca9328c..3f48fec 100644 --- a/ProvisionQL/Shared.h +++ b/ProvisionQL/Shared.h @@ -16,6 +16,9 @@ static NSString * const kDataType_osx_provision = @"com.apple.provisionprofi static NSString * const kDataType_xcode_archive = @"com.apple.xcode.archive"; static NSString * const kDataType_app_extension = @"com.apple.application-and-system-extension"; +NSData *unzipFile(NSURL *url, NSString *filePath); +void unzipFileToDir(NSURL *url, NSString *filePath, NSString *targetDir); + NSImage *roundCorners(NSImage *image); NSImage *imageFromApp(NSURL *URL, NSString *dataType, NSString *fileName); NSString *mainIconNameForApp(NSDictionary *appPropertyList); diff --git a/ProvisionQL/Shared.m b/ProvisionQL/Shared.m index 8155d4a..c8bee85 100644 --- a/ProvisionQL/Shared.m +++ b/ProvisionQL/Shared.m @@ -1,5 +1,28 @@ #import "Shared.h" +NSData *unzipFile(NSURL *url, NSString *filePath) { + NSTask *task = [NSTask new]; + [task setLaunchPath:@"/usr/bin/unzip"]; + [task setStandardOutput:[NSPipe pipe]]; + [task setArguments:@[@"-p", [url path], filePath]]; // @"-x", @"*/*/*/*" + [task launch]; + + NSData *pipeData = [[[task standardOutput] fileHandleForReading] readDataToEndOfFile]; + [task waitUntilExit]; + if (pipeData.length == 0) { + return nil; + } + return pipeData; +} + +void unzipFileToDir(NSURL *url, NSString *targetDir, NSString *filePath) { + NSTask *task = [NSTask new]; + [task setLaunchPath:@"/usr/bin/unzip"]; + [task setArguments:@[@"-u", @"-j", @"-d", targetDir, [url path], filePath]]; // @"-x", @"*/*/*/*" + [task launch]; + [task waitUntilExit]; +} + NSImage *roundCorners(NSImage *image) { NSImage *existingImage = image; NSSize existingSize = [existingImage size]; @@ -67,17 +90,10 @@ int expirationStatus(NSDate *date, NSCalendar *calendar) { NSURL *appIconFullURL = [appURL URLByAppendingPathComponent:appIconFullName]; appIcon = [[NSImage alloc] initWithContentsOfURL:appIconFullURL]; } else if([dataType isEqualToString:kDataType_ipa]) { - // get the embedded icon from an app arcive using: unzip -p 'Payload/*.app/' (piped to standard output) - NSTask *unzipTask = [NSTask new]; - [unzipTask setLaunchPath:@"/usr/bin/unzip"]; - [unzipTask setStandardOutput:[NSPipe pipe]]; - [unzipTask setArguments:@[@"-p", [URL path], [NSString stringWithFormat:@"Payload/*.app/%@*", fileName], @"-x", @"*/*/*/*"]]; - [unzipTask launch]; - - NSData *pipeData = [[[unzipTask standardOutput] fileHandleForReading] readDataToEndOfFile]; - [unzipTask waitUntilExit]; - - appIcon = [[NSImage alloc] initWithData:pipeData]; + NSData *data = unzipFile(URL, [NSString stringWithFormat:@"Payload/*.app/%@*", fileName]); + if (data != nil) { + appIcon = [[NSImage alloc] initWithData:data]; + } } return appIcon; From 5ddcdc97c9fcc61b1a0e7a9023f8e0d7ed030e83 Mon Sep 17 00:00:00 2001 From: relikd Date: Sat, 20 Jan 2024 22:35:12 +0100 Subject: [PATCH 02/32] feat: use high-res iTunes Artwork as icon --- ProvisionQL/Shared.m | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ProvisionQL/Shared.m b/ProvisionQL/Shared.m index c8bee85..8bb024e 100644 --- a/ProvisionQL/Shared.m +++ b/ProvisionQL/Shared.m @@ -90,7 +90,10 @@ int expirationStatus(NSDate *date, NSCalendar *calendar) { NSURL *appIconFullURL = [appURL URLByAppendingPathComponent:appIconFullName]; appIcon = [[NSImage alloc] initWithContentsOfURL:appIconFullURL]; } else if([dataType isEqualToString:kDataType_ipa]) { - NSData *data = unzipFile(URL, [NSString stringWithFormat:@"Payload/*.app/%@*", fileName]); + NSData *data = unzipFile(URL, @"iTunesArtwork"); + if (!data && fileName.length > 0) { + data = unzipFile(URL, [NSString stringWithFormat:@"Payload/*.app/%@*", fileName]); + } if (data != nil) { appIcon = [[NSImage alloc] initWithData:data]; } From 2a339fc52e9692067c20cee1a98fd3c9666f519a Mon Sep 17 00:00:00 2001 From: relikd Date: Sat, 20 Jan 2024 23:19:07 +0100 Subject: [PATCH 03/32] fix: crash if plist key does not exist --- ProvisionQL/GeneratePreviewForURL.m | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ProvisionQL/GeneratePreviewForURL.m b/ProvisionQL/GeneratePreviewForURL.m index 0f1b94e..f9315c8 100644 --- a/ProvisionQL/GeneratePreviewForURL.m +++ b/ProvisionQL/GeneratePreviewForURL.m @@ -359,10 +359,10 @@ OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, if (!bundleName) { bundleName = [appPropertyList objectForKey:@"CFBundleName"]; } - [synthesizedInfo setObject:bundleName forKey:@"CFBundleName"]; - [synthesizedInfo setObject:[appPropertyList objectForKey:@"CFBundleIdentifier"] forKey:@"CFBundleIdentifier"]; - [synthesizedInfo setObject:[appPropertyList objectForKey:@"CFBundleShortVersionString"] forKey:@"CFBundleShortVersionString"]; - [synthesizedInfo setObject:[appPropertyList objectForKey:@"CFBundleVersion"] forKey:@"CFBundleVersion"]; + [synthesizedInfo setObject:bundleName ?: @"" forKey:@"CFBundleName"]; + [synthesizedInfo setObject:[appPropertyList objectForKey:@"CFBundleIdentifier"] ?: @"" forKey:@"CFBundleIdentifier"]; + [synthesizedInfo setObject:[appPropertyList objectForKey:@"CFBundleShortVersionString"] ?: @"" forKey:@"CFBundleShortVersionString"]; + [synthesizedInfo setObject:[appPropertyList objectForKey:@"CFBundleVersion"] ?: @"" forKey:@"CFBundleVersion"]; NSString *extensionType = [[appPropertyList objectForKey:@"NSExtension"] objectForKey:@"NSExtensionPointIdentifier"]; if(extensionType != nil) { @@ -699,10 +699,10 @@ OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, #endif synthesizedValue = [[NSBundle bundleWithIdentifier:kPluginBundleId] objectForInfoDictionaryKey:@"CFBundleShortVersionString"]; - [synthesizedInfo setObject:synthesizedValue forKey:@"BundleShortVersionString"]; + [synthesizedInfo setObject:synthesizedValue ?: @"" forKey:@"BundleShortVersionString"]; synthesizedValue = [[NSBundle bundleWithIdentifier:kPluginBundleId] objectForInfoDictionaryKey:@"CFBundleVersion"]; - [synthesizedInfo setObject:synthesizedValue forKey:@"BundleVersion"]; + [synthesizedInfo setObject:synthesizedValue ?: @"" forKey:@"BundleVersion"]; for (NSString *key in [synthesizedInfo allKeys]) { NSString *replacementValue = [synthesizedInfo objectForKey:key]; From 8e430bc5560b66b85d5182dd85e792cbceba3032 Mon Sep 17 00:00:00 2001 From: relikd Date: Sun, 21 Jan 2024 00:26:38 +0100 Subject: [PATCH 04/32] ref: extract function for app icon --- ProvisionQL/GeneratePreviewForURL.m | 31 +++++++++++++++-------------- ProvisionQL/Resources/template.html | 2 +- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/ProvisionQL/GeneratePreviewForURL.m b/ProvisionQL/GeneratePreviewForURL.m index f9315c8..431cd27 100644 --- a/ProvisionQL/GeneratePreviewForURL.m +++ b/ProvisionQL/GeneratePreviewForURL.m @@ -266,6 +266,18 @@ void displayKeyAndValue(NSUInteger level, NSString *key, id value, NSMutableStri return outputData; } +NSString *iconAsBase64(NSImage *appIcon) { + if (!appIcon) { + NSURL *iconURL = [[NSBundle bundleWithIdentifier:kPluginBundleId] URLForResource:@"defaultIcon" withExtension:@"png"]; + appIcon = [[NSImage alloc] initWithContentsOfURL:iconURL]; + } + appIcon = roundCorners(appIcon); + NSData *imageData = [appIcon TIFFRepresentation]; + NSBitmapImageRep *imageRep = [NSBitmapImageRep imageRepWithData:imageData]; + imageData = [imageRep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; + return [imageData base64EncodedStringWithOptions:0]; +} + OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, CFURLRef url, CFStringRef contentTypeUTI, CFDictionaryRef options) { @autoreleasepool { // create temp directory @@ -355,6 +367,10 @@ OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, if (appPlist != nil) { NSDictionary *appPropertyList = [NSPropertyListSerialization propertyListWithData:appPlist options:0 format:NULL error:NULL]; + NSString *iconName = mainIconNameForApp(appPropertyList); + appIcon = imageFromApp(URL, dataType, iconName); + [synthesizedInfo setObject:iconAsBase64(appIcon) forKey:@"AppIcon"]; + NSString *bundleName = [appPropertyList objectForKey:@"CFBundleDisplayName"]; if (!bundleName) { bundleName = [appPropertyList objectForKey:@"CFBundleName"]; @@ -410,9 +426,6 @@ OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, } [synthesizedInfo setObject:appTransportSecurityFormatted forKey:@"AppTransportSecurityFormatted"]; - - NSString *iconName = mainIconNameForApp(appPropertyList); - appIcon = imageFromApp(URL, dataType, iconName); NSMutableArray *platforms = [NSMutableArray array]; for (NSNumber *number in [appPropertyList objectForKey:@"UIDeviceFamily"]) { @@ -434,18 +447,6 @@ OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, } } [synthesizedInfo setObject:[platforms componentsJoinedByString:@", "] forKey:@"UIDeviceFamily"]; - - if (!appIcon) { - NSURL *iconURL = [[NSBundle bundleWithIdentifier:kPluginBundleId] URLForResource:@"defaultIcon" withExtension:@"png"]; - appIcon = [[NSImage alloc] initWithContentsOfURL:iconURL]; - } - appIcon = roundCorners(appIcon); - - NSData *imageData = [appIcon TIFFRepresentation]; - NSBitmapImageRep *imageRep = [NSBitmapImageRep imageRepWithData:imageData]; - imageData = [imageRep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; - NSString *base64 = [imageData base64EncodedStringWithOptions:0]; - [synthesizedInfo setObject:base64 forKey:@"AppIcon"]; [synthesizedInfo setObject:@"" forKey:@"AppInfo"]; [synthesizedInfo setObject:@"hiddenDiv" forKey:@"ProvisionAsSubheader"]; } else { diff --git a/ProvisionQL/Resources/template.html b/ProvisionQL/Resources/template.html index 6eb172a..340ce41 100644 --- a/ProvisionQL/Resources/template.html +++ b/ProvisionQL/Resources/template.html @@ -23,7 +23,7 @@ .app img { -webkit-filter: drop-shadow(0px 0px 3px rgba(0,0,0,0.5)); filter: drop-shadow(0px 0px 3px rgba(0,0,0,0.5)); - max-width: 60px; + width: 100px; } .info .subsection { From f5facd5dd03d777f72e363321dc1618edeae9acb Mon Sep 17 00:00:00 2001 From: relikd Date: Sun, 21 Jan 2024 01:24:36 +0100 Subject: [PATCH 05/32] fix: codesign --xml on old macOS --- ProvisionQL/GeneratePreviewForURL.m | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ProvisionQL/GeneratePreviewForURL.m b/ProvisionQL/GeneratePreviewForURL.m index 431cd27..15b0f5c 100644 --- a/ProvisionQL/GeneratePreviewForURL.m +++ b/ProvisionQL/GeneratePreviewForURL.m @@ -252,7 +252,11 @@ void displayKeyAndValue(NSUInteger level, NSString *key, id value, NSMutableStri [codesignTask setLaunchPath:@"/usr/bin/codesign"]; [codesignTask setStandardOutput:[NSPipe pipe]]; [codesignTask setStandardError:[NSPipe pipe]]; - [codesignTask setArguments:@[@"-d", binaryPath, @"--entitlements", @"-", @"--xml"]]; + if (@available(macOS 11, *)) { + [codesignTask setArguments:@[@"-d", binaryPath, @"--entitlements", @"-", @"--xml"]]; + } else { + [codesignTask setArguments:@[@"-d", binaryPath, @"--entitlements", @":-"]]; + } [codesignTask launch]; NSData *outputData = [[[codesignTask standardOutput] fileHandleForReading] readDataToEndOfFile]; From 47a3cb338bb9627e4c2def751eb3a6f93d3e2b13 Mon Sep 17 00:00:00 2001 From: relikd Date: Sun, 21 Jan 2024 07:49:34 +0100 Subject: [PATCH 06/32] fix: IconFlavor in >10.15 --- ProvisionQL/GenerateThumbnailForURL.m | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/ProvisionQL/GenerateThumbnailForURL.m b/ProvisionQL/GenerateThumbnailForURL.m index c2e7c6f..84e4b17 100644 --- a/ProvisionQL/GenerateThumbnailForURL.m +++ b/ProvisionQL/GenerateThumbnailForURL.m @@ -36,6 +36,13 @@ OSStatus GenerateThumbnailForURL(void *thisInterface, QLThumbnailRequestRef thum NSUInteger devicesCount = 0; int expStatus = 0; + static const NSString *IconFlavor; + if (@available(macOS 10.15, *)) { + IconFlavor = @"icon"; + } else { + IconFlavor = @"IconFlavor"; + } + if ([dataType isEqualToString:kDataType_xcode_archive]) { // get the embedded plist for the iOS app NSURL *appsDir = [URL URLByAppendingPathComponent:@"Products/Applications/"]; @@ -68,9 +75,11 @@ OSStatus GenerateThumbnailForURL(void *thisInterface, QLThumbnailRequestRef thum } appIcon = roundCorners(appIcon); if ([dataType isEqualToString:kDataType_xcode_archive]) { - propertiesDict = @{@"IconFlavor" : @(12)}; + // 0: Plain transparent, 1: Shadow, 2: Book, 3: Movie, 4: Address, 5: Image, + // 6: Gloss, 7: Slide, 8: Square, 9: Border, 11: Calendar, 12: Pattern + propertiesDict = @{IconFlavor : @(12)}; } else { - propertiesDict = @{@"IconFlavor" : @(0)}; + propertiesDict = @{IconFlavor : @(0)}; } } else { if (iconMode) { From cae9ef32e3fb9c95f526c859b293f4a24fd2a6f5 Mon Sep 17 00:00:00 2001 From: relikd Date: Sun, 21 Jan 2024 07:51:14 +0100 Subject: [PATCH 07/32] ref: draw image directly with QLThumbnail --- ProvisionQL/GenerateThumbnailForURL.m | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ProvisionQL/GenerateThumbnailForURL.m b/ProvisionQL/GenerateThumbnailForURL.m index 84e4b17..cd445b2 100644 --- a/ProvisionQL/GenerateThumbnailForURL.m +++ b/ProvisionQL/GenerateThumbnailForURL.m @@ -124,6 +124,13 @@ OSStatus GenerateThumbnailForURL(void *thisInterface, QLThumbnailRequestRef thum return noErr; } + // image-only can be drawn efficiently. + if ([dataType isEqualToString:kDataType_ipa] || [dataType isEqualToString:kDataType_xcode_archive]) { + [appIcon setSize:QLThumbnailRequestGetMaximumSize(thumbnail)]; + QLThumbnailRequestSetImageWithData(thumbnail, (__bridge CFDataRef)[appIcon TIFFRepresentation], (__bridge CFDictionaryRef)propertiesDict); + return noErr; + } + NSSize canvasSize = appIcon.size; NSRect renderRect = NSMakeRect(0.0, 0.0, appIcon.size.width, appIcon.size.height); @@ -133,7 +140,7 @@ OSStatus GenerateThumbnailForURL(void *thisInterface, QLThumbnailRequestRef thum [NSGraphicsContext setCurrentContext:_graphicsContext]; if ([dataType isEqualToString:kDataType_ipa] || [dataType isEqualToString:kDataType_xcode_archive]) { - [appIcon drawInRect:renderRect]; + // handled above } else { [appIcon drawInRect:renderRect]; From 09a94ab4c48ffc3d8f596c4b076f8b28359b6431 Mon Sep 17 00:00:00 2001 From: relikd Date: Sun, 21 Jan 2024 08:14:56 +0100 Subject: [PATCH 08/32] ref: reorganize thumbnail generator --- ProvisionQL/GenerateThumbnailForURL.m | 204 +++++++++++++------------- 1 file changed, 102 insertions(+), 102 deletions(-) diff --git a/ProvisionQL/GenerateThumbnailForURL.m b/ProvisionQL/GenerateThumbnailForURL.m index cd445b2..3def4a8 100644 --- a/ProvisionQL/GenerateThumbnailForURL.m +++ b/ProvisionQL/GenerateThumbnailForURL.m @@ -28,20 +28,8 @@ OSStatus GenerateThumbnailForURL(void *thisInterface, QLThumbnailRequestRef thum @autoreleasepool { NSURL *URL = (__bridge NSURL *)url; NSString *dataType = (__bridge NSString *)contentTypeUTI; - NSDictionary *optionsDict = (__bridge NSDictionary *)options; - BOOL iconMode = ([optionsDict objectForKey:(NSString *)kQLThumbnailOptionIconModeKey]) ? YES : NO; - NSData *provisionData = nil; NSData *appPlist = nil; NSImage *appIcon = nil; - NSUInteger devicesCount = 0; - int expStatus = 0; - - static const NSString *IconFlavor; - if (@available(macOS 10.15, *)) { - IconFlavor = @"icon"; - } else { - IconFlavor = @"IconFlavor"; - } if ([dataType isEqualToString:kDataType_xcode_archive]) { // get the embedded plist for the iOS app @@ -54,26 +42,34 @@ OSStatus GenerateThumbnailForURL(void *thisInterface, QLThumbnailRequestRef thum } } else if([dataType isEqualToString:kDataType_ipa]) { appPlist = unzipFile(URL, @"Payload/*.app/Info.plist"); - } else { - // use provisioning directly - provisionData = [NSData dataWithContentsOfURL:URL]; } if (QLThumbnailRequestIsCancelled(thumbnail)) { return noErr; } - NSDictionary *propertiesDict = nil; + // MARK: .ipa & .xarchive + if ([dataType isEqualToString:kDataType_ipa] || [dataType isEqualToString:kDataType_xcode_archive]) { NSDictionary *appPropertyList = [NSPropertyListSerialization propertyListWithData:appPlist options:0 format:NULL error:NULL]; NSString *iconName = mainIconNameForApp(appPropertyList); appIcon = imageFromApp(URL, dataType, iconName); + if (QLThumbnailRequestIsCancelled(thumbnail)) { + return noErr; + } + if (!appIcon) { NSURL *iconURL = [[NSBundle bundleWithIdentifier:kPluginBundleId] URLForResource:@"defaultIcon" withExtension:@"png"]; appIcon = [[NSImage alloc] initWithContentsOfURL:iconURL]; } - appIcon = roundCorners(appIcon); + static const NSString *IconFlavor; + if (@available(macOS 10.15, *)) { + IconFlavor = @"icon"; + } else { + IconFlavor = @"IconFlavor"; + } + NSDictionary *propertiesDict = nil; if ([dataType isEqualToString:kDataType_xcode_archive]) { // 0: Plain transparent, 1: Shadow, 2: Book, 3: Movie, 4: Address, 5: Image, // 6: Gloss, 7: Slide, 8: Square, 9: Border, 11: Calendar, 12: Pattern @@ -81,108 +77,112 @@ OSStatus GenerateThumbnailForURL(void *thisInterface, QLThumbnailRequestRef thum } else { propertiesDict = @{IconFlavor : @(0)}; } - } else { - if (iconMode) { - NSURL *iconURL = [[NSBundle bundleWithIdentifier:kPluginBundleId] URLForResource:@"blankIcon" withExtension:@"png"]; - appIcon = [[NSImage alloc] initWithContentsOfURL:iconURL]; - } else { - appIcon = [[NSWorkspace sharedWorkspace] iconForFileType:dataType]; - [appIcon setSize:NSMakeSize(512,512)]; - } + // image-only icons can be drawn efficiently. + appIcon = roundCorners(appIcon); + [appIcon setSize:QLThumbnailRequestGetMaximumSize(thumbnail)]; + QLThumbnailRequestSetImageWithData(thumbnail, (__bridge CFDataRef)[appIcon TIFFRepresentation], (__bridge CFDictionaryRef)propertiesDict); + return noErr; + } - if (!provisionData) { - NSLog(@"No provisionData for %@", URL); - return noErr; - } + // MARK: .provisioning - CMSDecoderRef decoder = NULL; - CMSDecoderCreate(&decoder); - CMSDecoderUpdateMessage(decoder, provisionData.bytes, provisionData.length); - CMSDecoderFinalizeMessage(decoder); - CFDataRef dataRef = NULL; - CMSDecoderCopyContent(decoder, &dataRef); - NSData *data = (NSData *)CFBridgingRelease(dataRef); - CFRelease(decoder); - - if (!data || QLThumbnailRequestIsCancelled(thumbnail)) { - return noErr; - } + // use provisioning directly + NSData *provisionData = [NSData dataWithContentsOfURL:URL]; + if (!provisionData) { + NSLog(@"No provisionData for %@", URL); + return noErr; + } - NSDictionary *propertyList = [NSPropertyListSerialization propertyListWithData:data options:0 format:NULL error:NULL]; - id value = [propertyList objectForKey:@"ProvisionedDevices"]; - if ([value isKindOfClass:[NSArray class]]) { - devicesCount = [value count]; - } + NSDictionary *optionsDict = (__bridge NSDictionary *)options; + BOOL iconMode = ([optionsDict objectForKey:(NSString *)kQLThumbnailOptionIconModeKey]) ? YES : NO; + NSUInteger devicesCount = 0; + int expStatus = 0; - value = [propertyList objectForKey:@"ExpirationDate"]; - if ([value isKindOfClass:[NSDate class]]) { - expStatus = expirationStatus(value, [NSCalendar currentCalendar]); - } - } + if (iconMode) { + NSURL *iconURL = [[NSBundle bundleWithIdentifier:kPluginBundleId] URLForResource:@"blankIcon" withExtension:@"png"]; + appIcon = [[NSImage alloc] initWithContentsOfURL:iconURL]; + } else { + appIcon = [[NSWorkspace sharedWorkspace] iconForFileType:dataType]; + [appIcon setSize:NSMakeSize(512,512)]; + } - if (QLThumbnailRequestIsCancelled(thumbnail)) { - return noErr; - } + CMSDecoderRef decoder = NULL; + CMSDecoderCreate(&decoder); + CMSDecoderUpdateMessage(decoder, provisionData.bytes, provisionData.length); + CMSDecoderFinalizeMessage(decoder); + CFDataRef dataRef = NULL; + CMSDecoderCopyContent(decoder, &dataRef); + NSData *data = (NSData *)CFBridgingRelease(dataRef); + CFRelease(decoder); - // image-only can be drawn efficiently. - if ([dataType isEqualToString:kDataType_ipa] || [dataType isEqualToString:kDataType_xcode_archive]) { - [appIcon setSize:QLThumbnailRequestGetMaximumSize(thumbnail)]; - QLThumbnailRequestSetImageWithData(thumbnail, (__bridge CFDataRef)[appIcon TIFFRepresentation], (__bridge CFDictionaryRef)propertiesDict); + if (!data || QLThumbnailRequestIsCancelled(thumbnail)) { return noErr; } + NSDictionary *propertyList = [NSPropertyListSerialization propertyListWithData:data options:0 format:NULL error:NULL]; + id value = [propertyList objectForKey:@"ProvisionedDevices"]; + if ([value isKindOfClass:[NSArray class]]) { + devicesCount = [value count]; + } + + value = [propertyList objectForKey:@"ExpirationDate"]; + if ([value isKindOfClass:[NSDate class]]) { + expStatus = expirationStatus(value, [NSCalendar currentCalendar]); + } + + if (QLThumbnailRequestIsCancelled(thumbnail)) { + return noErr; + } + NSSize canvasSize = appIcon.size; NSRect renderRect = NSMakeRect(0.0, 0.0, appIcon.size.width, appIcon.size.height); - CGContextRef _context = QLThumbnailRequestCreateContext(thumbnail, canvasSize, false, (__bridge CFDictionaryRef)propertiesDict); + CGContextRef _context = QLThumbnailRequestCreateContext(thumbnail, canvasSize, false, NULL); if (_context) { NSGraphicsContext *_graphicsContext = [NSGraphicsContext graphicsContextWithCGContext:(void *)_context flipped:NO]; [NSGraphicsContext setCurrentContext:_graphicsContext]; - if ([dataType isEqualToString:kDataType_ipa] || [dataType isEqualToString:kDataType_xcode_archive]) { - // handled above - } else { - [appIcon drawInRect:renderRect]; - - NSString *badge = [NSString stringWithFormat:@"%lu",(unsigned long)devicesCount]; - NSColor *outlineColor; - - if (expStatus == 2) { - outlineColor = BADGE_VALID_COLOR; - } else if (expStatus == 1) { - outlineColor = BADGE_EXPIRING_COLOR; - } else { - outlineColor = BADGE_EXPIRED_COLOR; - } - - NSMutableParagraphStyle *paragraphStyle = [[NSParagraphStyle defaultParagraphStyle] mutableCopy]; - paragraphStyle.lineBreakMode = NSLineBreakByTruncatingTail; - paragraphStyle.alignment = NSTextAlignmentCenter; - - NSDictionary *attrDict = @{NSFontAttributeName : BADGE_FONT, NSForegroundColorAttributeName : outlineColor, NSParagraphStyleAttributeName: paragraphStyle}; - NSSize badgeNumSize = [badge sizeWithAttributes:attrDict]; - int badgeWidth = badgeNumSize.width + BADGE_MARGIN * 2; - badgeWidth = MAX(badgeWidth, MIN_BADGE_WIDTH); - - int badgeX = renderRect.origin.x + BADGE_MARGIN_X; - int badgeY = renderRect.origin.y + renderRect.size.height - BADGE_HEIGHT - BADGE_MARGIN_Y; - if (!iconMode) { - badgeX += 75; - badgeY -= 10; - } - int badgeNumX = badgeX + BADGE_MARGIN; - NSRect badgeRect = NSMakeRect(badgeX, badgeY, badgeWidth, BADGE_HEIGHT); - - NSBezierPath *badgePath = [NSBezierPath bezierPathWithRoundedRect:badgeRect xRadius:10 yRadius:10]; - [badgePath setLineWidth:8.0]; - [BADGE_BG_COLOR set]; - [badgePath fill]; - [outlineColor set]; - [badgePath stroke]; - - [badge drawAtPoint:NSMakePoint(badgeNumX,badgeY) withAttributes:attrDict]; - } + [appIcon drawInRect:renderRect]; + + NSString *badge = [NSString stringWithFormat:@"%lu",(unsigned long)devicesCount]; + NSColor *outlineColor; + + if (expStatus == 2) { + outlineColor = BADGE_VALID_COLOR; + } else if (expStatus == 1) { + outlineColor = BADGE_EXPIRING_COLOR; + } else { + outlineColor = BADGE_EXPIRED_COLOR; + } + + NSMutableParagraphStyle *paragraphStyle = [[NSParagraphStyle defaultParagraphStyle] mutableCopy]; + paragraphStyle.lineBreakMode = NSLineBreakByTruncatingTail; + paragraphStyle.alignment = NSTextAlignmentCenter; + + NSDictionary *attrDict = @{NSFontAttributeName : BADGE_FONT, NSForegroundColorAttributeName : outlineColor, NSParagraphStyleAttributeName: paragraphStyle}; + + NSSize badgeNumSize = [badge sizeWithAttributes:attrDict]; + int badgeWidth = badgeNumSize.width + BADGE_MARGIN * 2; + badgeWidth = MAX(badgeWidth, MIN_BADGE_WIDTH); + + int badgeX = renderRect.origin.x + BADGE_MARGIN_X; + int badgeY = renderRect.origin.y + renderRect.size.height - BADGE_HEIGHT - BADGE_MARGIN_Y; + if (!iconMode) { + badgeX += 75; + badgeY -= 10; + } + int badgeNumX = badgeX + BADGE_MARGIN; + NSRect badgeRect = NSMakeRect(badgeX, badgeY, badgeWidth, BADGE_HEIGHT); + + NSBezierPath *badgePath = [NSBezierPath bezierPathWithRoundedRect:badgeRect xRadius:10 yRadius:10]; + [badgePath setLineWidth:8.0]; + [BADGE_BG_COLOR set]; + [badgePath fill]; + [outlineColor set]; + [badgePath stroke]; + + [badge drawAtPoint:NSMakePoint(badgeNumX,badgeY) withAttributes:attrDict]; QLThumbnailRequestFlushContext(thumbnail, _context); CFRelease(_context); From dda2a1f8dbc20bf3ee94ae95ede269f3f05f22d8 Mon Sep 17 00:00:00 2001 From: relikd Date: Sun, 21 Jan 2024 14:51:28 +0100 Subject: [PATCH 09/32] fix: downscale only --- ProvisionQL/GenerateThumbnailForURL.m | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ProvisionQL/GenerateThumbnailForURL.m b/ProvisionQL/GenerateThumbnailForURL.m index 3def4a8..5bc8c68 100644 --- a/ProvisionQL/GenerateThumbnailForURL.m +++ b/ProvisionQL/GenerateThumbnailForURL.m @@ -79,7 +79,10 @@ OSStatus GenerateThumbnailForURL(void *thisInterface, QLThumbnailRequestRef thum } // image-only icons can be drawn efficiently. appIcon = roundCorners(appIcon); - [appIcon setSize:QLThumbnailRequestGetMaximumSize(thumbnail)]; + // downscale as required by QLThumbnailRequestSetImageWithData + if (appIcon.size.width > maxSize.width && appIcon.size.height > maxSize.height) { + [appIcon setSize:maxSize]; + } QLThumbnailRequestSetImageWithData(thumbnail, (__bridge CFDataRef)[appIcon TIFFRepresentation], (__bridge CFDictionaryRef)propertiesDict); return noErr; } From b9c24b187c8e724bba1ba262692ebd219a84e19c Mon Sep 17 00:00:00 2001 From: relikd Date: Thu, 25 Jan 2024 19:28:45 +0100 Subject: [PATCH 10/32] ref: full rewrite with modular processing --- ProvisionQL/GeneratePreviewForURL.m | 1230 ++++++++++++------------- ProvisionQL/GenerateThumbnailForURL.m | 302 +++--- ProvisionQL/Resources/template.html | 13 +- ProvisionQL/Shared.h | 63 +- ProvisionQL/Shared.m | 299 +++--- 5 files changed, 965 insertions(+), 942 deletions(-) diff --git a/ProvisionQL/GeneratePreviewForURL.m b/ProvisionQL/GeneratePreviewForURL.m index 15b0f5c..de631ed 100644 --- a/ProvisionQL/GeneratePreviewForURL.m +++ b/ProvisionQL/GeneratePreviewForURL.m @@ -1,5 +1,8 @@ #import "Shared.h" +// makro to stop further processing +#define ALLOW_EXIT if (QLPreviewRequestIsCancelled(preview)) { return noErr; } + OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, CFURLRef url, CFStringRef contentTypeUTI, CFDictionaryRef options); void CancelPreviewGeneration(void *thisInterface, QLPreviewRequestRef preview); @@ -9,7 +12,27 @@ This function's job is to create preview for designated file ----------------------------------------------------------------------------- */ -void displayKeyAndValue(NSUInteger level, NSString *key, id value, NSMutableString *output) { +// MARK: - Generic data formatting & printing + +typedef NSArray TableRow; + +/// Print html table with arbitrary number of columns +/// @param header If set, start the table with a @c tr column row. +NSString * _Nonnull formatAsTable(TableRow * _Nullable header, NSArray* data) { + NSMutableString *table = [NSMutableString string]; + [table appendString:@"\n"]; + if (header) { + [table appendFormat:@"\n", [header componentsJoinedByString:@"\n", [row componentsJoinedByString:@"
%@
"]]; + } + for (TableRow *row in data) { + [table appendFormat:@"
%@
"]]; + } + [table appendString:@"
\n"]; + return table; +} + +/// Print recursive tree of key-value mappings. +void recursiveKeyValue(NSUInteger level, NSString *key, id value, NSMutableString *output) { int indent = (int)(level * 4); if ([value isKindOfClass:[NSDictionary class]]) { @@ -21,17 +44,17 @@ void displayKeyAndValue(NSUInteger level, NSString *key, id value, NSMutableStri NSDictionary *dictionary = (NSDictionary *)value; NSArray *keys = [[dictionary allKeys] sortedArrayUsingSelector:@selector(compare:)]; for (NSString *subKey in keys) { - NSUInteger subLevel = (key == nil && level == 0) ? 0 : level + 1; - displayKeyAndValue(subLevel, subKey, [dictionary valueForKey:subKey], output); + NSUInteger subLevel = (key == nil && level == 0) ? 0 : level + 1; + recursiveKeyValue(subLevel, subKey, [dictionary valueForKey:subKey], output); + } + if (level != 0) { + [output appendFormat:@"%*s}\n", indent, ""]; } - if (level != 0) { - [output appendFormat:@"%*s}\n", indent, ""]; - } } else if ([value isKindOfClass:[NSArray class]]) { [output appendFormat:@"%*s%@ = (\n", indent, "", key]; NSArray *array = (NSArray *)value; for (id value in array) { - displayKeyAndValue(level + 1, nil, value, output); + recursiveKeyValue(level + 1, nil, value, output); } [output appendFormat:@"%*s)\n", indent, ""]; } else if ([value isKindOfClass:[NSData class]]) { @@ -50,681 +73,604 @@ void displayKeyAndValue(NSUInteger level, NSString *key, id value, NSMutableStri } } -NSString *expirationStringForDateInCalendar(NSDate *date, NSCalendar *calendar) { - NSString *result = nil; - - if (date) { - NSDateComponentsFormatter *formatter = [[NSDateComponentsFormatter alloc] init]; - formatter.unitsStyle = NSDateComponentsFormatterUnitsStyleFull; - formatter.maximumUnitCount = 1; - - NSDateComponents *dateComponents = [calendar components:(NSCalendarUnitDay|NSCalendarUnitHour|NSCalendarUnitMinute) - fromDate:[NSDate date] - toDate:date - options:0]; - if ([date compare:[NSDate date]] == NSOrderedAscending) { - if ([calendar isDate:date inSameDayAsDate:[NSDate date]]) { - result = @"Expired today"; - } else { - NSDateComponents *reverseDateComponents = [calendar components:(NSCalendarUnitDay|NSCalendarUnitHour|NSCalendarUnitMinute) - fromDate:date - toDate:[NSDate date] - options:0]; - result = [NSString stringWithFormat:@"Expired %@ ago", [formatter stringFromDateComponents:reverseDateComponents]]; - } - } else { - if (dateComponents.day == 0) { - result = @"Expires today"; - } else if (dateComponents.day < 30) { - result = [NSString stringWithFormat:@"Expires in %@", [formatter stringFromDateComponents:dateComponents]]; - } else { - result = [NSString stringWithFormat:@"Expires in %@", [formatter stringFromDateComponents:dateComponents]]; - } - } - - } - - return result; +/// Print recursive tree of key-value mappings. +void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *replacements, int level, NSMutableString *output) { + for (NSString *key in dictionary) { + NSString *localizedKey = replacements[key] ?: key; + NSObject *object = dictionary[key]; + + for (int idx = 0; idx < level; idx++) { + [output appendString:(level == 1) ? @"- " : @"  "]; + } + + if ([object isKindOfClass:[NSDictionary class]]) { + [output appendFormat:@"%@:
", localizedKey]; + recursiveDictWithReplacements((NSDictionary *)object, replacements, level + 1, output); + [output appendString:@"
"]; + } else if ([object isKindOfClass:[NSNumber class]]) { + object = [(NSNumber *)object boolValue] ? @"YES" : @"NO"; + [output appendFormat:@"%@: %@
", localizedKey, object]; + } else { + [output appendFormat:@"%@: %@
", localizedKey, object]; + } + } } -NSString *formattedStringForCertificates(NSArray *value) { - static NSString *const devCertSummaryKey = @"summary"; - static NSString *const devCertInvalidityDateKey = @"invalidity"; - - NSMutableArray *certificateDetails = [NSMutableArray array]; - NSArray *array = (NSArray *)value; - for (NSData *data in array) { - SecCertificateRef certificateRef = SecCertificateCreateWithData(NULL, (__bridge CFDataRef)data); - if (certificateRef) { - CFStringRef summaryRef = SecCertificateCopySubjectSummary(certificateRef); - NSString *summary = (NSString *)CFBridgingRelease(summaryRef); - if (summary) { - NSMutableDictionary *detailsDict = [NSMutableDictionary dictionaryWithObject:summary forKey:devCertSummaryKey]; - - CFErrorRef error; - CFDictionaryRef valuesDict = SecCertificateCopyValues(certificateRef, (__bridge CFArrayRef)@[(__bridge id)kSecOIDInvalidityDate], &error); - if (valuesDict) { - CFDictionaryRef invalidityDateDictionaryRef = CFDictionaryGetValue(valuesDict, kSecOIDInvalidityDate); - if (invalidityDateDictionaryRef) { - CFTypeRef invalidityRef = CFDictionaryGetValue(invalidityDateDictionaryRef, kSecPropertyKeyValue); - CFRetain(invalidityRef); - - // NOTE: the invalidity date type of kSecPropertyTypeDate is documented as a CFStringRef in the "Certificate, Key, and Trust Services Reference". - // In reality, it's a __NSTaggedDate (presumably a tagged pointer representing an NSDate.) But to sure, we'll check: - id invalidity = CFBridgingRelease(invalidityRef); - if (invalidity) { - if ([invalidity isKindOfClass:[NSDate class]]) { - // use the date directly - [detailsDict setObject:invalidity forKey:devCertInvalidityDateKey]; - } else { - // parse the date from a string - NSString *string = [invalidity description]; - NSDateFormatter *invalidityDateFormatter = [NSDateFormatter new]; - [invalidityDateFormatter setDateFormat:@"yyyy-MM-dd HH:mm:ss Z"]; - NSDate *invalidityDate = [invalidityDateFormatter dateFromString:string]; - if (invalidityDate) { - [detailsDict setObject:invalidityDate forKey:devCertInvalidityDateKey]; - } - } - } else { - NSLog(@"No invalidity date in '%@' certificate, dictionary = %@", summary, invalidityDateDictionaryRef); - } - } else { - NSLog(@"No invalidity values in '%@' certificate, dictionary = %@", summary, valuesDict); - } - - CFRelease(valuesDict); - } else { - NSLog(@"Could not get values in '%@' certificate, error = %@", summary, error); - } - - [certificateDetails addObject:detailsDict]; - } else { - NSLog(@"Could not get summary from certificate"); - } - - CFRelease(certificateRef); - } - } - - NSMutableString *certificates = [NSMutableString string]; - [certificates appendString:@"\n"]; - - NSArray *sortedCertificateDetails = [certificateDetails sortedArrayUsingComparator:^NSComparisonResult(id obj1, id obj2) { - return [((NSDictionary *)obj1)[devCertSummaryKey] compare:((NSDictionary *)obj2)[devCertSummaryKey]]; - }]; - - for (NSDictionary *detailsDict in sortedCertificateDetails) { - NSString *summary = detailsDict[devCertSummaryKey]; - NSDate *invalidityDate = detailsDict[devCertInvalidityDateKey]; - NSString *expiration = expirationStringForDateInCalendar(invalidityDate, [NSCalendar currentCalendar]); - if (! expiration) { - expiration = @"No invalidity date in certificate"; - } - [certificates appendFormat:@"\n", summary, expiration]; - } - [certificates appendString:@"
%@%@
\n"]; - - return [certificates copy]; +/// Replace occurrences of chars @c &"'<> with html encoding. +NSString *escapedXML(NSString *stringToEscape) { + stringToEscape = [stringToEscape stringByReplacingOccurrencesOfString:@"&" withString:@"&"]; + NSDictionary *htmlEntityReplacement = @{ + @"\"": @""", + @"'": @"'", + @"<": @"<", + @">": @">", + }; + for (NSString *key in [htmlEntityReplacement allKeys]) { + NSString *replacement = [htmlEntityReplacement objectForKey:key]; + stringToEscape = [stringToEscape stringByReplacingOccurrencesOfString:key withString:replacement]; + } + return stringToEscape; } -NSDictionary *formattedDevicesData(NSArray *value) { +/// Convert image to PNG and encode with base64 to be embeded in html output. +NSString * _Nonnull iconAsBase64(NSImage *appIcon) { + appIcon = roundCorners(appIcon); + NSData *imageData = [appIcon TIFFRepresentation]; + NSBitmapImageRep *imageRep = [NSBitmapImageRep imageRepWithData:imageData]; + imageData = [imageRep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; + return [imageData base64EncodedStringWithOptions:0]; +} + + +// MARK: - Date processing + +/// @return Difference between two dates as components. +NSDateComponents * _Nonnull dateDiff(NSDate *start, NSDate *end, NSCalendar *calendar) { + return [calendar components:(NSCalendarUnitDay|NSCalendarUnitHour|NSCalendarUnitMinute) + fromDate:start toDate:end options:0]; +} + +/// @return Print largest component. E.g., "3 days" or "14 hours" +NSString * _Nonnull relativeDateString(NSDateComponents *comp) { + NSDateComponentsFormatter *formatter = [[NSDateComponentsFormatter alloc] init]; + formatter.unitsStyle = NSDateComponentsFormatterUnitsStyleFull; + formatter.maximumUnitCount = 1; + return [formatter stringFromDateComponents:comp]; +} - NSArray *array = (NSArray *)value; - NSArray *sortedArray = [array sortedArrayUsingSelector:@selector(compare:)]; +/// @return Print the date with current locale and medium length style. +NSString * _Nonnull formattedDate(NSDate *date) { + NSDateFormatter *formatter = [NSDateFormatter new]; + [formatter setDateStyle:NSDateFormatterMediumStyle]; + [formatter setTimeStyle:NSDateFormatterMediumStyle]; + return [formatter stringFromDate:date]; +} - NSString *currentPrefix = nil; - NSMutableString *devices = [NSMutableString string]; - [devices appendString:@"\n"]; - [devices appendString:@"\n"]; +/// @return Relative distance to today. E.g., "Expired today" +NSString * _Nullable relativeExpirationDateString(NSDate *date) { + if (!date) { + return nil; + } - for (NSString *device in sortedArray) { - // compute the prefix for the first column of the table - NSString *displayPrefix = @""; - NSString *devicePrefix = [device substringToIndex:1]; - if (! [currentPrefix isEqualToString:devicePrefix]) { - currentPrefix = devicePrefix; - displayPrefix = [NSString stringWithFormat:@"%@ ➞ ", devicePrefix]; - } + NSCalendar *calendar = [NSCalendar currentCalendar]; + BOOL isPast = [date compare:[NSDate date]] == NSOrderedAscending; + BOOL isToday = [calendar isDate:date inSameDayAsDate:[NSDate date]]; - [devices appendFormat:@"\n", displayPrefix, device]; - } - [devices appendString:@"
UDID
%@%@
\n"]; + if (isToday) { + return isPast ? @"Expired today" : @"Expires today"; + } + + if (isPast) { + NSDateComponents *comp = dateDiff(date, [NSDate date], calendar); + return [NSString stringWithFormat:@"Expired %@ ago", relativeDateString(comp)]; + } - return @{@"ProvisionedDevicesFormatted" : [devices copy], @"ProvisionedDevicesCount" : [NSString stringWithFormat:@"%zd Device%s", [array count], ([array count] == 1 ? "" : "s")]}; + NSDateComponents *comp = dateDiff([NSDate date], date, calendar); + if (comp.day < 30) { + return [NSString stringWithFormat:@"Expires in %@", relativeDateString(comp)]; + } + return [NSString stringWithFormat:@"Expires in %@", relativeDateString(comp)]; } -NSString *formattedDictionaryWithReplacements(NSDictionary *dictionary, NSDictionary *replacements, int level) { - - NSMutableString *string = [NSMutableString string]; - - for (NSString *key in dictionary) { - NSString *localizedKey = replacements[key] ?: key; - NSObject *object = dictionary[key]; - - for (int idx = 0; idx < level; idx++) { - if (level == 1) { - [string appendString:@"- "]; - } else { - [string appendString:@"  "]; - } - } - - if ([object isKindOfClass:[NSDictionary class]]) { - object = formattedDictionaryWithReplacements((NSDictionary *)object, replacements, level + 1); - [string appendFormat:@"%@:
%@
", localizedKey, object]; - } - else if ([object isKindOfClass:[NSNumber class]]) { - object = [(NSNumber *)object boolValue] ? @"YES" : @"NO"; - [string appendFormat:@"%@: %@
", localizedKey, object]; - } - else { - [string appendFormat:@"%@: %@
", localizedKey, object]; - } - } - - return string; +/// @return Relative distance to today. E.g., "DATE (Expires in 3 days)" +NSString * _Nonnull formattedExpirationDate(NSDate *expireDate) { + return [NSString stringWithFormat:@"%@ (%@)", formattedDate(expireDate), relativeExpirationDateString(expireDate)]; } -NSString *escapedXML(NSString *stringToEscape) { - stringToEscape = [stringToEscape stringByReplacingOccurrencesOfString:@"&" withString:@"&"]; - NSDictionary *htmlEntityReplacement = @{ - @"\"": @""", - @"'": @"'", - @"<": @"<", - @">": @">", - }; - for (NSString *key in [htmlEntityReplacement allKeys]) { - NSString *replacement = [htmlEntityReplacement objectForKey:key]; - stringToEscape = [stringToEscape stringByReplacingOccurrencesOfString:key withString:replacement]; - } - return stringToEscape; +/// @return Relative distance to today. E.g., "DATE (Created 3 days ago)" +NSString * _Nonnull formattedCreationDate(NSDate *creationDate) { + NSCalendar *calendar = [NSCalendar currentCalendar]; + NSDateComponents *comp = dateDiff(creationDate, [NSDate date], calendar); + BOOL isToday = [calendar isDate:creationDate inSameDayAsDate:[NSDate date]]; + return [NSString stringWithFormat:@"%@ (Created %@)", formattedDate(creationDate), + isToday ? @"today" : [NSString stringWithFormat:@"%@ ago", relativeDateString(comp)]]; +} + +/// @return CSS class for expiration status. +NSString * _Nonnull classNameForExpirationStatus(NSDate *date) { + switch (expirationStatus(date)) { + case ExpirationStatusExpired: return @"expired"; + case ExpirationStatusExpiring: return @"expiring"; + case ExpirationStatusValid: return @"valid"; + } +} + + +// MARK: - App Info + +/// @return List of ATS flags. +NSString * _Nonnull formattedAppTransportSecurity(NSDictionary *appPlist) { + NSDictionary *value = appPlist[@"NSAppTransportSecurity"]; + if ([value isKindOfClass:[NSDictionary class]]) { + NSDictionary *localizedKeys = @{ + @"NSAllowsArbitraryLoads": @"Allows Arbitrary Loads", + @"NSAllowsArbitraryLoadsForMedia": @"Allows Arbitrary Loads for Media", + @"NSAllowsArbitraryLoadsInWebContent": @"Allows Arbitrary Loads in Web Content", + @"NSAllowsLocalNetworking": @"Allows Local Networking", + @"NSExceptionDomains": @"Exception Domains", + + @"NSIncludesSubdomains": @"Includes Subdomains", + @"NSRequiresCertificateTransparency": @"Requires Certificate Transparency", + + @"NSExceptionAllowsInsecureHTTPLoads": @"Allows Insecure HTTP Loads", + @"NSExceptionMinimumTLSVersion": @"Minimum TLS Version", + @"NSExceptionRequiresForwardSecrecy": @"Requires Forward Secrecy", + + @"NSThirdPartyExceptionAllowsInsecureHTTPLoads": @"Allows Insecure HTTP Loads", + @"NSThirdPartyExceptionMinimumTLSVersion": @"Minimum TLS Version", + @"NSThirdPartyExceptionRequiresForwardSecrecy": @"Requires Forward Secrecy" + }; + + NSMutableString *output = [NSMutableString string]; + recursiveDictWithReplacements(value, localizedKeys, 0, output); + return [NSString stringWithFormat:@"
%@
", output]; + } + + NSString *sdkName = appPlist[@"DTSDKName"]; + double sdkNumber = [[sdkName stringByTrimmingCharactersInSet:[NSCharacterSet letterCharacterSet]] doubleValue]; + if (sdkNumber < 9.0) { + return @"Not applicable before iOS 9.0"; + } + return @"No exceptions"; +} + +/// Process info stored in @c Info.plist +NSDictionary * _Nonnull procAppInfo(NSDictionary *appPlist) { + if (!appPlist) { + return @{ + @"AppInfo": @"hiddenDiv", + @"ProvisionAsSubheader": @"", + }; + } + + NSString *bundleName = appPlist[@"CFBundleDisplayName"]; + if (!bundleName) { + bundleName = appPlist[@"CFBundleName"]; + } + NSString *extensionType = appPlist[@"NSExtension"][@"NSExtensionPointIdentifier"]; + + NSMutableArray *platforms = [NSMutableArray array]; + for (NSNumber *number in appPlist[@"UIDeviceFamily"]) { + switch ([number intValue]) { + case 1: [platforms addObject:@"iPhone"]; break; + case 2: [platforms addObject:@"iPad"]; break; + case 3: [platforms addObject:@"TV"]; break; + case 4: [platforms addObject:@"Watch"]; break; + default: break; + } + } + + return @{ + @"AppInfo": @"", + @"ProvisionAsSubheader": @"hiddenDiv", + + @"CFBundleName": appPlist[@"CFBundleDisplayName"] ?: appPlist[@"CFBundleName"] ?: @"", + @"CFBundleShortVersionString": appPlist[@"CFBundleShortVersionString"] ?: @"", + @"CFBundleVersion": appPlist[@"CFBundleVersion"] ?: @"", + @"CFBundleIdentifier": appPlist[@"CFBundleIdentifier"] ?: @"", + + @"ExtensionInfo": extensionType ? @"" : @"hiddenDiv", + @"NSExtensionPointIdentifier": extensionType ?: @"", + + @"UIDeviceFamily": [platforms componentsJoinedByString:@", "], + @"DTSDKName": appPlist[@"DTSDKName"] ?: @"", + @"MinimumOSVersion": appPlist[@"MinimumOSVersion"] ?: @"", + @"AppTransportSecurityFormatted": formattedAppTransportSecurity(appPlist), + }; +} + + +// MARK: - Certificates + +/// Process a single certificate. Extract invalidity / expiration date. +/// @param subject just used for printing error logs. +NSDate * _Nullable getCertificateInvalidityDate(SecCertificateRef certificateRef, NSString *subject) { + NSDate *invalidityDate = nil; + CFErrorRef error = nil; + CFDictionaryRef outerDictRef = SecCertificateCopyValues(certificateRef, (__bridge CFArrayRef)@[(__bridge NSString*)kSecOIDInvalidityDate], &error); + if (outerDictRef && !error) { + CFDictionaryRef innerDictRef = CFDictionaryGetValue(outerDictRef, kSecOIDInvalidityDate); + if (innerDictRef) { + // NOTE: the invalidity date type of kSecPropertyTypeDate is documented as a CFStringRef in the "Certificate, Key, and Trust Services Reference". + // In reality, it's a __NSTaggedDate (presumably a tagged pointer representing an NSDate.) But to sure, we'll check: + id value = CFBridgingRelease(CFDictionaryGetValue(innerDictRef, kSecPropertyKeyValue)); + if (value) { + if ([value isKindOfClass:[NSDate class]]) { + invalidityDate = value; + } else { + // parse the date from a string + NSDateFormatter *dateFormatter = [NSDateFormatter new]; + [dateFormatter setDateFormat:@"yyyy-MM-dd HH:mm:ss Z"]; + invalidityDate = [dateFormatter dateFromString:[value description]]; + } + } else { + NSLog(@"No invalidity date in '%@' certificate, dictionary = %@", subject, innerDictRef); + } + // no CFRelease(innerDictRef); since it has the same references as outerDictRef + } else { + NSLog(@"No invalidity values in '%@' certificate, dictionary = %@", subject, outerDictRef); + } + CFRelease(outerDictRef); + } else { + NSLog(@"Could not get values in '%@' certificate, error = %@", subject, error); + CFRelease(error); + } + return invalidityDate; +} + +/// Process list of all certificates. Return a two column table with subject and expiration date. +NSArray * _Nonnull getCertificateList(NSDictionary *provisionPlist) { + NSArray *certArr = provisionPlist[@"DeveloperCertificates"]; + if (![certArr isKindOfClass:[NSArray class]]) { + return @[]; + } + + NSMutableArray *entries = [NSMutableArray array]; + for (NSData *data in certArr) { + SecCertificateRef certificateRef = SecCertificateCreateWithData(NULL, (__bridge CFDataRef)data); + if (!certificateRef) { + continue; + } + NSString *subject = (NSString *)CFBridgingRelease(SecCertificateCopySubjectSummary(certificateRef)); + if (subject) { + NSDate *invalidityDate = getCertificateInvalidityDate(certificateRef, subject); + NSString *expiration = relativeExpirationDateString(invalidityDate); + [entries addObject:@[subject, expiration ?: @"No invalidity date in certificate"]]; + } else { + NSLog(@"Could not get subject from certificate"); + } + CFRelease(certificateRef); + } + + [entries sortUsingComparator:^NSComparisonResult(NSArray *obj1, NSArray *obj2) { + return [obj1[0] compare:obj2[0]]; + }]; + return entries; +} + + +// MARK: - Provisioning + +/// Returns provision type string like "Development" or "Distribution (App Store)". +NSString * _Nonnull stringForProfileType(NSDictionary *provisionPlist, BOOL isOSX) { + BOOL hasDevices = [provisionPlist[@"ProvisionedDevices"] isKindOfClass:[NSArray class]]; + if (isOSX) { + return hasDevices ? @"Development" : @"Distribution (App Store)"; + } + if (hasDevices) { + BOOL getTaskAllow = [[provisionPlist[@"Entitlements"] valueForKey:@"get-task-allow"] boolValue]; + return getTaskAllow ? @"Development" : @"Distribution (Ad Hoc)"; + } + BOOL isEnterprise = [provisionPlist[@"ProvisionsAllDevices"] boolValue]; + return isEnterprise ? @"Enterprise" : @"Distribution (App Store)"; } -NSData *codesignEntitlementsDataFromApp(NSData *infoPlistData, NSString *basePath) { - // read the CFBundleExecutable and extract it - NSDictionary *appPropertyList = [NSPropertyListSerialization propertyListWithData:infoPlistData options:0 format:NULL error:NULL]; - NSString *bundleExecutable = [appPropertyList objectForKey:@"CFBundleExecutable"]; - - NSString *binaryPath = [basePath stringByAppendingPathComponent:bundleExecutable]; - // get entitlements: codesign -d --entitlements - --xml - NSTask *codesignTask = [NSTask new]; - [codesignTask setLaunchPath:@"/usr/bin/codesign"]; - [codesignTask setStandardOutput:[NSPipe pipe]]; - [codesignTask setStandardError:[NSPipe pipe]]; +/// Enumerate all entries from provison plist with key @c ProvisionedDevices +NSArray * _Nonnull getDeviceList(NSDictionary *provisionPlist) { + NSArray *devArr = provisionPlist[@"ProvisionedDevices"]; + if (![devArr isKindOfClass:[NSArray class]]) { + return @[]; + } + + NSMutableArray *devices = [NSMutableArray array]; + NSString *currentPrefix = nil; + + for (NSString *device in [devArr sortedArrayUsingSelector:@selector(compare:)]) { + // compute the prefix for the first column of the table + NSString *displayPrefix = @""; + NSString *devicePrefix = [device substringToIndex:1]; + if (! [currentPrefix isEqualToString:devicePrefix]) { + currentPrefix = devicePrefix; + displayPrefix = [NSString stringWithFormat:@"%@ ➞ ", devicePrefix]; + } + [devices addObject:@[displayPrefix, device]]; + } + return devices; +} + +/// Process info stored in @c embedded.mobileprovision +NSDictionary * _Nonnull procProvision(NSDictionary *provisionPlist, BOOL isOSX) { + if (!provisionPlist) { + return @{ + @"ProvisionInfo": @"hiddenDiv", + }; + } + + NSDate *creationDate = dateOrNil(provisionPlist[@"CreationDate"]); + NSDate *expireDate = dateOrNil(provisionPlist[@"ExpirationDate"]); + NSArray* devices = getDeviceList(provisionPlist); + + return @{ + @"ProvisionInfo": @"", + @"ProfileName": provisionPlist[@"Name"] ?: @"", + @"ProfileUUID": provisionPlist[@"UUID"] ?: @"", + @"TeamName": provisionPlist[@"TeamName"] ?: @"Team name not available", + @"TeamIds": [provisionPlist[@"TeamIdentifier"] componentsJoinedByString:@", "] ?: @"Team ID not available", + @"CreationDateFormatted": creationDate ? formattedCreationDate(creationDate) : @"", + @"ExpirationDateFormatted": expireDate ? formattedExpirationDate(expireDate) : @"", + @"ExpStatus": classNameForExpirationStatus(expireDate), + + @"ProfilePlatform": isOSX ? @"Mac" : @"iOS", + @"ProfileType": stringForProfileType(provisionPlist, isOSX), + + @"ProvisionedDevicesCount": devices.count ? [NSString stringWithFormat:@"%zd Device%s", devices.count, (devices.count == 1 ? "" : "s")] : @"No Devices", + @"ProvisionedDevicesFormatted": devices.count ? formatAsTable(@[@"", @"UDID"], devices) : @"Distribution Profile", + + @"DeveloperCertificatesFormatted": formatAsTable(nil, getCertificateList(provisionPlist)) ?: @"No Developer Certificates", + }; +} + + +// MARK: - Entitlements + +/// run: @c codesign -d --entitlements - --xml +NSData *runCodeSign(NSString *binaryPath) { + NSTask *codesignTask = [NSTask new]; + [codesignTask setLaunchPath:@"/usr/bin/codesign"]; + [codesignTask setStandardOutput:[NSPipe pipe]]; + [codesignTask setStandardError:[NSPipe pipe]]; if (@available(macOS 11, *)) { [codesignTask setArguments:@[@"-d", binaryPath, @"--entitlements", @"-", @"--xml"]]; } else { [codesignTask setArguments:@[@"-d", binaryPath, @"--entitlements", @":-"]]; } - [codesignTask launch]; + [codesignTask launch]; - NSData *outputData = [[[codesignTask standardOutput] fileHandleForReading] readDataToEndOfFile]; - NSData *errorData = [[[codesignTask standardError] fileHandleForReading] readDataToEndOfFile]; - [codesignTask waitUntilExit]; + NSData *outputData = [[[codesignTask standardOutput] fileHandleForReading] readDataToEndOfFile]; + NSData *errorData = [[[codesignTask standardError] fileHandleForReading] readDataToEndOfFile]; + [codesignTask waitUntilExit]; - if (outputData.length == 0) { - return errorData; - } - - return outputData; + if (outputData.length == 0) { + return errorData; + } + return outputData; } -NSString *iconAsBase64(NSImage *appIcon) { - if (!appIcon) { - NSURL *iconURL = [[NSBundle bundleWithIdentifier:kPluginBundleId] URLForResource:@"defaultIcon" withExtension:@"png"]; - appIcon = [[NSImage alloc] initWithContentsOfURL:iconURL]; +/// Search for app binary and run @c codesign on it. +NSData *getCodeSignEntitlements(QuickLookInfo meta, NSString *bundleExecutable) { + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSString *tempDirFolder = [NSTemporaryDirectory() stringByAppendingPathComponent:kPluginBundleId]; + NSString *currentTempDirFolder = [tempDirFolder stringByAppendingPathComponent:[[NSUUID UUID] UUIDString]]; + + NSString *basePath = nil; + switch (meta.type) { + case FileTypeIPA: + basePath = currentTempDirFolder; + [fileManager createDirectoryAtPath:currentTempDirFolder withIntermediateDirectories:YES attributes:nil error:nil]; + unzipFileToDir(meta.url, currentTempDirFolder, [@"Payload/*.app/" stringByAppendingPathComponent:bundleExecutable]); + break; + case FileTypeArchive: + basePath = meta.effectiveUrl.path; + break; + case FileTypeExtension: + basePath = meta.url.path; + break; + case FileTypeProvision: + return nil; } - appIcon = roundCorners(appIcon); - NSData *imageData = [appIcon TIFFRepresentation]; - NSBitmapImageRep *imageRep = [NSBitmapImageRep imageRepWithData:imageData]; - imageData = [imageRep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; - return [imageData base64EncodedStringWithOptions:0]; + + NSData *data = runCodeSign([basePath stringByAppendingPathComponent:bundleExecutable]); + [fileManager removeItemAtPath:currentTempDirFolder error:nil]; + return data; } -OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, CFURLRef url, CFStringRef contentTypeUTI, CFDictionaryRef options) { - @autoreleasepool { - // create temp directory - NSFileManager *fileManager = [NSFileManager defaultManager]; - NSString *tempDirFolder = [NSTemporaryDirectory() stringByAppendingPathComponent:kPluginBundleId]; - NSString *currentTempDirFolder = [tempDirFolder stringByAppendingPathComponent:[[NSUUID UUID] UUIDString]]; - [fileManager createDirectoryAtPath:currentTempDirFolder withIntermediateDirectories:YES attributes:nil error:nil]; - - NSURL *URL = (__bridge NSURL *)url; - NSString *dataType = (__bridge NSString *)contentTypeUTI; - NSData *provisionData = nil; - NSData *appPlist = nil; - NSData *codesignEntitlementsData = nil; - NSImage *appIcon = nil; - - if ([dataType isEqualToString:kDataType_ipa]) { - provisionData = unzipFile(URL, @"Payload/*.app/embedded.mobileprovision"); - appPlist = unzipFile(URL, @"Payload/*.app/Info.plist"); - - // read codesigning entitlements from application binary (extract it first) - NSDictionary *appPropertyList = [NSPropertyListSerialization propertyListWithData:appPlist options:0 format:NULL error:NULL]; - NSString *bundleExecutable = [appPropertyList objectForKey:@"CFBundleExecutable"]; - - unzipFileToDir(URL, currentTempDirFolder, [@"Payload/*.app/" stringByAppendingPathComponent:bundleExecutable]); - - codesignEntitlementsData = codesignEntitlementsDataFromApp(appPlist, currentTempDirFolder); - - [fileManager removeItemAtPath:tempDirFolder error:nil]; - } else if ([dataType isEqualToString:kDataType_xcode_archive]) { - // get the embedded plist for the iOS app - NSURL *appsDir = [URL URLByAppendingPathComponent:@"Products/Applications/"]; - if (appsDir != nil) { - NSArray *dirFiles = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:appsDir.path error:nil]; - if (dirFiles.count > 0) { - NSURL *appURL = [appsDir URLByAppendingPathComponent:dirFiles[0] isDirectory:YES]; - - provisionData = [NSData dataWithContentsOfURL:[appURL URLByAppendingPathComponent:@"embedded.mobileprovision"]]; - appPlist = [NSData dataWithContentsOfURL:[appURL URLByAppendingPathComponent:@"Info.plist"]]; - - // read codesigning entitlements from application binary - codesignEntitlementsData = codesignEntitlementsDataFromApp(appPlist, appURL.path); - } - } - } else if ([dataType isEqualToString:kDataType_app_extension]) { - // get embedded plist and provisioning - provisionData = [NSData dataWithContentsOfURL:[URL URLByAppendingPathComponent:@"embedded.mobileprovision"]]; - appPlist = [NSData dataWithContentsOfURL:[URL URLByAppendingPathComponent:@"Info.plist"]]; - // read codesigning entitlements from application binary - codesignEntitlementsData = codesignEntitlementsDataFromApp(appPlist, URL.path); - } else { - // use provisioning directly - provisionData = [NSData dataWithContentsOfURL:URL]; - } - - NSMutableDictionary *synthesizedInfo = [NSMutableDictionary dictionary]; - NSURL *htmlURL = [[NSBundle bundleWithIdentifier:kPluginBundleId] URLForResource:@"template" withExtension:@"html"]; - NSMutableString *html = [NSMutableString stringWithContentsOfURL:htmlURL encoding:NSUTF8StringEncoding error:NULL]; - NSDateFormatter *dateFormatter = [NSDateFormatter new]; - [dateFormatter setDateStyle:NSDateFormatterMediumStyle]; - [dateFormatter setTimeStyle:NSDateFormatterMediumStyle]; - NSCalendar *calendar = [NSCalendar currentCalendar]; - id value = nil; - NSString *synthesizedValue = nil; - - if ([dataType isEqualToString:kDataType_ipa]) { - [synthesizedInfo setObject:@"App info" forKey:@"AppInfoTitle"]; - } else if ([dataType isEqualToString:kDataType_app_extension]) { - [synthesizedInfo setObject:@"App extension info" forKey:@"AppInfoTitle"]; - } else if ([dataType isEqualToString:kDataType_xcode_archive]) { - [synthesizedInfo setObject:@"Archive info" forKey:@"AppInfoTitle"]; - } - - if (!provisionData) { - NSLog(@"No provisionData for %@", URL); - - if (appPlist != nil) { - [synthesizedInfo setObject:@"hiddenDiv" forKey:@"ProvisionInfo"]; - } else { - return noErr; - } +/// Print formatted plist in a @c \
 tag
+NSString * _Nonnull formattedPlist(NSDictionary *dict) {
+	NSMutableString *output = [NSMutableString string];
+	recursiveKeyValue(0, nil, dict, output);
+	return [NSString stringWithFormat:@"
%@
", output]; +} + +/// First, try to extract real entitlements by running codesign. +/// If that fails, fallback to entitlements provided by provision plist. +NSDictionary * _Nonnull procEntitlements(NSData *codeSignData, NSDictionary *provisionPlist) { + BOOL showEntitlementsWarning = false; + NSString *formattedOutput = nil; + if (codeSignData != nil) { + NSDictionary *plist = [NSPropertyListSerialization propertyListWithData:codeSignData options:0 format:NULL error:NULL]; + if (plist != nil) { + formattedOutput = formattedPlist(plist); } else { - [synthesizedInfo setObject:@"" forKey:@"ProvisionInfo"]; - } - - // MARK: App Info - - if (appPlist != nil) { - NSDictionary *appPropertyList = [NSPropertyListSerialization propertyListWithData:appPlist options:0 format:NULL error:NULL]; - - NSString *iconName = mainIconNameForApp(appPropertyList); - appIcon = imageFromApp(URL, dataType, iconName); - [synthesizedInfo setObject:iconAsBase64(appIcon) forKey:@"AppIcon"]; - - NSString *bundleName = [appPropertyList objectForKey:@"CFBundleDisplayName"]; - if (!bundleName) { - bundleName = [appPropertyList objectForKey:@"CFBundleName"]; - } - [synthesizedInfo setObject:bundleName ?: @"" forKey:@"CFBundleName"]; - [synthesizedInfo setObject:[appPropertyList objectForKey:@"CFBundleIdentifier"] ?: @"" forKey:@"CFBundleIdentifier"]; - [synthesizedInfo setObject:[appPropertyList objectForKey:@"CFBundleShortVersionString"] ?: @"" forKey:@"CFBundleShortVersionString"]; - [synthesizedInfo setObject:[appPropertyList objectForKey:@"CFBundleVersion"] ?: @"" forKey:@"CFBundleVersion"]; - - NSString *extensionType = [[appPropertyList objectForKey:@"NSExtension"] objectForKey:@"NSExtensionPointIdentifier"]; - if(extensionType != nil) { - [synthesizedInfo setObject:@"" forKey:@"ExtensionInfo"]; - [synthesizedInfo setObject:extensionType forKey:@"NSExtensionPointIdentifier"]; - } else { - [synthesizedInfo setObject:@"hiddenDiv" forKey:@"ExtensionInfo"]; - } - - NSString *sdkName = [appPropertyList objectForKey:@"DTSDKName"] ?: @""; - [synthesizedInfo setObject:sdkName forKey:@"DTSDKName"]; - - NSString *minimumOSVersion = [appPropertyList objectForKey:@"MinimumOSVersion"] ?: @""; - [synthesizedInfo setObject:minimumOSVersion forKey:@"MinimumOSVersion"]; - - NSDictionary *appTransportSecurity = [appPropertyList objectForKey:@"NSAppTransportSecurity"]; - NSString *appTransportSecurityFormatted = @"No exceptions"; - if ([appTransportSecurity isKindOfClass:[NSDictionary class]]) { - NSDictionary *localizedKeys = @{ - @"NSAllowsArbitraryLoads": @"Allows Arbitrary Loads", - @"NSAllowsArbitraryLoadsForMedia": @"Allows Arbitrary Loads for Media", - @"NSAllowsArbitraryLoadsInWebContent": @"Allows Arbitrary Loads in Web Content", - @"NSAllowsLocalNetworking": @"Allows Local Networking", - @"NSExceptionDomains": @"Exception Domains", - - @"NSIncludesSubdomains": @"Includes Subdomains", - @"NSRequiresCertificateTransparency": @"Requires Certificate Transparency", - - @"NSExceptionAllowsInsecureHTTPLoads": @"Allows Insecure HTTP Loads", - @"NSExceptionMinimumTLSVersion": @"Minimum TLS Version", - @"NSExceptionRequiresForwardSecrecy": @"Requires Forward Secrecy", - - @"NSThirdPartyExceptionAllowsInsecureHTTPLoads": @"Allows Insecure HTTP Loads", - @"NSThirdPartyExceptionMinimumTLSVersion": @"Minimum TLS Version", - @"NSThirdPartyExceptionRequiresForwardSecrecy": @"Requires Forward Secrecy" - }; - - NSString *formattedDictionaryString = formattedDictionaryWithReplacements(appTransportSecurity, localizedKeys, 0); - appTransportSecurityFormatted = [NSString stringWithFormat:@"
%@
", formattedDictionaryString]; - } else { - double sdkNumber = [[sdkName stringByTrimmingCharactersInSet:[NSCharacterSet letterCharacterSet]] doubleValue]; - if (sdkNumber < 9.0) { - appTransportSecurityFormatted = @"Not applicable before iOS 9.0"; - } - } - - [synthesizedInfo setObject:appTransportSecurityFormatted forKey:@"AppTransportSecurityFormatted"]; - - NSMutableArray *platforms = [NSMutableArray array]; - for (NSNumber *number in [appPropertyList objectForKey:@"UIDeviceFamily"]) { - switch ([number intValue]) { - case 1: - [platforms addObject:@"iPhone"]; - break; - case 2: - [platforms addObject:@"iPad"]; - break; - case 3: - [platforms addObject:@"TV"]; - break; - case 4: - [platforms addObject:@"Watch"]; - break; - default: - break; - } - } - [synthesizedInfo setObject:[platforms componentsJoinedByString:@", "] forKey:@"UIDeviceFamily"]; - [synthesizedInfo setObject:@"" forKey:@"AppInfo"]; - [synthesizedInfo setObject:@"hiddenDiv" forKey:@"ProvisionAsSubheader"]; - } else { - [synthesizedInfo setObject:@"hiddenDiv" forKey:@"AppInfo"]; - [synthesizedInfo setObject:@"" forKey:@"ProvisionAsSubheader"]; - } - - // MARK: Provisioning - - CMSDecoderRef decoder = NULL; - CMSDecoderCreate(&decoder); - CMSDecoderUpdateMessage(decoder, provisionData.bytes, provisionData.length); - CMSDecoderFinalizeMessage(decoder); - CFDataRef dataRef = NULL; - CMSDecoderCopyContent(decoder, &dataRef); - NSData *data = (NSData *)CFBridgingRelease(dataRef); - CFRelease(decoder); - - if ((!data && !appPlist) || QLPreviewRequestIsCancelled(preview)) { - return noErr; - } - - if (data) { - // use all keys and values in the property list to generate replacement tokens and values - NSDictionary *propertyList = [NSPropertyListSerialization propertyListWithData:data options:0 format:NULL error:NULL]; - for (NSString *key in [propertyList allKeys]) { - NSString *replacementValue = [[propertyList valueForKey:key] description]; - NSString *replacementToken = [NSString stringWithFormat:@"__%@__", key]; - [html replaceOccurrencesOfString:replacementToken withString:replacementValue options:0 range:NSMakeRange(0, [html length])]; - } - - // synthesize other replacement tokens and values - value = [propertyList objectForKey:@"CreationDate"]; - if ([value isKindOfClass:[NSDate class]]) { - NSDate *date = (NSDate *)value; - synthesizedValue = [dateFormatter stringFromDate:date]; - [synthesizedInfo setObject:synthesizedValue forKey:@"CreationDateFormatted"]; - - NSDateComponents *dateComponents = [calendar components:(NSCalendarUnitDay|NSCalendarUnitHour|NSCalendarUnitMinute) - fromDate:date - toDate:[NSDate date] - options:0]; - if ([calendar isDate:date inSameDayAsDate:[NSDate date]]) { - synthesizedValue = @"Created today"; - } else { - NSDateComponentsFormatter *formatter = [[NSDateComponentsFormatter alloc] init]; - formatter.unitsStyle = NSDateComponentsFormatterUnitsStyleFull; - formatter.maximumUnitCount = 1; - - synthesizedValue = [NSString stringWithFormat:@"Created %@ ago", [formatter stringFromDateComponents:dateComponents]]; - } - [synthesizedInfo setObject:synthesizedValue forKey:@"CreationSummary"]; - } - - value = [propertyList objectForKey:@"ExpirationDate"]; - if ([value isKindOfClass:[NSDate class]]) { - NSDate *date = (NSDate *)value; - synthesizedValue = [dateFormatter stringFromDate:date]; - [synthesizedInfo setObject:synthesizedValue forKey:@"ExpirationDateFormatted"]; - - synthesizedValue = expirationStringForDateInCalendar(date, calendar); - [synthesizedInfo setObject:synthesizedValue forKey:@"ExpirationSummary"]; - - int expStatus = expirationStatus(date, calendar); - if (expStatus == 0) { - synthesizedValue = @"expired"; - } else if (expStatus == 1) { - synthesizedValue = @"expiring"; - } else { - synthesizedValue = @"valid"; - } - [synthesizedInfo setObject:synthesizedValue forKey:@"ExpStatus"]; - } - - value = [propertyList objectForKey:@"TeamIdentifier"]; - if ([value isKindOfClass:[NSArray class]]) { - NSArray *array = (NSArray *)value; - synthesizedValue = [array componentsJoinedByString:@", "]; - [synthesizedInfo setObject:synthesizedValue forKey:@"TeamIds"]; - } - - BOOL showEntitlementsWarning = false; - if (codesignEntitlementsData != nil) { - // read the entitlements directly from the codesign output - NSDictionary *entitlementsPropertyList = [NSPropertyListSerialization propertyListWithData:codesignEntitlementsData options:0 format:NULL error:NULL]; - if (entitlementsPropertyList != nil) { - NSMutableString *dictionaryFormatted = [NSMutableString string]; - displayKeyAndValue(0, nil, entitlementsPropertyList, dictionaryFormatted); - synthesizedValue = [NSString stringWithFormat:@"
%@
", dictionaryFormatted]; - } else { - NSString *outputString = [[NSString alloc] initWithData:codesignEntitlementsData encoding:NSUTF8StringEncoding]; - NSString *errorOutput; - if ([outputString hasPrefix:@"Executable="]) { - // remove first line with long temporary path to the executable - NSArray *allLines = [outputString componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]; - errorOutput = [[allLines subarrayWithRange:NSMakeRange(1, allLines.count - 1)] componentsJoinedByString:@"
"]; - } else { - errorOutput = outputString; - } - showEntitlementsWarning = true; - synthesizedValue = errorOutput; - } - [synthesizedInfo setObject:synthesizedValue forKey:@"EntitlementsFormatted"]; - } else { - // read the entitlements from the provisioning profile instead - value = [propertyList objectForKey:@"Entitlements"]; - if ([value isKindOfClass:[NSDictionary class]]) { - NSDictionary *dictionary = (NSDictionary *)value; - NSMutableString *dictionaryFormatted = [NSMutableString string]; - displayKeyAndValue(0, nil, dictionary, dictionaryFormatted); - synthesizedValue = [NSString stringWithFormat:@"
%@
", dictionaryFormatted]; - - [synthesizedInfo setObject:synthesizedValue forKey:@"EntitlementsFormatted"]; - } else { - [synthesizedInfo setObject:@"No Entitlements" forKey:@"EntitlementsFormatted"]; - } - } - if (showEntitlementsWarning) { - [synthesizedInfo setObject:@"" forKey:@"EntitlementsWarning"]; - } else { - [synthesizedInfo setObject:@"hiddenDiv" forKey:@"EntitlementsWarning"]; - } - - value = [propertyList objectForKey:@"DeveloperCertificates"]; - if ([value isKindOfClass:[NSArray class]]) { - [synthesizedInfo setObject:formattedStringForCertificates(value) forKey:@"DeveloperCertificatesFormatted"]; - } else { - [synthesizedInfo setObject:@"No Developer Certificates" forKey:@"DeveloperCertificatesFormatted"]; - } - - value = [propertyList objectForKey:@"ProvisionedDevices"]; - if ([value isKindOfClass:[NSArray class]]) { - [synthesizedInfo addEntriesFromDictionary:formattedDevicesData(value)]; - } else { - [synthesizedInfo setObject:@"No Devices" forKey:@"ProvisionedDevicesFormatted"]; - [synthesizedInfo setObject:@"Distribution Profile" forKey:@"ProvisionedDevicesCount"]; - } - - { - NSString *profileString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - profileString = escapedXML(profileString); - synthesizedValue = [NSString stringWithFormat:@"
%@
", profileString]; - [synthesizedInfo setObject:synthesizedValue forKey:@"RawData"]; - } - - // older provisioning files don't include some key/value pairs - value = [propertyList objectForKey:@"TeamName"]; - if (! value) { - [synthesizedInfo setObject:@"Team name not available" forKey:@"TeamName"]; - } - value = [propertyList objectForKey:@"TeamIdentifier"]; - if (! value) { - [synthesizedInfo setObject:@"Team ID not available" forKey:@"TeamIds"]; - } - value = [propertyList objectForKey:@"AppIDName"]; - if (! value) { - [synthesizedInfo setObject:@"App name not available" forKey:@"AppIDName"]; - } - - // determine the profile type - BOOL getTaskAllow = NO; - value = [propertyList objectForKey:@"Entitlements"]; - if ([value isKindOfClass:[NSDictionary class]]) { - NSDictionary *dictionary = (NSDictionary *)value; - getTaskAllow = [[dictionary valueForKey:@"get-task-allow"] boolValue]; - } - - BOOL hasDevices = NO; - value = [propertyList objectForKey:@"ProvisionedDevices"]; - if ([value isKindOfClass:[NSArray class]]) { - hasDevices = YES; - } - - BOOL isEnterprise = [[propertyList objectForKey:@"ProvisionsAllDevices"] boolValue]; - - if ([dataType isEqualToString:kDataType_osx_provision]) { - [synthesizedInfo setObject:@"mac" forKey:@"Platform"]; - - [synthesizedInfo setObject:@"Mac" forKey:@"ProfilePlatform"]; - if (hasDevices) { - [synthesizedInfo setObject:@"Development" forKey:@"ProfileType"]; - } else { - [synthesizedInfo setObject:@"Distribution (App Store)" forKey:@"ProfileType"]; - } - } else { - [synthesizedInfo setObject:@"ios" forKey:@"Platform"]; - - [synthesizedInfo setObject:@"iOS" forKey:@"ProfilePlatform"]; - if (hasDevices) { - if (getTaskAllow) { - [synthesizedInfo setObject:@"Development" forKey:@"ProfileType"]; - } else { - [synthesizedInfo setObject:@"Distribution (Ad Hoc)" forKey:@"ProfileType"]; - } - } else { - if (isEnterprise) { - [synthesizedInfo setObject:@"Enterprise" forKey:@"ProfileType"]; - } else { - [synthesizedInfo setObject:@"Distribution (App Store)" forKey:@"ProfileType"]; - } - } - } - } - - // MARK: File Info - - [synthesizedInfo setObject:escapedXML([URL lastPathComponent]) forKey:@"FileName"]; - - if ([[URL pathExtension] isEqualToString:@"app"] || [[URL pathExtension] isEqualToString:@"appex"]) { - // get the "file" information using the application package folder - NSString *folderPath = [URL path]; - - NSDictionary *folderAttributes = [fileManager attributesOfItemAtPath:folderPath error:NULL]; - if (folderAttributes) { - NSDate *folderModificationDate = [folderAttributes fileModificationDate]; - - unsigned long long folderSize = 0; - NSArray *filesArray = [fileManager subpathsOfDirectoryAtPath:folderPath error:nil]; - for (NSString *fileName in filesArray) { - NSDictionary *fileAttributes = [fileManager attributesOfItemAtPath:[folderPath stringByAppendingPathComponent:fileName] error:NULL]; - if (fileAttributes) - folderSize += [fileAttributes fileSize]; - } - - synthesizedValue = [NSString stringWithFormat:@"%@, Modified %@", - [NSByteCountFormatter stringFromByteCount:folderSize countStyle:NSByteCountFormatterCountStyleFile], - [dateFormatter stringFromDate:folderModificationDate]]; - [synthesizedInfo setObject:synthesizedValue forKey:@"FileInfo"]; - } else { - [synthesizedInfo setObject:@"" forKey:@"FileInfo"]; - } - } else { - NSDictionary *fileAttributes = [fileManager attributesOfItemAtPath:[URL path] error:NULL]; - if (fileAttributes) { - NSDate *fileModificationDate = [fileAttributes fileModificationDate]; - unsigned long long fileSize = [fileAttributes fileSize]; - - synthesizedValue = [NSString stringWithFormat:@"%@, Modified %@", - [NSByteCountFormatter stringFromByteCount:fileSize countStyle:NSByteCountFormatterCountStyleFile], - [dateFormatter stringFromDate:fileModificationDate]]; - [synthesizedInfo setObject:synthesizedValue forKey:@"FileInfo"]; - } - } - - // MARK: Footer + showEntitlementsWarning = true; + NSString *output = [[NSString alloc] initWithData:codeSignData encoding:NSUTF8StringEncoding]; + if ([output hasPrefix:@"Executable="]) { + // remove first line with long temporary path to the executable + NSArray *allLines = [output componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]; + formattedOutput = [[allLines subarrayWithRange:NSMakeRange(1, allLines.count - 1)] componentsJoinedByString:@"
"]; + } else { + formattedOutput = output; + } + } + } else { + // read the entitlements from the provisioning profile instead + NSDictionary *value = provisionPlist[@"Entitlements"]; + if ([value isKindOfClass:[NSDictionary class]]) { + formattedOutput = formattedPlist(value); + } else { + formattedOutput = @"No Entitlements"; + } + } + + return @{ + @"EntitlementsFormatted": formattedOutput ?: @"", + @"EntitlementsWarning": showEntitlementsWarning ? @"" : @"hiddenDiv", + }; +} + + +// MARK: - File Info + +/// Title of the preview window +NSString * _Nullable stringForFileType(QuickLookInfo meta) { + switch (meta.type) { + case FileTypeIPA: return @"App info"; + case FileTypeArchive: return @"Archive info"; + case FileTypeExtension: return @"App extension info"; + case FileTypeProvision: return nil; + } +} + +/// Calculate file / folder size. +unsigned long long getFileSize(NSString *path) { + NSFileManager *fileManager = [NSFileManager defaultManager]; + BOOL isDir; + [fileManager fileExistsAtPath:path isDirectory:&isDir]; + if (!isDir) { + return [[fileManager attributesOfItemAtPath:path error:NULL] fileSize]; + } + + unsigned long long fileSize = 0; + NSArray *children = [fileManager subpathsOfDirectoryAtPath:path error:nil]; + for (NSString *fileName in children) { + fileSize += [[fileManager attributesOfItemAtPath:[path stringByAppendingPathComponent:fileName] error:NULL] fileSize]; + } + return fileSize; +} + +/// Process meta information about the file itself. Like file size and last modification. +NSDictionary * _Nonnull procFileInfo(NSURL *url) { + NSString *formattedValue = nil; + NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:url.path error:NULL]; + if (attrs) { + formattedValue = [NSString stringWithFormat:@"%@, Modified %@", + [NSByteCountFormatter stringFromByteCount:getFileSize(url.path) countStyle:NSByteCountFormatterCountStyleFile], + formattedDate([attrs fileModificationDate])]; + } + + return @{ + @"FileName": escapedXML([url lastPathComponent]), + @"FileInfo": formattedValue ?: @"", + }; +} + +// MARK: - Footer Info + +/// Process meta information about the plugin. Like version and debug flag. +NSDictionary * _Nonnull procFooterInfo() { + NSBundle *mainBundle = [NSBundle bundleWithIdentifier:kPluginBundleId]; + return @{ #ifdef DEBUG - [synthesizedInfo setObject:@"(debug)" forKey:@"DEBUG"]; + @"DEBUG": @"(debug)", #else - [synthesizedInfo setObject:@"" forKey:@"DEBUG"]; + @"DEBUG": @"", #endif + @"BundleShortVersionString": [mainBundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @"", + @"BundleVersion": [mainBundle objectForInfoDictionaryKey:@"CFBundleVersion"] ?: @"", + }; +} - synthesizedValue = [[NSBundle bundleWithIdentifier:kPluginBundleId] objectForInfoDictionaryKey:@"CFBundleShortVersionString"]; - [synthesizedInfo setObject:synthesizedValue ?: @"" forKey:@"BundleShortVersionString"]; - synthesizedValue = [[NSBundle bundleWithIdentifier:kPluginBundleId] objectForInfoDictionaryKey:@"CFBundleVersion"]; - [synthesizedInfo setObject:synthesizedValue ?: @"" forKey:@"BundleVersion"]; +// MARK: - Main Entry + +NSString *applyHtmlTemplate(NSDictionary *templateValues) { + NSURL *templateURL = [[NSBundle bundleWithIdentifier:kPluginBundleId] URLForResource:@"template" withExtension:@"html"]; + NSString *html = [NSString stringWithContentsOfURL:templateURL encoding:NSUTF8StringEncoding error:NULL]; + + // this is less efficient +// for (NSString *key in [templateValues allKeys]) { +// [html replaceOccurrencesOfString:[NSString stringWithFormat:@"__%@__", key] +// withString:[templateValues objectForKey:key] +// options:0 range:NSMakeRange(0, [html length])]; +// } + + NSMutableString *rv = [NSMutableString string]; + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"__[^ _]{1,40}?__" options:0 error:nil]; + __block NSUInteger prevLoc = 0; + [regex enumerateMatchesInString:html options:0 range:NSMakeRange(0, html.length) usingBlock:^(NSTextCheckingResult * _Nullable result, NSMatchingFlags flags, BOOL * _Nonnull stop) { + NSUInteger start = result.range.location; + NSString *key = [html substringWithRange:NSMakeRange(start + 2, result.range.length - 4)]; + [rv appendString:[html substringWithRange:NSMakeRange(prevLoc, start - prevLoc)]]; + NSString *value = templateValues[key]; + if (!value) { + NSLog(@"WARN: unused key %@", key); + } else { + [rv appendString:value]; + } + prevLoc = start + result.range.length; + }]; + [rv appendString:[html substringWithRange:NSMakeRange(prevLoc, html.length - prevLoc)]]; + return rv; +} - for (NSString *key in [synthesizedInfo allKeys]) { - NSString *replacementValue = [synthesizedInfo objectForKey:key]; - NSString *replacementToken = [NSString stringWithFormat:@"__%@__", key]; - [html replaceOccurrencesOfString:replacementToken withString:replacementValue options:0 range:NSMakeRange(0, [html length])]; - } +OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, CFURLRef url, CFStringRef contentTypeUTI, CFDictionaryRef options) { + @autoreleasepool { + QuickLookInfo meta = initQLInfo(contentTypeUTI, url); + NSMutableDictionary* infoLayer = [NSMutableDictionary dictionary]; + infoLayer[@"AppInfoTitle"] = stringForFileType(meta); - NSDictionary *properties = @{ // properties for the HTML data - (__bridge NSString *)kQLPreviewPropertyTextEncodingNameKey : @"UTF-8", - (__bridge NSString *)kQLPreviewPropertyMIMETypeKey : @"text/html" }; + // App Info + NSDictionary *plistApp = readPlistApp(meta); + ALLOW_EXIT - QLPreviewRequestSetDataRepresentation(preview, (__bridge CFDataRef)[html dataUsingEncoding:NSUTF8StringEncoding], kUTTypeHTML, (__bridge CFDictionaryRef)properties); - } + [infoLayer addEntriesFromDictionary:procAppInfo(plistApp)]; + ALLOW_EXIT + + // Provisioning + NSDictionary *plistProvision = readPlistProvision(meta); + ALLOW_EXIT + + if (!plistApp && !plistProvision) { + return noErr; // nothing to do. Maybe another QL plugin can do better. + } + + [infoLayer addEntriesFromDictionary:procProvision(plistProvision, meta.isOSX)]; + ALLOW_EXIT + + // App Icon + infoLayer[@"AppIcon"] = iconAsBase64(imageFromApp(meta, plistApp)); + ALLOW_EXIT + + // Entitlements + NSString *bundleExecutable = plistApp[@"CFBundleExecutable"]; + NSData *codeSignData = getCodeSignEntitlements(meta, bundleExecutable); + ALLOW_EXIT + + [infoLayer addEntriesFromDictionary:procEntitlements(codeSignData, plistProvision)]; + ALLOW_EXIT + // File Info + [infoLayer addEntriesFromDictionary:procFileInfo(meta.url)]; + ALLOW_EXIT + + // Footer Info + [infoLayer addEntriesFromDictionary:procFooterInfo()]; + ALLOW_EXIT + + // prepare html, replace values + NSString *html = applyHtmlTemplate(infoLayer); + ALLOW_EXIT + + // QL render html + NSDictionary *properties = @{ // properties for the HTML data + (__bridge NSString *)kQLPreviewPropertyTextEncodingNameKey : @"UTF-8", + (__bridge NSString *)kQLPreviewPropertyMIMETypeKey : @"text/html" + }; + QLPreviewRequestSetDataRepresentation(preview, (__bridge CFDataRef)[html dataUsingEncoding:NSUTF8StringEncoding], kUTTypeHTML, (__bridge CFDictionaryRef)properties); + } return noErr; } void CancelThumbnailGeneration(void *thisInterface, QLThumbnailRequestRef thumbnail) { - // Implement only if supported + // Implement only if supported } diff --git a/ProvisionQL/GenerateThumbnailForURL.m b/ProvisionQL/GenerateThumbnailForURL.m index 5bc8c68..ba5f008 100644 --- a/ProvisionQL/GenerateThumbnailForURL.m +++ b/ProvisionQL/GenerateThumbnailForURL.m @@ -1,5 +1,8 @@ #import "Shared.h" +// makro to stop further processing +#define ALLOW_EXIT if (QLThumbnailRequestIsCancelled(thumbnail)) { return noErr; } + //Layout constants #define BADGE_MARGIN 10.0 #define MIN_BADGE_WIDTH 40.0 @@ -19,182 +22,143 @@ void CancelThumbnailGeneration(void *thisInterface, QLThumbnailRequestRef thumbnail); /* ----------------------------------------------------------------------------- - Generate a thumbnail for file - - This function's job is to create thumbnail for designated file as fast as possible - ----------------------------------------------------------------------------- */ - -OSStatus GenerateThumbnailForURL(void *thisInterface, QLThumbnailRequestRef thumbnail, CFURLRef url, CFStringRef contentTypeUTI, CFDictionaryRef options, CGSize maxSize) { - @autoreleasepool { - NSURL *URL = (__bridge NSURL *)url; - NSString *dataType = (__bridge NSString *)contentTypeUTI; - NSData *appPlist = nil; - NSImage *appIcon = nil; - - if ([dataType isEqualToString:kDataType_xcode_archive]) { - // get the embedded plist for the iOS app - NSURL *appsDir = [URL URLByAppendingPathComponent:@"Products/Applications/"]; - if (appsDir != nil) { - NSArray *dirFiles = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:appsDir.path error:nil]; - if (dirFiles.count > 0) { - appPlist = [NSData dataWithContentsOfURL:[appsDir URLByAppendingPathComponent:[NSString stringWithFormat:@"%@/Info.plist", dirFiles[0]]]]; - } - } - } else if([dataType isEqualToString:kDataType_ipa]) { - appPlist = unzipFile(URL, @"Payload/*.app/Info.plist"); - } - - if (QLThumbnailRequestIsCancelled(thumbnail)) { - return noErr; - } - - // MARK: .ipa & .xarchive - - if ([dataType isEqualToString:kDataType_ipa] || [dataType isEqualToString:kDataType_xcode_archive]) { - NSDictionary *appPropertyList = [NSPropertyListSerialization propertyListWithData:appPlist options:0 format:NULL error:NULL]; - NSString *iconName = mainIconNameForApp(appPropertyList); - appIcon = imageFromApp(URL, dataType, iconName); - - if (QLThumbnailRequestIsCancelled(thumbnail)) { - return noErr; - } - - if (!appIcon) { - NSURL *iconURL = [[NSBundle bundleWithIdentifier:kPluginBundleId] URLForResource:@"defaultIcon" withExtension:@"png"]; - appIcon = [[NSImage alloc] initWithContentsOfURL:iconURL]; - } - static const NSString *IconFlavor; - if (@available(macOS 10.15, *)) { - IconFlavor = @"icon"; - } else { - IconFlavor = @"IconFlavor"; - } - NSDictionary *propertiesDict = nil; - if ([dataType isEqualToString:kDataType_xcode_archive]) { - // 0: Plain transparent, 1: Shadow, 2: Book, 3: Movie, 4: Address, 5: Image, - // 6: Gloss, 7: Slide, 8: Square, 9: Border, 11: Calendar, 12: Pattern - propertiesDict = @{IconFlavor : @(12)}; - } else { - propertiesDict = @{IconFlavor : @(0)}; - } - // image-only icons can be drawn efficiently. - appIcon = roundCorners(appIcon); - // downscale as required by QLThumbnailRequestSetImageWithData - if (appIcon.size.width > maxSize.width && appIcon.size.height > maxSize.height) { - [appIcon setSize:maxSize]; - } - QLThumbnailRequestSetImageWithData(thumbnail, (__bridge CFDataRef)[appIcon TIFFRepresentation], (__bridge CFDictionaryRef)propertiesDict); - return noErr; - } - - // MARK: .provisioning + Generate a thumbnail for file + + This function's job is to create thumbnail for designated file as fast as possible + ----------------------------------------------------------------------------- */ + +// MARK: .ipa .xarchive + +OSStatus renderAppIcon(QuickLookInfo meta, QLThumbnailRequestRef thumbnail) { + NSImage *appIcon = imageFromApp(meta, nil); + ALLOW_EXIT + + // downscale as required by QLThumbnailRequestSetImageWithData + CGSize maxSize = QLThumbnailRequestGetMaximumSize(thumbnail); + if (appIcon.size.width > maxSize.width && appIcon.size.height > maxSize.height) { + [appIcon setSize:maxSize]; + } + + appIcon = roundCorners(appIcon); + ALLOW_EXIT + + // set magic flag to draw icon without additional markers + static const NSString *IconFlavor; + if (@available(macOS 10.15, *)) { + IconFlavor = @"icon"; + } else { + IconFlavor = @"IconFlavor"; + } + NSDictionary *propertiesDict = nil; + if (meta.type == FileTypeArchive) { + // 0: Plain transparent, 1: Shadow, 2: Book, 3: Movie, 4: Address, 5: Image, + // 6: Gloss, 7: Slide, 8: Square, 9: Border, 11: Calendar, 12: Pattern + propertiesDict = @{IconFlavor : @(12)}; // looks like "in development" + } else { + propertiesDict = @{IconFlavor : @(0)}; // no border, no anything + } + + // image-only icons can be drawn efficiently by calling `SetImage` directly. + QLThumbnailRequestSetImageWithData(thumbnail, (__bridge CFDataRef)[appIcon TIFFRepresentation], (__bridge CFDictionaryRef)propertiesDict); + return noErr; +} - // use provisioning directly - NSData *provisionData = [NSData dataWithContentsOfURL:URL]; - if (!provisionData) { - NSLog(@"No provisionData for %@", URL); - return noErr; - } - NSDictionary *optionsDict = (__bridge NSDictionary *)options; - BOOL iconMode = ([optionsDict objectForKey:(NSString *)kQLThumbnailOptionIconModeKey]) ? YES : NO; - NSUInteger devicesCount = 0; - int expStatus = 0; - - if (iconMode) { - NSURL *iconURL = [[NSBundle bundleWithIdentifier:kPluginBundleId] URLForResource:@"blankIcon" withExtension:@"png"]; - appIcon = [[NSImage alloc] initWithContentsOfURL:iconURL]; - } else { - appIcon = [[NSWorkspace sharedWorkspace] iconForFileType:dataType]; - [appIcon setSize:NSMakeSize(512,512)]; - } +// MARK: .provisioning + +OSStatus renderProvision(QuickLookInfo meta, QLThumbnailRequestRef thumbnail, BOOL iconMode) { + NSDictionary *propertyList = readPlistProvision(meta); + ALLOW_EXIT + + NSUInteger devicesCount = arrayOrNil(propertyList[@"ProvisionedDevices"]).count; + NSDate *expirationDate = dateOrNil(propertyList[@"ExpirationDate"]); + + NSImage *appIcon = nil; + if (iconMode) { + NSURL *iconURL = [[NSBundle bundleWithIdentifier:kPluginBundleId] URLForResource:@"blankIcon" withExtension:@"png"]; + appIcon = [[NSImage alloc] initWithContentsOfURL:iconURL]; + } else { + appIcon = [[NSWorkspace sharedWorkspace] iconForFileType:meta.UTI]; + [appIcon setSize:NSMakeSize(512, 512)]; + } + ALLOW_EXIT + + NSRect renderRect = NSMakeRect(0.0, 0.0, appIcon.size.width, appIcon.size.height); + + // Font attributes + NSColor *outlineColor; + switch (expirationStatus(expirationDate)) { + case ExpirationStatusExpired: outlineColor = BADGE_EXPIRED_COLOR; break; + case ExpirationStatusExpiring: outlineColor = BADGE_EXPIRING_COLOR; break; + case ExpirationStatusValid: outlineColor = BADGE_VALID_COLOR; break; + } + + NSMutableParagraphStyle *paragraphStyle = [[NSParagraphStyle defaultParagraphStyle] mutableCopy]; + paragraphStyle.lineBreakMode = NSLineBreakByTruncatingTail; + paragraphStyle.alignment = NSTextAlignmentCenter; + + NSDictionary *fontAttrs = @{ + NSFontAttributeName : BADGE_FONT, + NSForegroundColorAttributeName : outlineColor, + NSParagraphStyleAttributeName: paragraphStyle + }; + + // Badge size & placement + int badgeX = renderRect.origin.x + BADGE_MARGIN_X; + int badgeY = renderRect.origin.y + renderRect.size.height - BADGE_HEIGHT - BADGE_MARGIN_Y; + if (!iconMode) { + badgeX += 75; + badgeY -= 10; + } + int badgeNumX = badgeX + BADGE_MARGIN; + NSPoint badgeTextPoint = NSMakePoint(badgeNumX, badgeY); + + NSString *badge = [NSString stringWithFormat:@"%lu",(unsigned long)devicesCount]; + NSSize badgeNumSize = [badge sizeWithAttributes:fontAttrs]; + int badgeWidth = badgeNumSize.width + BADGE_MARGIN * 2; + NSRect badgeOutlineRect = NSMakeRect(badgeX, badgeY, MAX(badgeWidth, MIN_BADGE_WIDTH), BADGE_HEIGHT); + ALLOW_EXIT + + // Do as much work as possible before the `CreateContext`. We can try to quit early before that! + CGContextRef _context = QLThumbnailRequestCreateContext(thumbnail, renderRect.size, false, NULL); + if (_context) { + NSGraphicsContext *_graphicsContext = [NSGraphicsContext graphicsContextWithCGContext:(void *)_context flipped:NO]; + [NSGraphicsContext setCurrentContext:_graphicsContext]; + [appIcon drawInRect:renderRect]; + + NSBezierPath *badgePath = [NSBezierPath bezierPathWithRoundedRect:badgeOutlineRect xRadius:10 yRadius:10]; + [badgePath setLineWidth:8.0]; + [BADGE_BG_COLOR set]; + [badgePath fill]; + [outlineColor set]; + [badgePath stroke]; + + [badge drawAtPoint:badgeTextPoint withAttributes:fontAttrs]; + + QLThumbnailRequestFlushContext(thumbnail, _context); + CFRelease(_context); + } + return noErr; +} - CMSDecoderRef decoder = NULL; - CMSDecoderCreate(&decoder); - CMSDecoderUpdateMessage(decoder, provisionData.bytes, provisionData.length); - CMSDecoderFinalizeMessage(decoder); - CFDataRef dataRef = NULL; - CMSDecoderCopyContent(decoder, &dataRef); - NSData *data = (NSData *)CFBridgingRelease(dataRef); - CFRelease(decoder); - - if (!data || QLThumbnailRequestIsCancelled(thumbnail)) { - return noErr; - } - NSDictionary *propertyList = [NSPropertyListSerialization propertyListWithData:data options:0 format:NULL error:NULL]; - id value = [propertyList objectForKey:@"ProvisionedDevices"]; - if ([value isKindOfClass:[NSArray class]]) { - devicesCount = [value count]; - } +// MARK: Main Entry - value = [propertyList objectForKey:@"ExpirationDate"]; - if ([value isKindOfClass:[NSDate class]]) { - expStatus = expirationStatus(value, [NSCalendar currentCalendar]); +OSStatus GenerateThumbnailForURL(void *thisInterface, QLThumbnailRequestRef thumbnail, CFURLRef url, CFStringRef contentTypeUTI, CFDictionaryRef options, CGSize maxSize) { + @autoreleasepool { + QuickLookInfo meta = initQLInfo(contentTypeUTI, url); + + if (meta.type == FileTypeIPA || meta.type == FileTypeArchive) { + return renderAppIcon(meta, thumbnail); + } else if (meta.type == FileTypeProvision) { + NSDictionary *optionsDict = (__bridge NSDictionary *)options; + BOOL iconMode = ([optionsDict objectForKey:(NSString *)kQLThumbnailOptionIconModeKey]) ? YES : NO; + return renderProvision(meta, thumbnail, iconMode); } - - if (QLThumbnailRequestIsCancelled(thumbnail)) { - return noErr; - } - - NSSize canvasSize = appIcon.size; - NSRect renderRect = NSMakeRect(0.0, 0.0, appIcon.size.width, appIcon.size.height); - - CGContextRef _context = QLThumbnailRequestCreateContext(thumbnail, canvasSize, false, NULL); - if (_context) { - NSGraphicsContext *_graphicsContext = [NSGraphicsContext graphicsContextWithCGContext:(void *)_context flipped:NO]; - - [NSGraphicsContext setCurrentContext:_graphicsContext]; - - [appIcon drawInRect:renderRect]; - - NSString *badge = [NSString stringWithFormat:@"%lu",(unsigned long)devicesCount]; - NSColor *outlineColor; - - if (expStatus == 2) { - outlineColor = BADGE_VALID_COLOR; - } else if (expStatus == 1) { - outlineColor = BADGE_EXPIRING_COLOR; - } else { - outlineColor = BADGE_EXPIRED_COLOR; - } - - NSMutableParagraphStyle *paragraphStyle = [[NSParagraphStyle defaultParagraphStyle] mutableCopy]; - paragraphStyle.lineBreakMode = NSLineBreakByTruncatingTail; - paragraphStyle.alignment = NSTextAlignmentCenter; - - NSDictionary *attrDict = @{NSFontAttributeName : BADGE_FONT, NSForegroundColorAttributeName : outlineColor, NSParagraphStyleAttributeName: paragraphStyle}; - - NSSize badgeNumSize = [badge sizeWithAttributes:attrDict]; - int badgeWidth = badgeNumSize.width + BADGE_MARGIN * 2; - badgeWidth = MAX(badgeWidth, MIN_BADGE_WIDTH); - - int badgeX = renderRect.origin.x + BADGE_MARGIN_X; - int badgeY = renderRect.origin.y + renderRect.size.height - BADGE_HEIGHT - BADGE_MARGIN_Y; - if (!iconMode) { - badgeX += 75; - badgeY -= 10; - } - int badgeNumX = badgeX + BADGE_MARGIN; - NSRect badgeRect = NSMakeRect(badgeX, badgeY, badgeWidth, BADGE_HEIGHT); - - NSBezierPath *badgePath = [NSBezierPath bezierPathWithRoundedRect:badgeRect xRadius:10 yRadius:10]; - [badgePath setLineWidth:8.0]; - [BADGE_BG_COLOR set]; - [badgePath fill]; - [outlineColor set]; - [badgePath stroke]; - - [badge drawAtPoint:NSMakePoint(badgeNumX,badgeY) withAttributes:attrDict]; - - QLThumbnailRequestFlushContext(thumbnail, _context); - CFRelease(_context); - } - } - - return noErr; + } + return noErr; } void CancelPreviewGeneration(void *thisInterface, QLPreviewRequestRef preview) { - // Implement only if supported + // Implement only if supported } diff --git a/ProvisionQL/Resources/template.html b/ProvisionQL/Resources/template.html index 340ce41..2d04525 100644 --- a/ProvisionQL/Resources/template.html +++ b/ProvisionQL/Resources/template.html @@ -58,6 +58,7 @@ h2 { font-size: 14px; + margin-top: 4ex; text-transform: uppercase; } @@ -158,18 +159,18 @@

App Transport Security

-

Provisioning

- Profile name: __Name__
+

Provisioning

+ Profile name: __ProfileName__
-

__Name__

+

__ProfileName__

- Profile UUID: __UUID__
+ Profile UUID: __ProfileUUID__
Profile Type: __ProfilePlatform__ __ProfileType__
Team: __TeamName__ (__TeamIds__)
- Creation date: __CreationDateFormatted__ (__CreationSummary__)
- Expiration Date: __ExpirationDateFormatted__ (__ExpirationSummary__)
+ Creation date: __CreationDateFormatted__
+ Expiration Date: __ExpirationDateFormatted__

Entitlements

diff --git a/ProvisionQL/Shared.h b/ProvisionQL/Shared.h index 3f48fec..a39ba90 100644 --- a/ProvisionQL/Shared.h +++ b/ProvisionQL/Shared.h @@ -8,18 +8,51 @@ #import -static NSString * const kPluginBundleId = @"com.ealeksandrov.ProvisionQL"; -static NSString * const kDataType_ipa = @"com.apple.itunes.ipa"; -static NSString * const kDataType_ios_provision = @"com.apple.mobileprovision"; -static NSString * const kDataType_ios_provision_old = @"com.apple.iphone.mobileprovision"; -static NSString * const kDataType_osx_provision = @"com.apple.provisionprofile"; -static NSString * const kDataType_xcode_archive = @"com.apple.xcode.archive"; -static NSString * const kDataType_app_extension = @"com.apple.application-and-system-extension"; - -NSData *unzipFile(NSURL *url, NSString *filePath); -void unzipFileToDir(NSURL *url, NSString *filePath, NSString *targetDir); - -NSImage *roundCorners(NSImage *image); -NSImage *imageFromApp(NSURL *URL, NSString *dataType, NSString *fileName); -NSString *mainIconNameForApp(NSDictionary *appPropertyList); -int expirationStatus(NSDate *date, NSCalendar *calendar); +static NSString * _Nonnull const kPluginBundleId = @"com.ealeksandrov.ProvisionQL"; +static NSString * _Nonnull const kDataType_ipa = @"com.apple.itunes.ipa"; +static NSString * _Nonnull const kDataType_ios_provision = @"com.apple.mobileprovision"; +static NSString * _Nonnull const kDataType_ios_provision_old = @"com.apple.iphone.mobileprovision"; +static NSString * _Nonnull const kDataType_osx_provision = @"com.apple.provisionprofile"; +static NSString * _Nonnull const kDataType_xcode_archive = @"com.apple.xcode.archive"; +static NSString * _Nonnull const kDataType_app_extension = @"com.apple.application-and-system-extension"; + +// Init QuickLook Type +typedef NS_ENUM(NSUInteger, FileType) { + FileTypeIPA = 1, + FileTypeArchive, + FileTypeExtension, + FileTypeProvision, +}; + +typedef struct QuickLookMeta { + NSString * _Nonnull UTI; + NSURL * _Nonnull url; + NSURL * _Nullable effectiveUrl; // if set, will point to the app inside of an archive + + FileType type; + BOOL isOSX; +} QuickLookInfo; + +QuickLookInfo initQLInfo(_Nonnull CFStringRef contentTypeUTI, _Nonnull CFURLRef url); + + +// Unzip +void unzipFileToDir(NSURL * _Nonnull url, NSString * _Nonnull filePath, NSString * _Nonnull targetDir); + +// Plist +NSDictionary * _Nullable readPlistApp(QuickLookInfo meta); +NSDictionary * _Nullable readPlistProvision(QuickLookInfo meta); + +// Other helper +typedef NS_ENUM(NSUInteger, ExpirationStatus) { + ExpirationStatusExpired = 0, + ExpirationStatusExpiring = 1, + ExpirationStatusValid = 2, +}; +ExpirationStatus expirationStatus(NSDate * _Nullable date); +NSDate * _Nullable dateOrNil(NSDate * _Nullable value); +NSArray * _Nullable arrayOrNil(NSArray * _Nullable value); + +// App Icon +NSImage * _Nonnull roundCorners(NSImage * _Nonnull image); +NSImage * _Nonnull imageFromApp(QuickLookInfo meta, NSDictionary * _Nullable appPlist); diff --git a/ProvisionQL/Shared.m b/ProvisionQL/Shared.m index 8bb024e..7eaa4c8 100644 --- a/ProvisionQL/Shared.m +++ b/ProvisionQL/Shared.m @@ -1,6 +1,48 @@ #import "Shared.h" -NSData *unzipFile(NSURL *url, NSString *filePath) { +// MARK: - Meta data for QuickLook + +/// Search an archive for the .app or .ipa bundle. +NSURL * _Nullable appPathForArchive(NSURL *url) { + NSURL *appsDir = [url URLByAppendingPathComponent:@"Products/Applications/"]; + if (appsDir != nil) { + NSArray *dirFiles = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:appsDir.path error:nil]; + if (dirFiles.count > 0) { + return [appsDir URLByAppendingPathComponent:dirFiles[0] isDirectory:YES]; + } + } + return nil; +} + +/// Use file url and UTI type to generate an info object to pass around. +QuickLookInfo initQLInfo(CFStringRef contentTypeUTI, CFURLRef url) { + QuickLookInfo data = {}; + data.UTI = (__bridge NSString *)contentTypeUTI; + data.url = (__bridge NSURL *)url; + + if ([data.UTI isEqualToString:kDataType_ipa]) { + data.type = FileTypeIPA; + } else if ([data.UTI isEqualToString:kDataType_xcode_archive]) { + data.type = FileTypeArchive; + data.effectiveUrl = appPathForArchive(data.url); + } else if ([data.UTI isEqualToString:kDataType_app_extension]) { + data.type = FileTypeExtension; + } else if ([data.UTI isEqualToString:kDataType_ios_provision]) { + data.type = FileTypeProvision; + } else if ([data.UTI isEqualToString:kDataType_ios_provision_old]) { + data.type = FileTypeProvision; + } else if ([data.UTI isEqualToString:kDataType_osx_provision]) { + data.type = FileTypeProvision; + data.isOSX = YES; + } + return data; +} + + +// MARK: Unzip + +/// Unzip file directly into memory. +NSData * _Nullable unzipFile(NSURL *url, NSString *filePath) { NSTask *task = [NSTask new]; [task setLaunchPath:@"/usr/bin/unzip"]; [task setStandardOutput:[NSPipe pipe]]; @@ -15,6 +57,7 @@ return pipeData; } +/// Unzip file to filesystem. void unzipFileToDir(NSURL *url, NSString *targetDir, NSString *filePath) { NSTask *task = [NSTask new]; [task setLaunchPath:@"/usr/bin/unzip"]; @@ -23,7 +66,89 @@ void unzipFileToDir(NSURL *url, NSString *targetDir, NSString *filePath) { [task waitUntilExit]; } -NSImage *roundCorners(NSImage *image) { +/// Load a file from bundle into memory. Either by file path or via unzip. +NSData * _Nullable readPayloadFile(QuickLookInfo meta, NSString *filename) { + switch (meta.type) { + case FileTypeIPA: return unzipFile(meta.url, [@"Payload/*.app/" stringByAppendingString:filename]); + case FileTypeArchive: return [NSData dataWithContentsOfURL:[meta.effectiveUrl URLByAppendingPathComponent:filename]]; + case FileTypeExtension: return [NSData dataWithContentsOfURL:[meta.url URLByAppendingPathComponent:filename]]; + case FileTypeProvision: return nil; + } +} + + +// MARK: Plist + +/// Read app default @c Info.plist. +NSDictionary * _Nullable readPlistApp(QuickLookInfo meta) { + NSLog(@"read once"); + switch (meta.type) { + case FileTypeIPA: + case FileTypeArchive: + case FileTypeExtension: { + NSData *plistData = readPayloadFile(meta, @"Info.plist"); + return [NSPropertyListSerialization propertyListWithData:plistData options:0 format:NULL error:NULL]; + } + case FileTypeProvision: + return nil; + } +} + +/// Read @c embedded.mobileprovision file and decode with CMS decoder. +NSDictionary * _Nullable readPlistProvision(QuickLookInfo meta) { + NSData *provisionData; + if (meta.type == FileTypeProvision) { + provisionData = [NSData dataWithContentsOfURL:meta.url]; // the target file itself + } else { + provisionData = readPayloadFile(meta, @"embedded.mobileprovision"); + } + if (!provisionData) { + NSLog(@"No provisionData for %@", meta.url); + return nil; + } + + CMSDecoderRef decoder = NULL; + CMSDecoderCreate(&decoder); + CMSDecoderUpdateMessage(decoder, provisionData.bytes, provisionData.length); + CMSDecoderFinalizeMessage(decoder); + CFDataRef dataRef = NULL; + CMSDecoderCopyContent(decoder, &dataRef); + NSData *data = (NSData *)CFBridgingRelease(dataRef); + CFRelease(decoder); + + if (!data) { + return nil; + } + return [NSPropertyListSerialization propertyListWithData:data options:0 format:NULL error:NULL]; +} + + +// MARK: - Other helper + +/// Check time between date and now. Set Expiring if less than 30 days until expiration +ExpirationStatus expirationStatus(NSDate *date) { + if (!date || [date compare:[NSDate date]] == NSOrderedAscending) { + return ExpirationStatusExpired; + } + NSDateComponents *dateComponents = [[NSCalendar currentCalendar] components:NSCalendarUnitDay fromDate:[NSDate date] toDate:date options:0]; + return dateComponents.day < 30 ? ExpirationStatusExpiring : ExpirationStatusValid; +} + +/// Ensures the value is of type @c NSDate +NSDate * _Nullable dateOrNil(NSDate * _Nullable value) { + return [value isKindOfClass:[NSDate class]] ? value : nil; +} + +/// Ensures the value is of type @c NSArray +NSArray * _Nullable arrayOrNil(NSArray * _Nullable value) { + return [value isKindOfClass:[NSArray class]] ? value : nil; +} + + +// MARK: - App Icon + +/// Apply rounded corners to image (iOS7 style) +NSImage * _Nonnull roundCorners(NSImage *image) { NSImage *existingImage = image; NSSize existingSize = [existingImage size]; NSImage *composedImage = [[NSImage alloc] initWithSize:existingSize]; @@ -43,121 +168,75 @@ void unzipFileToDir(NSURL *url, NSString *targetDir, NSString *filePath) { return composedImage; } -int expirationStatus(NSDate *date, NSCalendar *calendar) { - int result = 0; - - if (date) { - NSDateComponents *dateComponents = [calendar components:NSCalendarUnitDay fromDate:[NSDate date] toDate:date options:0]; - if ([date compare:[NSDate date]] == NSOrderedAscending) { - // expired - result = 0; - } else if (dateComponents.day < 30) { - // expiring - result = 1; - } else { - // valid - result = 2; +/// Given a list of filenames, try to find the one with the highest resolution +NSString *selectBestIcon(NSArray *icons) { + for (NSString *match in @[@"@3x", @"@2x", @"180", @"167", @"152", @"120"]) { + for (NSString *icon in icons) { + if ([icon containsString:match]) { + return icon; + } } } - - return result; + //If no one matches any pattern, just take last item + return [icons lastObject]; } -NSImage *imageFromApp(NSURL *URL, NSString *dataType, NSString *fileName) { - NSImage *appIcon = nil; - - if ([dataType isEqualToString:kDataType_xcode_archive]) { - // get the embedded icon for the iOS app - NSURL *appsDir = [URL URLByAppendingPathComponent:@"Products/Applications/"]; - if (!appsDir) { - return nil; - } - - NSArray *dirFiles = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:appsDir.path error:nil]; - NSString *appName = dirFiles.firstObject; - if (!appName) { - return nil; - } - - NSURL *appURL = [appsDir URLByAppendingPathComponent:appName]; - NSArray *appContents = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:appURL.path error:nil]; - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"SELF contains %@", fileName]; - NSString *appIconFullName = [appContents filteredArrayUsingPredicate:predicate].lastObject; - if (!appIconFullName) { - return nil; - } - - NSURL *appIconFullURL = [appURL URLByAppendingPathComponent:appIconFullName]; - appIcon = [[NSImage alloc] initWithContentsOfURL:appIconFullURL]; - } else if([dataType isEqualToString:kDataType_ipa]) { - NSData *data = unzipFile(URL, @"iTunesArtwork"); - if (!data && fileName.length > 0) { - data = unzipFile(URL, [NSString stringWithFormat:@"Payload/*.app/%@*", fileName]); +/// Deep select icons from plist key @c CFBundleIcons and @c CFBundleIcons~ipad +NSArray * _Nullable iconsListForDictionary(NSDictionary *bundleDict) { + if ([bundleDict isKindOfClass:[NSDictionary class]]) { + NSDictionary *primaryDict = [bundleDict objectForKey:@"CFBundlePrimaryIcon"]; + if ([primaryDict isKindOfClass:[NSDictionary class]]) { + NSArray *icons = [primaryDict objectForKey:@"CFBundleIconFiles"]; + if ([icons isKindOfClass:[NSArray class]]) { + return icons; + } } - if (data != nil) { - appIcon = [[NSImage alloc] initWithData:data]; - } - } - - return appIcon; + } + return nil; } -NSArray *iconsListForDictionary(NSDictionary *iconsDict) { - if ([iconsDict isKindOfClass:[NSDictionary class]]) { - id primaryIconDict = [iconsDict objectForKey:@"CFBundlePrimaryIcon"]; - if ([primaryIconDict isKindOfClass:[NSDictionary class]]) { - id tempIcons = [primaryIconDict objectForKey:@"CFBundleIconFiles"]; - if ([tempIcons isKindOfClass:[NSArray class]]) { - return tempIcons; - } - } - } - - return nil; +/// Parse app plist to find the bundle icon filename. +NSString * _Nullable mainIconNameForApp(NSDictionary *appPlist) { + //Check for CFBundleIcons (since 5.0) + NSArray *icons = iconsListForDictionary(appPlist[@"CFBundleIcons"]); + if (!icons) { + icons = iconsListForDictionary(appPlist[@"CFBundleIcons~ipad"]); + if (!icons) { + //Check for CFBundleIconFiles (since 3.2) + icons = arrayOrNil(appPlist[@"CFBundleIconFiles"]); + if (!icons) { + //Check for CFBundleIconFile (legacy, before 3.2) + return appPlist[@"CFBundleIconFile"]; // may be nil + } + } + } + return selectBestIcon(icons); } -NSString *mainIconNameForApp(NSDictionary *appPropertyList) { - NSArray *icons; - NSString *iconName; - - //Check for CFBundleIcons (since 5.0) - icons = iconsListForDictionary([appPropertyList objectForKey:@"CFBundleIcons"]); - if (!icons) { - icons = iconsListForDictionary([appPropertyList objectForKey:@"CFBundleIcons~ipad"]); - } - - if (!icons) { - //Check for CFBundleIconFiles (since 3.2) - id tempIcons = [appPropertyList objectForKey:@"CFBundleIconFiles"]; - if ([tempIcons isKindOfClass:[NSArray class]]) { - icons = tempIcons; - } - } - - if (icons) { - //Search some patterns for primary app icon (120x120) - NSArray *matches = @[@"120",@"60"]; - - for (NSString *match in matches) { - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"SELF contains[c] %@",match]; - NSArray *results = [icons filteredArrayUsingPredicate:predicate]; - if ([results count]) { - iconName = [results firstObject]; - break; - } - } - - //If no one matches any pattern, just take last item - if (!iconName) { - iconName = [icons lastObject]; - } - } else { - //Check for CFBundleIconFile (legacy, before 3.2) - NSString *legacyIcon = [appPropertyList objectForKey:@"CFBundleIconFile"]; - if ([legacyIcon length]) { - iconName = legacyIcon; - } - } - - return iconName; +/// Depending on the file type, find the icon within the bundle +/// @param appPlist If @c nil, will load plist on the fly (used for thumbnail) +NSImage * _Nonnull imageFromApp(QuickLookInfo meta, NSDictionary *appPlist) { + if (meta.type == FileTypeIPA) { + NSData *data = unzipFile(meta.url, @"iTunesArtwork"); + if (!data) { + NSString *fileName = mainIconNameForApp(appPlist ?: readPlistApp(meta)); + data = unzipFile(meta.url, [NSString stringWithFormat:@"Payload/*.app/%@*", fileName]); + } + if (data) { + return [[NSImage alloc] initWithData:data]; + } + } else if (meta.type == FileTypeArchive) { + // get the embedded icon for the iOS app + NSString *fileName = mainIconNameForApp(appPlist ?: readPlistApp(meta)); + NSArray *appContents = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:meta.effectiveUrl.path error:nil]; + NSPredicate *predicate = [NSPredicate predicateWithFormat:@"SELF contains %@", fileName]; + NSString *matchedName = [appContents filteredArrayUsingPredicate:predicate].lastObject; + if (matchedName) { + NSURL *appIconFullURL = [meta.effectiveUrl URLByAppendingPathComponent:matchedName]; + return [[NSImage alloc] initWithContentsOfURL:appIconFullURL]; + } + } + // Fallback to default icon + NSURL *iconURL = [[NSBundle bundleWithIdentifier:kPluginBundleId] URLForResource:@"defaultIcon" withExtension:@"png"]; + return [[NSImage alloc] initWithContentsOfURL:iconURL]; } From 3e5789b7bc4bfe72fef872146e6fafe429a951c7 Mon Sep 17 00:00:00 2001 From: relikd Date: Thu, 25 Jan 2024 19:45:13 +0100 Subject: [PATCH 11/32] fix: move functions to corresponding class --- ProvisionQL/GeneratePreviewForURL.m | 2 +- ProvisionQL/GenerateThumbnailForURL.m | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ProvisionQL/GeneratePreviewForURL.m b/ProvisionQL/GeneratePreviewForURL.m index de631ed..809bf3f 100644 --- a/ProvisionQL/GeneratePreviewForURL.m +++ b/ProvisionQL/GeneratePreviewForURL.m @@ -671,6 +671,6 @@ OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, return noErr; } -void CancelThumbnailGeneration(void *thisInterface, QLThumbnailRequestRef thumbnail) { +void CancelPreviewGeneration(void *thisInterface, QLPreviewRequestRef preview) { // Implement only if supported } diff --git a/ProvisionQL/GenerateThumbnailForURL.m b/ProvisionQL/GenerateThumbnailForURL.m index ba5f008..8f6e20d 100644 --- a/ProvisionQL/GenerateThumbnailForURL.m +++ b/ProvisionQL/GenerateThumbnailForURL.m @@ -159,6 +159,6 @@ OSStatus GenerateThumbnailForURL(void *thisInterface, QLThumbnailRequestRef thum return noErr; } -void CancelPreviewGeneration(void *thisInterface, QLPreviewRequestRef preview) { +void CancelThumbnailGeneration(void *thisInterface, QLThumbnailRequestRef thumbnail) { // Implement only if supported } From ce1eccc2e9bf7cdcb6b6401efd012187e222fad6 Mon Sep 17 00:00:00 2001 From: relikd Date: Thu, 25 Jan 2024 19:45:47 +0100 Subject: [PATCH 12/32] chore: reduce polling --- ProvisionQL/GeneratePreviewForURL.m | 7 ------- ProvisionQL/GenerateThumbnailForURL.m | 1 - 2 files changed, 8 deletions(-) diff --git a/ProvisionQL/GeneratePreviewForURL.m b/ProvisionQL/GeneratePreviewForURL.m index 809bf3f..b6d7788 100644 --- a/ProvisionQL/GeneratePreviewForURL.m +++ b/ProvisionQL/GeneratePreviewForURL.m @@ -621,14 +621,11 @@ OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, // App Info NSDictionary *plistApp = readPlistApp(meta); - ALLOW_EXIT - [infoLayer addEntriesFromDictionary:procAppInfo(plistApp)]; ALLOW_EXIT // Provisioning NSDictionary *plistProvision = readPlistProvision(meta); - ALLOW_EXIT if (!plistApp && !plistProvision) { return noErr; // nothing to do. Maybe another QL plugin can do better. @@ -644,14 +641,11 @@ OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, // Entitlements NSString *bundleExecutable = plistApp[@"CFBundleExecutable"]; NSData *codeSignData = getCodeSignEntitlements(meta, bundleExecutable); - ALLOW_EXIT - [infoLayer addEntriesFromDictionary:procEntitlements(codeSignData, plistProvision)]; ALLOW_EXIT // File Info [infoLayer addEntriesFromDictionary:procFileInfo(meta.url)]; - ALLOW_EXIT // Footer Info [infoLayer addEntriesFromDictionary:procFooterInfo()]; @@ -659,7 +653,6 @@ OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, // prepare html, replace values NSString *html = applyHtmlTemplate(infoLayer); - ALLOW_EXIT // QL render html NSDictionary *properties = @{ // properties for the HTML data diff --git a/ProvisionQL/GenerateThumbnailForURL.m b/ProvisionQL/GenerateThumbnailForURL.m index 8f6e20d..a316deb 100644 --- a/ProvisionQL/GenerateThumbnailForURL.m +++ b/ProvisionQL/GenerateThumbnailForURL.m @@ -117,7 +117,6 @@ OSStatus renderProvision(QuickLookInfo meta, QLThumbnailRequestRef thumbnail, BO NSSize badgeNumSize = [badge sizeWithAttributes:fontAttrs]; int badgeWidth = badgeNumSize.width + BADGE_MARGIN * 2; NSRect badgeOutlineRect = NSMakeRect(badgeX, badgeY, MAX(badgeWidth, MIN_BADGE_WIDTH), BADGE_HEIGHT); - ALLOW_EXIT // Do as much work as possible before the `CreateContext`. We can try to quit early before that! CGContextRef _context = QLThumbnailRequestCreateContext(thumbnail, renderRect.size, false, NULL); From 8d7cc8510cbc66901dd136204364f4c9a962d08a Mon Sep 17 00:00:00 2001 From: relikd Date: Thu, 25 Jan 2024 19:46:08 +0100 Subject: [PATCH 13/32] fix: disable downscaling for retina display --- ProvisionQL/GeneratePreviewForURL.m | 4 +++- ProvisionQL/GenerateThumbnailForURL.m | 10 +++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ProvisionQL/GeneratePreviewForURL.m b/ProvisionQL/GeneratePreviewForURL.m index b6d7788..871c1e4 100644 --- a/ProvisionQL/GeneratePreviewForURL.m +++ b/ProvisionQL/GeneratePreviewForURL.m @@ -603,7 +603,9 @@ unsigned long long getFileSize(NSString *path) { [rv appendString:[html substringWithRange:NSMakeRange(prevLoc, start - prevLoc)]]; NSString *value = templateValues[key]; if (!value) { - NSLog(@"WARN: unused key %@", key); +#ifdef DEBUG + NSLog(@"WARN: unused key %@", key); +#endif } else { [rv appendString:value]; } diff --git a/ProvisionQL/GenerateThumbnailForURL.m b/ProvisionQL/GenerateThumbnailForURL.m index a316deb..f8963bd 100644 --- a/ProvisionQL/GenerateThumbnailForURL.m +++ b/ProvisionQL/GenerateThumbnailForURL.m @@ -33,11 +33,11 @@ OSStatus renderAppIcon(QuickLookInfo meta, QLThumbnailRequestRef thumbnail) { NSImage *appIcon = imageFromApp(meta, nil); ALLOW_EXIT - // downscale as required by QLThumbnailRequestSetImageWithData - CGSize maxSize = QLThumbnailRequestGetMaximumSize(thumbnail); - if (appIcon.size.width > maxSize.width && appIcon.size.height > maxSize.height) { - [appIcon setSize:maxSize]; - } + // if downscale, then this should respect retina resolution +// CGSize maxSize = QLThumbnailRequestGetMaximumSize(thumbnail); +// if (appIcon.size.width > maxSize.width && appIcon.size.height > maxSize.height) { +// [appIcon setSize:maxSize]; +// } appIcon = roundCorners(appIcon); ALLOW_EXIT From 03c76b86bc073799e813e98133d7103426eed14d Mon Sep 17 00:00:00 2001 From: relikd Date: Thu, 25 Jan 2024 20:04:08 +0100 Subject: [PATCH 14/32] feat: inline helper functions --- ProvisionQL/Shared.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ProvisionQL/Shared.m b/ProvisionQL/Shared.m index 7eaa4c8..d8fd7d8 100644 --- a/ProvisionQL/Shared.m +++ b/ProvisionQL/Shared.m @@ -135,12 +135,12 @@ ExpirationStatus expirationStatus(NSDate *date) { } /// Ensures the value is of type @c NSDate -NSDate * _Nullable dateOrNil(NSDate * _Nullable value) { +inline NSDate * _Nullable dateOrNil(NSDate * _Nullable value) { return [value isKindOfClass:[NSDate class]] ? value : nil; } /// Ensures the value is of type @c NSArray -NSArray * _Nullable arrayOrNil(NSArray * _Nullable value) { +inline NSArray * _Nullable arrayOrNil(NSArray * _Nullable value) { return [value isKindOfClass:[NSArray class]] ? value : nil; } From fb626b7a213c2aa8f92f2399f6431bc6ef26cd28 Mon Sep 17 00:00:00 2001 From: relikd Date: Sun, 28 Jan 2024 21:36:28 +0100 Subject: [PATCH 15/32] feat: use zlib to unzip from ipa --- ProvisionQL.xcodeproj/project.pbxproj | 44 ++++ ProvisionQL/3rd-party/pinch/ZipEntry.h | 64 +++++ ProvisionQL/3rd-party/pinch/ZipEntry.m | 56 +++++ ProvisionQL/3rd-party/pinch/pinch.h | 35 +++ ProvisionQL/3rd-party/pinch/pinch.m | 329 +++++++++++++++++++++++++ ProvisionQL/GeneratePreviewForURL.m | 6 +- ProvisionQL/Shared.h | 7 +- ProvisionQL/Shared.m | 43 +--- ProvisionQL/ZipFile.h | 18 ++ ProvisionQL/ZipFile.m | 102 ++++++++ 10 files changed, 665 insertions(+), 39 deletions(-) create mode 100755 ProvisionQL/3rd-party/pinch/ZipEntry.h create mode 100755 ProvisionQL/3rd-party/pinch/ZipEntry.m create mode 100755 ProvisionQL/3rd-party/pinch/pinch.h create mode 100755 ProvisionQL/3rd-party/pinch/pinch.m create mode 100644 ProvisionQL/ZipFile.h create mode 100644 ProvisionQL/ZipFile.m diff --git a/ProvisionQL.xcodeproj/project.pbxproj b/ProvisionQL.xcodeproj/project.pbxproj index 3c5e9ef..344f721 100644 --- a/ProvisionQL.xcodeproj/project.pbxproj +++ b/ProvisionQL.xcodeproj/project.pbxproj @@ -7,6 +7,13 @@ objects = { /* Begin PBXBuildFile section */ + 54F4EB002B6668A50000CE41 /* ZipEntry.h in Headers */ = {isa = PBXBuildFile; fileRef = 54F4EAFC2B6668A50000CE41 /* ZipEntry.h */; }; + 54F4EB012B6668A50000CE41 /* ZipEntry.m in Sources */ = {isa = PBXBuildFile; fileRef = 54F4EAFD2B6668A50000CE41 /* ZipEntry.m */; }; + 54F4EB022B6668A50000CE41 /* pinch.m in Sources */ = {isa = PBXBuildFile; fileRef = 54F4EAFE2B6668A50000CE41 /* pinch.m */; }; + 54F4EB032B6668A50000CE41 /* pinch.h in Headers */ = {isa = PBXBuildFile; fileRef = 54F4EAFF2B6668A50000CE41 /* pinch.h */; }; + 54F4EB0F2B668F7E0000CE41 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 54F4EB0E2B668F7E0000CE41 /* libz.dylib */; }; + 54F4EB222B66D6FE0000CE41 /* ZipFile.h in Headers */ = {isa = PBXBuildFile; fileRef = 54F4EB202B66D6FE0000CE41 /* ZipFile.h */; }; + 54F4EB232B66D6FE0000CE41 /* ZipFile.m in Sources */ = {isa = PBXBuildFile; fileRef = 54F4EB212B66D6FE0000CE41 /* ZipFile.m */; }; 553C6D321879E457002237FC /* blankIcon.png in Resources */ = {isa = PBXBuildFile; fileRef = 553C6D311879E457002237FC /* blankIcon.png */; }; 55424C611870D4AA002F5408 /* AppKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 55424C601870D4AA002F5408 /* AppKit.framework */; }; 55424C631870D90E002F5408 /* defaultIcon.png in Resources */ = {isa = PBXBuildFile; fileRef = 55424C621870D90E002F5408 /* defaultIcon.png */; }; @@ -27,6 +34,13 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 54F4EAFC2B6668A50000CE41 /* ZipEntry.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ZipEntry.h; sourceTree = ""; }; + 54F4EAFD2B6668A50000CE41 /* ZipEntry.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ZipEntry.m; sourceTree = ""; }; + 54F4EAFE2B6668A50000CE41 /* pinch.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = pinch.m; sourceTree = ""; }; + 54F4EAFF2B6668A50000CE41 /* pinch.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = pinch.h; sourceTree = ""; }; + 54F4EB0E2B668F7E0000CE41 /* libz.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libz.dylib; path = ../../../../../usr/lib/libz.dylib; sourceTree = ""; }; + 54F4EB202B66D6FE0000CE41 /* ZipFile.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ZipFile.h; sourceTree = ""; }; + 54F4EB212B66D6FE0000CE41 /* ZipFile.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ZipFile.m; sourceTree = ""; }; 553C6D311879E457002237FC /* blankIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = blankIcon.png; sourceTree = ""; }; 55424C601870D4AA002F5408 /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = System/Library/Frameworks/AppKit.framework; sourceTree = SDKROOT; }; 55424C621870D90E002F5408 /* defaultIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = defaultIcon.png; sourceTree = ""; }; @@ -62,6 +76,7 @@ 55DB729B186E195500CAFEE7 /* Security.framework in Frameworks */, 55DB7287186E193500CAFEE7 /* CoreFoundation.framework in Frameworks */, 55DB7281186E193500CAFEE7 /* QuickLook.framework in Frameworks */, + 54F4EB0F2B668F7E0000CE41 /* libz.dylib in Frameworks */, 55DB7285186E193500CAFEE7 /* CoreServices.framework in Frameworks */, 55DB7283186E193500CAFEE7 /* ApplicationServices.framework in Frameworks */, ); @@ -70,6 +85,25 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 54F4EAFB2B6668940000CE41 /* 3rd-party */ = { + isa = PBXGroup; + children = ( + 54F4EB162B669A510000CE41 /* pinch */, + ); + path = "3rd-party"; + sourceTree = ""; + }; + 54F4EB162B669A510000CE41 /* pinch */ = { + isa = PBXGroup; + children = ( + 54F4EAFF2B6668A50000CE41 /* pinch.h */, + 54F4EAFE2B6668A50000CE41 /* pinch.m */, + 54F4EAFC2B6668A50000CE41 /* ZipEntry.h */, + 54F4EAFD2B6668A50000CE41 /* ZipEntry.m */, + ); + path = pinch; + sourceTree = ""; + }; 55424C641870DB2A002F5408 /* NSBezierPath+IOS7RoundedRect */ = { isa = PBXGroup; children = ( @@ -138,6 +172,7 @@ 55DB727F186E193500CAFEE7 /* Frameworks */ = { isa = PBXGroup; children = ( + 54F4EB0E2B668F7E0000CE41 /* libz.dylib */, 557C842118731FB7008A2A0C /* WebKit.framework */, 55424C601870D4AA002F5408 /* AppKit.framework */, 55DB729A186E195500CAFEE7 /* Security.framework */, @@ -152,11 +187,14 @@ 55DB7288186E193500CAFEE7 /* ProvisionQL */ = { isa = PBXGroup; children = ( + 54F4EAFB2B6668940000CE41 /* 3rd-party */, 555E9511186E2D67001D406A /* Supporting-files */, 555E951A186E2DC0001D406A /* Scripts */, 555E9518186E2DC0001D406A /* Resources */, 557C842718733828008A2A0C /* Shared.h */, 557C842418732599008A2A0C /* Shared.m */, + 54F4EB202B66D6FE0000CE41 /* ZipFile.h */, + 54F4EB212B66D6FE0000CE41 /* ZipFile.m */, 55DB728E186E193500CAFEE7 /* GenerateThumbnailForURL.m */, 55DB7290186E193500CAFEE7 /* GeneratePreviewForURL.m */, ); @@ -171,7 +209,10 @@ buildActionMask = 2147483647; files = ( 55424C671870DB2A002F5408 /* NSBezierPath+IOS7RoundedRect.h in Headers */, + 54F4EB002B6668A50000CE41 /* ZipEntry.h in Headers */, 557C842818733828008A2A0C /* Shared.h in Headers */, + 54F4EB032B6668A50000CE41 /* pinch.h in Headers */, + 54F4EB222B66D6FE0000CE41 /* ZipFile.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -279,9 +320,12 @@ files = ( 55424C681870DB2A002F5408 /* NSBezierPath+IOS7RoundedRect.m in Sources */, 555E9515186E2D67001D406A /* main.c in Sources */, + 54F4EB012B6668A50000CE41 /* ZipEntry.m in Sources */, + 54F4EB022B6668A50000CE41 /* pinch.m in Sources */, 557C842618732599008A2A0C /* Shared.m in Sources */, 55DB728F186E193500CAFEE7 /* GenerateThumbnailForURL.m in Sources */, 55DB7291186E193500CAFEE7 /* GeneratePreviewForURL.m in Sources */, + 54F4EB232B66D6FE0000CE41 /* ZipFile.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ProvisionQL/3rd-party/pinch/ZipEntry.h b/ProvisionQL/3rd-party/pinch/ZipEntry.h new file mode 100755 index 0000000..777c357 --- /dev/null +++ b/ProvisionQL/3rd-party/pinch/ZipEntry.h @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------- + + Modified 2024 by relikd + + Based on original version: + + https://github.com/epatel/pinch-objc + + Copyright (c) 2011-2012 Edward Patel + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + + ---------------------------------------------------------------------------*/ + +#import + + +@interface ZipEntry : NSObject { + NSString *url; + NSString *filepath; + int offset; + int method; + int sizeCompressed; + int sizeUncompressed; + unsigned int crc32; + int filenameLength; + int extraFieldLength; + NSData *data; +} + +@property (nonatomic, retain) NSString *url; +@property (nonatomic, retain) NSString *filepath; +@property (nonatomic, assign) int offset; +@property (nonatomic, assign) int method; +@property (nonatomic, assign) int sizeCompressed; +@property (nonatomic, assign) int sizeUncompressed; +@property (nonatomic, assign) unsigned int crc32; +@property (nonatomic, assign) int filenameLength; +@property (nonatomic, assign) int extraFieldLength; +@property (nonatomic, retain) NSData *data; + +@end + +@interface NSArray (ZipEntry) + +- (ZipEntry*)zipEntryWithPath:(NSString*)path; + +@end diff --git a/ProvisionQL/3rd-party/pinch/ZipEntry.m b/ProvisionQL/3rd-party/pinch/ZipEntry.m new file mode 100755 index 0000000..ca54491 --- /dev/null +++ b/ProvisionQL/3rd-party/pinch/ZipEntry.m @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------- + + Modified 2024 by relikd + + Based on original version: + + https://github.com/epatel/pinch-objc + + Copyright (c) 2011-2012 Edward Patel + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + + ---------------------------------------------------------------------------*/ + +#import "ZipEntry.h" + + +@implementation ZipEntry + +@synthesize url; +@synthesize filepath; +@synthesize offset; +@synthesize method; +@synthesize sizeCompressed; +@synthesize sizeUncompressed; +@synthesize crc32; +@synthesize filenameLength; +@synthesize extraFieldLength; +@synthesize data; + +@end + +@implementation NSArray (ZipEntry) + +- (ZipEntry*)zipEntryWithPath:(NSString*)path { + NSPredicate *pred = [NSPredicate predicateWithFormat:@"filepath LIKE %@", path]; + return [self filteredArrayUsingPredicate:pred].firstObject; +} + +@end diff --git a/ProvisionQL/3rd-party/pinch/pinch.h b/ProvisionQL/3rd-party/pinch/pinch.h new file mode 100755 index 0000000..5499cc0 --- /dev/null +++ b/ProvisionQL/3rd-party/pinch/pinch.h @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------- + + Modified 2024 by relikd + + Based on original version: + + https://github.com/epatel/pinch-objc + + Copyright (c) 2011-2012 Edward Patel + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + + ---------------------------------------------------------------------------*/ + +#import +#import "ZipEntry.h" + +NSData *unzipFileEntry(NSString *path, ZipEntry *entry); +NSArray *listZip(NSString *path); diff --git a/ProvisionQL/3rd-party/pinch/pinch.m b/ProvisionQL/3rd-party/pinch/pinch.m new file mode 100755 index 0000000..f333b44 --- /dev/null +++ b/ProvisionQL/3rd-party/pinch/pinch.m @@ -0,0 +1,329 @@ +/*--------------------------------------------------------------------------- + + Modified 2024 by relikd + + Based on original version: + + https://github.com/epatel/pinch-objc + + Copyright (c) 2011-2012 Edward Patel + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + + ---------------------------------------------------------------------------*/ + +#import "pinch.h" +#import "ZipEntry.h" + +#include +#include +#include + +typedef unsigned int uint32; +typedef unsigned short uint16; + +// The headers, see http://en.wikipedia.org/wiki/ZIP_(file_format)#File_headers +// Note that here they will not be as tightly packed as defined in the file format, +// so the extraction is done with a macro below. + +typedef struct ZipRecordEnd { + uint32 endOfCentralDirectorySignature; + uint16 numberOfThisDisk; + uint16 diskWhereCentralDirectoryStarts; + uint16 numberOfCentralDirectoryRecordsOnThisDisk; + uint16 totalNumberOfCentralDirectoryRecords; + uint32 sizeOfCentralDirectory; + uint32 offsetOfStartOfCentralDirectory; + uint16 ZIPfileCommentLength; +} ZipRecordEnd; + +typedef struct ZipRecordDir { + uint32 centralDirectoryFileHeaderSignature; + uint16 versionMadeBy; + uint16 versionNeededToExtract; + uint16 generalPurposeBitFlag; + uint16 compressionMethod; + uint16 fileLastModificationTime; + uint16 fileLastModificationDate; + uint32 CRC32; + uint32 compressedSize; + uint32 uncompressedSize; + uint16 fileNameLength; + uint16 extraFieldLength; + uint16 fileCommentLength; + uint16 diskNumberWhereFileStarts; + uint16 internalFileAttributes; + uint32 externalFileAttributes; + uint32 relativeOffsetOfLocalFileHeader; +} ZipRecordDir; + +typedef struct ZipFileHeader { + uint32 localFileHeaderSignature; + uint16 versionNeededToExtract; + uint16 generalPurposeBitFlag; + uint16 compressionMethod; + uint16 fileLastModificationTime; + uint16 fileLastModificationDate; + uint32 CRC32; + uint32 compressedSize; + uint32 uncompressedSize; + uint16 fileNameLength; + uint16 extraFieldLength; +} ZipFileHeader; + + +BOOL isValid(unsigned char *ptr, int lenUncompressed, uint32 expectedCrc32) { + unsigned long crc = crc32(0L, Z_NULL, 0); + crc = crc32(crc, (const unsigned char*)ptr, lenUncompressed); + BOOL valid = crc == expectedCrc32; + if (!valid) { + NSLog(@"WARN: CRC check failed."); + } + return valid; +} + + +// MARK: - Unzip data + +NSData *unzipFileEntry(NSString *path, ZipEntry *entry) { + NSData *inputData = nil; + NSData *outputData = nil; + int length = sizeof(ZipFileHeader) + entry.sizeCompressed + entry.filenameLength + entry.extraFieldLength; + + // Download '16' extra bytes as I've seen that extraFieldLength sometimes differs + // from the centralDirectory and the fileEntry header... + NSFileHandle *fp = [NSFileHandle fileHandleForReadingAtPath:path]; + @try { + [fp seekToFileOffset:entry.offset]; + inputData = [fp readDataOfLength:length + 16]; + } @finally { + [fp closeFile]; + } + + if (!inputData) + return nil; + + // NSData *data = [NSData new]; + unsigned char *cptr = (unsigned char*)[inputData bytes]; + + ZipFileHeader file_record; + int idx = 0; + + // Extract fields with a macro, if we would need to swap byteorder this would be the place +#define GETFIELD( _field ) \ +memcpy(&file_record._field, &cptr[idx], sizeof(file_record._field)); \ +idx += sizeof(file_record._field) + GETFIELD( localFileHeaderSignature ); + GETFIELD( versionNeededToExtract ); + GETFIELD( generalPurposeBitFlag ); + GETFIELD( compressionMethod ); + GETFIELD( fileLastModificationTime ); + GETFIELD( fileLastModificationDate ); + GETFIELD( CRC32 ); + GETFIELD( compressedSize ); + GETFIELD( uncompressedSize ); + GETFIELD( fileNameLength ); + GETFIELD( extraFieldLength ); +#undef GETFIELD + + if (entry.method == Z_DEFLATED) { + z_stream zstream; + int ret; + + zstream.zalloc = Z_NULL; + zstream.zfree = Z_NULL; + zstream.opaque = Z_NULL; + zstream.avail_in = 0; + zstream.next_in = Z_NULL; + + ret = inflateInit2(&zstream, -MAX_WBITS); + if (ret != Z_OK) + return nil; + + zstream.avail_in = entry.sizeCompressed; + zstream.next_in = &cptr[idx + file_record.fileNameLength + file_record.extraFieldLength]; + + unsigned char *ptr = malloc(entry.sizeUncompressed); + + zstream.avail_out = entry.sizeUncompressed; + zstream.next_out = ptr; + + ret = inflate(&zstream, Z_SYNC_FLUSH); + + if (isValid(ptr, entry.sizeUncompressed, file_record.CRC32)) { + outputData = [NSData dataWithBytes:ptr length:entry.sizeUncompressed]; + } + + free(ptr); + + // TODO: handle inflate errors + assert(ret != Z_STREAM_ERROR); /* state not clobbered */ + switch (ret) { + case Z_NEED_DICT: + ret = Z_DATA_ERROR; /* and fall through */ + case Z_DATA_ERROR: + case Z_MEM_ERROR: + //inflateEnd(&zstream); + //return; + ; + } + + inflateEnd(&zstream); + + } else if (entry.method == 0) { + + unsigned char *ptr = &cptr[idx + file_record.fileNameLength + file_record.extraFieldLength]; + + if (isValid(ptr, entry.sizeUncompressed, file_record.CRC32)) { + outputData = [NSData dataWithBytes:ptr length:entry.sizeUncompressed]; + } + + } else { + NSLog(@"WARN: unimplemented compression method: %d", entry.method); + } + + return outputData; +} + + +// MARK: - List files + +/// Find signature for central directory. +ZipRecordEnd findCentralDirectory(NSFileHandle *fp) { + unsigned long long eof = [fp seekToEndOfFile]; + [fp seekToFileOffset:MAX(0, eof - 4096)]; + NSData *data = [fp readDataToEndOfFile]; + + char centralDirSignature[4] = { + 0x50, 0x4b, 0x05, 0x06 + }; + + const char *cptr = (const char*)[data bytes]; + long len = [data length]; + char *found = NULL; + + do { + char *fptr = memchr(cptr, 0x50, len); + + if (!fptr) // done searching + break; + + // Use the last found directory + if (!memcmp(centralDirSignature, fptr, 4)) + found = fptr; + + len = len - (fptr - cptr) - 1; + cptr = fptr + 1; + } while (1); + + ZipRecordEnd end_record = {}; + if (!found) { + NSLog(@"WARN: no zip end-header found!"); + return end_record; + } + + int idx = 0; + // Extract fields with a macro, if we would need to swap byteorder this would be the place +#define GETFIELD( _field ) \ +memcpy(&end_record._field, &found[idx], sizeof(end_record._field)); \ +idx += sizeof(end_record._field) + GETFIELD( endOfCentralDirectorySignature ); + GETFIELD( numberOfThisDisk ); + GETFIELD( diskWhereCentralDirectoryStarts ); + GETFIELD( numberOfCentralDirectoryRecordsOnThisDisk ); + GETFIELD( totalNumberOfCentralDirectoryRecords ); + GETFIELD( sizeOfCentralDirectory ); + GETFIELD( offsetOfStartOfCentralDirectory ); + GETFIELD( ZIPfileCommentLength ); +#undef GETFIELD + return end_record; +} + +/// List all files and folders of of the central directory. +NSArray *listCentralDirectory(NSFileHandle *fp, ZipRecordEnd end_record) { + [fp seekToFileOffset:end_record.offsetOfStartOfCentralDirectory]; + NSData *data = [fp readDataOfLength:end_record.sizeOfCentralDirectory]; + + const char *cptr = (const char*)[data bytes]; + long len = [data length]; + + // 46 ?!? That's the record length up to the filename see + // http://en.wikipedia.org/wiki/ZIP_(file_format)#File_headers + + NSMutableArray *array = [NSMutableArray array]; + while (len > 46) { + ZipRecordDir dir_record; + int idx = 0; + + // Extract fields with a macro, if we would need to swap byteorder this would be the place +#define GETFIELD( _field ) \ +memcpy(&dir_record._field, &cptr[idx], sizeof(dir_record._field)); \ +idx += sizeof(dir_record._field) + GETFIELD( centralDirectoryFileHeaderSignature ); + GETFIELD( versionMadeBy ); + GETFIELD( versionNeededToExtract ); + GETFIELD( generalPurposeBitFlag ); + GETFIELD( compressionMethod ); + GETFIELD( fileLastModificationTime ); + GETFIELD( fileLastModificationDate ); + GETFIELD( CRC32 ); + GETFIELD( compressedSize ); + GETFIELD( uncompressedSize ); + GETFIELD( fileNameLength ); + GETFIELD( extraFieldLength ); + GETFIELD( fileCommentLength ); + GETFIELD( diskNumberWhereFileStarts ); + GETFIELD( internalFileAttributes ); + GETFIELD( externalFileAttributes ); + GETFIELD( relativeOffsetOfLocalFileHeader ); +#undef GETFIELD + + NSString *filename = [[NSString alloc] initWithBytes:cptr + 46 + length:dir_record.fileNameLength + encoding:NSUTF8StringEncoding]; + ZipEntry *entry = [[ZipEntry alloc] init]; + entry.url = @""; //url + entry.filepath = filename; + entry.method = dir_record.compressionMethod; + entry.sizeCompressed = dir_record.compressedSize; + entry.sizeUncompressed = dir_record.uncompressedSize; + entry.offset = dir_record.relativeOffsetOfLocalFileHeader; + entry.filenameLength = dir_record.fileNameLength; + entry.extraFieldLength = dir_record.extraFieldLength; + [array addObject:entry]; + + len -= 46 + dir_record.fileNameLength + dir_record.extraFieldLength + dir_record.fileCommentLength; + cptr += 46 + dir_record.fileNameLength + dir_record.extraFieldLength + dir_record.fileCommentLength; + } + return array; +} + +NSArray *listZip(NSString *path) { + NSFileHandle *fp = [NSFileHandle fileHandleForReadingAtPath:path]; + @try { + ZipRecordEnd end_record = findCentralDirectory(fp); + if (end_record.sizeOfCentralDirectory == 0) { + return nil; + } + return listCentralDirectory(fp, end_record); + } @finally { + [fp closeFile]; + } + return nil; +} diff --git a/ProvisionQL/GeneratePreviewForURL.m b/ProvisionQL/GeneratePreviewForURL.m index 871c1e4..a9236dc 100644 --- a/ProvisionQL/GeneratePreviewForURL.m +++ b/ProvisionQL/GeneratePreviewForURL.m @@ -436,6 +436,10 @@ void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *repla } [codesignTask launch]; +#ifdef DEBUG + NSLog(@"[sys-call] codesign %@", [[codesignTask arguments] componentsJoinedByString:@" "]); +#endif + NSData *outputData = [[[codesignTask standardOutput] fileHandleForReading] readDataToEndOfFile]; NSData *errorData = [[[codesignTask standardError] fileHandleForReading] readDataToEndOfFile]; [codesignTask waitUntilExit]; @@ -457,7 +461,7 @@ void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *repla case FileTypeIPA: basePath = currentTempDirFolder; [fileManager createDirectoryAtPath:currentTempDirFolder withIntermediateDirectories:YES attributes:nil error:nil]; - unzipFileToDir(meta.url, currentTempDirFolder, [@"Payload/*.app/" stringByAppendingPathComponent:bundleExecutable]); + [meta.zipFile unzipFile:[@"Payload/*.app/" stringByAppendingPathComponent:bundleExecutable] toDir:currentTempDirFolder]; break; case FileTypeArchive: basePath = meta.effectiveUrl.path; diff --git a/ProvisionQL/Shared.h b/ProvisionQL/Shared.h index a39ba90..87d8741 100644 --- a/ProvisionQL/Shared.h +++ b/ProvisionQL/Shared.h @@ -8,6 +8,8 @@ #import +#import "ZipFile.h" + static NSString * _Nonnull const kPluginBundleId = @"com.ealeksandrov.ProvisionQL"; static NSString * _Nonnull const kDataType_ipa = @"com.apple.itunes.ipa"; static NSString * _Nonnull const kDataType_ios_provision = @"com.apple.mobileprovision"; @@ -31,14 +33,11 @@ typedef struct QuickLookMeta { FileType type; BOOL isOSX; + ZipFile * _Nullable zipFile; // only set for zipped file types } QuickLookInfo; QuickLookInfo initQLInfo(_Nonnull CFStringRef contentTypeUTI, _Nonnull CFURLRef url); - -// Unzip -void unzipFileToDir(NSURL * _Nonnull url, NSString * _Nonnull filePath, NSString * _Nonnull targetDir); - // Plist NSDictionary * _Nullable readPlistApp(QuickLookInfo meta); NSDictionary * _Nullable readPlistProvision(QuickLookInfo meta); diff --git a/ProvisionQL/Shared.m b/ProvisionQL/Shared.m index d8fd7d8..834d2af 100644 --- a/ProvisionQL/Shared.m +++ b/ProvisionQL/Shared.m @@ -1,4 +1,5 @@ #import "Shared.h" +#import "ZipFile.h" // MARK: - Meta data for QuickLook @@ -22,6 +23,7 @@ QuickLookInfo initQLInfo(CFStringRef contentTypeUTI, CFURLRef url) { if ([data.UTI isEqualToString:kDataType_ipa]) { data.type = FileTypeIPA; + data.zipFile = [ZipFile open:data.url.path]; } else if ([data.UTI isEqualToString:kDataType_xcode_archive]) { data.type = FileTypeArchive; data.effectiveUrl = appPathForArchive(data.url); @@ -38,50 +40,20 @@ QuickLookInfo initQLInfo(CFStringRef contentTypeUTI, CFURLRef url) { return data; } - -// MARK: Unzip - -/// Unzip file directly into memory. -NSData * _Nullable unzipFile(NSURL *url, NSString *filePath) { - NSTask *task = [NSTask new]; - [task setLaunchPath:@"/usr/bin/unzip"]; - [task setStandardOutput:[NSPipe pipe]]; - [task setArguments:@[@"-p", [url path], filePath]]; // @"-x", @"*/*/*/*" - [task launch]; - - NSData *pipeData = [[[task standardOutput] fileHandleForReading] readDataToEndOfFile]; - [task waitUntilExit]; - if (pipeData.length == 0) { - return nil; - } - return pipeData; -} - -/// Unzip file to filesystem. -void unzipFileToDir(NSURL *url, NSString *targetDir, NSString *filePath) { - NSTask *task = [NSTask new]; - [task setLaunchPath:@"/usr/bin/unzip"]; - [task setArguments:@[@"-u", @"-j", @"-d", targetDir, [url path], filePath]]; // @"-x", @"*/*/*/*" - [task launch]; - [task waitUntilExit]; -} - /// Load a file from bundle into memory. Either by file path or via unzip. NSData * _Nullable readPayloadFile(QuickLookInfo meta, NSString *filename) { switch (meta.type) { - case FileTypeIPA: return unzipFile(meta.url, [@"Payload/*.app/" stringByAppendingString:filename]); + case FileTypeIPA: return [meta.zipFile unzipFile:[@"Payload/*.app/" stringByAppendingString:filename]]; case FileTypeArchive: return [NSData dataWithContentsOfURL:[meta.effectiveUrl URLByAppendingPathComponent:filename]]; case FileTypeExtension: return [NSData dataWithContentsOfURL:[meta.url URLByAppendingPathComponent:filename]]; case FileTypeProvision: return nil; } } - -// MARK: Plist +// MARK: - Plist /// Read app default @c Info.plist. NSDictionary * _Nullable readPlistApp(QuickLookInfo meta) { - NSLog(@"read once"); switch (meta.type) { case FileTypeIPA: case FileTypeArchive: @@ -217,10 +189,13 @@ ExpirationStatus expirationStatus(NSDate *date) { /// @param appPlist If @c nil, will load plist on the fly (used for thumbnail) NSImage * _Nonnull imageFromApp(QuickLookInfo meta, NSDictionary *appPlist) { if (meta.type == FileTypeIPA) { - NSData *data = unzipFile(meta.url, @"iTunesArtwork"); + NSData *data = [meta.zipFile unzipFile:@"iTunesArtwork"]; if (!data) { NSString *fileName = mainIconNameForApp(appPlist ?: readPlistApp(meta)); - data = unzipFile(meta.url, [NSString stringWithFormat:@"Payload/*.app/%@*", fileName]); + if (fileName) { + data = [meta.zipFile unzipFile:[NSString stringWithFormat:@"Payload/*.app/%@*", fileName]]; + } + // TODO: load assets.car } if (data) { return [[NSImage alloc] initWithData:data]; diff --git a/ProvisionQL/ZipFile.h b/ProvisionQL/ZipFile.h new file mode 100644 index 0000000..d2885aa --- /dev/null +++ b/ProvisionQL/ZipFile.h @@ -0,0 +1,18 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface ZipFile : NSObject ++ (instancetype)open:(NSString *)path; + +/// Unzip file directly into memory. +/// @param filePath File path inside zip file. +- (NSData * _Nullable)unzipFile:(NSString *)filePath; + +/// Unzip file to filesystem. +/// @param filePath File path inside zip file. +/// @param targetDir Directory in which to unzip the file. +- (void)unzipFile:(NSString *)filePath toDir:(NSString *)targetDir; +@end + +NS_ASSUME_NONNULL_END diff --git a/ProvisionQL/ZipFile.m b/ProvisionQL/ZipFile.m new file mode 100644 index 0000000..9d444e3 --- /dev/null +++ b/ProvisionQL/ZipFile.m @@ -0,0 +1,102 @@ +#import "ZipFile.h" +#import "pinch.h" + +@interface ZipFile() +@property (nonatomic, retain, readonly) NSString * pathToZipFile; +@property (nonatomic, retain, readonly, nullable) NSArray *centralDirectory; +@end + + +@implementation ZipFile + ++ (instancetype)open:(NSString *)path { + return [[self alloc] initWithFile:path]; +} + +- (instancetype)initWithFile:(NSString *)path { + self = [super init]; + if (self) { + _pathToZipFile = path; + _centralDirectory = listZip(path); + } + return self; +} + + +// MARK: - public methods + +- (NSData * _Nullable)unzipFile:(NSString *)filePath { + if (self.centralDirectory) { + ZipEntry *matchingFile = [self.centralDirectory zipEntryWithPath:filePath]; + if (!matchingFile) { +#ifdef DEBUG + NSLog(@"[unzip] cant find '%@'", filePath); +#endif + // There is a dir listing but no matching file. + // This means there wont be anything to extract. + // Not even a sys-call can help here. + return nil; + } +#ifdef DEBUG + NSLog(@"[unzip] %@", matchingFile.filepath); +#endif + NSData *data = unzipFileEntry(self.pathToZipFile, matchingFile); + if (data) { + return data; + } + } + // fallback to sys unzip + return [self sysUnzipFile:filePath]; +} + +- (void)unzipFile:(NSString *)filePath toDir:(NSString *)targetDir { + if (self.centralDirectory) { + NSData *data = [self unzipFile:filePath]; + if (data) { + NSString *outputPath = [targetDir stringByAppendingPathComponent:[filePath lastPathComponent]]; +#ifdef DEBUG + NSLog(@"[unzip] write to %@", outputPath); +#endif + [data writeToFile:outputPath atomically:NO]; + return; + } + } + [self sysUnzipFile:filePath toDir:targetDir]; +} + + +// MARK: - fallback to sys call + +- (NSData * _Nullable)sysUnzipFile:(NSString *)filePath { + NSTask *task = [NSTask new]; + [task setLaunchPath:@"/usr/bin/unzip"]; + [task setStandardOutput:[NSPipe pipe]]; + [task setArguments:@[@"-p", self.pathToZipFile, filePath]]; // @"-x", @"*/*/*/*" + [task launch]; + +#ifdef DEBUG + NSLog(@"[sys-call] unzip %@", [[task arguments] componentsJoinedByString:@" "]); +#endif + + NSData *pipeData = [[[task standardOutput] fileHandleForReading] readDataToEndOfFile]; + [task waitUntilExit]; + if (pipeData.length == 0) { + return nil; + } + return pipeData; +} + +- (void)sysUnzipFile:(NSString *)filePath toDir:(NSString *)targetDir { + NSTask *task = [NSTask new]; + [task setLaunchPath:@"/usr/bin/unzip"]; + [task setArguments:@[@"-u", @"-j", @"-d", targetDir, self.pathToZipFile, filePath]]; // @"-x", @"*/*/*/*" + [task launch]; + +#ifdef DEBUG + NSLog(@"[sys-call] unzip %@", [[task arguments] componentsJoinedByString:@" "]); +#endif + + [task waitUntilExit]; +} + +@end From 035e9ba41db78b36c9626f3b404543af1a53804f Mon Sep 17 00:00:00 2001 From: relikd Date: Sun, 28 Jan 2024 21:54:48 +0100 Subject: [PATCH 16/32] chore: update changelog --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4f2704..d22537d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # ProvisionQL +## Version 1.7.0 + +* fix codesign unkown param on <10.15 (`--xml` flag) +* fix crash if a plist key is not present (e.g. `CFBundleShortVersionString` for some old iOS 3.2 ipa) +* fix fixed-width size for preview of app-icon (consistency) +* fix `IconFlavor` attribute for thumbnail drawing in 10.15+ +* unzip with zlib instead of sys-call (performance) +* parse html template tags with regex (performance) +* use higher resolution app icon if available (try `iTunesArtwork`) +* minor html template improvements +* some refactoring to reduce duplicate code + ## Version 1.6.4 * Adds error handling to entitlements parsing ([#47](https://github.com/ealeksandrov/ProvisionQL/pull/47)) From bde8f0d5ec53fa03eb5037723e5cab3631a5e668 Mon Sep 17 00:00:00 2001 From: relikd Date: Mon, 29 Jan 2024 17:32:04 +0100 Subject: [PATCH 17/32] fix: dont load plist if data is nil --- ProvisionQL/Shared.m | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/ProvisionQL/Shared.m b/ProvisionQL/Shared.m index 834d2af..cb4b7d8 100644 --- a/ProvisionQL/Shared.m +++ b/ProvisionQL/Shared.m @@ -52,14 +52,19 @@ QuickLookInfo initQLInfo(CFStringRef contentTypeUTI, CFURLRef url) { // MARK: - Plist +/// Helper for optional chaining. +NSDictionary * _Nullable asPlistOrNil(NSData * _Nullable data) { + if (!data) { return nil; } + return [NSPropertyListSerialization propertyListWithData:data options:0 format:NULL error:NULL]; +} + /// Read app default @c Info.plist. NSDictionary * _Nullable readPlistApp(QuickLookInfo meta) { switch (meta.type) { case FileTypeIPA: case FileTypeArchive: case FileTypeExtension: { - NSData *plistData = readPayloadFile(meta, @"Info.plist"); - return [NSPropertyListSerialization propertyListWithData:plistData options:0 format:NULL error:NULL]; + return asPlistOrNil(readPayloadFile(meta, @"Info.plist")); } case FileTypeProvision: return nil; @@ -87,11 +92,7 @@ QuickLookInfo initQLInfo(CFStringRef contentTypeUTI, CFURLRef url) { CMSDecoderCopyContent(decoder, &dataRef); NSData *data = (NSData *)CFBridgingRelease(dataRef); CFRelease(decoder); - - if (!data) { - return nil; - } - return [NSPropertyListSerialization propertyListWithData:data options:0 format:NULL error:NULL]; + return asPlistOrNil(data); } From 808612750237ae1def1d57bc16b1baa9d6b824f8 Mon Sep 17 00:00:00 2001 From: relikd Date: Mon, 29 Jan 2024 17:34:08 +0100 Subject: [PATCH 18/32] ref: date-string parser --- ProvisionQL/GeneratePreviewForURL.m | 34 ++++++++++++++++++----------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/ProvisionQL/GeneratePreviewForURL.m b/ProvisionQL/GeneratePreviewForURL.m index a9236dc..a6a4d35 100644 --- a/ProvisionQL/GeneratePreviewForURL.m +++ b/ProvisionQL/GeneratePreviewForURL.m @@ -146,6 +146,25 @@ void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *repla return [formatter stringFromDate:date]; } +/// Parse date from plist regardless if it has @c NSDate or @c NSString type. +NSDate *parseDate(id value) { + if (!value) { + return nil; + } + if ([value isKindOfClass:[NSDate class]]) { + return value; + } + // parse the date from a string + NSString *dateStr = [value description]; + NSDateFormatter *dateFormatter = [NSDateFormatter new]; + [dateFormatter setDateFormat:@"yyyy-MM-dd HH:mm:ss Z"]; + NSDate *rv = [dateFormatter dateFromString:dateStr]; + if (!rv) { + NSLog(@"ERROR formatting date: %@", dateStr); + } + return rv; +} + /// @return Relative distance to today. E.g., "Expired today" NSString * _Nullable relativeExpirationDateString(NSDate *date) { if (!date) { @@ -295,14 +314,7 @@ void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *repla // In reality, it's a __NSTaggedDate (presumably a tagged pointer representing an NSDate.) But to sure, we'll check: id value = CFBridgingRelease(CFDictionaryGetValue(innerDictRef, kSecPropertyKeyValue)); if (value) { - if ([value isKindOfClass:[NSDate class]]) { - invalidityDate = value; - } else { - // parse the date from a string - NSDateFormatter *dateFormatter = [NSDateFormatter new]; - [dateFormatter setDateFormat:@"yyyy-MM-dd HH:mm:ss Z"]; - invalidityDate = [dateFormatter dateFromString:[value description]]; - } + invalidityDate = parseDate(value); } else { NSLog(@"No invalidity date in '%@' certificate, dictionary = %@", subject, innerDictRef); } @@ -606,11 +618,7 @@ unsigned long long getFileSize(NSString *path) { NSString *key = [html substringWithRange:NSMakeRange(start + 2, result.range.length - 4)]; [rv appendString:[html substringWithRange:NSMakeRange(prevLoc, start - prevLoc)]]; NSString *value = templateValues[key]; - if (!value) { -#ifdef DEBUG - NSLog(@"WARN: unused key %@", key); -#endif - } else { + if (value) { [rv appendString:value]; } prevLoc = start + result.range.length; From 1747f75ba266525d19c70e4b382de9d16ac0345e Mon Sep 17 00:00:00 2001 From: relikd Date: Mon, 29 Jan 2024 17:38:42 +0100 Subject: [PATCH 19/32] feat: show iTunes Metadata + purchase information --- CHANGELOG.md | 19 ++-- ProvisionQL.xcodeproj/project.pbxproj | 8 ++ ProvisionQL/AppCategories.h | 4 + ProvisionQL/AppCategories.m | 142 ++++++++++++++++++++++++++ ProvisionQL/GeneratePreviewForURL.m | 66 ++++++++++++ ProvisionQL/Resources/template.html | 13 +++ ProvisionQL/Shared.h | 1 + ProvisionQL/Shared.m | 7 ++ 8 files changed, 251 insertions(+), 9 deletions(-) create mode 100644 ProvisionQL/AppCategories.h create mode 100644 ProvisionQL/AppCategories.m diff --git a/CHANGELOG.md b/CHANGELOG.md index d22537d..722220a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,16 @@ ## Version 1.7.0 -* fix codesign unkown param on <10.15 (`--xml` flag) -* fix crash if a plist key is not present (e.g. `CFBundleShortVersionString` for some old iOS 3.2 ipa) -* fix fixed-width size for preview of app-icon (consistency) -* fix `IconFlavor` attribute for thumbnail drawing in 10.15+ -* unzip with zlib instead of sys-call (performance) -* parse html template tags with regex (performance) -* use higher resolution app icon if available (try `iTunesArtwork`) -* minor html template improvements -* some refactoring to reduce duplicate code +* New: show iTunes Metadata & purchase information +* New: use higher resolution app icon if available (try `iTunesArtwork`) +* Performance: unzip with zlib instead of sys-call +* Performance: parse html template tags with regex +* Fix codesign unkown param on <10.15 (`--xml` flag) +* Fix crash if a plist key is not present (e.g. `CFBundleShortVersionString` for some old iOS 3.2 ipa) +* Fix fixed-width size for preview of app-icon (consistency) +* Fix `IconFlavor` attribute for thumbnail drawing in 10.15+ +* Minor html template improvements +* Some refactoring to reduce duplicate code ## Version 1.6.4 diff --git a/ProvisionQL.xcodeproj/project.pbxproj b/ProvisionQL.xcodeproj/project.pbxproj index 344f721..f529809 100644 --- a/ProvisionQL.xcodeproj/project.pbxproj +++ b/ProvisionQL.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 54F4EB0F2B668F7E0000CE41 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 54F4EB0E2B668F7E0000CE41 /* libz.dylib */; }; 54F4EB222B66D6FE0000CE41 /* ZipFile.h in Headers */ = {isa = PBXBuildFile; fileRef = 54F4EB202B66D6FE0000CE41 /* ZipFile.h */; }; 54F4EB232B66D6FE0000CE41 /* ZipFile.m in Sources */ = {isa = PBXBuildFile; fileRef = 54F4EB212B66D6FE0000CE41 /* ZipFile.m */; }; + 54F4EB682B6719310000CE41 /* AppCategories.h in Headers */ = {isa = PBXBuildFile; fileRef = 54F4EB662B6719310000CE41 /* AppCategories.h */; }; + 54F4EB692B6719310000CE41 /* AppCategories.m in Sources */ = {isa = PBXBuildFile; fileRef = 54F4EB672B6719310000CE41 /* AppCategories.m */; }; 553C6D321879E457002237FC /* blankIcon.png in Resources */ = {isa = PBXBuildFile; fileRef = 553C6D311879E457002237FC /* blankIcon.png */; }; 55424C611870D4AA002F5408 /* AppKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 55424C601870D4AA002F5408 /* AppKit.framework */; }; 55424C631870D90E002F5408 /* defaultIcon.png in Resources */ = {isa = PBXBuildFile; fileRef = 55424C621870D90E002F5408 /* defaultIcon.png */; }; @@ -41,6 +43,8 @@ 54F4EB0E2B668F7E0000CE41 /* libz.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libz.dylib; path = ../../../../../usr/lib/libz.dylib; sourceTree = ""; }; 54F4EB202B66D6FE0000CE41 /* ZipFile.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ZipFile.h; sourceTree = ""; }; 54F4EB212B66D6FE0000CE41 /* ZipFile.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ZipFile.m; sourceTree = ""; }; + 54F4EB662B6719310000CE41 /* AppCategories.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppCategories.h; sourceTree = ""; }; + 54F4EB672B6719310000CE41 /* AppCategories.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppCategories.m; sourceTree = ""; }; 553C6D311879E457002237FC /* blankIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = blankIcon.png; sourceTree = ""; }; 55424C601870D4AA002F5408 /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = System/Library/Frameworks/AppKit.framework; sourceTree = SDKROOT; }; 55424C621870D90E002F5408 /* defaultIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = defaultIcon.png; sourceTree = ""; }; @@ -191,6 +195,8 @@ 555E9511186E2D67001D406A /* Supporting-files */, 555E951A186E2DC0001D406A /* Scripts */, 555E9518186E2DC0001D406A /* Resources */, + 54F4EB662B6719310000CE41 /* AppCategories.h */, + 54F4EB672B6719310000CE41 /* AppCategories.m */, 557C842718733828008A2A0C /* Shared.h */, 557C842418732599008A2A0C /* Shared.m */, 54F4EB202B66D6FE0000CE41 /* ZipFile.h */, @@ -208,6 +214,7 @@ isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( + 54F4EB682B6719310000CE41 /* AppCategories.h in Headers */, 55424C671870DB2A002F5408 /* NSBezierPath+IOS7RoundedRect.h in Headers */, 54F4EB002B6668A50000CE41 /* ZipEntry.h in Headers */, 557C842818733828008A2A0C /* Shared.h in Headers */, @@ -324,6 +331,7 @@ 54F4EB022B6668A50000CE41 /* pinch.m in Sources */, 557C842618732599008A2A0C /* Shared.m in Sources */, 55DB728F186E193500CAFEE7 /* GenerateThumbnailForURL.m in Sources */, + 54F4EB692B6719310000CE41 /* AppCategories.m in Sources */, 55DB7291186E193500CAFEE7 /* GeneratePreviewForURL.m in Sources */, 54F4EB232B66D6FE0000CE41 /* ZipFile.m in Sources */, ); diff --git a/ProvisionQL/AppCategories.h b/ProvisionQL/AppCategories.h new file mode 100644 index 0000000..683f9b0 --- /dev/null +++ b/ProvisionQL/AppCategories.h @@ -0,0 +1,4 @@ +#import +#import + +NSDictionary *getAppCategories(void); diff --git a/ProvisionQL/AppCategories.m b/ProvisionQL/AppCategories.m new file mode 100644 index 0000000..489d00c --- /dev/null +++ b/ProvisionQL/AppCategories.m @@ -0,0 +1,142 @@ +#import "AppCategories.h" + +NSDictionary *getAppCategories() { + static NSDictionary* categories = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + categories = @{ + // MARK: iOS + @6018: @"Books", + @6000: @"Business", + @6022: @"Catalogs", + @6026: @"Developer Tools", + @6017: @"Education", + @6016: @"Entertainment", + @6015: @"Finance", + @6023: @"Food & Drink", + @6014: @"Games", + @7001: @"Action", + @7002: @"Adventure", + @7004: @"Board", + @7005: @"Card", + @7006: @"Casino", + @7003: @"Casual", + @7007: @"Dice", + @7008: @"Educational", + @7009: @"Family", + @7011: @"Music", + @7012: @"Puzzle", + @7013: @"Racing", + @7014: @"Role Playing", + @7015: @"Simulation", + @7016: @"Sports", + @7017: @"Strategy", + @7018: @"Trivia", + @7019: @"Word", + @6027: @"Graphics & Design", + @6013: @"Health & Fitness", + @6012: @"Lifestyle", + @6021: @"Magazines & Newspapers", + @13007: @"Arts & Photography", + @13006: @"Automotive", + @13008: @"Brides & Weddings", + @13009: @"Business & Investing", + @13010: @"Children's Magazines", + @13011: @"Computers & Internet", + @13012: @"Cooking, Food & Drink", + @13013: @"Crafts & Hobbies", + @13014: @"Electronics & Audio", + @13015: @"Entertainment", + @13002: @"Fashion & Style", + @13017: @"Health, Mind & Body", + @13018: @"History", + @13003: @"Home & Garden", + @13019: @"Literary Magazines & Journals", + @13020: @"Men's Interest", + @13021: @"Movies & Music", + @13001: @"News & Politics", + @13004: @"Outdoors & Nature", + @13023: @"Parenting & Family", + @13024: @"Pets", + @13025: @"Professional & Trade", + @13026: @"Regional News", + @13027: @"Science", + @13005: @"Sports & Leisure", + @13028: @"Teens", + @13029: @"Travel & Regional", + @13030: @"Women's Interest", + @6020: @"Medical", + @6011: @"Music", + @6010: @"Navigation", + @6009: @"News", + @6008: @"Photo & Video", + @6007: @"Productivity", + @6006: @"Reference", + @6024: @"Shopping", + @6005: @"Social Networking", + @6004: @"Sports", + @6025: @"Stickers", + @16003: @"Animals & Nature", + @16005: @"Art", + @16006: @"Celebrations", + @16007: @"Celebrities", + @16008: @"Comics & Cartoons", + @16009: @"Eating & Drinking", + @16001: @"Emoji & Expressions", + @16026: @"Fashion", + @16010: @"Gaming", + @16025: @"Kids & Family", + @16014: @"Movies & TV", + @16015: @"Music", + @16017: @"People", + @16019: @"Places & Objects", + @16021: @"Sports & Activities", + @6003: @"Travel", + @6002: @"Utilities", + @6001: @"Weather", + + // MARK: macOS + @12001: @"Business", + @12002: @"Developer Tools", + @12003: @"Education", + @12004: @"Entertainment", + @12005: @"Finance", + @12006: @"Games", + @12201: @"Action", + @12202: @"Adventure", + @12204: @"Board", + @12205: @"Card", + @12206: @"Casino", + @12203: @"Casual", + @12207: @"Dice", + @12208: @"Educational", + @12209: @"Family", + @12210: @"Kids", + @12211: @"Music", + @12212: @"Puzzle", + @12213: @"Racing", + @12214: @"Role Playing", + @12215: @"Simulation", + @12216: @"Sports", + @12217: @"Strategy", + @12218: @"Trivia", + @12219: @"Word", + @12022: @"Graphics & Design", + @12007: @"Health & Fitness", + @12008: @"Lifestyle", + @12010: @"Medical", + @12011: @"Music", + @12012: @"News", + @12013: @"Photography", + @12014: @"Productivity", + @12015: @"Reference", + @12016: @"Social Networking", + @12017: @"Sports", + @12018: @"Travel", + @12019: @"Utilities", + @12020: @"Video", + @12021: @"Weather" + }; + }); + return categories; +} diff --git a/ProvisionQL/GeneratePreviewForURL.m b/ProvisionQL/GeneratePreviewForURL.m index a6a4d35..0207fc8 100644 --- a/ProvisionQL/GeneratePreviewForURL.m +++ b/ProvisionQL/GeneratePreviewForURL.m @@ -1,4 +1,5 @@ #import "Shared.h" +#import "AppCategories.h" // makro to stop further processing #define ALLOW_EXIT if (QLPreviewRequestIsCancelled(preview)) { return noErr; } @@ -159,6 +160,10 @@ void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *repla NSDateFormatter *dateFormatter = [NSDateFormatter new]; [dateFormatter setDateFormat:@"yyyy-MM-dd HH:mm:ss Z"]; NSDate *rv = [dateFormatter dateFromString:dateStr]; + if (!rv) { + [dateFormatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ssZ"]; + rv = [dateFormatter dateFromString:dateStr]; + } if (!rv) { NSLog(@"ERROR formatting date: %@", dateStr); } @@ -299,6 +304,63 @@ void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *repla } +// MARK: - iTunes Purchase Information + +/// Concatenate all (sub)genres into a comma separated list. +NSString *formattedGenres(NSDictionary *itunesPlist) { + NSDictionary *categories = getAppCategories(); + NSMutableArray *genres = [NSMutableArray array]; + NSString *mainGenre = categories[itunesPlist[@"genreId"] ?: @0] ?: itunesPlist[@"genre"]; + if (mainGenre) { + [genres addObject:mainGenre]; + } + for (NSDictionary *item in itunesPlist[@"subgenres"]) { + NSString *subgenre = categories[item[@"genreId"] ?: @0] ?: item[@"genre"]; + if (subgenre) { + [genres addObject:subgenre]; + } + } + return [genres componentsJoinedByString:@", "]; +} + +/// Process info stored in @c iTunesMetadata.plist +NSDictionary *parseItunesMeta(NSDictionary *itunesPlist) { + if (!itunesPlist) { + return @{ + @"iTunesHidden": @"hiddenDiv", + }; + } + + NSDictionary *downloadInfo = itunesPlist[@"com.apple.iTunesStore.downloadInfo"]; + NSDictionary *accountInfo = downloadInfo[@"accountInfo"]; + + NSDate *purchaseDate = parseDate(downloadInfo[@"purchaseDate"] ?: itunesPlist[@"purchaseDate"]); + NSDate *releaseDate = parseDate(downloadInfo[@"releaseDate"] ?: itunesPlist[@"releaseDate"]); + // AppleId & purchaser name + NSString *appleId = accountInfo[@"AppleID"] ?: itunesPlist[@"appleId"]; + NSString *firstName = accountInfo[@"FirstName"]; + NSString *lastName = accountInfo[@"LastName"]; + NSString *name; + if (firstName || lastName) { + name = [NSString stringWithFormat:@"%@ %@ (%@)", firstName, lastName, appleId]; + } else { + name = appleId; + } + + return @{ + @"iTunesHidden": @"", + @"iTunesId": [itunesPlist[@"itemId"] description] ?: @"", + @"iTunesName": itunesPlist[@"itemName"] ?: @"", + @"iTunesGenres": formattedGenres(itunesPlist), + @"iTunesReleaseDate": releaseDate ? formattedDate(releaseDate) : @"", + + @"iTunesAppleId": name ?: @"", + @"iTunesPurchaseDate": purchaseDate ? formattedDate(purchaseDate) : @"", + @"iTunesPrice": itunesPlist[@"priceDisplay"] ?: @"", + }; +} + + // MARK: - Certificates /// Process a single certificate. Extract invalidity / expiration date. @@ -638,6 +700,10 @@ OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, [infoLayer addEntriesFromDictionary:procAppInfo(plistApp)]; ALLOW_EXIT + NSDictionary *plistItunes = readPlistItunes(meta); + [infoLayer addEntriesFromDictionary:parseItunesMeta(plistItunes)]; + ALLOW_EXIT + // Provisioning NSDictionary *plistProvision = readPlistProvision(meta); diff --git a/ProvisionQL/Resources/template.html b/ProvisionQL/Resources/template.html index 2d04525..8fb91e7 100644 --- a/ProvisionQL/Resources/template.html +++ b/ProvisionQL/Resources/template.html @@ -185,6 +185,19 @@

Devices (__ProvisionedDevicesCount__)

__ProvisionedDevicesFormatted__
+ +
+

iTunes Metadata

+ iTunesId: __iTunesId__
+ Title: __iTunesName__
+ Genres: __iTunesGenres__
+ Released: __iTunesReleaseDate__
+
+ AppleId: __iTunesAppleId__
+ Purchased: __iTunesPurchaseDate__
+ Price: __iTunesPrice__
+
+

File info

__FileName__
diff --git a/ProvisionQL/Shared.h b/ProvisionQL/Shared.h index 87d8741..6e572cf 100644 --- a/ProvisionQL/Shared.h +++ b/ProvisionQL/Shared.h @@ -41,6 +41,7 @@ QuickLookInfo initQLInfo(_Nonnull CFStringRef contentTypeUTI, _Nonnull CFURLRef // Plist NSDictionary * _Nullable readPlistApp(QuickLookInfo meta); NSDictionary * _Nullable readPlistProvision(QuickLookInfo meta); +NSDictionary * _Nullable readPlistItunes(QuickLookInfo meta); // Other helper typedef NS_ENUM(NSUInteger, ExpirationStatus) { diff --git a/ProvisionQL/Shared.m b/ProvisionQL/Shared.m index cb4b7d8..e6a9097 100644 --- a/ProvisionQL/Shared.m +++ b/ProvisionQL/Shared.m @@ -95,6 +95,13 @@ QuickLookInfo initQLInfo(CFStringRef contentTypeUTI, CFURLRef url) { return asPlistOrNil(data); } +/// Read @c iTunesMetadata.plist if available +NSDictionary * _Nullable readPlistItunes(QuickLookInfo meta) { + if (meta.type == FileTypeIPA) { + return asPlistOrNil([meta.zipFile unzipFile:@"iTunesMetadata.plist"]); + } + return nil; +} // MARK: - Other helper From 3431bced020690d20de41ec4eb65eb9067187bae Mon Sep 17 00:00:00 2001 From: relikd Date: Mon, 29 Jan 2024 18:04:51 +0100 Subject: [PATCH 20/32] fix: remove potential leak --- ProvisionQL/GeneratePreviewForURL.m | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ProvisionQL/GeneratePreviewForURL.m b/ProvisionQL/GeneratePreviewForURL.m index 0207fc8..c55e561 100644 --- a/ProvisionQL/GeneratePreviewForURL.m +++ b/ProvisionQL/GeneratePreviewForURL.m @@ -267,10 +267,6 @@ void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *repla }; } - NSString *bundleName = appPlist[@"CFBundleDisplayName"]; - if (!bundleName) { - bundleName = appPlist[@"CFBundleName"]; - } NSString *extensionType = appPlist[@"NSExtension"][@"NSExtensionPointIdentifier"]; NSMutableArray *platforms = [NSMutableArray array]; @@ -369,7 +365,7 @@ void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *repla NSDate *invalidityDate = nil; CFErrorRef error = nil; CFDictionaryRef outerDictRef = SecCertificateCopyValues(certificateRef, (__bridge CFArrayRef)@[(__bridge NSString*)kSecOIDInvalidityDate], &error); - if (outerDictRef && !error) { + if (outerDictRef) { CFDictionaryRef innerDictRef = CFDictionaryGetValue(outerDictRef, kSecOIDInvalidityDate); if (innerDictRef) { // NOTE: the invalidity date type of kSecPropertyTypeDate is documented as a CFStringRef in the "Certificate, Key, and Trust Services Reference". From a351275e96382f6d82bfa821249f1a5cba37bd0e Mon Sep 17 00:00:00 2001 From: relikd Date: Mon, 29 Jan 2024 21:50:46 +0100 Subject: [PATCH 21/32] chore: update settings + version bump --- ProvisionQL.xcodeproj/project.pbxproj | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ProvisionQL.xcodeproj/project.pbxproj b/ProvisionQL.xcodeproj/project.pbxproj index f529809..8868d1b 100644 --- a/ProvisionQL.xcodeproj/project.pbxproj +++ b/ProvisionQL.xcodeproj/project.pbxproj @@ -252,7 +252,7 @@ 55DB7273186E193500CAFEE7 /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1000; + LastUpgradeCheck = 1240; ORGANIZATIONNAME = "Evgeny Aleksandrov"; TargetAttributes = { 55DB727C186E193500CAFEE7 = { @@ -361,6 +361,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -412,6 +413,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -447,7 +449,7 @@ ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = "ProvisionQL/Supporting-files/Info.plist"; INSTALL_PATH = /Library/QuickLook; - MARKETING_VERSION = 1.6.4; + MARKETING_VERSION = 1.7.0; OTHER_CODE_SIGN_FLAGS = "--timestamp"; PRODUCT_BUNDLE_IDENTIFIER = com.ealeksandrov.ProvisionQL; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -467,7 +469,7 @@ ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = "ProvisionQL/Supporting-files/Info.plist"; INSTALL_PATH = /Library/QuickLook; - MARKETING_VERSION = 1.6.4; + MARKETING_VERSION = 1.7.0; OTHER_CODE_SIGN_FLAGS = "--timestamp"; PRODUCT_BUNDLE_IDENTIFIER = com.ealeksandrov.ProvisionQL; PRODUCT_NAME = "$(TARGET_NAME)"; From 3b8de8b426971539701a3655cf2d1b6b3ea96f8e Mon Sep 17 00:00:00 2001 From: relikd Date: Tue, 30 Jan 2024 23:18:33 +0100 Subject: [PATCH 22/32] ref: rename ProvisionHidden and split div --- ProvisionQL/GeneratePreviewForURL.m | 4 ++-- ProvisionQL/Resources/template.html | 21 +++++++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/ProvisionQL/GeneratePreviewForURL.m b/ProvisionQL/GeneratePreviewForURL.m index c55e561..fa57699 100644 --- a/ProvisionQL/GeneratePreviewForURL.m +++ b/ProvisionQL/GeneratePreviewForURL.m @@ -462,7 +462,7 @@ void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *repla NSDictionary * _Nonnull procProvision(NSDictionary *provisionPlist, BOOL isOSX) { if (!provisionPlist) { return @{ - @"ProvisionInfo": @"hiddenDiv", + @"ProvisionHidden": @"hiddenDiv", }; } @@ -471,7 +471,7 @@ void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *repla NSArray* devices = getDeviceList(provisionPlist); return @{ - @"ProvisionInfo": @"", + @"ProvisionHidden": @"", @"ProfileName": provisionPlist[@"Name"] ?: @"", @"ProfileUUID": provisionPlist[@"UUID"] ?: @"", @"TeamName": provisionPlist[@"TeamName"] ?: @"Team name not available", diff --git a/ProvisionQL/Resources/template.html b/ProvisionQL/Resources/template.html index 8fb91e7..fcbea7b 100644 --- a/ProvisionQL/Resources/template.html +++ b/ProvisionQL/Resources/template.html @@ -157,7 +157,7 @@

App Transport Security

__AppTransportSecurityFormatted__
-
+

Provisioning

Profile name: __ProfileName__
@@ -171,19 +171,24 @@

__ProfileName__

Team: __TeamName__ (__TeamIds__)
Creation date: __CreationDateFormatted__
Expiration Date: __ExpirationDateFormatted__
+
-

Entitlements

-
- Entitlements extraction failed. -
- __EntitlementsFormatted__ +
+

Entitlements

+
+ Entitlements extraction failed. +
+ __EntitlementsFormatted__ +
+

Developer Certificates

__DeveloperCertificatesFormatted__ +
+

Devices (__ProvisionedDevicesCount__)

- __ProvisionedDevicesFormatted__ - + __ProvisionedDevicesFormatted__
From 7aba5087b8ab2f452c487b81e5c1bfe03a33cc82 Mon Sep 17 00:00:00 2001 From: relikd Date: Tue, 30 Jan 2024 23:24:23 +0100 Subject: [PATCH 23/32] feat: replace codesign sys-call with SecCodeSigning --- CHANGELOG.md | 1 + ProvisionQL.xcodeproj/project.pbxproj | 8 + ProvisionQL/Entitlements.h | 17 +++ ProvisionQL/Entitlements.m | 212 ++++++++++++++++++++++++++ ProvisionQL/GeneratePreviewForURL.m | 140 +++-------------- 5 files changed, 259 insertions(+), 119 deletions(-) create mode 100644 ProvisionQL/Entitlements.h create mode 100644 ProvisionQL/Entitlements.m diff --git a/CHANGELOG.md b/CHANGELOG.md index 722220a..922e655 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * New: use higher resolution app icon if available (try `iTunesArtwork`) * Performance: unzip with zlib instead of sys-call * Performance: parse html template tags with regex +* Performance: use `SecCodeSigning` instead of `codesign` sys-call * Fix codesign unkown param on <10.15 (`--xml` flag) * Fix crash if a plist key is not present (e.g. `CFBundleShortVersionString` for some old iOS 3.2 ipa) * Fix fixed-width size for preview of app-icon (consistency) diff --git a/ProvisionQL.xcodeproj/project.pbxproj b/ProvisionQL.xcodeproj/project.pbxproj index 8868d1b..b600748 100644 --- a/ProvisionQL.xcodeproj/project.pbxproj +++ b/ProvisionQL.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 54B1E00B2B6989E7009E654A /* Entitlements.h in Headers */ = {isa = PBXBuildFile; fileRef = 54B1E0092B6989E7009E654A /* Entitlements.h */; }; + 54B1E00C2B6989E7009E654A /* Entitlements.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B1E00A2B6989E7009E654A /* Entitlements.m */; }; 54F4EB002B6668A50000CE41 /* ZipEntry.h in Headers */ = {isa = PBXBuildFile; fileRef = 54F4EAFC2B6668A50000CE41 /* ZipEntry.h */; }; 54F4EB012B6668A50000CE41 /* ZipEntry.m in Sources */ = {isa = PBXBuildFile; fileRef = 54F4EAFD2B6668A50000CE41 /* ZipEntry.m */; }; 54F4EB022B6668A50000CE41 /* pinch.m in Sources */ = {isa = PBXBuildFile; fileRef = 54F4EAFE2B6668A50000CE41 /* pinch.m */; }; @@ -36,6 +38,8 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 54B1E0092B6989E7009E654A /* Entitlements.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Entitlements.h; sourceTree = ""; }; + 54B1E00A2B6989E7009E654A /* Entitlements.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Entitlements.m; sourceTree = ""; }; 54F4EAFC2B6668A50000CE41 /* ZipEntry.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ZipEntry.h; sourceTree = ""; }; 54F4EAFD2B6668A50000CE41 /* ZipEntry.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ZipEntry.m; sourceTree = ""; }; 54F4EAFE2B6668A50000CE41 /* pinch.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = pinch.m; sourceTree = ""; }; @@ -197,6 +201,8 @@ 555E9518186E2DC0001D406A /* Resources */, 54F4EB662B6719310000CE41 /* AppCategories.h */, 54F4EB672B6719310000CE41 /* AppCategories.m */, + 54B1E0092B6989E7009E654A /* Entitlements.h */, + 54B1E00A2B6989E7009E654A /* Entitlements.m */, 557C842718733828008A2A0C /* Shared.h */, 557C842418732599008A2A0C /* Shared.m */, 54F4EB202B66D6FE0000CE41 /* ZipFile.h */, @@ -216,6 +222,7 @@ files = ( 54F4EB682B6719310000CE41 /* AppCategories.h in Headers */, 55424C671870DB2A002F5408 /* NSBezierPath+IOS7RoundedRect.h in Headers */, + 54B1E00B2B6989E7009E654A /* Entitlements.h in Headers */, 54F4EB002B6668A50000CE41 /* ZipEntry.h in Headers */, 557C842818733828008A2A0C /* Shared.h in Headers */, 54F4EB032B6668A50000CE41 /* pinch.h in Headers */, @@ -332,6 +339,7 @@ 557C842618732599008A2A0C /* Shared.m in Sources */, 55DB728F186E193500CAFEE7 /* GenerateThumbnailForURL.m in Sources */, 54F4EB692B6719310000CE41 /* AppCategories.m in Sources */, + 54B1E00C2B6989E7009E654A /* Entitlements.m in Sources */, 55DB7291186E193500CAFEE7 /* GeneratePreviewForURL.m in Sources */, 54F4EB232B66D6FE0000CE41 /* ZipFile.m in Sources */, ); diff --git a/ProvisionQL/Entitlements.h b/ProvisionQL/Entitlements.h new file mode 100644 index 0000000..576966e --- /dev/null +++ b/ProvisionQL/Entitlements.h @@ -0,0 +1,17 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface Entitlements : NSObject +@property (nonatomic, assign, readonly) BOOL hasError; +/// only set after calling @c applyFallbackIfNeeded: +@property (nonatomic, retain, readonly) NSString * _Nullable html; + ++ (instancetype)withoutBinary; ++ (instancetype)withBinary:(NSString *)appBinaryPath; +- (instancetype)init UNAVAILABLE_ATTRIBUTE; + +- (void)applyFallbackIfNeeded:(NSDictionary * _Nullable)fallbackEntitlementsPlist; +@end + +NS_ASSUME_NONNULL_END diff --git a/ProvisionQL/Entitlements.m b/ProvisionQL/Entitlements.m new file mode 100644 index 0000000..89f085f --- /dev/null +++ b/ProvisionQL/Entitlements.m @@ -0,0 +1,212 @@ +#import "Entitlements.h" + +void recursiveKeyValue(NSUInteger level, NSString *key, id value, NSMutableString *output); + + +@interface Entitlements() +@property (nonatomic, copy, readonly) NSString * _Nonnull binaryPath; +/// It is either @c plist or @c codeSignErrors not both. +@property (nonatomic, retain, readonly) NSDictionary * _Nullable plist; +/// It is either @c plist or @c codeSignErrors not both. +@property (nonatomic, retain, readonly) NSString * _Nullable codeSignError; +@end + + +@implementation Entitlements + +/// Use provision plist data without running @c codesign or ++ (instancetype)withoutBinary { + return [[self alloc] init]; +} + +/// First, try to extract real entitlements by running @c SecCode module in-memory. +/// If that fails, fallback to running @c codesign via system call. ++ (instancetype)withBinary:(NSString * _Nonnull)appBinaryPath { + return [[self alloc] initWithBinaryPath:appBinaryPath]; +} + +- (instancetype)initWithBinaryPath:(NSString * _Nonnull)path { + self = [super init]; + if (self) { + _binaryPath = path; + _plist = [self getSecCodeEntitlements]; + if (!_plist) { + _plist = [self sysCallCodeSign]; // fallback to system call + } + } + return self; +} + +// MARK: - public methods + +/// Provided provision plist is only used if @c SecCode and @c CodeSign failed. +- (void)applyFallbackIfNeeded:(NSDictionary * _Nullable)fallbackEntitlementsPlist { + // checking for !error ensures that codesign gets precedence. + // show error before falling back to provision based entitlements. + if (!_plist && !_codeSignError) { + // read the entitlements from the provisioning profile instead + if ([fallbackEntitlementsPlist isKindOfClass:[NSDictionary class]]) { +#ifdef DEBUG + NSLog(@"[entitlements] fallback to provision plist entitlements"); +#endif + _plist = fallbackEntitlementsPlist; + } + } + _html = [self format:_plist]; + _plist = nil; // free memory + _codeSignError = nil; +} + +/// Print formatted plist in a @c \
 tag
+- (NSString * _Nullable)format:(NSDictionary *)plist {
+	if (plist) {
+		NSMutableString *output = [NSMutableString string];
+		recursiveKeyValue(0, nil, plist, output);
+		return [NSString stringWithFormat:@"
%@
", output]; + } + return _codeSignError; // may be nil +} + + +// MARK: - SecCode in-memory reader + +/// use in-memory @c SecCode for entitlement extraction +- (NSDictionary *)getSecCodeEntitlements { + NSURL *url = [NSURL fileURLWithPath:_binaryPath]; + NSDictionary *plist = nil; + SecStaticCodeRef codeRef; + SecStaticCodeCreateWithPath((__bridge CFURLRef)url, kSecCSDefaultFlags, &codeRef); + if (codeRef) { + CFDictionaryRef requirementInfo; + SecCodeCopySigningInformation(codeRef, kSecCSRequirementInformation, &requirementInfo); + if (requirementInfo) { +#ifdef DEBUG + NSLog(@"[entitlements] read SecCode 'entitlements-dict' key"); +#endif + CFDictionaryRef dict = CFDictionaryGetValue(requirementInfo, kSecCodeInfoEntitlementsDict); + // if 'entitlements-dict' key exists, use that one + if (dict) { + plist = (__bridge NSDictionary *)dict; + } + // else, fallback to parse data from 'entitlements' key + if (!plist) { +#ifdef DEBUG + NSLog(@"[entitlements] read SecCode 'entitlements' key"); +#endif + NSData *data = (__bridge NSData*)CFDictionaryGetValue(requirementInfo, kSecCodeInfoEntitlements); + if (data) { + NSData *header = [data subdataWithRange:NSMakeRange(0, 8)]; + const char *cptr = (const char*)[header bytes]; + + // expected magic header number. Currently no support for other formats. + if (memcmp("\xFA\xDE\x71\x71", cptr, 4) == 0) { + // big endian, so no memcpy for us :( + uint32_t size = ((uint8_t)cptr[4] << 24) | ((uint8_t)cptr[5] << 16) | ((uint8_t)cptr[6] << 8) | (uint8_t)cptr[7]; + if (size == data.length) { + data = [data subdataWithRange:NSMakeRange(8, data.length - 8)]; + } else { + NSLog(@"[entitlements] unpack error for FADE7171 size %lu != %u", data.length, size); + } + } else { + NSLog(@"[entitlements] unsupported embedded plist format: %@", header); + } + plist = [NSPropertyListSerialization propertyListWithData:data options:0 format:NULL error:NULL]; + } + } + CFRelease(requirementInfo); + } + CFRelease(codeRef); + } + return plist; +} + + +// MARK: - fallback to sys call + +/// run: @c codesign -d --entitlements - --xml +- (NSDictionary *)sysCallCodeSign { + NSTask *codesignTask = [NSTask new]; + [codesignTask setLaunchPath:@"/usr/bin/codesign"]; + [codesignTask setStandardOutput:[NSPipe pipe]]; + [codesignTask setStandardError:[NSPipe pipe]]; + if (@available(macOS 11, *)) { + [codesignTask setArguments:@[@"-d", _binaryPath, @"--entitlements", @"-", @"--xml"]]; + } else { + [codesignTask setArguments:@[@"-d", _binaryPath, @"--entitlements", @":-"]]; + } + [codesignTask launch]; + +#ifdef DEBUG + NSLog(@"[sys-call] codesign %@", [[codesignTask arguments] componentsJoinedByString:@" "]); +#endif + + NSData *outputData = [[[codesignTask standardOutput] fileHandleForReading] readDataToEndOfFile]; + NSData *errorData = [[[codesignTask standardError] fileHandleForReading] readDataToEndOfFile]; + [codesignTask waitUntilExit]; + + if (outputData) { + NSDictionary *plist = [NSPropertyListSerialization propertyListWithData:outputData options:0 format:NULL error:NULL]; + if (plist) { + return plist; + } + // errorData = outputData; // not sure if necessary + } + + NSString *output = [[NSString alloc] initWithData:errorData ?: outputData encoding:NSUTF8StringEncoding]; + if ([output hasPrefix:@"Executable="]) { + // remove first line with long temporary path to the executable + NSArray *allLines = [output componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]; + _codeSignError = [[allLines subarrayWithRange:NSMakeRange(1, allLines.count - 1)] componentsJoinedByString:@"
"]; + } else { + _codeSignError = output; + } + _hasError = YES; + return nil; +} + +@end + + +// MARK: - Plist formatter + +/// Print recursive tree of key-value mappings. +void recursiveKeyValue(NSUInteger level, NSString *key, id value, NSMutableString *output) { + int indent = (int)(level * 4); + + if ([value isKindOfClass:[NSDictionary class]]) { + if (key) { + [output appendFormat:@"%*s%@ = {\n", indent, "", key]; + } else if (level != 0) { + [output appendFormat:@"%*s{\n", indent, ""]; + } + NSDictionary *dictionary = (NSDictionary *)value; + NSArray *keys = [[dictionary allKeys] sortedArrayUsingSelector:@selector(compare:)]; + for (NSString *subKey in keys) { + NSUInteger subLevel = (key == nil && level == 0) ? 0 : level + 1; + recursiveKeyValue(subLevel, subKey, [dictionary valueForKey:subKey], output); + } + if (level != 0) { + [output appendFormat:@"%*s}\n", indent, ""]; + } + } else if ([value isKindOfClass:[NSArray class]]) { + [output appendFormat:@"%*s%@ = (\n", indent, "", key]; + NSArray *array = (NSArray *)value; + for (id value in array) { + recursiveKeyValue(level + 1, nil, value, output); + } + [output appendFormat:@"%*s)\n", indent, ""]; + } else if ([value isKindOfClass:[NSData class]]) { + NSData *data = (NSData *)value; + if (key) { + [output appendFormat:@"%*s%@ = %zd bytes of data\n", indent, "", key, [data length]]; + } else { + [output appendFormat:@"%*s%zd bytes of data\n", indent, "", [data length]]; + } + } else { + if (key) { + [output appendFormat:@"%*s%@ = %@\n", indent, "", key, value]; + } else { + [output appendFormat:@"%*s%@\n", indent, "", value]; + } + } +} diff --git a/ProvisionQL/GeneratePreviewForURL.m b/ProvisionQL/GeneratePreviewForURL.m index fa57699..6ceedfc 100644 --- a/ProvisionQL/GeneratePreviewForURL.m +++ b/ProvisionQL/GeneratePreviewForURL.m @@ -1,5 +1,6 @@ #import "Shared.h" #import "AppCategories.h" +#import "Entitlements.h" // makro to stop further processing #define ALLOW_EXIT if (QLPreviewRequestIsCancelled(preview)) { return noErr; } @@ -32,48 +33,6 @@ return table; } -/// Print recursive tree of key-value mappings. -void recursiveKeyValue(NSUInteger level, NSString *key, id value, NSMutableString *output) { - int indent = (int)(level * 4); - - if ([value isKindOfClass:[NSDictionary class]]) { - if (key) { - [output appendFormat:@"%*s%@ = {\n", indent, "", key]; - } else if (level != 0) { - [output appendFormat:@"%*s{\n", indent, ""]; - } - NSDictionary *dictionary = (NSDictionary *)value; - NSArray *keys = [[dictionary allKeys] sortedArrayUsingSelector:@selector(compare:)]; - for (NSString *subKey in keys) { - NSUInteger subLevel = (key == nil && level == 0) ? 0 : level + 1; - recursiveKeyValue(subLevel, subKey, [dictionary valueForKey:subKey], output); - } - if (level != 0) { - [output appendFormat:@"%*s}\n", indent, ""]; - } - } else if ([value isKindOfClass:[NSArray class]]) { - [output appendFormat:@"%*s%@ = (\n", indent, "", key]; - NSArray *array = (NSArray *)value; - for (id value in array) { - recursiveKeyValue(level + 1, nil, value, output); - } - [output appendFormat:@"%*s)\n", indent, ""]; - } else if ([value isKindOfClass:[NSData class]]) { - NSData *data = (NSData *)value; - if (key) { - [output appendFormat:@"%*s%@ = %zd bytes of data\n", indent, "", key, [data length]]; - } else { - [output appendFormat:@"%*s%zd bytes of data\n", indent, "", [data length]]; - } - } else { - if (key) { - [output appendFormat:@"%*s%@ = %@\n", indent, "", key, value]; - } else { - [output appendFormat:@"%*s%@\n", indent, "", value]; - } - } -} - /// Print recursive tree of key-value mappings. void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *replacements, int level, NSMutableString *output) { for (NSString *key in dictionary) { @@ -493,45 +452,20 @@ void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *repla // MARK: - Entitlements -/// run: @c codesign -d --entitlements - --xml -NSData *runCodeSign(NSString *binaryPath) { - NSTask *codesignTask = [NSTask new]; - [codesignTask setLaunchPath:@"/usr/bin/codesign"]; - [codesignTask setStandardOutput:[NSPipe pipe]]; - [codesignTask setStandardError:[NSPipe pipe]]; - if (@available(macOS 11, *)) { - [codesignTask setArguments:@[@"-d", binaryPath, @"--entitlements", @"-", @"--xml"]]; - } else { - [codesignTask setArguments:@[@"-d", binaryPath, @"--entitlements", @":-"]]; - } - [codesignTask launch]; - -#ifdef DEBUG - NSLog(@"[sys-call] codesign %@", [[codesignTask arguments] componentsJoinedByString:@" "]); -#endif - - NSData *outputData = [[[codesignTask standardOutput] fileHandleForReading] readDataToEndOfFile]; - NSData *errorData = [[[codesignTask standardError] fileHandleForReading] readDataToEndOfFile]; - [codesignTask waitUntilExit]; - - if (outputData.length == 0) { - return errorData; - } - return outputData; -} - /// Search for app binary and run @c codesign on it. -NSData *getCodeSignEntitlements(QuickLookInfo meta, NSString *bundleExecutable) { - NSFileManager *fileManager = [NSFileManager defaultManager]; +Entitlements *readEntitlements(QuickLookInfo meta, NSString *bundleExecutable) { + if (!bundleExecutable) { + return [Entitlements withoutBinary]; + } NSString *tempDirFolder = [NSTemporaryDirectory() stringByAppendingPathComponent:kPluginBundleId]; - NSString *currentTempDirFolder = [tempDirFolder stringByAppendingPathComponent:[[NSUUID UUID] UUIDString]]; - + NSString *currentTempDirFolder = nil; NSString *basePath = nil; switch (meta.type) { case FileTypeIPA: - basePath = currentTempDirFolder; - [fileManager createDirectoryAtPath:currentTempDirFolder withIntermediateDirectories:YES attributes:nil error:nil]; + currentTempDirFolder = [tempDirFolder stringByAppendingPathComponent:[[NSUUID UUID] UUIDString]]; + [[NSFileManager defaultManager] createDirectoryAtPath:currentTempDirFolder withIntermediateDirectories:YES attributes:nil error:nil]; [meta.zipFile unzipFile:[@"Payload/*.app/" stringByAppendingPathComponent:bundleExecutable] toDir:currentTempDirFolder]; + basePath = currentTempDirFolder; break; case FileTypeArchive: basePath = meta.effectiveUrl.path; @@ -543,51 +477,21 @@ void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *repla return nil; } - NSData *data = runCodeSign([basePath stringByAppendingPathComponent:bundleExecutable]); - [fileManager removeItemAtPath:currentTempDirFolder error:nil]; - return data; -} - -/// Print formatted plist in a @c \
 tag
-NSString * _Nonnull formattedPlist(NSDictionary *dict) {
-	NSMutableString *output = [NSMutableString string];
-	recursiveKeyValue(0, nil, dict, output);
-	return [NSString stringWithFormat:@"
%@
", output]; + Entitlements *rv = [Entitlements withBinary:[basePath stringByAppendingPathComponent:bundleExecutable]]; + if (currentTempDirFolder) { + [[NSFileManager defaultManager] removeItemAtPath:currentTempDirFolder error:nil]; + } + return rv; } -/// First, try to extract real entitlements by running codesign. -/// If that fails, fallback to entitlements provided by provision plist. -NSDictionary * _Nonnull procEntitlements(NSData *codeSignData, NSDictionary *provisionPlist) { - BOOL showEntitlementsWarning = false; - NSString *formattedOutput = nil; - if (codeSignData != nil) { - NSDictionary *plist = [NSPropertyListSerialization propertyListWithData:codeSignData options:0 format:NULL error:NULL]; - if (plist != nil) { - formattedOutput = formattedPlist(plist); - } else { - showEntitlementsWarning = true; - NSString *output = [[NSString alloc] initWithData:codeSignData encoding:NSUTF8StringEncoding]; - if ([output hasPrefix:@"Executable="]) { - // remove first line with long temporary path to the executable - NSArray *allLines = [output componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]; - formattedOutput = [[allLines subarrayWithRange:NSMakeRange(1, allLines.count - 1)] componentsJoinedByString:@"
"]; - } else { - formattedOutput = output; - } - } - } else { - // read the entitlements from the provisioning profile instead - NSDictionary *value = provisionPlist[@"Entitlements"]; - if ([value isKindOfClass:[NSDictionary class]]) { - formattedOutput = formattedPlist(value); - } else { - formattedOutput = @"No Entitlements"; - } - } +/// Process compiled binary and provision plist to extract @c Entitlements +NSDictionary * _Nonnull procEntitlements(QuickLookInfo meta, NSDictionary *appPlist, NSDictionary *provisionPlist) { + Entitlements *entitlements = readEntitlements(meta, appPlist[@"CFBundleExecutable"]); + [entitlements applyFallbackIfNeeded:provisionPlist[@"Entitlements"]]; return @{ - @"EntitlementsFormatted": formattedOutput ?: @"", - @"EntitlementsWarning": showEntitlementsWarning ? @"" : @"hiddenDiv", + @"EntitlementsFormatted": entitlements.html ?: @"No Entitlements", + @"EntitlementsWarning": entitlements.hasError ? @"" : @"hiddenDiv", }; } @@ -715,9 +619,7 @@ OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, ALLOW_EXIT // Entitlements - NSString *bundleExecutable = plistApp[@"CFBundleExecutable"]; - NSData *codeSignData = getCodeSignEntitlements(meta, bundleExecutable); - [infoLayer addEntriesFromDictionary:procEntitlements(codeSignData, plistProvision)]; + [infoLayer addEntriesFromDictionary:procEntitlements(meta, plistApp, plistProvision)]; ALLOW_EXIT // File Info From d4b01f0e4c6ebb01899c2e25f63dcab602253f34 Mon Sep 17 00:00:00 2001 From: relikd Date: Tue, 30 Jan 2024 23:31:13 +0100 Subject: [PATCH 24/32] style: remove css indentation --- ProvisionQL/Resources/template.html | 344 ++++++++++++++-------------- 1 file changed, 172 insertions(+), 172 deletions(-) diff --git a/ProvisionQL/Resources/template.html b/ProvisionQL/Resources/template.html index fcbea7b..aa3c5cf 100644 --- a/ProvisionQL/Resources/template.html +++ b/ProvisionQL/Resources/template.html @@ -1,172 +1,172 @@ - - - - - - -
-

__AppInfoTitle__

-
App icon
-
+ + + + + + +
+

__AppInfoTitle__

+
App icon
+
Name: __CFBundleName__
Version: __CFBundleShortVersionString__ (__CFBundleVersion__)
BundleId: __CFBundleIdentifier__
-
- Extension type: __NSExtensionPointIdentifier__
-
+
+ Extension type: __NSExtensionPointIdentifier__
+
DeviceFamily: __UIDeviceFamily__
- SDK: __DTSDKName__
- Minimum OS Version: __MinimumOSVersion__
-
-
-

App Transport Security

- __AppTransportSecurityFormatted__ -
- -
-
-

Provisioning

- Profile name: __ProfileName__
-
-
-

__ProfileName__

-
- - Profile UUID: __ProfileUUID__
+ SDK: __DTSDKName__
+ Minimum OS Version: __MinimumOSVersion__
+
+
+

App Transport Security

+ __AppTransportSecurityFormatted__ +
+ +
+
+

Provisioning

+ Profile name: __ProfileName__
+
+
+

__ProfileName__

+
+ + Profile UUID: __ProfileUUID__
Profile Type: __ProfilePlatform__ __ProfileType__
Team: __TeamName__ (__TeamIds__)
Creation date: __CreationDateFormatted__
@@ -203,13 +203,13 @@

iTunes Metadata

Price: __iTunesPrice__
-
-

File info

- __FileName__
- __FileInfo__
-
- - +
+

File info

+ __FileName__
+ __FileInfo__
+
+ + From 18e3e3f9daa49ec5e49510c7d5cca22c9385a348 Mon Sep 17 00:00:00 2001 From: relikd Date: Tue, 30 Jan 2024 23:48:21 +0100 Subject: [PATCH 25/32] ref: naming convention for all hiddenDiv --- ProvisionQL/GeneratePreviewForURL.m | 14 +++++++------- ProvisionQL/Resources/template.html | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/ProvisionQL/GeneratePreviewForURL.m b/ProvisionQL/GeneratePreviewForURL.m index 6ceedfc..9b7afa4 100644 --- a/ProvisionQL/GeneratePreviewForURL.m +++ b/ProvisionQL/GeneratePreviewForURL.m @@ -221,8 +221,8 @@ void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *repla NSDictionary * _Nonnull procAppInfo(NSDictionary *appPlist) { if (!appPlist) { return @{ - @"AppInfo": @"hiddenDiv", - @"ProvisionAsSubheader": @"", + @"AppInfoHidden": @"hiddenDiv", + @"ProvisionTitleHidden": @"", }; } @@ -240,16 +240,16 @@ void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *repla } return @{ - @"AppInfo": @"", - @"ProvisionAsSubheader": @"hiddenDiv", + @"AppInfoHidden": @"", + @"ProvisionTitleHidden": @"hiddenDiv", @"CFBundleName": appPlist[@"CFBundleDisplayName"] ?: appPlist[@"CFBundleName"] ?: @"", @"CFBundleShortVersionString": appPlist[@"CFBundleShortVersionString"] ?: @"", @"CFBundleVersion": appPlist[@"CFBundleVersion"] ?: @"", @"CFBundleIdentifier": appPlist[@"CFBundleIdentifier"] ?: @"", - @"ExtensionInfo": extensionType ? @"" : @"hiddenDiv", - @"NSExtensionPointIdentifier": extensionType ?: @"", + @"ExtensionTypeHidden": extensionType ? @"" : @"hiddenDiv", + @"ExtensionType": extensionType ?: @"", @"UIDeviceFamily": [platforms componentsJoinedByString:@", "], @"DTSDKName": appPlist[@"DTSDKName"] ?: @"", @@ -490,8 +490,8 @@ void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *repla [entitlements applyFallbackIfNeeded:provisionPlist[@"Entitlements"]]; return @{ + @"EntitlementsWarningHidden": entitlements.hasError ? @"" : @"hiddenDiv", @"EntitlementsFormatted": entitlements.html ?: @"No Entitlements", - @"EntitlementsWarning": entitlements.hasError ? @"" : @"hiddenDiv", }; } diff --git a/ProvisionQL/Resources/template.html b/ProvisionQL/Resources/template.html index aa3c5cf..8e02dda 100644 --- a/ProvisionQL/Resources/template.html +++ b/ProvisionQL/Resources/template.html @@ -138,15 +138,15 @@ -
+

__AppInfoTitle__

App icon
Name: __CFBundleName__
Version: __CFBundleShortVersionString__ (__CFBundleVersion__)
BundleId: __CFBundleIdentifier__
-
- Extension type: __NSExtensionPointIdentifier__
+
+ Extension type: __ExtensionType__
DeviceFamily: __UIDeviceFamily__
SDK: __DTSDKName__
@@ -158,11 +158,11 @@

App Transport Security

-
+

Provisioning

Profile name: __ProfileName__
-
+

__ProfileName__

@@ -175,7 +175,7 @@

__ProfileName__

Entitlements

-
+
Entitlements extraction failed.
__EntitlementsFormatted__ From fa6ac77a89fae2f7f011d212825148732e12013a Mon Sep 17 00:00:00 2001 From: relikd Date: Tue, 30 Jan 2024 23:56:20 +0100 Subject: [PATCH 26/32] doc: py script for app categories --- CHANGELOG.md | 1 + ProvisionQL/AppCategories.m | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 922e655..86830f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * New: show iTunes Metadata & purchase information * New: use higher resolution app icon if available (try `iTunesArtwork`) +* New: show entitlements regardless of provisioning plist if available * Performance: unzip with zlib instead of sys-call * Performance: parse html template tags with regex * Performance: use `SecCodeSigning` instead of `codesign` sys-call diff --git a/ProvisionQL/AppCategories.m b/ProvisionQL/AppCategories.m index 489d00c..80508da 100644 --- a/ProvisionQL/AppCategories.m +++ b/ProvisionQL/AppCategories.m @@ -1,5 +1,26 @@ #import "AppCategories.h" +/* + #!/usr/bin/env python3 + # download: https://itunes.apple.com/WebObjects/MZStoreServices.woa/ws/genres + import json + ids = {} + + def fn(data): + for k, v in data.items(): + ids[k] = v['name'] + if 'subgenres' in v: + fn(v['subgenres']) + + with open('genres.json', 'r') as fp: + for cat in json.load(fp).values(): + if 'App Store' in cat['name']: + fn(cat['subgenres']) + + print(',\n'.join(f'@{k}: @"{v}"' for k, v in ids.items())) + print(len(ids)) + */ + NSDictionary *getAppCategories() { static NSDictionary* categories = nil; static dispatch_once_t onceToken; From c1a888d3592d3a2fae079e4e137f45fef833a564 Mon Sep 17 00:00:00 2001 From: relikd Date: Sun, 4 Feb 2024 20:52:00 +0100 Subject: [PATCH 27/32] fix: default case return --- ProvisionQL/GeneratePreviewForURL.m | 4 ++++ ProvisionQL/GenerateThumbnailForURL.m | 2 +- ProvisionQL/Shared.m | 10 +++++++++- ProvisionQL/ZipFile.h | 7 ------- ProvisionQL/ZipFile.m | 5 +++++ 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/ProvisionQL/GeneratePreviewForURL.m b/ProvisionQL/GeneratePreviewForURL.m index 9b7afa4..99314f8 100644 --- a/ProvisionQL/GeneratePreviewForURL.m +++ b/ProvisionQL/GeneratePreviewForURL.m @@ -506,6 +506,7 @@ void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *repla case FileTypeExtension: return @"App extension info"; case FileTypeProvision: return nil; } + return nil; } /// Calculate file / folder size. @@ -592,6 +593,9 @@ unsigned long long getFileSize(NSString *path) { OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, CFURLRef url, CFStringRef contentTypeUTI, CFDictionaryRef options) { @autoreleasepool { QuickLookInfo meta = initQLInfo(contentTypeUTI, url); + if (!meta.type) { + return noErr; + } NSMutableDictionary* infoLayer = [NSMutableDictionary dictionary]; infoLayer[@"AppInfoTitle"] = stringForFileType(meta); diff --git a/ProvisionQL/GenerateThumbnailForURL.m b/ProvisionQL/GenerateThumbnailForURL.m index f8963bd..b20ad99 100644 --- a/ProvisionQL/GenerateThumbnailForURL.m +++ b/ProvisionQL/GenerateThumbnailForURL.m @@ -27,7 +27,7 @@ This function's job is to create thumbnail for designated file as fast as possible ----------------------------------------------------------------------------- */ -// MARK: .ipa .xarchive +// MARK: .ipa .xcarchive OSStatus renderAppIcon(QuickLookInfo meta, QLThumbnailRequestRef thumbnail) { NSImage *appIcon = imageFromApp(meta, nil); diff --git a/ProvisionQL/Shared.m b/ProvisionQL/Shared.m index e6a9097..5f6c63a 100644 --- a/ProvisionQL/Shared.m +++ b/ProvisionQL/Shared.m @@ -48,6 +48,7 @@ QuickLookInfo initQLInfo(CFStringRef contentTypeUTI, CFURLRef url) { case FileTypeExtension: return [NSData dataWithContentsOfURL:[meta.url URLByAppendingPathComponent:filename]]; case FileTypeProvision: return nil; } + return nil; } // MARK: - Plist @@ -55,7 +56,13 @@ QuickLookInfo initQLInfo(CFStringRef contentTypeUTI, CFURLRef url) { /// Helper for optional chaining. NSDictionary * _Nullable asPlistOrNil(NSData * _Nullable data) { if (!data) { return nil; } - return [NSPropertyListSerialization propertyListWithData:data options:0 format:NULL error:NULL]; + NSError *err; + NSDictionary *dict = [NSPropertyListSerialization propertyListWithData:data options:0 format:NULL error:&err]; + if (err) { + NSLog(@"ERROR reading plist %@", err); + return nil; + } + return dict; } /// Read app default @c Info.plist. @@ -69,6 +76,7 @@ QuickLookInfo initQLInfo(CFStringRef contentTypeUTI, CFURLRef url) { case FileTypeProvision: return nil; } + return nil; } /// Read @c embedded.mobileprovision file and decode with CMS decoder. diff --git a/ProvisionQL/ZipFile.h b/ProvisionQL/ZipFile.h index d2885aa..f87d33b 100644 --- a/ProvisionQL/ZipFile.h +++ b/ProvisionQL/ZipFile.h @@ -4,14 +4,7 @@ NS_ASSUME_NONNULL_BEGIN @interface ZipFile : NSObject + (instancetype)open:(NSString *)path; - -/// Unzip file directly into memory. -/// @param filePath File path inside zip file. - (NSData * _Nullable)unzipFile:(NSString *)filePath; - -/// Unzip file to filesystem. -/// @param filePath File path inside zip file. -/// @param targetDir Directory in which to unzip the file. - (void)unzipFile:(NSString *)filePath toDir:(NSString *)targetDir; @end diff --git a/ProvisionQL/ZipFile.m b/ProvisionQL/ZipFile.m index 9d444e3..9fe9d97 100644 --- a/ProvisionQL/ZipFile.m +++ b/ProvisionQL/ZipFile.m @@ -25,6 +25,8 @@ - (instancetype)initWithFile:(NSString *)path { // MARK: - public methods +/// Unzip file directly into memory. +/// @param filePath File path inside zip file. - (NSData * _Nullable)unzipFile:(NSString *)filePath { if (self.centralDirectory) { ZipEntry *matchingFile = [self.centralDirectory zipEntryWithPath:filePath]; @@ -49,6 +51,9 @@ - (NSData * _Nullable)unzipFile:(NSString *)filePath { return [self sysUnzipFile:filePath]; } +/// Unzip file to filesystem. +/// @param filePath File path inside zip file. +/// @param targetDir Directory in which to unzip the file. - (void)unzipFile:(NSString *)filePath toDir:(NSString *)targetDir { if (self.centralDirectory) { NSData *data = [self unzipFile:filePath]; From 7b6ef29aebd1267bee3cebf3ed03a318469875ce Mon Sep 17 00:00:00 2001 From: relikd Date: Sun, 4 Feb 2024 21:02:25 +0100 Subject: [PATCH 28/32] feat: support for Asset.car icons --- CHANGELOG.md | 2 + .../NSBezierPath+IOS7RoundedRect.h | 15 - .../NSBezierPath+IOS7RoundedRect.m | 49 --- PrivateFrameworks/CoreUI.framework/Headers | 1 + .../Versions/A/Headers/CUICatalog.h | 54 +++ .../Versions/A/Headers/CUINamedImage.h | 53 +++ .../Versions/A/Headers/CUINamedLookup.h | 16 + .../CoreUI.framework/Versions/Current | 1 + .../CoreUI.framework/module.modulemap | 9 + ProvisionQL.xcodeproj/project.pbxproj | 52 ++- ProvisionQL/AppIcon.h | 17 + ProvisionQL/AppIcon.m | 383 ++++++++++++++++++ ProvisionQL/GeneratePreviewForURL.m | 21 +- ProvisionQL/GenerateThumbnailForURL.m | 25 +- ProvisionQL/Shared.h | 7 +- ProvisionQL/Shared.m | 100 ----- ProvisionQL/ZipFile.h | 2 + ProvisionQL/ZipFile.m | 8 + 18 files changed, 602 insertions(+), 213 deletions(-) delete mode 100644 NSBezierPath+IOS7RoundedRect/NSBezierPath+IOS7RoundedRect.h delete mode 100644 NSBezierPath+IOS7RoundedRect/NSBezierPath+IOS7RoundedRect.m create mode 120000 PrivateFrameworks/CoreUI.framework/Headers create mode 100755 PrivateFrameworks/CoreUI.framework/Versions/A/Headers/CUICatalog.h create mode 100755 PrivateFrameworks/CoreUI.framework/Versions/A/Headers/CUINamedImage.h create mode 100755 PrivateFrameworks/CoreUI.framework/Versions/A/Headers/CUINamedLookup.h create mode 120000 PrivateFrameworks/CoreUI.framework/Versions/Current create mode 100755 PrivateFrameworks/CoreUI.framework/module.modulemap create mode 100644 ProvisionQL/AppIcon.h create mode 100644 ProvisionQL/AppIcon.m diff --git a/CHANGELOG.md b/CHANGELOG.md index 86830f3..e6e2a49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * New: show iTunes Metadata & purchase information * New: use higher resolution app icon if available (try `iTunesArtwork`) * New: show entitlements regardless of provisioning plist if available +* New: load icon from `Assets.car` * Performance: unzip with zlib instead of sys-call * Performance: parse html template tags with regex * Performance: use `SecCodeSigning` instead of `codesign` sys-call @@ -12,6 +13,7 @@ * Fix crash if a plist key is not present (e.g. `CFBundleShortVersionString` for some old iOS 3.2 ipa) * Fix fixed-width size for preview of app-icon (consistency) * Fix `IconFlavor` attribute for thumbnail drawing in 10.15+ +* Fix prefer icons without "small" siffix * Minor html template improvements * Some refactoring to reduce duplicate code diff --git a/NSBezierPath+IOS7RoundedRect/NSBezierPath+IOS7RoundedRect.h b/NSBezierPath+IOS7RoundedRect/NSBezierPath+IOS7RoundedRect.h deleted file mode 100644 index 74f4088..0000000 --- a/NSBezierPath+IOS7RoundedRect/NSBezierPath+IOS7RoundedRect.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// NSBezierPath+IOS7RoundedRect.h -// -// Created by Matej Dunik on 11/12/13. -// Copyright (c) 2013 PixelCut. All rights reserved except as below: -// This code is provided as-is, without warranty of any kind. You may use it in your projects as you wish. -// - -#import - -@interface NSBezierPath (IOS7RoundedRect) - -+ (NSBezierPath *)bezierPathWithIOS7RoundedRect:(NSRect)rect cornerRadius:(CGFloat)radius; - -@end diff --git a/NSBezierPath+IOS7RoundedRect/NSBezierPath+IOS7RoundedRect.m b/NSBezierPath+IOS7RoundedRect/NSBezierPath+IOS7RoundedRect.m deleted file mode 100644 index df7b911..0000000 --- a/NSBezierPath+IOS7RoundedRect/NSBezierPath+IOS7RoundedRect.m +++ /dev/null @@ -1,49 +0,0 @@ -// -// NSBezierPath+IOS7RoundedRect.m -// -// Created by Matej Dunik on 11/12/13. -// Copyright (c) 2013 PixelCut. All rights reserved except as below: -// This code is provided as-is, without warranty of any kind. You may use it in your projects as you wish. -// - -#import "NSBezierPath+IOS7RoundedRect.h" - -@implementation NSBezierPath (IOS7RoundedRect) - -#define TOP_LEFT(X, Y) NSMakePoint(rect.origin.x + X * limitedRadius, rect.origin.y + Y * limitedRadius) -#define TOP_RIGHT(X, Y) NSMakePoint(rect.origin.x + rect.size.width - X * limitedRadius, rect.origin.y + Y * limitedRadius) -#define BOTTOM_RIGHT(X, Y) NSMakePoint(rect.origin.x + rect.size.width - X * limitedRadius, rect.origin.y + rect.size.height - Y * limitedRadius) -#define BOTTOM_LEFT(X, Y) NSMakePoint(rect.origin.x + X * limitedRadius, rect.origin.y + rect.size.height - Y * limitedRadius) - - -+ (NSBezierPath *)bezierPathWithIOS7RoundedRect:(NSRect)rect cornerRadius:(CGFloat)radius { - NSBezierPath *path = NSBezierPath.bezierPath; - CGFloat limit = MIN(rect.size.width, rect.size.height) / 2 / 1.52866483; - CGFloat limitedRadius = MIN(radius, limit); - - [path moveToPoint: TOP_LEFT(1.52866483, 0.00000000)]; - [path lineToPoint: TOP_RIGHT(1.52866471, 0.00000000)]; - [path curveToPoint: TOP_RIGHT(0.66993427, 0.06549600) controlPoint1: TOP_RIGHT(1.08849323, 0.00000000) controlPoint2: TOP_RIGHT(0.86840689, 0.00000000)]; - [path lineToPoint: TOP_RIGHT(0.63149399, 0.07491100)]; - [path curveToPoint: TOP_RIGHT(0.07491176, 0.63149399) controlPoint1: TOP_RIGHT(0.37282392, 0.16905899) controlPoint2: TOP_RIGHT(0.16906013, 0.37282401)]; - [path curveToPoint: TOP_RIGHT(0.00000000, 1.52866483) controlPoint1: TOP_RIGHT(0.00000000, 0.86840701) controlPoint2: TOP_RIGHT(0.00000000, 1.08849299)]; - [path lineToPoint: BOTTOM_RIGHT(0.00000000, 1.52866471)]; - [path curveToPoint: BOTTOM_RIGHT(0.06549569, 0.66993493) controlPoint1: BOTTOM_RIGHT(0.00000000, 1.08849323) controlPoint2: BOTTOM_RIGHT(0.00000000, 0.86840689)]; - [path lineToPoint: BOTTOM_RIGHT(0.07491111, 0.63149399)]; - [path curveToPoint: BOTTOM_RIGHT(0.63149399, 0.07491111) controlPoint1: BOTTOM_RIGHT(0.16905883, 0.37282392) controlPoint2: BOTTOM_RIGHT(0.37282392, 0.16905883)]; - [path curveToPoint: BOTTOM_RIGHT(1.52866471, 0.00000000) controlPoint1: BOTTOM_RIGHT(0.86840689, 0.00000000) controlPoint2: BOTTOM_RIGHT(1.08849323, 0.00000000)]; - [path lineToPoint: BOTTOM_LEFT(1.52866483, 0.00000000)]; - [path curveToPoint: BOTTOM_LEFT(0.66993397, 0.06549569) controlPoint1: BOTTOM_LEFT(1.08849299, 0.00000000) controlPoint2: BOTTOM_LEFT(0.86840701, 0.00000000)]; - [path lineToPoint: BOTTOM_LEFT(0.63149399, 0.07491111)]; - [path curveToPoint: BOTTOM_LEFT(0.07491100, 0.63149399) controlPoint1: BOTTOM_LEFT(0.37282401, 0.16905883) controlPoint2: BOTTOM_LEFT(0.16906001, 0.37282392)]; - [path curveToPoint: BOTTOM_LEFT(0.00000000, 1.52866471) controlPoint1: BOTTOM_LEFT(0.00000000, 0.86840689) controlPoint2: BOTTOM_LEFT(0.00000000, 1.08849323)]; - [path lineToPoint: TOP_LEFT(0.00000000, 1.52866483)]; - [path curveToPoint: TOP_LEFT(0.06549600, 0.66993397) controlPoint1: TOP_LEFT(0.00000000, 1.08849299) controlPoint2: TOP_LEFT(0.00000000, 0.86840701)]; - [path lineToPoint: TOP_LEFT(0.07491100, 0.63149399)]; - [path curveToPoint: TOP_LEFT(0.63149399, 0.07491100) controlPoint1: TOP_LEFT(0.16906001, 0.37282401) controlPoint2: TOP_LEFT(0.37282401, 0.16906001)]; - [path curveToPoint: TOP_LEFT(1.52866483, 0.00000000) controlPoint1: TOP_LEFT(0.86840701, 0.00000000) controlPoint2: TOP_LEFT(1.08849299, 0.00000000)]; - [path closePath]; - return path; -} - -@end diff --git a/PrivateFrameworks/CoreUI.framework/Headers b/PrivateFrameworks/CoreUI.framework/Headers new file mode 120000 index 0000000..fc757d7 --- /dev/null +++ b/PrivateFrameworks/CoreUI.framework/Headers @@ -0,0 +1 @@ +Versions/Current/Headers/ \ No newline at end of file diff --git a/PrivateFrameworks/CoreUI.framework/Versions/A/Headers/CUICatalog.h b/PrivateFrameworks/CoreUI.framework/Versions/A/Headers/CUICatalog.h new file mode 100755 index 0000000..ab1f24d --- /dev/null +++ b/PrivateFrameworks/CoreUI.framework/Versions/A/Headers/CUICatalog.h @@ -0,0 +1,54 @@ +#import + +@class NSBundle, NSCache, NSMapTable, NSString; +@class CUINamedImage, CUIStructuredThemeStore; + +@interface CUICatalog: NSObject { + NSString * _assetStoreName; + NSBundle * _bundle; + unsigned int _fileHasDisplayGamutInKeySpace; + NSCache * _localObjectCache; + NSCache * _lookupCache; + NSCache * _negativeCache; + unsigned short _preferredLocalization; + unsigned int _purgeWhenFinished; + unsigned int _reserved; + NSMapTable * _storageMapTable; + unsigned long long _storageRef; + NSDictionary * _vibrantColorMatrixTints; +} + +- (CUIStructuredThemeStore *)_themeStore; + ++ (id)defaultUICatalogForBundle:(id)arg1; + +- (id)initWithBytes:(const void*)arg1 length:(unsigned long long)arg2 error:(NSError **)arg3; +- (id)initWithName:(id)arg1 fromBundle:(id)arg2; +- (id)initWithName:(id)arg1 fromBundle:(id)arg2 error:(id*)arg3; +- (id)initWithURL:(id)arg1 error:(NSError **)arg2; + +- (BOOL)imageExistsWithName:(id)arg1; +- (BOOL)imageExistsWithName:(id)arg1 scaleFactor:(double)arg2; +- (CUINamedImage *)imageWithName:(id)arg1 scaleFactor:(double)arg2; +- (CUINamedImage *)imageWithName:(id)arg1 scaleFactor:(double)arg2 appearanceName:(id)arg3; +- (CUINamedImage *)imageWithName:(id)arg1 scaleFactor:(double)arg2 deviceIdiom:(long long)arg3; +- (CUINamedImage *)imageWithName:(id)arg1 scaleFactor:(double)arg2 deviceIdiom:(long long)arg3 appearanceName:(id)arg4; +- (CUINamedImage *)imageWithName:(id)arg1 scaleFactor:(double)arg2 deviceIdiom:(long long)arg3 deviceSubtype:(unsigned long long)arg4; +- (CUINamedImage *)imageWithName:(id)arg1 scaleFactor:(double)arg2 deviceIdiom:(long long)arg3 deviceSubtype:(unsigned long long)arg4 appearanceName:(id)arg5; +- (CUINamedImage *)imageWithName:(id)arg1 scaleFactor:(double)arg2 deviceIdiom:(long long)arg3 deviceSubtype:(unsigned long long)arg4 displayGamut:(long long)arg5 layoutDirection:(long long)arg6 sizeClassHorizontal:(long long)arg7 sizeClassVertical:(long long)arg8; +- (CUINamedImage *)imageWithName:(id)arg1 scaleFactor:(double)arg2 deviceIdiom:(long long)arg3 deviceSubtype:(unsigned long long)arg4 displayGamut:(long long)arg5 layoutDirection:(long long)arg6 sizeClassHorizontal:(long long)arg7 sizeClassVertical:(long long)arg8 appearanceName:(id)arg9; +- (CUINamedImage *)imageWithName:(id)arg1 scaleFactor:(double)arg2 deviceIdiom:(long long)arg3 deviceSubtype:(unsigned long long)arg4 displayGamut:(long long)arg5 layoutDirection:(long long)arg6 sizeClassHorizontal:(long long)arg7 sizeClassVertical:(long long)arg8 memoryClass:(long long)arg9 graphicsClass:(long long)arg10; +- (CUINamedImage *)imageWithName:(id)arg1 scaleFactor:(double)arg2 deviceIdiom:(long long)arg3 deviceSubtype:(unsigned long long)arg4 displayGamut:(long long)arg5 layoutDirection:(long long)arg6 sizeClassHorizontal:(long long)arg7 sizeClassVertical:(long long)arg8 memoryClass:(unsigned long long)arg9 graphicsClass:(unsigned long long)arg10 appearanceIdentifier:(long long)arg11 graphicsFallBackOrder:(id)arg12 deviceSubtypeFallBackOrder:(id)arg13; +- (CUINamedImage *)imageWithName:(id)arg1 scaleFactor:(double)arg2 deviceIdiom:(long long)arg3 deviceSubtype:(unsigned long long)arg4 displayGamut:(long long)arg5 layoutDirection:(long long)arg6 sizeClassHorizontal:(long long)arg7 sizeClassVertical:(long long)arg8 memoryClass:(unsigned long long)arg9 graphicsClass:(unsigned long long)arg10 graphicsFallBackOrder:(id)arg11 deviceSubtypeFallBackOrder:(id)arg12; +- (CUINamedImage *)imageWithName:(id)arg1 scaleFactor:(double)arg2 deviceIdiom:(long long)arg3 deviceSubtype:(unsigned long long)arg4 sizeClassHorizontal:(long long)arg5 sizeClassVertical:(long long)arg6; +- (CUINamedImage *)imageWithName:(id)arg1 scaleFactor:(double)arg2 deviceIdiom:(long long)arg3 deviceSubtype:(unsigned long long)arg4 sizeClassHorizontal:(long long)arg5 sizeClassVertical:(long long)arg6 appearanceName:(id)arg7; +- (CUINamedImage *)imageWithName:(id)arg1 scaleFactor:(double)arg2 deviceIdiom:(long long)arg3 layoutDirection:(long long)arg4 adjustRenditionKeyWithBlock:(id)arg5; +- (CUINamedImage *)imageWithName:(id)arg1 scaleFactor:(double)arg2 displayGamut:(long long)arg3 layoutDirection:(long long)arg4; +- (CUINamedImage *)imageWithName:(id)arg1 scaleFactor:(double)arg2 displayGamut:(long long)arg3 layoutDirection:(long long)arg4 appearanceName:(id)arg5; +- (NSArray *)imagesWithName:(id)arg1; + +- (NSArray *)allImageNames; +- (NSArray *)appearanceNames; + +@end + diff --git a/PrivateFrameworks/CoreUI.framework/Versions/A/Headers/CUINamedImage.h b/PrivateFrameworks/CoreUI.framework/Versions/A/Headers/CUINamedImage.h new file mode 100755 index 0000000..7643f7d --- /dev/null +++ b/PrivateFrameworks/CoreUI.framework/Versions/A/Headers/CUINamedImage.h @@ -0,0 +1,53 @@ +#import +#import + +@interface CUINamedImage: CUINamedLookup { + struct _cuiniproperties { + unsigned int isVectorBased : 1; + unsigned int hasSliceInformation : 1; + unsigned int hasAlignmentInformation : 1; + unsigned int resizingMode : 2; + unsigned int templateRenderingMode : 3; + unsigned int exifOrientation : 4; + unsigned int isAlphaCropped : 1; + unsigned int isFlippable : 1; + unsigned int isTintable : 1; + unsigned int preservedVectorRepresentation : 1; + unsigned int _reserved : 16; + } _imageProperties; + double _scale; +} + +@property (readonly) CGRect NS_alignmentRect; +@property (nonatomic, readonly) NSEdgeInsets alignmentEdgeInsets; +@property (nonatomic, readonly) int blendMode; +@property (nonatomic, readonly) CGImageRef croppedImage; +@property (nonatomic, readonly) NSEdgeInsets edgeInsets; +@property (nonatomic, readonly) int exifOrientation; +@property (nonatomic, readonly) BOOL hasAlignmentInformation; +@property (nonatomic, readonly) BOOL hasSliceInformation; +@property (nonatomic, readonly) CGImageRef image; +@property (nonatomic, readonly) long long imageType; +@property (nonatomic, readonly) BOOL isAlphaCropped; +@property (nonatomic, readonly) BOOL isFlippable; +@property (nonatomic, readonly) BOOL isStructured; +@property (nonatomic, readonly) BOOL isTemplate; +@property (nonatomic, readonly) BOOL isVectorBased; +@property (nonatomic, readonly) double opacity; +@property (nonatomic, readonly) BOOL preservedVectorRepresentation; +@property (nonatomic, readonly) long long resizingMode; +@property (nonatomic, readonly) double scale; +@property (nonatomic, readonly) CGSize size; +@property (nonatomic, readonly) long long templateRenderingMode; + +- (id)baseKey; +- (CGRect)alphaCroppedRect; +- (CGImageRef)createImageFromPDFRenditionWithScale:(double)arg1; +- (CGImageRef)croppedImage; + +- (id)initWithName:(id)arg1 usingRenditionKey:(id)arg2 fromTheme:(unsigned long long)arg3; + +- (CGSize)originalUncroppedSize; +- (double)positionOfSliceBoundary:(unsigned int)arg1; + +@end diff --git a/PrivateFrameworks/CoreUI.framework/Versions/A/Headers/CUINamedLookup.h b/PrivateFrameworks/CoreUI.framework/Versions/A/Headers/CUINamedLookup.h new file mode 100755 index 0000000..25dbcf2 --- /dev/null +++ b/PrivateFrameworks/CoreUI.framework/Versions/A/Headers/CUINamedLookup.h @@ -0,0 +1,16 @@ +#import + +@class CUIRenditionKey; + +@interface CUINamedLookup: NSObject { + unsigned int _distilledInVersion; + CUIRenditionKey * _key; + NSString * _name; + unsigned int _odContent; + NSString * _signature; + unsigned long long _storageRef; +} + +- (id)initWithName:(id)arg1 usingRenditionKey:(id)arg2 fromTheme:(unsigned long long)arg3; + +@end diff --git a/PrivateFrameworks/CoreUI.framework/Versions/Current b/PrivateFrameworks/CoreUI.framework/Versions/Current new file mode 120000 index 0000000..8c7e5a6 --- /dev/null +++ b/PrivateFrameworks/CoreUI.framework/Versions/Current @@ -0,0 +1 @@ +A \ No newline at end of file diff --git a/PrivateFrameworks/CoreUI.framework/module.modulemap b/PrivateFrameworks/CoreUI.framework/module.modulemap new file mode 100755 index 0000000..c151ffb --- /dev/null +++ b/PrivateFrameworks/CoreUI.framework/module.modulemap @@ -0,0 +1,9 @@ +module CoreUI { + // umbrella header "CoreUI.h" + // Here is the list of your private headers. + header "Headers/CUICatalog.h" + header "Headers/CUINamedLookup.h" + header "Headers/CUINamedImage.h" + + export * +} diff --git a/ProvisionQL.xcodeproj/project.pbxproj b/ProvisionQL.xcodeproj/project.pbxproj index b600748..ab40ae7 100644 --- a/ProvisionQL.xcodeproj/project.pbxproj +++ b/ProvisionQL.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 54768CFA2B6D3841007E81D2 /* AppIcon.h in Headers */ = {isa = PBXBuildFile; fileRef = 54768CF82B6D3841007E81D2 /* AppIcon.h */; }; + 54768CFB2B6D3841007E81D2 /* AppIcon.m in Sources */ = {isa = PBXBuildFile; fileRef = 54768CF92B6D3841007E81D2 /* AppIcon.m */; }; + 54768D052B701D6C007E81D2 /* CoreUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54768D042B701D6C007E81D2 /* CoreUI.framework */; }; 54B1E00B2B6989E7009E654A /* Entitlements.h in Headers */ = {isa = PBXBuildFile; fileRef = 54B1E0092B6989E7009E654A /* Entitlements.h */; }; 54B1E00C2B6989E7009E654A /* Entitlements.m in Sources */ = {isa = PBXBuildFile; fileRef = 54B1E00A2B6989E7009E654A /* Entitlements.m */; }; 54F4EB002B6668A50000CE41 /* ZipEntry.h in Headers */ = {isa = PBXBuildFile; fileRef = 54F4EAFC2B6668A50000CE41 /* ZipEntry.h */; }; @@ -21,8 +24,6 @@ 553C6D321879E457002237FC /* blankIcon.png in Resources */ = {isa = PBXBuildFile; fileRef = 553C6D311879E457002237FC /* blankIcon.png */; }; 55424C611870D4AA002F5408 /* AppKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 55424C601870D4AA002F5408 /* AppKit.framework */; }; 55424C631870D90E002F5408 /* defaultIcon.png in Resources */ = {isa = PBXBuildFile; fileRef = 55424C621870D90E002F5408 /* defaultIcon.png */; }; - 55424C671870DB2A002F5408 /* NSBezierPath+IOS7RoundedRect.h in Headers */ = {isa = PBXBuildFile; fileRef = 55424C651870DB2A002F5408 /* NSBezierPath+IOS7RoundedRect.h */; }; - 55424C681870DB2A002F5408 /* NSBezierPath+IOS7RoundedRect.m in Sources */ = {isa = PBXBuildFile; fileRef = 55424C661870DB2A002F5408 /* NSBezierPath+IOS7RoundedRect.m */; }; 555E9515186E2D67001D406A /* main.c in Sources */ = {isa = PBXBuildFile; fileRef = 555E9512186E2D67001D406A /* main.c */; }; 555E951C186E2DC0001D406A /* template.html in Resources */ = {isa = PBXBuildFile; fileRef = 555E9519186E2DC0001D406A /* template.html */; }; 557C842218731FB7008A2A0C /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 557C842118731FB7008A2A0C /* WebKit.framework */; }; @@ -38,6 +39,9 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 54768CF82B6D3841007E81D2 /* AppIcon.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppIcon.h; sourceTree = ""; }; + 54768CF92B6D3841007E81D2 /* AppIcon.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppIcon.m; sourceTree = ""; }; + 54768D042B701D6C007E81D2 /* CoreUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = CoreUI.framework; sourceTree = ""; }; 54B1E0092B6989E7009E654A /* Entitlements.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Entitlements.h; sourceTree = ""; }; 54B1E00A2B6989E7009E654A /* Entitlements.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Entitlements.m; sourceTree = ""; }; 54F4EAFC2B6668A50000CE41 /* ZipEntry.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ZipEntry.h; sourceTree = ""; }; @@ -52,8 +56,6 @@ 553C6D311879E457002237FC /* blankIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = blankIcon.png; sourceTree = ""; }; 55424C601870D4AA002F5408 /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = System/Library/Frameworks/AppKit.framework; sourceTree = SDKROOT; }; 55424C621870D90E002F5408 /* defaultIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = defaultIcon.png; sourceTree = ""; }; - 55424C651870DB2A002F5408 /* NSBezierPath+IOS7RoundedRect.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSBezierPath+IOS7RoundedRect.h"; sourceTree = ""; }; - 55424C661870DB2A002F5408 /* NSBezierPath+IOS7RoundedRect.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSBezierPath+IOS7RoundedRect.m"; sourceTree = ""; }; 55457C11203C4A9E00ED02E5 /* LICENSE.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = LICENSE.md; sourceTree = ""; }; 55457C12203C4A9E00ED02E5 /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; 55457C13203C4A9E00ED02E5 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; @@ -84,6 +86,7 @@ 55DB729B186E195500CAFEE7 /* Security.framework in Frameworks */, 55DB7287186E193500CAFEE7 /* CoreFoundation.framework in Frameworks */, 55DB7281186E193500CAFEE7 /* QuickLook.framework in Frameworks */, + 54768D052B701D6C007E81D2 /* CoreUI.framework in Frameworks */, 54F4EB0F2B668F7E0000CE41 /* libz.dylib in Frameworks */, 55DB7285186E193500CAFEE7 /* CoreServices.framework in Frameworks */, 55DB7283186E193500CAFEE7 /* ApplicationServices.framework in Frameworks */, @@ -93,6 +96,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 54768CE82B69BB27007E81D2 /* PrivateFrameworks */ = { + isa = PBXGroup; + children = ( + 54768D042B701D6C007E81D2 /* CoreUI.framework */, + ); + path = PrivateFrameworks; + sourceTree = ""; + }; 54F4EAFB2B6668940000CE41 /* 3rd-party */ = { isa = PBXGroup; children = ( @@ -112,15 +123,6 @@ path = pinch; sourceTree = ""; }; - 55424C641870DB2A002F5408 /* NSBezierPath+IOS7RoundedRect */ = { - isa = PBXGroup; - children = ( - 55424C651870DB2A002F5408 /* NSBezierPath+IOS7RoundedRect.h */, - 55424C661870DB2A002F5408 /* NSBezierPath+IOS7RoundedRect.m */, - ); - path = "NSBezierPath+IOS7RoundedRect"; - sourceTree = ""; - }; 55457C10203C4A7500ED02E5 /* Metadata */ = { isa = PBXGroup; children = ( @@ -161,8 +163,8 @@ 55DB7272186E193500CAFEE7 = { isa = PBXGroup; children = ( + 54768CE82B69BB27007E81D2 /* PrivateFrameworks */, 55457C10203C4A7500ED02E5 /* Metadata */, - 55424C641870DB2A002F5408 /* NSBezierPath+IOS7RoundedRect */, 55DB7288186E193500CAFEE7 /* ProvisionQL */, 55DB727F186E193500CAFEE7 /* Frameworks */, 55DB727E186E193500CAFEE7 /* Products */, @@ -201,6 +203,8 @@ 555E9518186E2DC0001D406A /* Resources */, 54F4EB662B6719310000CE41 /* AppCategories.h */, 54F4EB672B6719310000CE41 /* AppCategories.m */, + 54768CF82B6D3841007E81D2 /* AppIcon.h */, + 54768CF92B6D3841007E81D2 /* AppIcon.m */, 54B1E0092B6989E7009E654A /* Entitlements.h */, 54B1E00A2B6989E7009E654A /* Entitlements.m */, 557C842718733828008A2A0C /* Shared.h */, @@ -221,7 +225,7 @@ buildActionMask = 2147483647; files = ( 54F4EB682B6719310000CE41 /* AppCategories.h in Headers */, - 55424C671870DB2A002F5408 /* NSBezierPath+IOS7RoundedRect.h in Headers */, + 54768CFA2B6D3841007E81D2 /* AppIcon.h in Headers */, 54B1E00B2B6989E7009E654A /* Entitlements.h in Headers */, 54F4EB002B6668A50000CE41 /* ZipEntry.h in Headers */, 557C842818733828008A2A0C /* Shared.h in Headers */, @@ -332,7 +336,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 55424C681870DB2A002F5408 /* NSBezierPath+IOS7RoundedRect.m in Sources */, + 54768CFB2B6D3841007E81D2 /* AppIcon.m in Sources */, 555E9515186E2D67001D406A /* main.c in Sources */, 54F4EB012B6668A50000CE41 /* ZipEntry.m in Sources */, 54F4EB022B6668A50000CE41 /* pinch.m in Sources */, @@ -455,12 +459,20 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 5567X9EQ9Q; ENABLE_HARDENED_RUNTIME = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/PrivateFrameworks", + ); INFOPLIST_FILE = "ProvisionQL/Supporting-files/Info.plist"; INSTALL_PATH = /Library/QuickLook; MARKETING_VERSION = 1.7.0; OTHER_CODE_SIGN_FLAGS = "--timestamp"; PRODUCT_BUNDLE_IDENTIFIER = com.ealeksandrov.ProvisionQL; PRODUCT_NAME = "$(TARGET_NAME)"; + SYSTEM_FRAMEWORK_SEARCH_PATHS = ( + "$(SYSTEM_LIBRARY_DIR)/PrivateFrameworks", + "$(inherited)", + ); WRAPPER_EXTENSION = qlgenerator; }; name = Debug; @@ -475,12 +487,20 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 5567X9EQ9Q; ENABLE_HARDENED_RUNTIME = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/PrivateFrameworks", + ); INFOPLIST_FILE = "ProvisionQL/Supporting-files/Info.plist"; INSTALL_PATH = /Library/QuickLook; MARKETING_VERSION = 1.7.0; OTHER_CODE_SIGN_FLAGS = "--timestamp"; PRODUCT_BUNDLE_IDENTIFIER = com.ealeksandrov.ProvisionQL; PRODUCT_NAME = "$(TARGET_NAME)"; + SYSTEM_FRAMEWORK_SEARCH_PATHS = ( + "$(SYSTEM_LIBRARY_DIR)/PrivateFrameworks", + "$(inherited)", + ); WRAPPER_EXTENSION = qlgenerator; }; name = Release; diff --git a/ProvisionQL/AppIcon.h b/ProvisionQL/AppIcon.h new file mode 100644 index 0000000..d9320c6 --- /dev/null +++ b/ProvisionQL/AppIcon.h @@ -0,0 +1,17 @@ +#import +#import + +#import "Shared.h" + +@interface NSImage (AppIcon) +- (NSImage * _Nonnull)withRoundCorners; +- (NSString * _Nonnull)asBase64; +- (void)downscale:(CGSize)maxSize; +@end + + +@interface AppIcon : NSObject ++ (instancetype _Nonnull)load:(QuickLookInfo)meta; +- (BOOL)canExtractImage; +- (NSImage * _Nonnull)extractImage:(NSDictionary * _Nullable)appPlist; +@end diff --git a/ProvisionQL/AppIcon.m b/ProvisionQL/AppIcon.m new file mode 100644 index 0000000..3825792 --- /dev/null +++ b/ProvisionQL/AppIcon.m @@ -0,0 +1,383 @@ +#import "AppIcon.h" +#import "Shared.h" +#import "ZipEntry.h" + +#define CUI_ENABLED 1 + +#ifdef CUI_ENABLED +#import +#import +#endif + + +@interface AppIcon() +@property (nonatomic, assign) QuickLookInfo meta; +@end + + +@implementation AppIcon + ++ (instancetype)load:(QuickLookInfo)meta { + return [[self alloc] initWithMeta:meta]; +} + +- (instancetype)initWithMeta:(QuickLookInfo)meta { + self = [super init]; + if (self) { + _meta = meta; + } + return self; +} + + +// MARK: - Public methods + +/// You should check this before calling @c extractImage +- (BOOL)canExtractImage { + switch (_meta.type) { + case FileTypeIPA: + case FileTypeArchive: + case FileTypeExtension: + return YES; + case FileTypeProvision: + return NO; + } + return NO; +} + + +// MARK: - Image Extraction + +/// Try multiple methods to extract image. You should check @c canExtractImage before calling this method. +/// This method will always return an image even if none is found, in which case it returns the default image. +- (NSImage * _Nonnull)extractImage:(NSDictionary * _Nullable)appPlist { + // no need to unwrap the plist, and most .ipa should include the Artwork anyway + if (_meta.type == FileTypeIPA) { + NSData *data = [_meta.zipFile unzipFile:@"iTunesArtwork"]; + if (data) { +#ifdef DEBUG + NSLog(@"[icon] using iTunesArtwork."); +#endif + return [[NSImage alloc] initWithData:data]; + } + } + + // Extract image name from app plist + NSString *plistImgName = [self iconNameFromPlist:appPlist]; +#ifdef DEBUG + NSLog(@"[icon] icon name: %@", plistImgName); +#endif + if (plistImgName) { + // First, try if an image file with that name exists. + NSString *actualName = [self expandImageName:plistImgName]; + if (actualName) { +#ifdef DEBUG + NSLog(@"[icon] using plist with key %@ and image file %@", plistImgName, actualName); +#endif + if (_meta.type == FileTypeIPA) { + NSData *data = [_meta.zipFile unzipFile:[@"Payload/*.app/" stringByAppendingString:actualName]]; + return [[NSImage alloc] initWithData:data]; + } + NSURL *basePath = _meta.effectiveUrl ?: _meta.url; + return [[NSImage alloc] initWithContentsOfURL:[basePath URLByAppendingPathComponent:actualName]]; + } + + // Else: try Assets.car +#ifdef CUI_ENABLED + @try { + NSImage *img = [self imageFromAssetsCar:plistImgName]; + if (img) { + return img; + } + } @catch (NSException *exception) { + NSLog(@"ERROR: unknown private framework issue: %@", exception); + } +#endif + } + + // Fallback to default icon + NSURL *iconURL = [[NSBundle bundleWithIdentifier:kPluginBundleId] URLForResource:@"defaultIcon" withExtension:@"png"]; + return [[NSImage alloc] initWithContentsOfURL:iconURL]; +} + +#ifdef CUI_ENABLED + +/// Use @c CUICatalog to extract an image from @c Assets.car +- (NSImage * _Nullable)imageFromAssetsCar:(NSString *)imageName { + NSData *data = readPayloadFile(_meta, @"Assets.car"); + if (!data) { + return nil; + } + NSError *err; + CUICatalog *catalog = [[CUICatalog alloc] initWithBytes:[data bytes] length:data.length error:&err]; + if (err) { + NSLog(@"[icon-car] ERROR: could not open catalog: %@", err); + return nil; + } + NSString *validName = [self carVerifyNameExists:imageName inCatalog:catalog]; + if (validName) { + CUINamedImage *bestImage = [self carFindHighestResolutionIcon:[catalog imagesWithName:validName]]; + if (bestImage) { +#ifdef DEBUG + NSLog(@"[icon] using Assets.car with key %@", validName); +#endif + return [[NSImage alloc] initWithCGImage:bestImage.image size:bestImage.size]; + } + } + return nil; +} + + +// MARK: - Helper: Assets.car + +/// Helper method to check available icon names. Will return a valid name or @c nil if no image with that key is found. +- (NSString * _Nullable)carVerifyNameExists:(NSString *)imageName inCatalog:(CUICatalog *)catalog { + NSArray *availableNames = nil; + @try { + availableNames = [catalog allImageNames]; + } @catch (NSException *exception) { + NSLog(@"[icon-car] ERROR: method allImageNames unavailable: %@", exception); + // fallback to use the provided imageName just in case it may still proceed. + } + if (availableNames && ![availableNames containsObject:imageName]) { + // Theoretically this should never happen. Assuming the image name is found in an image file. + NSLog(@"[icon-car] WARN: key '%@' does not match any available key", imageName); + NSString *alternativeName = [self carSearchAlternativeName:imageName inAvailable:availableNames]; + if (alternativeName) { + NSLog(@"[icon-car] falling back to '%@'", alternativeName); + return alternativeName; + } + // NSLog(@"[icon-car] available keys: %@", [car allImageNames]); + return nil; + } + return imageName; +} + +/// If exact name does not exist in catalog, search for a name that shares the same prefix. +/// E.g., "AppIcon60x60" may match "AppIcon" or "AppIcon60x60_small" +- (NSString * _Nullable)carSearchAlternativeName:(NSString *)originalName inAvailable:(NSArray *)availableNames { + NSString *bestOption = nil; + NSUInteger bestDiff = 999; + for (NSString *option in availableNames) { + if ([option hasPrefix:originalName] || [originalName hasPrefix:option]) { + NSUInteger thisDiff = MAX(originalName.length, option.length) - MIN(originalName.length, option.length); + if (thisDiff < bestDiff) { + bestDiff = thisDiff; + bestOption = option; + } + } + } + return bestOption; +} + +/// Given a list of @c CUINamedImage, return the one with the highest resolution. Vector graphics are ignored. +- (CUINamedImage * _Nullable)carFindHighestResolutionIcon:(NSArray *)availableImages { + CGFloat largestWidth = 0; + CUINamedImage *largestImage = nil; + for (CUINamedImage *img in availableImages) { + if (![img isKindOfClass:[CUINamedImage class]]) { + continue; // ignore CUINamedMultisizeImageSet + } + @try { + CGFloat w = img.size.width; + if (w > largestWidth) { + largestWidth = w; + largestImage = img; + } + } @catch (NSException *exception) { + continue; + } + } + return largestImage; +} + +#endif + + +// MARK: - Helper: Plist Filename + +/// Parse app plist to find the bundle icon filename. +/// @param appPlist If @c nil, will load plist on the fly (used for thumbnail) +/// @return Filename which is available in Bundle or Filesystem. This may include @c @2x and an arbitrary file extension. +- (NSString * _Nullable)iconNameFromPlist:(NSDictionary *)appPlist { + if (!appPlist) { + appPlist = readPlistApp(_meta); + } + //Check for CFBundleIcons (since 5.0) + NSArray *icons = [self unpackNameListFromPlistDict:appPlist[@"CFBundleIcons"]]; + if (!icons) { + icons = [self unpackNameListFromPlistDict:appPlist[@"CFBundleIcons~ipad"]]; + if (!icons) { + //Check for CFBundleIconFiles (since 3.2) + icons = arrayOrNil(appPlist[@"CFBundleIconFiles"]); + if (!icons) { + icons = arrayOrNil(appPlist[@"Icon files"]); // key found on iTunesU app + if (!icons) { + //Check for CFBundleIconFile (legacy, before 3.2) + return appPlist[@"CFBundleIconFile"]; // may be nil + } + } + } + } + return [self findHighestResolutionIconName:icons]; +} + +/// Given a filename, search Bundle or Filesystem for files that match. Select the filename with the highest resolution. +- (NSString * _Nullable)expandImageName:(NSString * _Nullable)fileName { + if (!fileName) { + return nil; + } + NSArray *matchingNames = nil; + if (_meta.type == FileTypeIPA) { + if (!_meta.zipFile) { + // in case unzip in memory is not available, fallback to pattern matching with dynamic suffix + return [fileName stringByAppendingString:@"*"]; + } + NSString *zipPath = [NSString stringWithFormat:@"Payload/*.app/%@*", fileName]; + NSMutableArray *matches = [NSMutableArray array]; + for (ZipEntry *zip in [_meta.zipFile filesMatching:zipPath]) { + [matches addObject:[zip.filepath lastPathComponent]]; + } + matchingNames = matches; + } else if (_meta.type == FileTypeArchive || _meta.type == FileTypeExtension) { + NSURL *basePath = _meta.effectiveUrl ?: _meta.url; + NSArray *appContents = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:basePath.path error:nil]; + NSPredicate *predicate = [NSPredicate predicateWithFormat:@"SELF beginswith %@", fileName]; + matchingNames = [appContents filteredArrayUsingPredicate:predicate]; + } + if (matchingNames.count > 0) { + return [self findHighestResolutionIconName:matchingNames]; + } + return nil; +} + +/// Deep select icons from plist key @c CFBundleIcons and @c CFBundleIcons~ipad +- (NSArray * _Nullable)unpackNameListFromPlistDict:(NSDictionary *)bundleDict { + if ([bundleDict isKindOfClass:[NSDictionary class]]) { + NSDictionary *primaryDict = [bundleDict objectForKey:@"CFBundlePrimaryIcon"]; + if ([primaryDict isKindOfClass:[NSDictionary class]]) { + NSArray *icons = [primaryDict objectForKey:@"CFBundleIconFiles"]; + if ([icons isKindOfClass:[NSArray class]]) { + return icons; + } + NSString *name = [primaryDict objectForKey:@"CFBundleIconName"]; // key found on a .tipa file + if ([name isKindOfClass:[NSString class]]) { + return @[name]; + } + } + } + return nil; +} + +/// Given a list of filenames, try to find the one with the highest resolution +- (NSString *)findHighestResolutionIconName:(NSArray *)icons { + for (NSString *match in @[@"@3x", @"@2x", @"180", @"167", @"152", @"120"]) { + for (NSString *icon in icons) { + if ([icon containsString:match]) { + return icon; + } + } + } + //If no one matches any pattern, just take last item + NSString *lastName = [icons lastObject]; + if ([[lastName lowercaseString] containsString:@"small"]) { + return [icons firstObject]; + } + return lastName; +} + +@end + + +// MARK: - Extension: NSBezierPath + +// +// NSBezierPath+IOS7RoundedRect +// +// Created by Matej Dunik on 11/12/13. +// Copyright (c) 2013 PixelCut. All rights reserved except as below: +// This code is provided as-is, without warranty of any kind. You may use it in your projects as you wish. +// + +@implementation NSBezierPath (IOS7RoundedRect) + +#define TOP_LEFT(X, Y) NSMakePoint(rect.origin.x + X * limitedRadius, rect.origin.y + Y * limitedRadius) +#define TOP_RIGHT(X, Y) NSMakePoint(rect.origin.x + rect.size.width - X * limitedRadius, rect.origin.y + Y * limitedRadius) +#define BOTTOM_RIGHT(X, Y) NSMakePoint(rect.origin.x + rect.size.width - X * limitedRadius, rect.origin.y + rect.size.height - Y * limitedRadius) +#define BOTTOM_LEFT(X, Y) NSMakePoint(rect.origin.x + X * limitedRadius, rect.origin.y + rect.size.height - Y * limitedRadius) + +/// iOS 7 rounded corners ++ (NSBezierPath *)bezierPathWithIOS7RoundedRect:(NSRect)rect cornerRadius:(CGFloat)radius { + NSBezierPath *path = NSBezierPath.bezierPath; + CGFloat limit = MIN(rect.size.width, rect.size.height) / 2 / 1.52866483; + CGFloat limitedRadius = MIN(radius, limit); + + [path moveToPoint: TOP_LEFT(1.52866483, 0.00000000)]; + [path lineToPoint: TOP_RIGHT(1.52866471, 0.00000000)]; + [path curveToPoint: TOP_RIGHT(0.66993427, 0.06549600) controlPoint1: TOP_RIGHT(1.08849323, 0.00000000) controlPoint2: TOP_RIGHT(0.86840689, 0.00000000)]; + [path lineToPoint: TOP_RIGHT(0.63149399, 0.07491100)]; + [path curveToPoint: TOP_RIGHT(0.07491176, 0.63149399) controlPoint1: TOP_RIGHT(0.37282392, 0.16905899) controlPoint2: TOP_RIGHT(0.16906013, 0.37282401)]; + [path curveToPoint: TOP_RIGHT(0.00000000, 1.52866483) controlPoint1: TOP_RIGHT(0.00000000, 0.86840701) controlPoint2: TOP_RIGHT(0.00000000, 1.08849299)]; + [path lineToPoint: BOTTOM_RIGHT(0.00000000, 1.52866471)]; + [path curveToPoint: BOTTOM_RIGHT(0.06549569, 0.66993493) controlPoint1: BOTTOM_RIGHT(0.00000000, 1.08849323) controlPoint2: BOTTOM_RIGHT(0.00000000, 0.86840689)]; + [path lineToPoint: BOTTOM_RIGHT(0.07491111, 0.63149399)]; + [path curveToPoint: BOTTOM_RIGHT(0.63149399, 0.07491111) controlPoint1: BOTTOM_RIGHT(0.16905883, 0.37282392) controlPoint2: BOTTOM_RIGHT(0.37282392, 0.16905883)]; + [path curveToPoint: BOTTOM_RIGHT(1.52866471, 0.00000000) controlPoint1: BOTTOM_RIGHT(0.86840689, 0.00000000) controlPoint2: BOTTOM_RIGHT(1.08849323, 0.00000000)]; + [path lineToPoint: BOTTOM_LEFT(1.52866483, 0.00000000)]; + [path curveToPoint: BOTTOM_LEFT(0.66993397, 0.06549569) controlPoint1: BOTTOM_LEFT(1.08849299, 0.00000000) controlPoint2: BOTTOM_LEFT(0.86840701, 0.00000000)]; + [path lineToPoint: BOTTOM_LEFT(0.63149399, 0.07491111)]; + [path curveToPoint: BOTTOM_LEFT(0.07491100, 0.63149399) controlPoint1: BOTTOM_LEFT(0.37282401, 0.16905883) controlPoint2: BOTTOM_LEFT(0.16906001, 0.37282392)]; + [path curveToPoint: BOTTOM_LEFT(0.00000000, 1.52866471) controlPoint1: BOTTOM_LEFT(0.00000000, 0.86840689) controlPoint2: BOTTOM_LEFT(0.00000000, 1.08849323)]; + [path lineToPoint: TOP_LEFT(0.00000000, 1.52866483)]; + [path curveToPoint: TOP_LEFT(0.06549600, 0.66993397) controlPoint1: TOP_LEFT(0.00000000, 1.08849299) controlPoint2: TOP_LEFT(0.00000000, 0.86840701)]; + [path lineToPoint: TOP_LEFT(0.07491100, 0.63149399)]; + [path curveToPoint: TOP_LEFT(0.63149399, 0.07491100) controlPoint1: TOP_LEFT(0.16906001, 0.37282401) controlPoint2: TOP_LEFT(0.37282401, 0.16906001)]; + [path curveToPoint: TOP_LEFT(1.52866483, 0.00000000) controlPoint1: TOP_LEFT(0.86840701, 0.00000000) controlPoint2: TOP_LEFT(1.08849299, 0.00000000)]; + [path closePath]; + return path; +} + +@end + + +// MARK: - Extension: NSImage + + +@implementation NSImage (AppIcon) + +/// Apply rounded corners to image (iOS7 style) +- (NSImage * _Nonnull)withRoundCorners { + NSSize existingSize = [self size]; + NSImage *composedImage = [[NSImage alloc] initWithSize:existingSize]; + + [composedImage lockFocus]; + [[NSGraphicsContext currentContext] setImageInterpolation:NSImageInterpolationHigh]; + + NSRect imageFrame = NSRectFromCGRect(CGRectMake(0, 0, existingSize.width, existingSize.height)); + NSBezierPath *clipPath = [NSBezierPath bezierPathWithIOS7RoundedRect:imageFrame cornerRadius:existingSize.width * 0.225]; + [clipPath setWindingRule:NSWindingRuleEvenOdd]; + [clipPath addClip]; + + [self drawAtPoint:NSZeroPoint fromRect:NSMakeRect(0, 0, existingSize.width, existingSize.height) operation:NSCompositingOperationSourceOver fraction:1]; + [composedImage unlockFocus]; + return composedImage; +} + +/// Convert image to PNG and encode with base64 to be embeded in html output. +- (NSString * _Nonnull)asBase64 { + // appIcon = [self roundCorners:appIcon]; + NSData *imageData = [self TIFFRepresentation]; + NSBitmapImageRep *imageRep = [NSBitmapImageRep imageRepWithData:imageData]; + imageData = [imageRep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; + return [imageData base64EncodedStringWithOptions:0]; +} + +/// If the image is larger than the provided maximum size, scale it down. Otherwise leave it untouched. +- (void)downscale:(CGSize)maxSize { + // TODO: if downscale, then this should respect retina resolution + if (self.size.width > maxSize.width && self.size.height > maxSize.height) { + [self setSize:maxSize]; + } +} + +@end diff --git a/ProvisionQL/GeneratePreviewForURL.m b/ProvisionQL/GeneratePreviewForURL.m index 99314f8..c636931 100644 --- a/ProvisionQL/GeneratePreviewForURL.m +++ b/ProvisionQL/GeneratePreviewForURL.m @@ -1,5 +1,6 @@ #import "Shared.h" #import "AppCategories.h" +#import "AppIcon.h" #import "Entitlements.h" // makro to stop further processing @@ -72,15 +73,6 @@ void recursiveDictWithReplacements(NSDictionary *dictionary, NSDictionary *repla return stringToEscape; } -/// Convert image to PNG and encode with base64 to be embeded in html output. -NSString * _Nonnull iconAsBase64(NSImage *appIcon) { - appIcon = roundCorners(appIcon); - NSData *imageData = [appIcon TIFFRepresentation]; - NSBitmapImageRep *imageRep = [NSBitmapImageRep imageRepWithData:imageData]; - imageData = [imageRep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; - return [imageData base64EncodedStringWithOptions:0]; -} - // MARK: - Date processing @@ -618,10 +610,6 @@ OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, [infoLayer addEntriesFromDictionary:procProvision(plistProvision, meta.isOSX)]; ALLOW_EXIT - // App Icon - infoLayer[@"AppIcon"] = iconAsBase64(imageFromApp(meta, plistApp)); - ALLOW_EXIT - // Entitlements [infoLayer addEntriesFromDictionary:procEntitlements(meta, plistApp, plistProvision)]; ALLOW_EXIT @@ -633,6 +621,13 @@ OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, [infoLayer addEntriesFromDictionary:procFooterInfo()]; ALLOW_EXIT + // App Icon (last, because the image uses a lot of memory) + AppIcon *icon = [AppIcon load:meta]; + if (icon.canExtractImage) { + infoLayer[@"AppIcon"] = [[[icon extractImage:plistApp] withRoundCorners] asBase64]; + ALLOW_EXIT + } + // prepare html, replace values NSString *html = applyHtmlTemplate(infoLayer); diff --git a/ProvisionQL/GenerateThumbnailForURL.m b/ProvisionQL/GenerateThumbnailForURL.m index b20ad99..a94be2e 100644 --- a/ProvisionQL/GenerateThumbnailForURL.m +++ b/ProvisionQL/GenerateThumbnailForURL.m @@ -1,4 +1,5 @@ #import "Shared.h" +#import "AppIcon.h" // makro to stop further processing #define ALLOW_EXIT if (QLThumbnailRequestIsCancelled(thumbnail)) { return noErr; } @@ -30,17 +31,10 @@ // MARK: .ipa .xcarchive OSStatus renderAppIcon(QuickLookInfo meta, QLThumbnailRequestRef thumbnail) { - NSImage *appIcon = imageFromApp(meta, nil); - ALLOW_EXIT - - // if downscale, then this should respect retina resolution -// CGSize maxSize = QLThumbnailRequestGetMaximumSize(thumbnail); -// if (appIcon.size.width > maxSize.width && appIcon.size.height > maxSize.height) { -// [appIcon setSize:maxSize]; -// } - - appIcon = roundCorners(appIcon); - ALLOW_EXIT + AppIcon *icon = [AppIcon load:meta]; + if (!icon.canExtractImage) { + return noErr; + } // set magic flag to draw icon without additional markers static const NSString *IconFlavor; @@ -58,6 +52,9 @@ OSStatus renderAppIcon(QuickLookInfo meta, QLThumbnailRequestRef thumbnail) { propertiesDict = @{IconFlavor : @(0)}; // no border, no anything } + NSImage *appIcon = [[icon extractImage:nil] withRoundCorners]; + ALLOW_EXIT + // image-only icons can be drawn efficiently by calling `SetImage` directly. QLThumbnailRequestSetImageWithData(thumbnail, (__bridge CFDataRef)[appIcon TIFFRepresentation], (__bridge CFDictionaryRef)propertiesDict); return noErr; @@ -147,12 +144,12 @@ OSStatus GenerateThumbnailForURL(void *thisInterface, QLThumbnailRequestRef thum @autoreleasepool { QuickLookInfo meta = initQLInfo(contentTypeUTI, url); - if (meta.type == FileTypeIPA || meta.type == FileTypeArchive) { - return renderAppIcon(meta, thumbnail); - } else if (meta.type == FileTypeProvision) { + if (meta.type == FileTypeProvision) { NSDictionary *optionsDict = (__bridge NSDictionary *)options; BOOL iconMode = ([optionsDict objectForKey:(NSString *)kQLThumbnailOptionIconModeKey]) ? YES : NO; return renderProvision(meta, thumbnail, iconMode); + } else { + return renderAppIcon(meta, thumbnail); } } return noErr; diff --git a/ProvisionQL/Shared.h b/ProvisionQL/Shared.h index 6e572cf..a372b5c 100644 --- a/ProvisionQL/Shared.h +++ b/ProvisionQL/Shared.h @@ -6,8 +6,6 @@ #import #import -#import - #import "ZipFile.h" static NSString * _Nonnull const kPluginBundleId = @"com.ealeksandrov.ProvisionQL"; @@ -39,6 +37,7 @@ typedef struct QuickLookMeta { QuickLookInfo initQLInfo(_Nonnull CFStringRef contentTypeUTI, _Nonnull CFURLRef url); // Plist +NSData * _Nullable readPayloadFile(QuickLookInfo meta, NSString * _Nonnull filename); NSDictionary * _Nullable readPlistApp(QuickLookInfo meta); NSDictionary * _Nullable readPlistProvision(QuickLookInfo meta); NSDictionary * _Nullable readPlistItunes(QuickLookInfo meta); @@ -52,7 +51,3 @@ typedef NS_ENUM(NSUInteger, ExpirationStatus) { ExpirationStatus expirationStatus(NSDate * _Nullable date); NSDate * _Nullable dateOrNil(NSDate * _Nullable value); NSArray * _Nullable arrayOrNil(NSArray * _Nullable value); - -// App Icon -NSImage * _Nonnull roundCorners(NSImage * _Nonnull image); -NSImage * _Nonnull imageFromApp(QuickLookInfo meta, NSDictionary * _Nullable appPlist); diff --git a/ProvisionQL/Shared.m b/ProvisionQL/Shared.m index 5f6c63a..23bb0e0 100644 --- a/ProvisionQL/Shared.m +++ b/ProvisionQL/Shared.m @@ -131,103 +131,3 @@ ExpirationStatus expirationStatus(NSDate *date) { inline NSArray * _Nullable arrayOrNil(NSArray * _Nullable value) { return [value isKindOfClass:[NSArray class]] ? value : nil; } - - -// MARK: - App Icon - -/// Apply rounded corners to image (iOS7 style) -NSImage * _Nonnull roundCorners(NSImage *image) { - NSImage *existingImage = image; - NSSize existingSize = [existingImage size]; - NSImage *composedImage = [[NSImage alloc] initWithSize:existingSize]; - - [composedImage lockFocus]; - [[NSGraphicsContext currentContext] setImageInterpolation:NSImageInterpolationHigh]; - - NSRect imageFrame = NSRectFromCGRect(CGRectMake(0, 0, existingSize.width, existingSize.height)); - NSBezierPath *clipPath = [NSBezierPath bezierPathWithIOS7RoundedRect:imageFrame cornerRadius:existingSize.width * 0.225]; - [clipPath setWindingRule:NSWindingRuleEvenOdd]; - [clipPath addClip]; - - [image drawAtPoint:NSZeroPoint fromRect:NSMakeRect(0, 0, existingSize.width, existingSize.height) operation:NSCompositingOperationSourceOver fraction:1]; - - [composedImage unlockFocus]; - - return composedImage; -} - -/// Given a list of filenames, try to find the one with the highest resolution -NSString *selectBestIcon(NSArray *icons) { - for (NSString *match in @[@"@3x", @"@2x", @"180", @"167", @"152", @"120"]) { - for (NSString *icon in icons) { - if ([icon containsString:match]) { - return icon; - } - } - } - //If no one matches any pattern, just take last item - return [icons lastObject]; -} - -/// Deep select icons from plist key @c CFBundleIcons and @c CFBundleIcons~ipad -NSArray * _Nullable iconsListForDictionary(NSDictionary *bundleDict) { - if ([bundleDict isKindOfClass:[NSDictionary class]]) { - NSDictionary *primaryDict = [bundleDict objectForKey:@"CFBundlePrimaryIcon"]; - if ([primaryDict isKindOfClass:[NSDictionary class]]) { - NSArray *icons = [primaryDict objectForKey:@"CFBundleIconFiles"]; - if ([icons isKindOfClass:[NSArray class]]) { - return icons; - } - } - } - return nil; -} - -/// Parse app plist to find the bundle icon filename. -NSString * _Nullable mainIconNameForApp(NSDictionary *appPlist) { - //Check for CFBundleIcons (since 5.0) - NSArray *icons = iconsListForDictionary(appPlist[@"CFBundleIcons"]); - if (!icons) { - icons = iconsListForDictionary(appPlist[@"CFBundleIcons~ipad"]); - if (!icons) { - //Check for CFBundleIconFiles (since 3.2) - icons = arrayOrNil(appPlist[@"CFBundleIconFiles"]); - if (!icons) { - //Check for CFBundleIconFile (legacy, before 3.2) - return appPlist[@"CFBundleIconFile"]; // may be nil - } - } - } - return selectBestIcon(icons); -} - -/// Depending on the file type, find the icon within the bundle -/// @param appPlist If @c nil, will load plist on the fly (used for thumbnail) -NSImage * _Nonnull imageFromApp(QuickLookInfo meta, NSDictionary *appPlist) { - if (meta.type == FileTypeIPA) { - NSData *data = [meta.zipFile unzipFile:@"iTunesArtwork"]; - if (!data) { - NSString *fileName = mainIconNameForApp(appPlist ?: readPlistApp(meta)); - if (fileName) { - data = [meta.zipFile unzipFile:[NSString stringWithFormat:@"Payload/*.app/%@*", fileName]]; - } - // TODO: load assets.car - } - if (data) { - return [[NSImage alloc] initWithData:data]; - } - } else if (meta.type == FileTypeArchive) { - // get the embedded icon for the iOS app - NSString *fileName = mainIconNameForApp(appPlist ?: readPlistApp(meta)); - NSArray *appContents = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:meta.effectiveUrl.path error:nil]; - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"SELF contains %@", fileName]; - NSString *matchedName = [appContents filteredArrayUsingPredicate:predicate].lastObject; - if (matchedName) { - NSURL *appIconFullURL = [meta.effectiveUrl URLByAppendingPathComponent:matchedName]; - return [[NSImage alloc] initWithContentsOfURL:appIconFullURL]; - } - } - // Fallback to default icon - NSURL *iconURL = [[NSBundle bundleWithIdentifier:kPluginBundleId] URLForResource:@"defaultIcon" withExtension:@"png"]; - return [[NSImage alloc] initWithContentsOfURL:iconURL]; -} diff --git a/ProvisionQL/ZipFile.h b/ProvisionQL/ZipFile.h index f87d33b..c178423 100644 --- a/ProvisionQL/ZipFile.h +++ b/ProvisionQL/ZipFile.h @@ -1,4 +1,5 @@ #import +@class ZipEntry; NS_ASSUME_NONNULL_BEGIN @@ -6,6 +7,7 @@ NS_ASSUME_NONNULL_BEGIN + (instancetype)open:(NSString *)path; - (NSData * _Nullable)unzipFile:(NSString *)filePath; - (void)unzipFile:(NSString *)filePath toDir:(NSString *)targetDir; +- (NSArray * _Nullable)filesMatching:(NSString * _Nonnull)path; @end NS_ASSUME_NONNULL_END diff --git a/ProvisionQL/ZipFile.m b/ProvisionQL/ZipFile.m index 9fe9d97..4ad3727 100644 --- a/ProvisionQL/ZipFile.m +++ b/ProvisionQL/ZipFile.m @@ -25,6 +25,14 @@ - (instancetype)initWithFile:(NSString *)path { // MARK: - public methods +- (NSArray * _Nullable)filesMatching:(NSString * _Nonnull)path { + if (self.centralDirectory) { + NSPredicate *pred = [NSPredicate predicateWithFormat:@"filepath LIKE %@", path]; + return [self.centralDirectory filteredArrayUsingPredicate:pred]; + } + return nil; +} + /// Unzip file directly into memory. /// @param filePath File path inside zip file. - (NSData * _Nullable)unzipFile:(NSString *)filePath { From 66226c9d7fb73dd5e918b9d70cbc32df0bb59513 Mon Sep 17 00:00:00 2001 From: relikd Date: Sun, 4 Feb 2024 21:53:03 +0100 Subject: [PATCH 29/32] chore: script to test ProvisionQL on multiple files --- ProvisionQL/Scripts/test_generate_all.sh | 61 ++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100755 ProvisionQL/Scripts/test_generate_all.sh diff --git a/ProvisionQL/Scripts/test_generate_all.sh b/ProvisionQL/Scripts/test_generate_all.sh new file mode 100755 index 0000000..4e00d23 --- /dev/null +++ b/ProvisionQL/Scripts/test_generate_all.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# Usage: this_script ~/Downloads/test_IPAs/*.ipa + +OUT_DIR=gen_output + +ql() { + # QL_dir=$HOME/Library/QuickLook + QL_dir=$(dirname "$HOME/Library/Developer/Xcode/DerivedData/ProvisionQL-"*"/Build/Products/Debug/.") + QL_generator="$QL_dir/ProvisionQL.qlgenerator" + QL_type=$(mdls -raw -name kMDItemContentType "$1") + qlmanage -g "$QL_generator" -c "$QL_type" "$@" 1> /dev/null +} + +thumb() { + echo + echo "=== Thumbnail: $1 ===" + ql "$1" -t -i -s 1024 -o "$OUT_DIR" + bn=$(basename "$1") + mv "$OUT_DIR/$bn.png" "$OUT_DIR/t_$bn.png" +} + +preview() { + echo + echo "=== Preview: $1 ===" + ql "$1" -p -o "$OUT_DIR" + bn=$(basename "$1") + mv "$OUT_DIR/$bn.qlpreview/Preview.html" "$OUT_DIR/p_$bn.html" + rm -rf "$OUT_DIR/$bn.qlpreview" +} + +fn() { + thumb "$1" + preview "$1" +} + + + +mkdir -p "$OUT_DIR" + +for file in "$@"; do + if [ -e "$file" ]; then + fn "$file" + fi +done + +echo 'done.' + +# fn 'a.appex' +# fn 'a.xcarchive' +# fn 'a.mobileprovision' + +# for x in *.ipa; do +# fn "$x" +# done +# fn 'a.ipa' +# fn 'aa.ipa' +# fn 'at.ipa' +# fn '10.Flight.Control-v1.9.ipa' +# fn 'Labyrinth 2 HD.ipa' +# fn 'Plague Inc. 1.10.1.ipa' +# fn 'iTunes U 1.3.1.ipa' From 5e153057191e22344a3548f0ab984dcaf950f235 Mon Sep 17 00:00:00 2001 From: relikd Date: Sun, 21 Jan 2024 13:51:57 +0100 Subject: [PATCH 30/32] feat: support for .tipa files --- ProvisionQL/Supporting-files/Info.plist | 1 + 1 file changed, 1 insertion(+) diff --git a/ProvisionQL/Supporting-files/Info.plist b/ProvisionQL/Supporting-files/Info.plist index bf2605b..70455d5 100644 --- a/ProvisionQL/Supporting-files/Info.plist +++ b/ProvisionQL/Supporting-files/Info.plist @@ -17,6 +17,7 @@ com.apple.provisionprofile com.apple.application-and-system-extension com.apple.xcode.archive + dyn.ah62d4rv4ge81k4puqe From bae1e20f62600ec8d701a4fd91793734016c3c39 Mon Sep 17 00:00:00 2001 From: relikd Date: Mon, 22 Jan 2024 17:55:52 +0100 Subject: [PATCH 31/32] feat: add non-dyn tipa type --- ProvisionQL/Supporting-files/Info.plist | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/ProvisionQL/Supporting-files/Info.plist b/ProvisionQL/Supporting-files/Info.plist index 70455d5..140dea4 100644 --- a/ProvisionQL/Supporting-files/Info.plist +++ b/ProvisionQL/Supporting-files/Info.plist @@ -17,6 +17,7 @@ com.apple.provisionprofile com.apple.application-and-system-extension com.apple.xcode.archive + com.opa334.trollstore.tipa dyn.ah62d4rv4ge81k4puqe @@ -63,5 +64,27 @@ QLThumbnailMinimumSize 16 + UTImportedTypeDeclarations + + + UTTypeIdentifier + com.opa334.trollstore.tipa + UTTypeDescription + AirDrop friendly iOS app + UTTypeConformsTo + + public.data + + UTTypeTagSpecification + + public.filename-extension + + tipa + + public.mime-type + application/trollstore-ipa + + + From 6671156df0b383ceb7389f272d2f4369122b1783 Mon Sep 17 00:00:00 2001 From: relikd Date: Mon, 5 Feb 2024 15:32:10 +0100 Subject: [PATCH 32/32] feat: treat .tipa as .ipa --- ProvisionQL/Shared.h | 4 ++++ ProvisionQL/Shared.m | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ProvisionQL/Shared.h b/ProvisionQL/Shared.h index a372b5c..78b66ef 100644 --- a/ProvisionQL/Shared.h +++ b/ProvisionQL/Shared.h @@ -16,6 +16,10 @@ static NSString * _Nonnull const kDataType_osx_provision = @"com.apple.provi static NSString * _Nonnull const kDataType_xcode_archive = @"com.apple.xcode.archive"; static NSString * _Nonnull const kDataType_app_extension = @"com.apple.application-and-system-extension"; +// 3rd party ipa-like file extensions +static NSString * _Nonnull const kDataType_trollstore_ipa = @"com.opa334.trollstore.tipa"; +static NSString * _Nonnull const kDataType_trollstore_ipa_dyn = @"dyn.ah62d4rv4ge81k4puqe"; + // Init QuickLook Type typedef NS_ENUM(NSUInteger, FileType) { FileTypeIPA = 1, diff --git a/ProvisionQL/Shared.m b/ProvisionQL/Shared.m index 23bb0e0..0d308db 100644 --- a/ProvisionQL/Shared.m +++ b/ProvisionQL/Shared.m @@ -21,7 +21,10 @@ QuickLookInfo initQLInfo(CFStringRef contentTypeUTI, CFURLRef url) { data.UTI = (__bridge NSString *)contentTypeUTI; data.url = (__bridge NSURL *)url; - if ([data.UTI isEqualToString:kDataType_ipa]) { + if ([data.UTI isEqualToString:kDataType_ipa] + // for now, treat .tipa as if it were a normal .ipa file. + || [data.UTI isEqualToString:kDataType_trollstore_ipa] || [data.UTI isEqualToString:kDataType_trollstore_ipa_dyn]) + { data.type = FileTypeIPA; data.zipFile = [ZipFile open:data.url.path]; } else if ([data.UTI isEqualToString:kDataType_xcode_archive]) {