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 100644 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 100644 index 0000000..8da0f0a --- /dev/null +++ b/PrivateFrameworks/CoreUI.framework/Versions/A/Headers/CUINamedImage.h @@ -0,0 +1,54 @@ +#import +#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 100644 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 100644 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/QLApps.xcodeproj/project.pbxproj b/QLApps.xcodeproj/project.pbxproj index e911190..6140208 100644 --- a/QLApps.xcodeproj/project.pbxproj +++ b/QLApps.xcodeproj/project.pbxproj @@ -6,17 +6,89 @@ objectVersion = 77; objects = { -/* Begin PBXFileReference section */ - 54442BF42E378B71008A870E /* QLApps.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QLApps.app; sourceTree = BUILT_PRODUCTS_DIR; }; -/* End PBXFileReference section */ +/* Begin PBXBuildFile section */ + 5405CF5C2EA1191A00613856 /* PreviewGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5405CF5B2EA1191A00613856 /* PreviewGenerator.swift */; }; + 5405CF5E2EA1199B00613856 /* Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5405CF5D2EA1199B00613856 /* Shared.swift */; }; + 5405CF652EA1376B00613856 /* Zip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5405CF642EA1376B00613856 /* Zip.swift */; }; + 54442C232E378BAF008A870E /* Quartz.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54442C222E378BAF008A870E /* Quartz.framework */; }; + 54442C302E378BAF008A870E /* QLPreview.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 54442C202E378BAF008A870E /* QLPreview.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 54442C702E378BDD008A870E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54442C6A2E378BDD008A870E /* AppDelegate.swift */; }; + 54442C712E378BDD008A870E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 54442C6B2E378BDD008A870E /* Assets.xcassets */; }; + 54442C722E378BDD008A870E /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 54442C6D2E378BDD008A870E /* MainMenu.xib */; }; + 54442C792E378BE0008A870E /* PreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54442C742E378BE0008A870E /* PreviewViewController.swift */; }; + 54442C7B2E378BE0008A870E /* PreviewViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 54442C762E378BE0008A870E /* PreviewViewController.xib */; }; + 545459C42EA469E4002892E5 /* defaultIcon.png in Resources */ = {isa = PBXBuildFile; fileRef = 54D3A6F22EA4603B001EF4F6 /* defaultIcon.png */; }; + 545459C52EA469EA002892E5 /* template.html in Resources */ = {isa = PBXBuildFile; fileRef = 54D3A6F32EA4603B001EF4F6 /* template.html */; }; + 545459C72EA4773A002892E5 /* AppIcon+Car.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545459C62EA4773A002892E5 /* AppIcon+Car.swift */; }; + 545459C92EA47C37002892E5 /* Plist.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545459C82EA47C37002892E5 /* Plist.swift */; }; + 5469E11D2EA5930C00D46CE7 /* Entitlements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5469E11C2EA5930C00D46CE7 /* Entitlements.swift */; }; + 54D3A6EC2EA31B52001EF4F6 /* AppCategories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D3A6EB2EA31B52001EF4F6 /* AppCategories.swift */; }; + 54D3A6EE2EA39CC6001EF4F6 /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D3A6ED2EA39CC6001EF4F6 /* AppIcon.swift */; }; + 54D3A6F02EA3F49F001EF4F6 /* RoundedIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D3A6EF2EA3F49F001EF4F6 /* RoundedIcon.swift */; }; + 54D3A6F72EA46154001EF4F6 /* CoreUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54D3A6F52EA4610B001EF4F6 /* CoreUI.framework */; }; + 54D3A6FE2EA465B4001EF4F6 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 54D3A6FA2EA46588001EF4F6 /* CoreGraphics.framework */; }; +/* End PBXBuildFile section */ -/* Begin PBXFileSystemSynchronizedRootGroup section */ - 54442BF62E378B71008A870E /* QLApps */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = QLApps; - sourceTree = ""; +/* Begin PBXContainerItemProxy section */ + 54442C2E2E378BAF008A870E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 54442BEC2E378B71008A870E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 54442C1F2E378BAF008A870E; + remoteInfo = QLPreview; }; -/* End PBXFileSystemSynchronizedRootGroup section */ +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 54442C312E378BAF008A870E /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 54442C302E378BAF008A870E /* QLPreview.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 5405CF5B2EA1191A00613856 /* PreviewGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewGenerator.swift; sourceTree = ""; }; + 5405CF5D2EA1199B00613856 /* Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shared.swift; sourceTree = ""; }; + 5405CF642EA1376B00613856 /* Zip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zip.swift; sourceTree = ""; }; + 54442BF42E378B71008A870E /* QLApps.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QLApps.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 54442C202E378BAF008A870E /* QLPreview.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = QLPreview.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 54442C222E378BAF008A870E /* Quartz.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Quartz.framework; path = System/Library/Frameworks/Quartz.framework; sourceTree = SDKROOT; }; + 54442C6A2E378BDD008A870E /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 54442C6B2E378BDD008A870E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 54442C6C2E378BDD008A870E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 54442C6E2E378BDD008A870E /* QLApps.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = QLApps.entitlements; sourceTree = ""; }; + 54442C732E378BE0008A870E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 54442C742E378BE0008A870E /* PreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewViewController.swift; sourceTree = ""; }; + 54442C752E378BE0008A870E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/PreviewViewController.xib; sourceTree = ""; }; + 54442C772E378BE0008A870E /* QLPreview.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = QLPreview.entitlements; sourceTree = ""; }; + 545459C62EA4773A002892E5 /* AppIcon+Car.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppIcon+Car.swift"; sourceTree = ""; }; + 545459C82EA47C37002892E5 /* Plist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plist.swift; sourceTree = ""; }; + 5469E11C2EA5930C00D46CE7 /* Entitlements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Entitlements.swift; sourceTree = ""; }; + 5485EE362EB1460C009E3905 /* Network.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Network.framework; path = System/Library/Frameworks/Network.framework; sourceTree = SDKROOT; }; + 54D3A6E52EA31B13001EF4F6 /* ThumbnailGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailGenerator.swift; sourceTree = ""; }; + 54D3A6EB2EA31B52001EF4F6 /* AppCategories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCategories.swift; sourceTree = ""; }; + 54D3A6ED2EA39CC6001EF4F6 /* AppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcon.swift; sourceTree = ""; }; + 54D3A6EF2EA3F49F001EF4F6 /* RoundedIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedIcon.swift; sourceTree = ""; }; + 54D3A6F22EA4603B001EF4F6 /* defaultIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = defaultIcon.png; sourceTree = ""; }; + 54D3A6F32EA4603B001EF4F6 /* template.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = template.html; sourceTree = ""; }; + 54D3A6F52EA4610B001EF4F6 /* CoreUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = CoreUI.framework; sourceTree = ""; }; + 54D3A6FA2EA46588001EF4F6 /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; + 54D3A6FC2EA465A9001EF4F6 /* CoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreServices.framework; path = System/Library/Frameworks/CoreServices.framework; sourceTree = SDKROOT; }; + 54D3A6FF2EA465C4001EF4F6 /* AppKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppKit.framework; path = System/Library/Frameworks/AppKit.framework; sourceTree = SDKROOT; }; + 54E0874C2EB1488300979D91 /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; }; + 54E0874E2EB1489100979D91 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; + 54E087512EB148B700979D91 /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; + 54E087532EB148DB00979D91 /* QuickLook.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuickLook.framework; path = System/Library/Frameworks/QuickLook.framework; sourceTree = SDKROOT; }; + 54E087552EB148DF00979D91 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; + 54E087572EB148E700979D91 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 54442BF12E378B71008A870E /* Frameworks */ = { @@ -26,13 +98,45 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 54442C1D2E378BAF008A870E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 54D3A6FE2EA465B4001EF4F6 /* CoreGraphics.framework in Frameworks */, + 54442C232E378BAF008A870E /* Quartz.framework in Frameworks */, + 54D3A6F72EA46154001EF4F6 /* CoreUI.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 541051562E37AFC10083670B /* src */ = { + isa = PBXGroup; + children = ( + 54D3A6EB2EA31B52001EF4F6 /* AppCategories.swift */, + 54D3A6ED2EA39CC6001EF4F6 /* AppIcon.swift */, + 545459C62EA4773A002892E5 /* AppIcon+Car.swift */, + 54D3A6EF2EA3F49F001EF4F6 /* RoundedIcon.swift */, + 5405CF5D2EA1199B00613856 /* Shared.swift */, + 545459C82EA47C37002892E5 /* Plist.swift */, + 5405CF642EA1376B00613856 /* Zip.swift */, + 5469E11C2EA5930C00D46CE7 /* Entitlements.swift */, + 54D3A6E52EA31B13001EF4F6 /* ThumbnailGenerator.swift */, + 5405CF5B2EA1191A00613856 /* PreviewGenerator.swift */, + ); + path = src; + sourceTree = ""; + }; 54442BEB2E378B71008A870E = { isa = PBXGroup; children = ( - 54442BF62E378B71008A870E /* QLApps */, + 54D3A6F62EA4610B001EF4F6 /* PrivateFrameworks */, + 54D3A6F42EA46069001EF4F6 /* resources */, + 541051562E37AFC10083670B /* src */, + 54442C6F2E378BDD008A870E /* QLApps */, + 54442C782E378BE0008A870E /* QLPreview */, + 54442C212E378BAF008A870E /* Frameworks */, 54442BF52E378B71008A870E /* Products */, ); sourceTree = ""; @@ -41,10 +145,68 @@ isa = PBXGroup; children = ( 54442BF42E378B71008A870E /* QLApps.app */, + 54442C202E378BAF008A870E /* QLPreview.appex */, ); name = Products; sourceTree = ""; }; + 54442C212E378BAF008A870E /* Frameworks */ = { + isa = PBXGroup; + children = ( + 54E087572EB148E700979D91 /* WebKit.framework */, + 54E087552EB148DF00979D91 /* Security.framework */, + 54E087532EB148DB00979D91 /* QuickLook.framework */, + 54E087512EB148B700979D91 /* libz.tbd */, + 54E0874E2EB1489100979D91 /* CoreFoundation.framework */, + 54E0874C2EB1488300979D91 /* ApplicationServices.framework */, + 5485EE362EB1460C009E3905 /* Network.framework */, + 54D3A6FF2EA465C4001EF4F6 /* AppKit.framework */, + 54D3A6FC2EA465A9001EF4F6 /* CoreServices.framework */, + 54D3A6FA2EA46588001EF4F6 /* CoreGraphics.framework */, + 54442C222E378BAF008A870E /* Quartz.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 54442C6F2E378BDD008A870E /* QLApps */ = { + isa = PBXGroup; + children = ( + 54442C6A2E378BDD008A870E /* AppDelegate.swift */, + 54442C6B2E378BDD008A870E /* Assets.xcassets */, + 54442C6D2E378BDD008A870E /* MainMenu.xib */, + 54442C6E2E378BDD008A870E /* QLApps.entitlements */, + ); + path = QLApps; + sourceTree = ""; + }; + 54442C782E378BE0008A870E /* QLPreview */ = { + isa = PBXGroup; + children = ( + 54442C732E378BE0008A870E /* Info.plist */, + 54442C742E378BE0008A870E /* PreviewViewController.swift */, + 54442C762E378BE0008A870E /* PreviewViewController.xib */, + 54442C772E378BE0008A870E /* QLPreview.entitlements */, + ); + path = QLPreview; + sourceTree = ""; + }; + 54D3A6F42EA46069001EF4F6 /* resources */ = { + isa = PBXGroup; + children = ( + 54D3A6F22EA4603B001EF4F6 /* defaultIcon.png */, + 54D3A6F32EA4603B001EF4F6 /* template.html */, + ); + path = resources; + sourceTree = ""; + }; + 54D3A6F62EA4610B001EF4F6 /* PrivateFrameworks */ = { + isa = PBXGroup; + children = ( + 54D3A6F52EA4610B001EF4F6 /* CoreUI.framework */, + ); + path = PrivateFrameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -55,13 +217,12 @@ 54442BF02E378B71008A870E /* Sources */, 54442BF12E378B71008A870E /* Frameworks */, 54442BF22E378B71008A870E /* Resources */, + 54442C312E378BAF008A870E /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( - ); - fileSystemSynchronizedGroups = ( - 54442BF62E378B71008A870E /* QLApps */, + 54442C2F2E378BAF008A870E /* PBXTargetDependency */, ); name = QLApps; packageProductDependencies = ( @@ -70,6 +231,25 @@ productReference = 54442BF42E378B71008A870E /* QLApps.app */; productType = "com.apple.product-type.application"; }; + 54442C1F2E378BAF008A870E /* QLPreview */ = { + isa = PBXNativeTarget; + buildConfigurationList = 54442C352E378BAF008A870E /* Build configuration list for PBXNativeTarget "QLPreview" */; + buildPhases = ( + 54442C1C2E378BAF008A870E /* Sources */, + 54442C1D2E378BAF008A870E /* Frameworks */, + 54442C1E2E378BAF008A870E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = QLPreview; + packageProductDependencies = ( + ); + productName = QLPreview; + productReference = 54442C202E378BAF008A870E /* QLPreview.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -83,6 +263,9 @@ 54442BF32E378B71008A870E = { CreatedOnToolsVersion = 16.4; }; + 54442C1F2E378BAF008A870E = { + CreatedOnToolsVersion = 16.4; + }; }; }; buildConfigurationList = 54442BEF2E378B71008A870E /* Build configuration list for PBXProject "QLApps" */; @@ -100,6 +283,7 @@ projectRoot = ""; targets = ( 54442BF32E378B71008A870E /* QLApps */, + 54442C1F2E378BAF008A870E /* QLPreview */, ); }; /* End PBXProject section */ @@ -109,6 +293,18 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 54442C712E378BDD008A870E /* Assets.xcassets in Resources */, + 54442C722E378BDD008A870E /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 54442C1E2E378BAF008A870E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 545459C42EA469E4002892E5 /* defaultIcon.png in Resources */, + 545459C52EA469EA002892E5 /* template.html in Resources */, + 54442C7B2E378BE0008A870E /* PreviewViewController.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -119,11 +315,56 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 54442C702E378BDD008A870E /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 54442C1C2E378BAF008A870E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 54D3A6F02EA3F49F001EF4F6 /* RoundedIcon.swift in Sources */, + 5469E11D2EA5930C00D46CE7 /* Entitlements.swift in Sources */, + 54442C792E378BE0008A870E /* PreviewViewController.swift in Sources */, + 5405CF5C2EA1191A00613856 /* PreviewGenerator.swift in Sources */, + 54D3A6EE2EA39CC6001EF4F6 /* AppIcon.swift in Sources */, + 545459C92EA47C37002892E5 /* Plist.swift in Sources */, + 5405CF5E2EA1199B00613856 /* Shared.swift in Sources */, + 545459C72EA4773A002892E5 /* AppIcon+Car.swift in Sources */, + 54D3A6EC2EA31B52001EF4F6 /* AppCategories.swift in Sources */, + 5405CF652EA1376B00613856 /* Zip.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 54442C2F2E378BAF008A870E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 54442C1F2E378BAF008A870E /* QLPreview */; + targetProxy = 54442C2E2E378BAF008A870E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 54442C6D2E378BDD008A870E /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 54442C6C2E378BDD008A870E /* Base */, + ); + name = MainMenu.xib; + sourceTree = ""; + }; + 54442C762E378BE0008A870E /* PreviewViewController.xib */ = { + isa = PBXVariantGroup; + children = ( + 54442C752E378BE0008A870E /* Base */, + ); + name = PreviewViewController.xib; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + /* Begin XCBuildConfiguration section */ 54442BFF2E378B71008A870E /* Debug */ = { isa = XCBuildConfiguration; @@ -164,6 +405,7 @@ ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; + FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/PrivateFrameworks"; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -179,13 +421,17 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 15.5; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SYSTEM_FRAMEWORK_SEARCH_PATHS = ( + "$(SYSTEM_LIBRARY_DIR)/PrivateFrameworks", + "$(inherited)", + ); }; name = Debug; }; @@ -228,8 +474,10 @@ ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; + FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/PrivateFrameworks"; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = ""; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; @@ -237,11 +485,15 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 15.5; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; + SYSTEM_FRAMEWORK_SEARCH_PATHS = ( + "$(SYSTEM_LIBRARY_DIR)/PrivateFrameworks", + "$(inherited)", + ); }; name = Release; }; @@ -251,10 +503,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = QLApps/QLApps.entitlements; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = UY657LKNHJ; + CURRENT_PROJECT_VERSION = 641; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = UY657LKNHJ; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -267,6 +521,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = de.relikd.QLApps; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -279,10 +534,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = QLApps/QLApps.entitlements; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = UY657LKNHJ; + CURRENT_PROJECT_VERSION = 641; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = UY657LKNHJ; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -295,12 +552,71 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = de.relikd.QLApps; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Release; }; + 54442C322E378BAF008A870E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = QLPreview/QLPreview.entitlements; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 641; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = UY657LKNHJ; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = QLPreview/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = QLPreview; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.relikd.QLApps.QLPreview; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INCLUDE_PATHS = "$(inherited) $(SRCROOT)/PrivateFrameworks/CoreUI.framework"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 54442C332E378BAF008A870E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = QLPreview/QLPreview.entitlements; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 641; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = UY657LKNHJ; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = QLPreview/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = QLPreview; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.relikd.QLApps.QLPreview; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INCLUDE_PATHS = "$(inherited) $(SRCROOT)/PrivateFrameworks/CoreUI.framework"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -322,6 +638,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 54442C352E378BAF008A870E /* Build configuration list for PBXNativeTarget "QLPreview" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 54442C322E378BAF008A870E /* Debug */, + 54442C332E378BAF008A870E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 54442BEC2E378B71008A870E /* Project object */; diff --git a/QLApps.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/QLApps.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/QLApps.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/QLApps.xcodeproj/xcshareddata/xcschemes/QLApps.xcscheme b/QLApps.xcodeproj/xcshareddata/xcschemes/QLApps.xcscheme new file mode 100644 index 0000000..6a60361 --- /dev/null +++ b/QLApps.xcodeproj/xcshareddata/xcschemes/QLApps.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/QLApps.xcodeproj/xcshareddata/xcschemes/QLPreview.xcscheme b/QLApps.xcodeproj/xcshareddata/xcschemes/QLPreview.xcscheme new file mode 100644 index 0000000..45bb663 --- /dev/null +++ b/QLApps.xcodeproj/xcshareddata/xcschemes/QLPreview.xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/QLApps/AppDelegate.swift b/QLApps/AppDelegate.swift index 6d144b8..929b9a1 100644 --- a/QLApps/AppDelegate.swift +++ b/QLApps/AppDelegate.swift @@ -1,30 +1,20 @@ -// -// AppDelegate.swift -// QLApps -// -// Created by - on 28.07.25. -// - import Cocoa @main class AppDelegate: NSObject, NSApplicationDelegate { - @IBOutlet var window: NSWindow! - - + + func applicationDidFinishLaunching(_ aNotification: Notification) { // Insert code here to initialize your application } - + func applicationWillTerminate(_ aNotification: Notification) { // Insert code here to tear down your application } - + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } - - } diff --git a/QLPreview/Base.lproj/PreviewViewController.xib b/QLPreview/Base.lproj/PreviewViewController.xib new file mode 100644 index 0000000..32b474e --- /dev/null +++ b/QLPreview/Base.lproj/PreviewViewController.xib @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/QLPreview/Info.plist b/QLPreview/Info.plist new file mode 100644 index 0000000..482981f --- /dev/null +++ b/QLPreview/Info.plist @@ -0,0 +1,26 @@ + + + + + NSExtension + + NSExtensionAttributes + + QLIsDataBasedPreview + + QLSupportedContentTypes + + com.apple.itunes.ipa + com.apple.application-and-system-extension + com.apple.xcode.archive + + QLSupportsSearchableItems + + + NSExtensionPointIdentifier + com.apple.quicklook.preview + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).PreviewViewController + + + diff --git a/QLPreview/PreviewViewController.swift b/QLPreview/PreviewViewController.swift new file mode 100644 index 0000000..28d5c16 --- /dev/null +++ b/QLPreview/PreviewViewController.swift @@ -0,0 +1,25 @@ +import Cocoa +import Quartz // QLPreviewingController +import WebKit // WebView +import os // OSLog + +// show Console logs with subsystem:de.relikd.QLApps +private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "main") + +class PreviewViewController: NSViewController, QLPreviewingController { + + override var nibName: NSNib.Name? { + return NSNib.Name("PreviewViewController") + } + + func preparePreviewOfFile(at url: URL) async throws { + let html = generateHtml(at: url) + // sure, we could use `WKWebView`, but that requires the `com.apple.security.network.client` entitlement + //let web = WKWebView(frame: self.view.bounds) + let web = WebView(frame: self.view.bounds) + web.autoresizingMask = [.width, .height] + self.view.addSubview(web) + web.mainFrame.loadHTMLString(html, baseURL: nil) // WebView + //web.loadHTMLString(html, baseURL: nil) // WKWebView + } +} diff --git a/QLPreview/QLPreview.entitlements b/QLPreview/QLPreview.entitlements new file mode 100644 index 0000000..18aff0c --- /dev/null +++ b/QLPreview/QLPreview.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + diff --git a/resources/defaultIcon.png b/resources/defaultIcon.png new file mode 100644 index 0000000..7899392 Binary files /dev/null and b/resources/defaultIcon.png differ diff --git a/resources/template.html b/resources/template.html new file mode 100644 index 0000000..44cfd42 --- /dev/null +++ b/resources/template.html @@ -0,0 +1,215 @@ + + + + + + + + +
+

