diff --git a/Quicksilver/Code-QuickStepCore/QSObject_FileHandling.m b/Quicksilver/Code-QuickStepCore/QSObject_FileHandling.m index 3c5c2e638..ba07cd81f 100644 --- a/Quicksilver/Code-QuickStepCore/QSObject_FileHandling.m +++ b/Quicksilver/Code-QuickStepCore/QSObject_FileHandling.m @@ -127,6 +127,12 @@ - (NSString *)kindOfObject:(QSObject *)object { return nil; } +/* The location the Finder presents a file at: /Applications for system apps + * like FindMy.app, the path itself for everything else */ +static NSString *QSDisplayPathForPath(NSString *path) { + return [[[[NSURL fileURLWithPath:path] URLByMappingSystemApplicationsToLocalDomain] path] stringByAbbreviatingWithTildeInPath]; +} + - (NSString *)detailsOfObject:(QSObject *)object { NSArray *theFiles = [object arrayForType:QSFilePathType]; if ([theFiles count] == 1) { @@ -134,7 +140,7 @@ - (NSString *)detailsOfObject:(QSObject *)object { if ([object isAlias] && ![object isUbiquitousItem]) { // isAlias returns YES for symlink or alias // Symlink file if (QSTypeConformsTo([object fileUTI], (NSString *)kUTTypeSymLink)) { - return [[path stringByResolvingSymlinksInPath] stringByAbbreviatingWithTildeInPath]; + return QSDisplayPathForPath([path stringByResolvingSymlinksInPath]); } // Finder alias file NSURL *fileURL = [NSURL fileURLWithPath:path]; @@ -169,7 +175,7 @@ - (NSString *)detailsOfObject:(QSObject *)object { } // normal file - return [path stringByAbbreviatingWithTildeInPath]; + return QSDisplayPathForPath(path); } else if ([theFiles count] > 1) { return [[theFiles arrayByPerformingSelector:@selector(lastPathComponent)] componentsJoinedByString:@", "]; diff --git a/Quicksilver/Code-QuickStepFoundation/NSURL_BLTRExtensions.h b/Quicksilver/Code-QuickStepFoundation/NSURL_BLTRExtensions.h index 0ab6f65ad..0357fcfc2 100644 --- a/Quicksilver/Code-QuickStepFoundation/NSURL_BLTRExtensions.h +++ b/Quicksilver/Code-QuickStepFoundation/NSURL_BLTRExtensions.h @@ -28,6 +28,42 @@ @end +@interface NSURL (QSCanonicalPath) + +/** + * The location the Finder shows this file at + * + * @return a new NSURL under the local /Applications folder, or self + * @discussion Finder presents the system domain's Applications + * directories (/System/Applications and the App cryptex that backs + * /Applications symlinks like Safari) merged into the local + * /Applications folder, so e.g. /System/Applications/FindMy.app is + * shown in /Applications. Paths outside those directories, and paths + * inside a bundle within them, are returned unchanged. The mapped path + * may not exist on disk (FindMy.app has no /Applications entry), so + * use it for display, not file access. + */ +- (NSURL *)URLByMappingSystemApplicationsToLocalDomain; + +/** + * The user-visible location of a file that may be reachable through + * multiple paths + * + * @return a new NSURL for the same file at its user-visible path, or self + * @discussion System applications shipped in a cryptex (e.g. Safari) + * are reported by Launch Services and directory scans at their backing + * location (/System/Volumes/Preboot/Cryptexes/App/... or + * /System/Cryptexes/App/...) rather than the path users see in the + * Finder (/Applications/Safari.app). There is no direct API for this + * mapping (see https://developer.apple.com/forums/thread/745673); it + * is derived from the system's Applications directories, and the + * result is only used when it exists and refers to the same file + * (NSURLFileResourceIdentifierKey), so this always returns a real path. + */ +- (NSURL *)URLByResolvingToUserVisiblePath; + +@end + @interface NSURL (QSBookmarkHelpers) + (instancetype)URLByResolvingBookmarkAtURL:(NSURL *)bookmarkURL options:(NSURLBookmarkResolutionOptions)options bookmarkDataIsStale:(BOOL *)isStale error:(NSError **)error; - (BOOL)writeBookmarkToURL:(NSURL *)destinationURL options:(NSURLBookmarkFileCreationOptions)options error:(NSError **)error; diff --git a/Quicksilver/Code-QuickStepFoundation/NSURL_BLTRExtensions.m b/Quicksilver/Code-QuickStepFoundation/NSURL_BLTRExtensions.m index ba70623e6..4e1cb1e5d 100644 --- a/Quicksilver/Code-QuickStepFoundation/NSURL_BLTRExtensions.m +++ b/Quicksilver/Code-QuickStepFoundation/NSURL_BLTRExtensions.m @@ -101,6 +101,81 @@ - (NSURL *)URLByReallyResolvingSymlinksInPath { @end +/* The system domain's Applications directories in every path form: as reported + * by NSSearchPathForDirectoriesInDomains, plus their symlink-resolved forms + * (e.g. /System/Cryptexes/App is a symlink to /System/Volumes/Preboot/Cryptexes/App, + * the form directory enumerators report) */ +static NSArray *QSSystemApplicationsDirectories(void) { + static NSArray *directories = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSMutableSet *set = [NSMutableSet set]; + for (NSString *dir in NSSearchPathForDirectoriesInDomains(NSApplicationDirectory, NSSystemDomainMask, YES)) { + [set addObject:dir]; + [set addObject:[dir stringByResolvingSymlinksInPath]]; + } + directories = [set allObjects]; + }); + return directories; +} + +/* YES if any directory between root and the last path component is a bundle */ +static BOOL QSPathHasBundleAncestor(NSString *root, NSArray *components, NSUInteger fromIndex) { + NSString *ancestor = root; + for (NSUInteger i = fromIndex; i + 1 < [components count]; i++) { + ancestor = [ancestor stringByAppendingPathComponent:components[i]]; + NSNumber *isPackage = nil; + [[NSURL fileURLWithPath:ancestor] getResourceValue:&isPackage forKey:NSURLIsPackageKey error:NULL]; + if ([isPackage boolValue]) return YES; + } + return NO; +} + +@implementation NSURL (QSCanonicalPath) + +- (NSURL *)URLByMappingSystemApplicationsToLocalDomain { + if (![self isFileURL]) return self; + + NSString *path = [self path]; + NSArray *components = nil; + for (NSString *systemApps in QSSystemApplicationsDirectories()) { + if (![path hasPrefix:systemApps]) continue; + if (!components) components = [path pathComponents]; + + // Must be a descendant on a component boundary: /System/ApplicationsFoo + // shares the prefix string but isn't inside /System/Applications + NSArray *systemAppsComponents = [systemApps pathComponents]; + NSUInteger prefixCount = [systemAppsComponents count]; + if ([components count] <= prefixCount) continue; + if (![[components subarrayWithRange:NSMakeRange(0, prefixCount)] isEqualToArray:systemAppsComponents]) continue; + + // Finder only merges the folder hierarchy; paths inside a bundle are not remapped + if (QSPathHasBundleAncestor(systemApps, components, prefixCount)) return self; + + NSString *localApps = [NSSearchPathForDirectoriesInDomains(NSApplicationDirectory, NSLocalDomainMask, YES) firstObject]; + NSArray *relativeComponents = [components subarrayWithRange:NSMakeRange(prefixCount, [components count] - prefixCount)]; + return [NSURL fileURLWithPathComponents:[[localApps pathComponents] arrayByAddingObjectsFromArray:relativeComponents]]; + } + return self; +} + +- (NSURL *)URLByResolvingToUserVisiblePath { + NSURL *mappedURL = [self URLByMappingSystemApplicationsToLocalDomain]; + if (mappedURL == self) return self; // not under a system Applications directory + + // Only adopt the mapped path if it exists and refers to the same file + // (following symlinks, since the user-visible path may be a symlink to the + // backing location, like /Applications/Safari.app) + id selfIdentifier = nil, mappedIdentifier = nil; + [[self URLByResolvingSymlinksInPath] getResourceValue:&selfIdentifier forKey:NSURLFileResourceIdentifierKey error:NULL]; + [[mappedURL URLByResolvingSymlinksInPath] getResourceValue:&mappedIdentifier forKey:NSURLFileResourceIdentifierKey error:NULL]; + if (!selfIdentifier || !mappedIdentifier || ![selfIdentifier isEqual:mappedIdentifier]) return self; + + return mappedURL; +} + +@end + @implementation NSURL (QSBookmarkHelpers) + (instancetype)URLByResolvingBookmarkAtURL:(NSURL *)bookmarkURL options:(NSURLBookmarkResolutionOptions)options bookmarkDataIsStale:(BOOL *)isStale error:(NSError **)error { diff --git a/Quicksilver/Code-QuickStepFoundation/NSWorkspace_BLTRExtensions.m b/Quicksilver/Code-QuickStepFoundation/NSWorkspace_BLTRExtensions.m index 37baab1e0..f0c8a0091 100644 --- a/Quicksilver/Code-QuickStepFoundation/NSWorkspace_BLTRExtensions.m +++ b/Quicksilver/Code-QuickStepFoundation/NSWorkspace_BLTRExtensions.m @@ -8,6 +8,8 @@ #import "NSWorkspace_BLTRExtensions.h" #import "NSApplication_BLTRExtensions.h" +#import "NSArray_BLTRExtensions.h" +#import "NSURL_BLTRExtensions.h" #include #include @@ -86,7 +88,10 @@ - (BOOL)setComment:(NSString*)comment forFile:(NSString *)path { - (NSArray *)allApplicationsURLs { CFArrayRef appURLs = NULL; _LSCopyAllApplicationURLs(&appURLs); - return (__bridge_transfer NSArray *)appURLs; + NSArray *urls = (__bridge_transfer NSArray *)appURLs; + // Launch Services reports cryptex-shipped apps (e.g. Safari) at their + // backing location; resolve to the user-visible path (#3125) + return [urls arrayByPerformingSelector:@selector(URLByResolvingToUserVisiblePath)]; } - (NSArray *)allApplications { diff --git a/Quicksilver/PlugIns-Main/QSCorePlugIn/Code/QSDirectoryParser.m b/Quicksilver/PlugIns-Main/QSCorePlugIn/Code/QSDirectoryParser.m index e1fc39f30..bb9ef396b 100644 --- a/Quicksilver/PlugIns-Main/QSCorePlugIn/Code/QSDirectoryParser.m +++ b/Quicksilver/PlugIns-Main/QSCorePlugIn/Code/QSDirectoryParser.m @@ -9,6 +9,7 @@ #import "QSDirectoryParser.h" #import "NDAlias+AliasFile.h" +#import "NSURL_BLTRExtensions.h" @implementation QSDirectoryParser @@ -138,7 +139,15 @@ - (NSArray *)objectsFromPath:(NSString *)path depth:(NSInteger)depth types:(NSAr } if (include) { - QSObject *obj = [QSObject fileObjectWithFileURL:theURL]; + NSURL *objectURL = theURL; + if (QSTypeConformsTo(type, (NSString *)kUTTypeApplication)) { + // Applications can be scanned via a cryptex backing path + // (e.g. /System/Cryptexes/App/System/Applications/Safari.app); + // catalog them at their user-visible path so every source + // yields the same object (#3125) + objectURL = [objectURL URLByResolvingToUserVisiblePath]; + } + QSObject *obj = [QSObject fileObjectWithFileURL:objectURL]; if (aliasSource) [obj setObject:[aliasSource data] forType:QSAliasDataType]; if (aliasFile) [obj setObject:aliasFile forType:QSAliasFilePathType]; if (obj) [array addObject:obj]; diff --git a/Quicksilver/PlugIns-Main/QSCorePlugIn/QSCorePlugIn-Info.plist b/Quicksilver/PlugIns-Main/QSCorePlugIn/QSCorePlugIn-Info.plist index 02292f86a..839cdd12d 100644 --- a/Quicksilver/PlugIns-Main/QSCorePlugIn/QSCorePlugIn-Info.plist +++ b/Quicksilver/PlugIns-Main/QSCorePlugIn/QSCorePlugIn-Info.plist @@ -50,6 +50,11 @@ name Applications + source QSFileSystemObjectSource @@ -62,7 +67,7 @@ com.apple.application folderDepth - 1 + 3 scanContents 1 kind diff --git a/Quicksilver/Quicksilver.xcodeproj/project.pbxproj b/Quicksilver/Quicksilver.xcodeproj/project.pbxproj index ed6f0fb2e..cc35da341 100644 --- a/Quicksilver/Quicksilver.xcodeproj/project.pbxproj +++ b/Quicksilver/Quicksilver.xcodeproj/project.pbxproj @@ -178,6 +178,7 @@ 6615228414F43355006FDCB4 /* PathWildcardsData in Resources */ = {isa = PBXBuildFile; fileRef = 6615228314F43355006FDCB4 /* PathWildcardsData */; }; 66448DF214F427B8000FA2E2 /* QSFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1E5F9AF07B1FCFC0044D6EF /* QSFoundation.framework */; }; 66448E1314F42A4E000FA2E2 /* TestPathWildcards.m in Sources */ = {isa = PBXBuildFile; fileRef = 66448E1214F42A4E000FA2E2 /* TestPathWildcards.m */; }; + 664D3125202608170000A001 /* TestNSURLCanonicalPath.m in Sources */ = {isa = PBXBuildFile; fileRef = 664D3125202608170000A000 /* TestNSURLCanonicalPath.m */; }; 666E45FF150226200034E60A /* QSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E103F1600647200700447FE0 /* QSCore.framework */; }; 7F05F1E20852441C00A8EC0C /* NSObject+BLTRExtensions.h in Headers */ = {isa = PBXBuildFile; fileRef = 7F05F1E00852441C00A8EC0C /* NSObject+BLTRExtensions.h */; settings = {ATTRIBUTES = (Public, ); }; }; 7F05F1E30852441C00A8EC0C /* NSObject+BLTRExtensions.m in Sources */ = {isa = PBXBuildFile; fileRef = 7F05F1E10852441C00A8EC0C /* NSObject+BLTRExtensions.m */; }; @@ -1259,6 +1260,7 @@ 66448D9614F42790000FA2E2 /* QSFoundationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = QSFoundationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 66448D9714F42790000FA2E2 /* QSFoundationTests-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "QSFoundationTests-Info.plist"; sourceTree = ""; }; 66448E1214F42A4E000FA2E2 /* TestPathWildcards.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = TestPathWildcards.m; path = "Tests/Tests-QSFoundation/TestPathWildcards.m"; sourceTree = ""; }; + 664D3125202608170000A000 /* TestNSURLCanonicalPath.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = TestNSURLCanonicalPath.m; path = "Tests/Tests-QSFoundation/TestNSURLCanonicalPath.m"; sourceTree = ""; }; 666E45EE150225EA0034E60A /* QSCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = QSCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 666E45EF150225EA0034E60A /* QSCoreTests-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "QSCoreTests-Info.plist"; sourceTree = ""; }; 66D11C6515022A75002EE6E5 /* QSEffectsTests-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "QSEffectsTests-Info.plist"; sourceTree = ""; }; @@ -2805,6 +2807,7 @@ children = ( CD661C5319DBD2F3000F3695 /* TestNSApplicationMethods.m */, 66448E1214F42A4E000FA2E2 /* TestPathWildcards.m */, + 664D3125202608170000A000 /* TestNSURLCanonicalPath.m */, 4DF329D81EA2C5F0003CD3CE /* TestQSSense.m */, CD72C74F2877C0D10040B065 /* TestNDHotkey.m */, ); @@ -4961,6 +4964,7 @@ 4DF329D91EA2C5F0003CD3CE /* TestQSSense.m in Sources */, CD661C5419DBD2F3000F3695 /* TestNSApplicationMethods.m in Sources */, 66448E1314F42A4E000FA2E2 /* TestPathWildcards.m in Sources */, + 664D3125202608170000A001 /* TestNSURLCanonicalPath.m in Sources */, CD72C7502877C0D10040B065 /* TestNDHotkey.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Quicksilver/Tests/Tests-QSCore/QSFileObjectTests.m b/Quicksilver/Tests/Tests-QSCore/QSFileObjectTests.m index ad04a0e4a..ad035a475 100644 --- a/Quicksilver/Tests/Tests-QSCore/QSFileObjectTests.m +++ b/Quicksilver/Tests/Tests-QSCore/QSFileObjectTests.m @@ -72,6 +72,41 @@ - (void)testFileUTI XCTAssertTrue(UTTypeConformsTo((__bridge CFStringRef)[object fileUTI], (__bridge CFStringRef)@"public.script"), @"The fast logout script should be seen as a script by Quicksilver"); } +- (void)testCryptexApplicationDetails +{ + // Cryptex-backed apps should show their /Applications location, not a + // backing path under /System/Volumes/Preboot. Issue #3125 + NSString *backingPath = [@"/Applications/Safari.app" stringByResolvingSymlinksInPath]; + if ([backingPath isEqualToString:@"/Applications/Safari.app"]) { + XCTSkip(@"Safari is not cryptex-backed on this system"); + } + QSFileSystemObjectHandler *handler = [[QSFileSystemObjectHandler alloc] init]; + QSObject *object = [QSObject fileObjectWithPath:backingPath]; + XCTAssertEqualObjects([handler detailsOfObject:object], @"/Applications/Safari.app"); + + // The /Applications symlink to the cryptex should show itself, not its target + object = [QSObject fileObjectWithPath:@"/Applications/Safari.app"]; + XCTAssertEqualObjects([handler detailsOfObject:object], @"/Applications/Safari.app"); +} + +- (void)testSystemApplicationDetails +{ + // Apps in /System/Applications are shown by the Finder in /Applications, + // so that's what details should show. Issue #3125 + NSString *terminalPath = @"/System/Applications/Utilities/Terminal.app"; + if (![[NSFileManager defaultManager] fileExistsAtPath:terminalPath]) { + XCTSkip(@"Terminal.app is not in /System/Applications on this system"); + } + QSFileSystemObjectHandler *handler = [[QSFileSystemObjectHandler alloc] init]; + QSObject *object = [QSObject fileObjectWithPath:terminalPath]; + XCTAssertEqualObjects([handler detailsOfObject:object], @"/Applications/Utilities/Terminal.app"); + + // ...but paths inside their bundles are not remapped + NSString *interiorPath = [terminalPath stringByAppendingPathComponent:@"Contents/Info.plist"]; + object = [QSObject fileObjectWithPath:interiorPath]; + XCTAssertEqualObjects([handler detailsOfObject:object], interiorPath); +} + - (void)testFileObject { NSString *path; diff --git a/Quicksilver/Tests/Tests-QSFoundation/TestNSURLCanonicalPath.m b/Quicksilver/Tests/Tests-QSFoundation/TestNSURLCanonicalPath.m new file mode 100644 index 000000000..a0b5d7544 --- /dev/null +++ b/Quicksilver/Tests/Tests-QSFoundation/TestNSURLCanonicalPath.m @@ -0,0 +1,66 @@ +// +// TestNSURLCanonicalPath.m +// Quicksilver +// +// Tests for the NSURL (QSCanonicalPath) category (issue #3125) +// + +#import +#import "NSURL_BLTRExtensions.h" + +@interface TestNSURLCanonicalPath : XCTestCase +@end + +@implementation TestNSURLCanonicalPath + +- (void)testRegularPathsAreUnchanged { + for (NSString *path in @[@"/Applications", NSHomeDirectory(), @"/usr/bin/yes"]) { + NSURL *url = [NSURL fileURLWithPath:path]; + XCTAssertEqualObjects([url URLByResolvingToUserVisiblePath].path, path); + XCTAssertEqualObjects([url URLByMappingSystemApplicationsToLocalDomain].path, path); + } +} + +- (void)testNonexistentPathIsUnchanged { + NSURL *url = [NSURL fileURLWithPath:@"/Applications/QSDoesNotExist.app"]; + XCTAssertEqualObjects([url URLByResolvingToUserVisiblePath], url); +} + +- (void)testNonFileURLIsUnchanged { + NSURL *url = [NSURL URLWithString:@"https://qsapp.com/"]; + XCTAssertEqualObjects([url URLByResolvingToUserVisiblePath], url); + XCTAssertEqualObjects([url URLByMappingSystemApplicationsToLocalDomain], url); +} + +- (void)testCryptexBackedAppResolvesToUserVisiblePath { + // Safari ships in a cryptex; Launch Services and directory scans can + // report it at the backing location instead of the /Applications symlink + NSString *userVisiblePath = @"/Applications/Safari.app"; + NSFileManager *manager = [NSFileManager defaultManager]; + if (![manager fileExistsAtPath:userVisiblePath] + || ![manager fileExistsAtPath:@"/System/Cryptexes/App/System/Applications/Safari.app"]) { + XCTSkip(@"Safari is not cryptex-backed on this system"); + } + for (NSString *backingPath in @[@"/System/Volumes/Preboot/Cryptexes/App/System/Applications/Safari.app", + @"/System/Cryptexes/App/System/Applications/Safari.app"]) { + NSURL *resolved = [[NSURL fileURLWithPath:backingPath] URLByResolvingToUserVisiblePath]; + XCTAssertEqualObjects(resolved.path, userVisiblePath); + } +} + +- (void)testSystemApplicationWithoutLocalCounterpart { + NSString *terminalPath = @"/System/Applications/Utilities/Terminal.app"; + if (![[NSFileManager defaultManager] fileExistsAtPath:terminalPath]) { + XCTSkip(@"Terminal.app is not in /System/Applications on this system"); + } + // The catalog mapping requires an existing file, so the real path is kept... + XCTAssertEqualObjects([[NSURL fileURLWithPath:terminalPath] URLByResolvingToUserVisiblePath].path, terminalPath); + // ...while the Finder-location mapping gives the /Applications location + XCTAssertEqualObjects([[NSURL fileURLWithPath:terminalPath] URLByMappingSystemApplicationsToLocalDomain].path, + @"/Applications/Utilities/Terminal.app"); + // Paths inside a bundle are never remapped + NSString *interiorPath = [terminalPath stringByAppendingPathComponent:@"Contents/Info.plist"]; + XCTAssertEqualObjects([[NSURL fileURLWithPath:interiorPath] URLByMappingSystemApplicationsToLocalDomain].path, interiorPath); +} + +@end