__AppInfoTitle__

+
App icon
+
+ Name: __CFBundleName__
+ Version: __CFBundleShortVersionString__ (__CFBundleVersion__)
+ BundleId: __CFBundleIdentifier__
+
+ Extension type: __ExtensionType__
+
+ DeviceFamily: __UIDeviceFamily__
+ 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__
+ Expiration Date: __ExpirationDateFormatted__
+
+ +
+

Entitlements

+
+ Entitlements extraction failed. +
+ __EntitlementsFormatted__ +
+ +
+

Developer Certificates

+ __DeveloperCertificatesFormatted__ +
+ +
+

Devices (__ProvisionedDevicesCount__)

+ __ProvisionedDevicesFormatted__ +
+ +
+

iTunes Metadata

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

File info

+ __FileName__
+ __FileInfo__
+
+ + + diff --git a/src/AppCategories.swift b/src/AppCategories.swift new file mode 100644 index 0000000..e5f80b1 --- /dev/null +++ b/src/AppCategories.swift @@ -0,0 +1,156 @@ +import Foundation + +/* + #!/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)) + */ + +let AppCategories: [Int: String] = [ + // 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", +] diff --git a/src/AppIcon+Car.swift b/src/AppIcon+Car.swift new file mode 100644 index 0000000..b136395 --- /dev/null +++ b/src/AppIcon+Car.swift @@ -0,0 +1,91 @@ +import Foundation +import AppKit // NSImage +import CoreUI // CUICatalog +import os // OSLog + +private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "AppIcon+Car") + +// this has been written from scratch but general usage on +// including the private framework has been taken from: +// https://github.com/showxu/cartools +// also see: +// https://blog.timac.org/2018/1018-reverse-engineering-the-car-file-format/ + +extension AppIcon { + /// Use `CUICatalog` to extract an image from `Assets.car` + func imageFromAssetsCar(_ imageName: String) -> NSImage? { + guard let data = meta.readPayloadFile("Assets.car") else { + return nil + } + let catalog: CUICatalog + do { + catalog = try data.withUnsafeBytes { try CUICatalog(bytes: $0.baseAddress!, length: UInt64(data.count)) } + } catch { + os_log(.error, log: log, "[icon-car] ERROR: could not open catalog: %{public}@", error.localizedDescription) + return nil + } + + if let validName = carVerifyNameExists(imageName, in: catalog) { + if let bestImage = carFindHighestResolutionIcon(catalog.images(withName: validName)) { + os_log(.debug, log: log, "[icon-car] using Assets.car with key %{public}@", validName) + return NSImage(cgImage: bestImage.image, size: bestImage.size) + } + } + return nil; + } + + + // MARK: - Helper: Assets.car + + /// Helper method to check available icon names. Will return a valid name or `nil` if no image with that key is found. + func carVerifyNameExists(_ imageName: String, in catalog: CUICatalog) -> String? { + if let availableNames = catalog.allImageNames(), !availableNames.contains(imageName) { + // Theoretically this should never happen. Assuming the image name is found in an image file. + os_log(.info, log: log, "[icon-car] WARN: key '%{public}@' does not match any available key", imageName) + + if let alternativeName = carSearchAlternativeName(imageName, inAvailable: availableNames) { + os_log(.info, log: log, "[icon-car] falling back to '%{public}@'", alternativeName) + return alternativeName + } + os_log(.debug, log: log, "[icon-car] available keys: %{public}@", catalog.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" + func carSearchAlternativeName(_ originalName: String, inAvailable availableNames: [String]) -> String? { + var bestOption: String? = nil + var bestDiff: Int = 999 + + for option in availableNames { + if option.hasPrefix(originalName) || originalName.hasPrefix(option) { + let thisDiff = max(originalName.count, option.count) - min(originalName.count, option.count) + if thisDiff < bestDiff { + bestDiff = thisDiff + bestOption = option + } + } + } + return bestOption + } + + /// Given a list of `CUINamedImage`, return the one with the highest resolution. Vector graphics are ignored. + func carFindHighestResolutionIcon(_ availableImages: [CUINamedImage]) -> CUINamedImage? { + var largestWidth: CGFloat = 0 + var largestImage: CUINamedImage? = nil + // cast to NSArray is necessary as otherwise this will crash + for img in availableImages as NSArray { + guard let img = img as? CUINamedImage else { + continue // ignore CUINamedMultisizeImageSet + } + let w = img.size.width + if w > largestWidth { + largestWidth = w + largestImage = img + } + } + return largestImage + } +} diff --git a/src/AppIcon.swift b/src/AppIcon.swift new file mode 100644 index 0000000..4e5b5f8 --- /dev/null +++ b/src/AppIcon.swift @@ -0,0 +1,105 @@ +import Foundation +import AppKit // NSImage +import os // OSLog + +private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "AppIcon") + + +struct AppIcon { + let meta: QuickLookInfo + + init(_ meta: QuickLookInfo) { + self.meta = meta + } + + /// Try multiple methods to extract image. + /// This method will always return an image even if none is found, in which case it returns the default image. + func extractImage(from appPlist: PlistDict?) -> NSImage { + // no need to unwrap the plist, and most .ipa should include the Artwork anyway + if meta.type == .IPA { + if let data = meta.zipFile!.unzipFile("iTunesArtwork") { + os_log(.debug, log: log, "[icon] using iTunesArtwork.") + return NSImage(data: data)! + } + } + + // Extract image name from app plist + var plistImgNames = iconNamesFromPlist(appPlist) + os_log(.debug, log: log, "[icon] icon names in plist: %{public}@", plistImgNames) + + // If no previous filename works (or empty), try default icon names + plistImgNames.append("Icon") + plistImgNames.append("icon") + + // First, try if an image file with that name exists. + if let actualName = expandImageName(plistImgNames) { + os_log(.debug, log: log, "[icon] using plist image file %{public}@", actualName) + if meta.type == .IPA { + let data = meta.zipFile!.unzipFile(actualName)! + return NSImage(data: data)! + } + return NSImage(contentsOfFile: actualName)! + } + + // Else: try Assets.car + if let img = imageFromAssetsCar(plistImgNames.first!) { + return img + } + + // Fallback to default icon + let iconURL = Bundle.main.url(forResource: "defaultIcon", withExtension: "png")! + return NSImage(contentsOf: iconURL)! + } +} + + +// MARK: - Extension: NSImage + +// AppIcon extension +extension NSImage { + /// Because some (PNG) image data will return weird float values + private func bestImageSize() -> NSSize { + var w: Int = 0 + var h: Int = 0 + for imageRep in self.representations { + w = max(w, imageRep.pixelsWide) + h = max(h, imageRep.pixelsHigh) + } + return NSSize(width: w, height: h) + } + + /// Apply rounded corners to image (iOS7 style) + func withRoundCorners() -> NSImage { + let existingSize = bestImageSize() + let composedImage = NSImage(size: existingSize) + + composedImage.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .high + + let imageFrame = NSRect(origin: .zero, size: existingSize) + let clipPath = NSBezierPath.IOS7RoundedRect(imageFrame, cornerRadius: existingSize.width * 0.225) + clipPath.windingRule = .evenOdd + clipPath.addClip() + + self.draw(in: imageFrame) + composedImage.unlockFocus() + return composedImage + } + + /// Convert image to PNG and encode with base64 to be embeded in html output. + func asBase64() -> String { + // appIcon = [self roundCorners:appIcon]; + let imageData = tiffRepresentation! + let imageRep = NSBitmapImageRep(data: imageData)! + let imageDataPNG = imageRep.representation(using: .png, properties: [:])! + return imageDataPNG.base64EncodedString() + } + + /// If the image is larger than the provided maximum size, scale it down. Otherwise leave it untouched. +// func downscale(ifLargerThan maxSize: CGSize) { +// // TODO: if downscale, then this should respect retina resolution +// if size.width > maxSize.width && size.height > maxSize.height { +// self.size = maxSize +// } +// } +} diff --git a/src/Entitlements.swift b/src/Entitlements.swift new file mode 100644 index 0000000..0a72f41 --- /dev/null +++ b/src/Entitlements.swift @@ -0,0 +1,143 @@ +import Foundation +import os // OSLog + +private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Entitlements") + + +struct Entitlements { + var hasError: Bool = false + /// only set after calling `applyFallbackIfNeeded(:)` + var html: String? = nil + + private let binaryPath: String + /// It is either `plist` or `codeSignErrors` not both. + private var plist: [String: Any]? = nil + /// It is either `plist` or `codeSignErrors` not both. + private var codeSignError: String? = nil + + /// Use provision plist data without running `codesign` or + static func withoutBinary() -> Self { + return Entitlements(forBinary: nil) + } + + /// First, try to extract real entitlements by running `SecCode` module in-memory. + /// If that fails, fallback to running `codesign` via system call. + init(forBinary path: String?) { + guard let path else { + self.binaryPath = "" + return + } + self.binaryPath = path + if FileManager.default.fileExists(atPath: path) { + self.plist = getSecCodeEntitlements() + } else { + os_log(.error, log: log, "[entitlements] provided binary '%{public}@' does not exist (unzip error?)", path) + self.plist = nil + self.codeSignError = nil + } + } + + + // MARK: - public methods + + /// Provided provision plist is only used if @c SecCode and @c CodeSign failed. + mutating func applyFallbackIfNeeded(_ fallbackEntitlementsPlist: PlistDict?) { + // checking for !error ensures that codesign gets precedence. + // show error before falling back to provision based entitlements. + if plist == nil && codeSignError == nil { + if let fallbackEntitlementsPlist { + os_log(.debug, log: log, "[entitlements] fallback to provision plist entitlements") + self.plist = fallbackEntitlementsPlist + } + } + self.html = format(plist) + self.plist = nil // free memory + self.codeSignError = nil + } + + /// Print formatted plist in a @c \
 tag
+	func format(_ plist: [String: Any]?) -> String? {
+		guard let plist else {
+			return codeSignError // may be nil
+		}
+		var output = ""
+		recursiveKeyValue(plist, &output)
+		return "
\(output)
" + } + + // MARK: - SecCode in-memory reader + + /// use in-memory `SecCode` for entitlement extraction + func getSecCodeEntitlements() -> PlistDict? { + let url = URL(fileURLWithPath: self.binaryPath) + var codeRef: SecStaticCode? + SecStaticCodeCreateWithPath(url as CFURL, [], &codeRef) + guard let codeRef else { + return nil + } + + var requirementInfo: CFDictionary? + SecCodeCopySigningInformation(codeRef, SecCSFlags(rawValue: kSecCSRequirementInformation), &requirementInfo) + guard let requirementInfo = requirementInfo as? PlistDict else { + return nil + } + + // if 'entitlements-dict' key exists, use that one + os_log(.debug, log: log, "[entitlements] read SecCode 'entitlements-dict' key") + if let plist = requirementInfo[kSecCodeInfoEntitlementsDict as String] as? PlistDict { + return plist + } + + // else, fallback to parse data from 'entitlements' key + os_log(.debug, log: log, "[entitlements] read SecCode 'entitlements' key") + guard let data = requirementInfo[kSecCodeInfoEntitlements as String] as? Data else { + return nil + } + + // expect magic number header. Currently no support for other formats. + let header = data.subdata(in: 0..<4) + guard header == Data([0xFA, 0xDE, 0x71, 0x71]) else { + os_log(.error, log: log, "[entitlements] unsupported embedded plist format: %{public}@", header as NSData) + return nil // try anyway? + } + + // big endian, so no memcpy for us :( + let size: UInt32 = (UInt32(data[4]) << 24) | (UInt32(data[5]) << 16) | (UInt32(data[6]) << 8) | UInt32(data[7]) + if size != data.count { + os_log(.error, log: log, "[entitlements] unpack error for FADE7171 size %lu != %lu", data.count, size) + // but try anyway + } + return data.subdata(in: 8.. 0 ? String(repeating: " ", count: level * 4) : "" + let prefix = indent + (key?.appending(" = ") ?? "") + + if let dict = value as? [String: Any] { + if level > -1 { + output.append(prefix + "{\n") + } + for (subKey, subValue) in dict.sorted(by: { $0.key < $1.key }) { + recursiveKeyValue(subValue, &output, level + 1, subKey) + } + if level > -1 { + output.append(indent + "}\n") + } + } else if let array = value as? [Any] { + output.append(prefix + "(\n") + for element in array { + recursiveKeyValue(element, &output, level + 1, nil) + } + output.append(indent + ")\n") + } else if let data = value as? Data { + output.append(prefix + "\(data.count) bytes of data\n") + } else { + output.append(prefix + "\(value)\n") + } + } +} diff --git a/src/Plist.swift b/src/Plist.swift new file mode 100644 index 0000000..e4bdd6b --- /dev/null +++ b/src/Plist.swift @@ -0,0 +1,190 @@ +import Foundation +import os // OSLog + +private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Plist") + + +typealias PlistDict = [String: Any] // basically an untyped Dict + + +// MARK: - + +extension Data { + /// Helper for optional chaining. + func asPlistOrNil() -> PlistDict? { + if self.isEmpty { + return nil + } + // var format: PropertyListSerialization.PropertyListFormat = .xml + do { + return try PropertyListSerialization.propertyList(from: self, format: nil) as? PlistDict + } catch { + os_log(.error, log: log, "ERROR reading plist %{public}@", error.localizedDescription) + return nil + } + } +} + + +// MARK: - + +extension QuickLookInfo { + /// Read app default `Info.plist`. + func readPlistApp() -> PlistDict? { + switch self.type { + case .IPA, .Archive, .Extension: + return self.readPayloadFile("Info.plist")?.asPlistOrNil() + } + } + + /// Read `iTunesMetadata.plist` if available + func readPlistItunes() -> PlistDict? { + switch self.type { + case .IPA: + return self.zipFile!.unzipFile("iTunesMetadata.plist")?.asPlistOrNil() + case .Archive, .Extension: + return nil + } + } + + /// Read `embedded.mobileprovision` file and decode with CMS decoder. + func readPlistProvision() -> PlistDict? { + guard let provisionData = self.readPayloadFile("embedded.mobileprovision") else { + os_log(.info, log: log, "No embedded.mobileprovision file for %{public}@", self.url.path) + return nil + } + + var decoder: CMSDecoder? = nil + CMSDecoderCreate(&decoder) + let data = provisionData.withUnsafeBytes { ptr in + CMSDecoderUpdateMessage(decoder!, ptr.baseAddress!, provisionData.count) + CMSDecoderFinalizeMessage(decoder!) + var dataRef: CFData? + CMSDecoderCopyContent(decoder!, &dataRef) + return Data(referencing: dataRef!) + } + return data.asPlistOrNil() + } +} + + +// MARK: - + +extension AppIcon { + /// Parse app plist to find the bundle icon filename. + /// @param appPlist If `nil`, will load plist on the fly (used for thumbnail) + /// @return Filenames which do not necessarily exist on filesystem. This may include `@2x` and/or no file extension. + func iconNamesFromPlist(_ appPlist: PlistDict?) -> [String] { + let appPlist = appPlist == nil ? meta.readPlistApp()! : appPlist! + // Check for CFBundleIcons (since 5.0) + if let icons = unpackNameListFromPlistDict(appPlist["CFBundleIcons"]), !icons.isEmpty { + return icons + } + // iPad-only apps + if let icons = unpackNameListFromPlistDict(appPlist["CFBundleIcons~ipad"]), !icons.isEmpty { + return icons + } + // Check for CFBundleIconFiles (since 3.2) + if let icons = appPlist["CFBundleIconFiles"] as? [String], !icons.isEmpty { + return icons + } + // key found on iTunesU app + if let icons = appPlist["Icon files"] as? [String], !icons.isEmpty { + return icons + } + // Check for CFBundleIconFile (legacy, before 3.2) + if let icon = appPlist["CFBundleIconFile"] as? String { // may be nil + return [icon] + } + return [] // [self sortedByResolution:icons]; + } + + /// Given a filename, search Bundle or Filesystem for files that match. Select the filename with the highest resolution. + func expandImageName(_ iconList: [String]) -> String? { + var matches: [String] = [] + switch meta.type { + case .IPA: + guard let zipFile = meta.zipFile else { + // in case unzip in memory is not available, fallback to pattern matching with dynamic suffix + return "Payload/*.app/\(iconList.first!)*" + } + for iconPath in iconList { + let zipPath = "Payload/*.app/\(iconPath)*" + for zip in zipFile.filesMatching(zipPath) { + if zip.sizeUncompressed > 0 { + matches.append(zip.filepath) + } + } + if matches.count > 0 { + break + } + } + + case .Archive, .Extension: + let basePath = meta.effectiveUrl ?? meta.url + for iconPath in iconList { + let fileName = iconPath.components(separatedBy: "/").last! + let parentDir = basePath.appendingPathComponent(iconPath, isDirectory: false).deletingLastPathComponent().path + guard let files = try? FileManager.default.contentsOfDirectory(atPath: parentDir) else { + continue + } + for file in files { + if file.hasPrefix(fileName) { + let fullPath = parentDir + "/" + file + if let fSize = try? FileManager.default.attributesOfItem(atPath: fullPath)[FileAttributeKey.size] as? Int { + if fSize > 0 { + matches.append(fullPath) + } + } + } + } + if matches.count > 0 { + break + } + } + } + return matches.isEmpty ? nil : sortedByResolution(matches).first + } + + /// Deep select icons from plist key `CFBundleIcons` and `CFBundleIcons~ipad` + private func unpackNameListFromPlistDict(_ bundleDict: Any?) -> [String]? { + if let bundleDict = bundleDict as? PlistDict { + if let primaryDict = bundleDict["CFBundlePrimaryIcon"] as? PlistDict { + if let icons = primaryDict["CFBundleIconFiles"] as? [String] { + return icons + } + if let name = primaryDict["CFBundleIconName"] as? String { // key found on a .tipa file + return [name] + } + } + } + return nil + } + + /// @return lower index means higher resolution. + private func resolutionIndex(_ iconName: String) -> Int { + let lower = iconName.lowercased() + // "defaultX" = launch image + let penalty = lower.contains("small") || lower.hasPrefix("default") ? 20 : 0 + + let resolutionOrder: [String] = [ + "@3x", "180", "167", "152", "@2x", "120", + "144", "114", "87", "80", "76", "72", "58", "57" + ] + for (i, res) in resolutionOrder.enumerated() { + if iconName.contains(res) { + return i + penalty + } + } + return 50 + penalty + } + + /// Given a list of filenames, order them highest resolution first. + private func sortedByResolution(_ icons: [String]) -> [String] { + return icons.sorted { (icon1, icon2) -> Bool in + let index1 = self.resolutionIndex(icon1) + let index2 = self.resolutionIndex(icon2) + return index1 < index2 + } + } +} diff --git a/src/PreviewGenerator.swift b/src/PreviewGenerator.swift new file mode 100644 index 0000000..64fcc92 --- /dev/null +++ b/src/PreviewGenerator.swift @@ -0,0 +1,555 @@ +import Foundation +import os // OSLog + +private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "PreviewGenerator") + +typealias HtmlDict = [String: String] // used for TAG replacements + + +// MARK: - Generic data formatting & printing + +typealias TableRow = [String] + +/// Print html table with arbitrary number of columns +/// @param header If set, start the table with a `tr` column row. +func formatAsTable(_ data: [[String]], header: TableRow? = nil) -> String { + var table = "\n" + if let header = header { + table += "\n" + } + for row in data { + table += "\n" + } + return table + "
\(header.joined(separator: ""))
\(row.joined(separator: ""))
\n" +} + +/// Print recursive tree of key-value mappings. +func recursiveDict(_ dictionary: [String: Any], withReplacements replacements: [String: String] = [:], _ level: Int = 0) -> String { + var output = "" + for (key, value) in dictionary { + let localizedKey = replacements[key] ?? key + for _ in 0..\n" + output += recursiveDict(subDict, withReplacements: replacements, level + 1) + output += "\n" + } else if let number = value as? NSNumber { + output += "\(localizedKey): \(number.boolValue ? "YES" : "NO")
" + } else { + output += "\(localizedKey): \(value)
" + } + } + return output +} + +/// Replace occurrences of chars `&"'<>` with html encoding. +func escapeXML(_ stringToEscape: String) -> String { + return stringToEscape + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") +} + + +// MARK: - Date processing + +/// @return Difference between two dates as components. +func dateDiff(_ start: Date, _ end: Date) -> DateComponents { + return Calendar.current.dateComponents([.day, .hour, .minute], from: start, to: end) +} + +/// @return Print largest component. E.g., "3 days" or "14 hours" +func relativeDateString(_ comp: DateComponents) -> String { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .full + formatter.maximumUnitCount = 1 + return formatter.string(from: comp)! +} + +/// @return Print the date with current locale and medium length style. +func formattedDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .medium + return formatter.string(from: date) +} + +/// Parse date from plist regardless if it has `NSDate` or `NSString` type. +func parseDate(_ value: Any?) -> Date? { + if let date = value as? Date { + return date + } + + guard let stringValue = value as? String else { + return nil + } + + // parse the date from a string + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z" + if let date = formatter.date(from: stringValue) { + return date + } + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + if let date = formatter.date(from: stringValue) { + return date + } + os_log(.error, log: log, "ERROR formatting date: %{public}@", stringValue) + return nil +} + +/// @return Relative distance to today. E.g., "Expired today" +func relativeExpirationDateString(_ date: Date) -> String { + let isPast = date < Date() + let isToday = Calendar.current.isDateInToday(date) + + if isToday { + return isPast ? "Expired today" : "Expires today" + } + + if isPast { + let comp = dateDiff(date, Date()) + return "Expired \(relativeDateString(comp)) ago" + } + + let comp = dateDiff(Date(), date) + if comp.day! < 30 { + return "Expires in \(relativeDateString(comp))" + } + return "Expires in \(relativeDateString(comp))" +} + +/// @return Relative distance to today. E.g., "DATE (Expires in 3 days)" +func formattedExpirationDate(_ date: Date) -> String { + return "\(formattedDate(date)) (\(relativeExpirationDateString(date)))" +} + +/// @return Relative distance to today. E.g., "DATE (Created 3 days ago)" +func formattedCreationDate(_ date: Date) -> String { + let isToday = Calendar.current.isDateInToday(date) + let comp = dateDiff(date, Date()) + return "\(formattedDate(date)) (Created \(isToday ? "today" : "\(relativeDateString(comp)) ago"))" +} + +/// @return CSS class for expiration status. +func classNameForExpirationStatus(_ date: Date?) -> String { + switch ExpirationStatus(date) { + case .Expired: return "expired" + case .Expiring: return "expiring" + case .Valid: return "valid" + } +} + + +// MARK: - App Info + +/// @return List of ATS flags. +func formattedAppTransportSecurity(_ appPlist: PlistDict) -> String { + if let value = appPlist["NSAppTransportSecurity"] as? PlistDict { + let 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", + ] + + return "
\(recursiveDict(value, withReplacements: localizedKeys))
" + } + + let sdkName = appPlist["DTSDKName"] as? String ?? "0" + let sdkNumber = Double(sdkName.trimmingCharacters(in: .letters)) ?? 0 + if sdkNumber < 9.0 { + return "Not applicable before iOS 9.0" + } + return "No exceptions" +} + +/// Process info stored in `Info.plist` +func procAppInfo(_ appPlist: PlistDict?) -> HtmlDict { + guard let appPlist else { + return [ + "AppInfoHidden": "hiddenDiv", + "ProvisionTitleHidden": "", + ] + } + + var platforms = (appPlist["UIDeviceFamily"] as? [Int])?.compactMap({ + switch $0 { + case 1: return "iPhone" + case 2: return "iPad" + case 3: return "TV" + case 4: return "Watch" + default: return nil + } + }).joined(separator: ", ") + + let minVersion = appPlist["MinimumOSVersion"] as? String ?? "" + if platforms?.isEmpty ?? true, minVersion.hasPrefix("1.") || minVersion.hasPrefix("2.") || minVersion.hasPrefix("3.") { + platforms = "iPhone" + } + + let extensionType = (appPlist["NSExtension"] as? PlistDict)?["NSExtensionPointIdentifier"] as? String + return [ + "AppInfoHidden": "", + "ProvisionTitleHidden": "hiddenDiv", + + "CFBundleName": appPlist["CFBundleDisplayName"] as? String ?? appPlist["CFBundleName"] as? String ?? "", + "CFBundleShortVersionString": appPlist["CFBundleShortVersionString"] as? String ?? "", + "CFBundleVersion": appPlist["CFBundleVersion"] as? String ?? "", + "CFBundleIdentifier": appPlist["CFBundleIdentifier"] as? String ?? "", + + "ExtensionTypeHidden": extensionType != nil ? "" : "hiddenDiv", + "ExtensionType": extensionType ?? "", + + "UIDeviceFamily": platforms ?? "", + "DTSDKName": appPlist["DTSDKName"] as? String ?? "", + "MinimumOSVersion": minVersion, + "AppTransportSecurityFormatted": formattedAppTransportSecurity(appPlist), + ] +} + + +// MARK: - iTunes Purchase Information + +/// Concatenate all (sub)genres into a comma separated list. +func formattedGenres(_ itunesPlist: PlistDict) -> String { + var genres: [String] = [] + let genreId = itunesPlist["genreId"] as? Int ?? 0 + if let mainGenre = AppCategories[genreId] ?? itunesPlist["genre"] as? String { + genres.append(mainGenre) + } + + for subgenre in itunesPlist["subgenres"] as? [PlistDict] ?? [] { + let subgenreId = subgenre["genreId"] as? Int ?? 0 + if let subgenreStr = AppCategories[subgenreId] ?? subgenre["genre"] as? String { + genres.append(subgenreStr) + } + } + return genres.joined(separator: ", ") +} + +/// Process info stored in `iTunesMetadata.plist` +func parseItunesMeta(_ itunesPlist: PlistDict?) -> HtmlDict { + guard let itunesPlist else { + return ["iTunesHidden": "hiddenDiv"] + } + + let downloadInfo = itunesPlist["com.apple.iTunesStore.downloadInfo"] as? PlistDict + let accountInfo = downloadInfo?["accountInfo"] as? PlistDict ?? [:] + + let purchaseDate = parseDate(downloadInfo?["purchaseDate"] ?? itunesPlist["purchaseDate"]) + let releaseDate = parseDate(downloadInfo?["releaseDate"] ?? itunesPlist["releaseDate"]) + // AppleId & purchaser name + let appleId = accountInfo["AppleID"] as? String ?? itunesPlist["appleId"] as? String ?? "" + let firstName = accountInfo["FirstName"] as? String ?? "" + let lastName = accountInfo["LastName"] as? String ?? "" + + let name: String + if !firstName.isEmpty || !lastName.isEmpty { + name = "\(firstName) \(lastName) (\(appleId))" + } else { + name = appleId + } + os_log(.error, log: log, "id: %{public}@", String(describing: itunesPlist["itemId"])) + return [ + "iTunesHidden": "", + "iTunesId": (itunesPlist["itemId"] as? Int)?.description ?? "", // description] + "iTunesName": itunesPlist["itemName"] as? String ?? "", + "iTunesGenres": formattedGenres(itunesPlist), + "iTunesReleaseDate": releaseDate == nil ? "" : formattedDate(releaseDate!), + + "iTunesAppleId": name, + "iTunesPurchaseDate": purchaseDate == nil ? "" : formattedDate(purchaseDate!), + "iTunesPrice": itunesPlist["priceDisplay"] as? String ?? "", + ] +} + + +// MARK: - Certificates + +/// Process a single certificate. Extract invalidity / expiration date. +/// @param subject just used for printing error logs. +func getCertificateInvalidityDate(_ certificate: SecCertificate, subject: String) -> Date? { + var error: Unmanaged? + guard let outerDict = SecCertificateCopyValues(certificate, [kSecOIDInvalidityDate] as CFArray, &error) as? PlistDict else { + os_log(.error, log: log, "Could not get values in '%{public}@' certificate, error = %{public}@", subject, error?.takeUnretainedValue().localizedDescription ?? "unknown error") + return nil + } + guard let innerDict = outerDict[kSecOIDInvalidityDate as String] as? PlistDict else { + os_log(.error, log: log, "No invalidity values in '%{public}@' certificate, dictionary = %{public}@", subject, outerDict) + return nil + } + // 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 be sure, we'll check: + guard let dateString = innerDict[kSecPropertyKeyValue as String] else { + os_log(.error, log: log, "No invalidity date in '%{public}@' certificate, dictionary = %{public}@", subject, innerDict) + return nil + } + return parseDate(dateString); +} + +/// Process list of all certificates. Return a two column table with subject and expiration date. +func getCertificateList(_ provisionPlist: PlistDict) -> [TableRow] { + guard let certs = provisionPlist["DeveloperCertificates"] as? [Data] else { + return [] + } + return certs.compactMap { + guard let cert = SecCertificateCreateWithData(nil, $0 as CFData) else { + return nil + } + guard let subject = SecCertificateCopySubjectSummary(cert) as? String else { + os_log(.error, log: log, "Could not get subject from certificate") + return nil + } + let expiration: String + if let invalidityDate = getCertificateInvalidityDate(cert, subject: subject) { + expiration = relativeExpirationDateString(invalidityDate) + } else { + expiration = "No invalidity date in certificate" + } + return TableRow([subject, expiration]) + }.sorted { $0[0] < $1[0] } +} + + +// MARK: - Provisioning + +/// Returns provision type string like "Development" or "Distribution (App Store)". +func stringForProfileType(_ provisionPlist: PlistDict, isOSX: Bool) -> String { + let hasDevices = provisionPlist["ProvisionedDevices"] is [Any] + if isOSX { + return hasDevices ? "Development" : "Distribution (App Store)" + } + if hasDevices { + let getTaskAllow = (provisionPlist["Entitlements"] as? PlistDict)?["get-task-allow"] as? Bool ?? false + return getTaskAllow ? "Development" : "Distribution (Ad Hoc)" + } + let isEnterprise = provisionPlist["ProvisionsAllDevices"] as? Bool ?? false + return isEnterprise ? "Enterprise" : "Distribution (App Store)" +} + +/// Enumerate all entries from provison plist with key `ProvisionedDevices` +func getDeviceList(_ provisionPlist: PlistDict) -> [TableRow] { + guard let devArr = provisionPlist["ProvisionedDevices"] as? [String] else { + return [] + } + var currentPrefix: String? = nil + return devArr.sorted().map { device in + // compute the prefix for the first column of the table + let displayPrefix: String + let devicePrefix = String(device.prefix(1)) + if currentPrefix != devicePrefix { + currentPrefix = devicePrefix + displayPrefix = "\(devicePrefix) ➞ " + } else { + displayPrefix = "" + } + return [displayPrefix, device] + } +} + +/// Process info stored in `embedded.mobileprovision` +func procProvision(_ provisionPlist: PlistDict?, isOSX: Bool) -> HtmlDict { + guard let provisionPlist else { + return ["ProvisionHidden": "hiddenDiv"] + } + + let creationDate = provisionPlist["CreationDate"] as? Date + let expireDate = provisionPlist["ExpirationDate"] as? Date + let devices = getDeviceList(provisionPlist) + let certs = getCertificateList(provisionPlist) + + return [ + "ProvisionHidden": "", + "ProfileName": provisionPlist["Name"] as? String ?? "", + "ProfileUUID": provisionPlist["UUID"] as? String ?? "", + "TeamName": provisionPlist["TeamName"] as? String ?? "Team name not available", + "TeamIds": (provisionPlist["TeamIdentifier"] as? [String])?.joined(separator: ", ") ?? "Team ID not available", + "CreationDateFormatted": creationDate == nil ? "" : formattedCreationDate(creationDate!), + "ExpirationDateFormatted": expireDate == nil ? "" : formattedExpirationDate(expireDate!), + "ExpStatus": classNameForExpirationStatus(expireDate), + + "ProfilePlatform": isOSX ? "Mac" : "iOS", + "ProfileType": stringForProfileType(provisionPlist, isOSX: isOSX), + + "ProvisionedDevicesCount": devices.isEmpty ? "No Devices" : "\(devices.count) Device\(devices.count == 1 ? "" : "s")", + "ProvisionedDevicesFormatted": devices.isEmpty ? "Distribution Profile" : formatAsTable(devices, header: ["", "UDID"]), + + "DeveloperCertificatesFormatted": certs.isEmpty ? "No Developer Certificates" : formatAsTable(certs), + ] +} + + +// MARK: - Entitlements + +/// Search for app binary and run `codesign` on it. +func readEntitlements(_ meta: QuickLookInfo, _ bundleExecutable: String?) -> Entitlements { + guard let bundleExecutable else { + return Entitlements.withoutBinary() + } + + switch meta.type { + case .IPA: + let tmpPath = NSTemporaryDirectory() + "/" + UUID().uuidString + try! FileManager.default.createDirectory(atPath: tmpPath, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(atPath: tmpPath) + } + try! meta.zipFile!.unzipFile("Payload/*.app/\(bundleExecutable)", toDir: tmpPath) + return Entitlements(forBinary: tmpPath + "/" + bundleExecutable) + case .Archive: + return Entitlements(forBinary: meta.effectiveUrl!.path + "/" + bundleExecutable) + case .Extension: + return Entitlements(forBinary: meta.url.path + "/" + bundleExecutable) + } +} + +/// Process compiled binary and provision plist to extract `Entitlements` +func procEntitlements(_ meta: QuickLookInfo, _ appPlist: PlistDict?, _ provisionPlist: PlistDict?) -> HtmlDict { + var entitlements = readEntitlements(meta, appPlist?["CFBundleExecutable"] as? String) + entitlements.applyFallbackIfNeeded(provisionPlist?["Entitlements"] as? PlistDict) + + return [ + "EntitlementsWarningHidden": entitlements.hasError ? "" : "hiddenDiv", + "EntitlementsFormatted": entitlements.html ?? "No Entitlements", + ] +} + + +// MARK: - File Info + +/// Title of the preview window +func stringForFileType(_ meta: QuickLookInfo) -> String { + switch meta.type { + case .IPA: return "App info" + case .Archive: return "Archive info" + case .Extension: return "App extension info" + } +} + +/// Calculate file / folder size. +func getFileSize(_ path: String) -> Int64 { + var isDir: ObjCBool = false + FileManager.default.fileExists(atPath: path, isDirectory: &isDir) + if !isDir.boolValue { + return try! FileManager.default.attributesOfItem(atPath: path)[.size] as! Int64 + } + var fileSize: Int64 = 0 + for child in try! FileManager.default.subpathsOfDirectory(atPath: path) { + fileSize += try! FileManager.default.attributesOfItem(atPath: path + "/" + child)[.size] as! Int64 + } + return fileSize +} + +/// Process meta information about the file itself. Like file size and last modification. +func procFileInfo(_ url: URL) -> HtmlDict { + let formattedValue : String + if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) { + let size = ByteCountFormatter.string(fromByteCount: getFileSize(url.path), countStyle: .file) + formattedValue = "\(size), Modified \(formattedDate(attrs[.modificationDate] as! Date))" + } else { + formattedValue = "" + } + return [ + "FileName": escapeXML(url.lastPathComponent), + "FileInfo": formattedValue, + ] +} + + +// MARK: - Footer Info + +/// Process meta information about the plugin. Like version and debug flag. +func procFooterInfo() -> HtmlDict { +#if DEBUG + let debugString = "(debug)" +#else + let debugString = "" +#endif + return [ + "DEBUG": debugString, + "BundleShortVersionString": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "", + "BundleVersion": Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "", + ] +} + + +// MARK: - Main Entry + +func applyHtmlTemplate(_ templateValues: HtmlDict) -> String { + let templateURL = Bundle.main.url(forResource: "template", withExtension: "html")! + let html = try! String(contentsOf: templateURL, encoding: .utf8) + + // this is less efficient +// for (key, value) in templateValues { +// html = html.replacingOccurrences(of: "__\(key)__", with: value) +// } + + var rv = "" + var prevLoc = html.startIndex + let regex = try! NSRegularExpression(pattern: "__[^ _]{1,40}?__") + regex.enumerateMatches(in: html, range: NSRange(location: 0, length: html.count), using: { match, flags, stop in + let start = html.index(html.startIndex, offsetBy: match!.range.lowerBound) + let key = String(html[html.index(start, offsetBy: 2) ..< html.index(start, offsetBy: match!.range.length - 2)]) + // append unrelated text up to this key + rv.append(contentsOf: html[prevLoc ..< start]) + prevLoc = html.index(start, offsetBy: match!.range.length) + // append key if exists (else remove template-key) + if let value = templateValues[key] { + rv.append(value) + } else { +// os_log(.debug, log: log, "unknown template key: %{public}@", key) + } + }) + // append remaining text + rv.append(contentsOf: html[prevLoc ..< html.endIndex]) + return rv +} + +func generateHtml(at url: URL) -> String { + let meta = QuickLookInfo(url) + var infoLayer: HtmlDict = [ + "AppInfoTitle": stringForFileType(meta), + ] + + // App Info + let plistApp = meta.readPlistApp() + infoLayer.merge(procAppInfo(plistApp)) { (_, new) in new } + + let plistItunes = meta.readPlistItunes() + infoLayer.merge(parseItunesMeta(plistItunes)) { (_, new) in new } + + // Provisioning + let plistProvision = meta.readPlistProvision() + infoLayer.merge(procProvision(plistProvision, isOSX: meta.isOSX)) { (_, new) in new } + + // Entitlements + let entitlements = procEntitlements(meta, plistApp, plistProvision) + infoLayer.merge(entitlements) { (_, new) in new } + // File Info + infoLayer.merge(procFileInfo(url)) { (_, new) in new } + // Footer Info + infoLayer.merge(procFooterInfo()) { (_, new) in new } + // App Icon (last, because the image uses a lot of memory) + let icon = AppIcon(meta) + infoLayer["AppIcon"] = icon.extractImage(from: plistApp).withRoundCorners().asBase64() + // prepare html, replace values + return applyHtmlTemplate(infoLayer) +} diff --git a/src/RoundedIcon.swift b/src/RoundedIcon.swift new file mode 100644 index 0000000..cc2cdee --- /dev/null +++ b/src/RoundedIcon.swift @@ -0,0 +1,58 @@ +import Foundation +import AppKit // 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. +// + +extension NSBezierPath { + public class func IOS7RoundedRect(_ rect: NSRect, cornerRadius: CGFloat) -> NSBezierPath { + let path = NSBezierPath() + let limit = min(rect.size.width, rect.size.height) / 2 / 1.52866483 + let limitedRadius = min(cornerRadius, limit) + + @inline(__always) func topLeft(_ x: CGFloat, _ y: CGFloat) -> NSPoint { + return NSPoint(x: rect.origin.x + x * limitedRadius, y: rect.origin.y + y * limitedRadius) + } + + @inline(__always) func topRight(_ x: CGFloat, _ y: CGFloat) -> NSPoint { + return NSPoint(x: rect.origin.x + rect.size.width - x * limitedRadius, y: rect.origin.y + y * limitedRadius) + } + + @inline(__always) func bottomRight(_ x: CGFloat, _ y: CGFloat) -> NSPoint { + return NSPoint(x: rect.origin.x + rect.size.width - x * limitedRadius, y: rect.origin.y + rect.size.height - y * limitedRadius) + } + + @inline(__always) func bottomLeft(_ x: CGFloat, _ y: CGFloat) -> NSPoint { + return NSPoint(x: rect.origin.x + x * limitedRadius, y: rect.origin.y + rect.size.height - y * limitedRadius) + } + + path.move(to: topLeft(1.52866483, 0.00000000)) + path.line(to: topRight(1.52866471, 0.00000000)) + path.curve(to: topRight(0.66993427, 0.06549600), controlPoint1: topRight(1.08849323, 0.00000000), controlPoint2: topRight(0.86840689, 0.00000000)) + path.line(to: topRight(0.63149399, 0.07491100)) + path.curve(to: topRight(0.07491176, 0.63149399), controlPoint1: topRight(0.37282392, 0.16905899), controlPoint2: topRight(0.16906013, 0.37282401)) + path.curve(to: topRight(0.00000000, 1.52866483), controlPoint1: topRight(0.00000000, 0.86840701), controlPoint2: topRight(0.00000000, 1.08849299)) + path.line(to: bottomRight(0.00000000, 1.52866471)) + path.curve(to: bottomRight(0.06549569, 0.66993493), controlPoint1: bottomRight(0.00000000, 1.08849323), controlPoint2: bottomRight(0.00000000, 0.86840689)) + path.line(to: bottomRight(0.07491111, 0.63149399)) + path.curve(to: bottomRight(0.63149399, 0.07491111), controlPoint1: bottomRight(0.16905883, 0.37282392), controlPoint2: bottomRight(0.37282392, 0.16905883)) + path.curve(to: bottomRight(1.52866471, 0.00000000), controlPoint1: bottomRight(0.86840689, 0.00000000), controlPoint2: bottomRight(1.08849323, 0.00000000)) + path.line(to: bottomLeft(1.52866483, 0.00000000)) + path.curve(to: bottomLeft(0.66993397, 0.06549569), controlPoint1: bottomLeft(1.08849299, 0.00000000), controlPoint2: bottomLeft(0.86840701, 0.00000000)) + path.line(to: bottomLeft(0.63149399, 0.07491111)) + path.curve(to: bottomLeft(0.07491100, 0.63149399), controlPoint1: bottomLeft(0.37282401, 0.16905883), controlPoint2: bottomLeft(0.16906001, 0.37282392)) + path.curve(to: bottomLeft(0.00000000, 1.52866471), controlPoint1: bottomLeft(0.00000000, 0.86840689), controlPoint2: bottomLeft(0.00000000, 1.08849323)) + path.line(to: topLeft(0.00000000, 1.52866483)) + path.curve(to: topLeft(0.06549600, 0.66993397), controlPoint1: topLeft(0.00000000, 1.08849299), controlPoint2: topLeft(0.00000000, 0.86840701)) + path.line(to: topLeft(0.07491100, 0.63149399)) + path.curve(to: topLeft(0.63149399, 0.07491100), controlPoint1: topLeft(0.16906001, 0.37282401), controlPoint2: topLeft(0.37282401, 0.16906001)) + path.curve(to: topLeft(1.52866483, 0.00000000), controlPoint1: topLeft(0.86840701, 0.00000000), controlPoint2: topLeft(1.08849299, 0.00000000)) + path.close() + return path + } +} diff --git a/src/Shared.swift b/src/Shared.swift new file mode 100644 index 0000000..948e465 --- /dev/null +++ b/src/Shared.swift @@ -0,0 +1,91 @@ +import Foundation +import os // OSLog + +private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Shared") + + +// Init QuickLook Type +enum FileType { + case IPA + case Archive + case Extension +} + +struct QuickLookInfo { + let UTI: String + let url: URL + let effectiveUrl: URL? // if set, will point to the app inside of an archive + + let type: FileType + let zipFile: ZipFile? // only set for zipped file types + let isOSX = false + + /// Use file url and UTI type to generate an info object to pass around. + init(_ url: URL) { + self.url = url + self.UTI = try! url.resourceValues(forKeys: [.typeIdentifierKey]).typeIdentifier ?? "Unknown" + + var effective: URL? = nil + var zipFile: ZipFile? = nil + + switch self.UTI { + case "com.apple.itunes.ipa": + self.type = FileType.IPA; + zipFile = ZipFile(self.url.path); + case "com.apple.xcode.archive": + self.type = FileType.Archive; + effective = appPathForArchive(self.url); + case "com.apple.application-and-system-extension": + self.type = FileType.Extension; + default: + os_log(.error, log: log, "Unsupported file type: %{public}@", self.UTI) + fatalError() + } + self.zipFile = zipFile + self.effectiveUrl = effective + } + + /// Load a file from bundle into memory. Either by file path or via unzip. + func readPayloadFile(_ filename: String) -> Data? { + switch (self.type) { + case .IPA: + return zipFile!.unzipFile("Payload/*.app/".appending(filename)) + case .Archive: + return try? Data(contentsOf: effectiveUrl!.appendingPathComponent(filename)) + case .Extension: + return try? Data(contentsOf: url.appendingPathComponent(filename)) + } + } +} + + +// MARK: - Meta data for QuickLook + +/// Search an archive for the .app or .ipa bundle. +func appPathForArchive(_ url: URL) -> URL? { + let appsDir = url.appendingPathComponent("Products/Applications/") + if FileManager.default.fileExists(atPath: appsDir.path) { + if let x = try? FileManager.default.contentsOfDirectory(at: appsDir, includingPropertiesForKeys: nil), !x.isEmpty { + return x.first + } + } + return nil; +} + + +// MARK: - Other helper + +enum ExpirationStatus { + case Expired + case Expiring + case Valid + + /// Check time between date and now. Set Expiring if less than 30 days until expiration + init(_ date: Date?) { + if date == nil || date!.timeIntervalSinceNow < 0 { + self = .Expired + } + let components = Calendar.current.dateComponents([.day], from: Date(), to: date!) + self = components.day! < 30 ? .Expiring : .Valid + } +} diff --git a/src/ThumbnailGenerator.swift b/src/ThumbnailGenerator.swift new file mode 100644 index 0000000..934dcb6 --- /dev/null +++ b/src/ThumbnailGenerator.swift @@ -0,0 +1,162 @@ +import Foundation + +////#import "Shared.h" +//#import "AppIcon.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 +//#define BADGE_HEIGHT 75.0 +//#define BADGE_MARGIN_X 60.0 +//#define BADGE_MARGIN_Y 80.0 +// +////Drawing constants +//#define BADGE_BG_COLOR [NSColor lightGrayColor] +//#define BADGE_VALID_COLOR [NSColor colorWithCalibratedRed:(0/255.0) green:(98/255.0) blue:(25/255.0) alpha:1] +//#define BADGE_EXPIRING_COLOR [NSColor colorWithCalibratedRed:(146/255.0) green:(95/255.0) blue:(28/255.0) alpha:1] +//#define BADGE_EXPIRED_COLOR [NSColor colorWithCalibratedRed:(141/255.0) green:(0/255.0) blue:(7/255.0) alpha:1] +//#define BADGE_FONT [NSFont boldSystemFontOfSize:64] +// +// +//OSStatus GenerateThumbnailForURL(void *thisInterface, QLThumbnailRequestRef thumbnail, CFURLRef url, CFStringRef contentTypeUTI, CFDictionaryRef options, CGSize maxSize); +//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 +// ----------------------------------------------------------------------------- */ +// +//// MARK: .ipa .xcarchive +// +//OSStatus renderAppIcon(QuickLookInfo meta, QLThumbnailRequestRef thumbnail) { +// AppIcon *icon = [AppIcon load:meta]; +// if (!icon.canExtractImage) { +// return noErr; +// } +// +// // 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 +// } +// +// 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; +//} +// +// +//// 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); +// +// // 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; +//} +// +// +//// MARK: Main Entry +// +//OSStatus GenerateThumbnailForURL(void *thisInterface, QLThumbnailRequestRef thumbnail, CFURLRef url, CFStringRef contentTypeUTI, CFDictionaryRef options, CGSize maxSize) { +// @autoreleasepool { +// QuickLookInfo meta = initQLInfo(contentTypeUTI, url); +// +// 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; +//} +// +//void CancelThumbnailGeneration(void *thisInterface, QLThumbnailRequestRef thumbnail) { +// // Implement only if supported +//} diff --git a/src/Zip.swift b/src/Zip.swift new file mode 100644 index 0000000..95b87be --- /dev/null +++ b/src/Zip.swift @@ -0,0 +1,366 @@ +import Foundation +import Compression // compression_decode_buffer +import zlib // Z_DEFLATED, crc32 +import os // OSLog + +private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Zip") + + +// MARK: - Helper to parse byte headers + +private struct ByteScanner { + private let data: Data + private var index: Int + private let endIndex: Int + + init (_ data: Data, start: Int) { + self.data = data + self.index = start + self.endIndex = data.endIndex + } + + mutating func scan() -> T { + let newIndex = index + MemoryLayout.size + if newIndex > endIndex { + os_log(.fault, log: log, "ByteScanner out of bounds") + fatalError() + } + let result = data.subdata(in: index ..< newIndex).withUnsafeBytes { $0.load(as: T.self) } + index = newIndex + return result + } + + mutating func scanString(length: Int) -> String { + let bytes = data.subdata(in: index ..< index + length) + index += length + return String(data: bytes, encoding: .utf8) ?? "" + } +} + + +// MARK: - ZIP Headers + +// See http://en.wikipedia.org/wiki/ZIP_(file_format)#File_headers + +/// Local file header +private struct ZIP_LocalFile { + static let LENGTH: Int = 30 + + let magicNumber: UInt32 // 50 4B 03 04 + let versionNeededToExtract: UInt16 + let generalPurposeBitFlag: UInt16 + let compressionMethod: UInt16 + let fileLastModificationTime: UInt16 + let fileLastModificationDate: UInt16 + let CRC32: UInt32 + let compressedSize: UInt32 + let uncompressedSize: UInt32 + let fileNameLength: UInt16 + let extraFieldLength: UInt16 + +// let fileName: String + // Extra field + + init(_ data: Data, start: Data.Index = 0) { + var scanner = ByteScanner(data, start: start) + magicNumber = scanner.scan() + versionNeededToExtract = scanner.scan() + generalPurposeBitFlag = scanner.scan() + compressionMethod = scanner.scan() + fileLastModificationTime = scanner.scan() + fileLastModificationDate = scanner.scan() + CRC32 = scanner.scan() + compressedSize = scanner.scan() + uncompressedSize = scanner.scan() + fileNameLength = scanner.scan() + extraFieldLength = scanner.scan() +// fileName = scanner.scanString(length: Int(fileNameLength)) + } +} + +/// Central directory file header +private struct ZIP_CDFH { + static let LENGTH: Int = 46 + + let magicNumber: UInt32 // 50 4B 01 02 + let versionMadeBy: UInt16 + let versionNeededToExtract: UInt16 + let generalPurposeBitFlag: UInt16 + let compressionMethod: UInt16 + let fileLastModificationTime: UInt16 + let fileLastModificationDate: UInt16 + let CRC32: UInt32 + let compressedSize: UInt32 + let uncompressedSize: UInt32 + let fileNameLength: UInt16 + let extraFieldLength: UInt16 + let fileCommentLength: UInt16 + let diskNumberWhereFileStarts: UInt16 + let internalFileAttributes: UInt16 + let externalFileAttributes: UInt32 + let relativeOffsetOfLocalFileHeader: UInt32 + + let fileName: String + // Extra field + // File comment + + init(_ data: Data, start: Data.Index = 0) { + var scanner = ByteScanner(data, start: start) + magicNumber = scanner.scan() + versionMadeBy = scanner.scan() + versionNeededToExtract = scanner.scan() + generalPurposeBitFlag = scanner.scan() + compressionMethod = scanner.scan() + fileLastModificationTime = scanner.scan() + fileLastModificationDate = scanner.scan() + CRC32 = scanner.scan() + compressedSize = scanner.scan() + uncompressedSize = scanner.scan() + fileNameLength = scanner.scan() + extraFieldLength = scanner.scan() + fileCommentLength = scanner.scan() + diskNumberWhereFileStarts = scanner.scan() + internalFileAttributes = scanner.scan() + externalFileAttributes = scanner.scan() + relativeOffsetOfLocalFileHeader = scanner.scan() + fileName = scanner.scanString(length: Int(fileNameLength)) + } +} + +/// End of central directory record +private struct ZIP_EOCD { + static let LENGTH: Int = 22 + + let magicNumber: UInt32 // 50 4B 05 06 + let numberOfThisDisk: UInt16 + let diskWhereCentralDirectoryStarts: UInt16 + let numberOfCentralDirectoryRecordsOnThisDisk: UInt16 + let totalNumberOfCentralDirectoryRecords: UInt16 + let sizeOfCentralDirectory: UInt32 + let offsetOfStartOfCentralDirectory: UInt32 + let commentLength: UInt16 + // Comment + + init(_ data: Data, start: Data.Index = 0) { + var scanner = ByteScanner(data, start: start) + magicNumber = scanner.scan() + numberOfThisDisk = scanner.scan() + diskWhereCentralDirectoryStarts = scanner.scan() + numberOfCentralDirectoryRecordsOnThisDisk = scanner.scan() + totalNumberOfCentralDirectoryRecords = scanner.scan() + sizeOfCentralDirectory = scanner.scan() + offsetOfStartOfCentralDirectory = scanner.scan() + commentLength = scanner.scan() + } +} + + +// MARK: - CRC32 check + +extension Data { + func crc() -> UInt32 { + return UInt32(self.withUnsafeBytes { crc32(0, $0.baseAddress!, UInt32($0.count)) }) + } +} + + +// MARK: - Unzip data + +func unzipFileEntry(_ path: String, _ entry: ZipEntry) -> Data? { + guard let fp = FileHandle(forReadingAtPath: path) else { + return nil + } + defer { + try? fp.close() + } + fp.seek(toFileOffset: UInt64(entry.offset)) + let file_record = ZIP_LocalFile(fp.readData(ofLength: ZIP_LocalFile.LENGTH)) + os_log(.debug, log: log, "header: %{public}@ vs %{public}@", String(describing: file_record), String(describing: entry)) + + // central directory size and local file size may differ! use local file for ground truth + let dataOffset = Int(entry.offset) + ZIP_LocalFile.LENGTH + Int(file_record.fileNameLength) + Int(file_record.extraFieldLength) + fp.seek(toFileOffset: UInt64(dataOffset)) + let rawData = fp.readData(ofLength: Int(entry.sizeCompressed)) + + if entry.method == Z_DEFLATED { + let size = Int(entry.sizeUncompressed) + let buffer = UnsafeMutablePointer.allocate(capacity: size) + defer { + buffer.deallocate() + } + + let uncompressedData = rawData.withUnsafeBytes ({ + let ptr = $0.baseAddress!.bindMemory(to: UInt8.self, capacity: 1) + let read = compression_decode_buffer(buffer, size, ptr, Int(entry.sizeCompressed), nil, COMPRESSION_ZLIB) + return Data(bytes: buffer, count:read) + }) + if file_record.CRC32 != 0, uncompressedData.crc() != file_record.CRC32 { + os_log(.error, log: log, "CRC check failed (after uncompress)") + return nil + } + return uncompressedData + + } else if entry.method == 0 { + if file_record.CRC32 != 0, rawData.crc() != file_record.CRC32 { + os_log(.error, log: log, "CRC check failed (uncompressed data)") + return nil + } + return rawData + + } else { + os_log(.error, log: log, "unimplemented compression method: %{public}d", entry.method) + return nil + } +} + + +// MARK: - List files + +private func listZip(_ path: String) -> [ZipEntry] { + guard let fp = FileHandle(forReadingAtPath: path) else { + return [] + } + defer { + try? fp.close() + } + + guard let endRecord = findCentralDirectory(fp), endRecord.sizeOfCentralDirectory > 0 else { + return []; + } + return listDirectoryEntries(fp, endRecord); +} + +/// Find signature for central directory. +private func findCentralDirectory(_ fp: FileHandle) -> ZIP_EOCD? { + let eof = fp.seekToEndOfFile() + fp.seek(toFileOffset: max(0, eof - 4096)) + let data = fp.readDataToEndOfFile() + + let centralDirSignature: [UInt8] = [0x50, 0x4b, 0x05, 0x06] + + guard let range = data.lastRange(of: centralDirSignature) else { + os_log(.error, log: log, "no zip end-header found!") + return nil + } + return ZIP_EOCD(data, start: range.lowerBound) +} + +/// List all files and folders of of the central directory. +private func listDirectoryEntries(_ fp: FileHandle, _ centralDir: ZIP_EOCD) -> [ZipEntry] { + fp.seek(toFileOffset: UInt64(centralDir.offsetOfStartOfCentralDirectory)) + let data = fp.readData(ofLength: Int(centralDir.sizeOfCentralDirectory)) + let total = data.count + + var idx = 0 + var entries: [ZipEntry] = [] + + while idx + ZIP_CDFH.LENGTH < total { + let record = ZIP_CDFH(data, start: idx) + // read filename + idx += ZIP_CDFH.LENGTH + let filename = String(data: data.subdata(in: idx ..< idx + Int(record.fileNameLength)), encoding: .utf8)! + entries.append(ZipEntry(filename, record)) + // update index + idx += Int(record.fileNameLength + record.extraFieldLength + record.fileCommentLength) + } + return entries +} + + +// MARK: - ZipEntry + +struct ZipEntry { + let filepath: String + let offset: UInt32 + let method: UInt16 + let sizeCompressed: UInt32 + let sizeUncompressed: UInt32 + let filenameLength: UInt16 + let extraFieldLength: UInt16 + let CRC32: UInt32 + + fileprivate init(_ filename: String, _ record: ZIP_CDFH) { + self.filepath = filename + self.offset = record.relativeOffsetOfLocalFileHeader + self.method = record.compressionMethod + self.sizeCompressed = record.compressedSize + self.sizeUncompressed = record.uncompressedSize + self.filenameLength = record.fileNameLength + self.extraFieldLength = record.extraFieldLength + self.CRC32 = record.CRC32 + } +} + +extension Array where Element == ZipEntry { + /// Return entry with shortest possible path (thus ignoring deeper nested files). + func zipEntryWithShortestPath() -> ZipEntry? { + var shortest = 99999 + var bestMatch: ZipEntry? = nil + + for entry in self { + if shortest > entry.filepath.count { + shortest = entry.filepath.count + bestMatch = entry + } + } + return bestMatch + } +} + + +// MARK: - ZipFile + +struct ZipFile { + private let pathToZipFile: String + private let centralDirectory: [ZipEntry] + + init(_ path: String) { + self.pathToZipFile = path + self.centralDirectory = listZip(path) + } + + // MARK: - public methods + + func filesMatching(_ path: String) -> [ZipEntry] { + let parts = path.split(separator: "*", omittingEmptySubsequences: false) + return centralDirectory.filter { + var idx = $0.filepath.startIndex + if !$0.filepath.hasPrefix(parts.first!) || !$0.filepath.hasSuffix(parts.last!) { + return false + } + for part in parts { + guard let found = $0.filepath.range(of: part, range: idx..<$0.filepath.endIndex) else { + return false + } + idx = found.upperBound + } + return true + } + } + + /// Unzip file directly into memory. + /// @param filePath File path inside zip file. + func unzipFile(_ filePath: String) -> Data? { + if let matchingFile = self.filesMatching(filePath).zipEntryWithShortestPath() { + os_log(.debug, log: log, "[unzip] %{public}@", matchingFile.filepath) + return unzipFileEntry(pathToZipFile, matchingFile) + } + + // There is a dir listing but no matching file. + // This means there wont be anything to extract. + os_log(.error, log: log, "cannot find '%{public}@' for unzip", filePath) + return nil + } + + /// Unzip file to filesystem. + /// @param filePath File path inside zip file. + /// @param targetDir Directory in which to unzip the file. + func unzipFile(_ filePath: String, toDir targetDir: String) throws { + if let data = self.unzipFile(filePath) { + let filename = filePath.components(separatedBy: "/").last! + let outputPath = targetDir.appending("/" + filename) + os_log(.debug, log: log, "[unzip] write to %{public}@", outputPath) + try data.write(to: URL(fileURLWithPath: outputPath), options: .atomic) + } + } +}