Last active
October 7, 2020 17:22
Determine APFS Volume Group relationships in macOS Catalina and Big Sur
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// Sample code for determining all invididual mounted volumes, including volume groups. | |
// Tested on macOS 10.13.6, 10.15.6 and 10.16 (11.0) b6 | |
// | |
// See https://stackoverflow.com/questions/63876549/ | |
// | |
// Created by Thomas Tempelmann on 14 Sep 20 to 17 Sep 20 (yes, this took me several days to get right). | |
// | |
#import <IOKit/IOKitLib.h> | |
#import <DiskArbitration/DiskArbitration.h> | |
#import "AppDelegate.h" | |
#define NOT ! | |
#if DEBUG && TARGET_CPU_X86 // https://www.cocoawithlove.com/2008/03/break-into-debugger.html | |
#define DebugBreak() { asm("int3;"); } | |
#else | |
#define DebugBreak() | |
#endif | |
@interface Volume : NSObject | |
@property (strong) NSURL *url; | |
@property (weak) Volume *groupMemberOf; | |
@property (strong) NSMutableSet<Volume*> *groupMembers; | |
@property (strong) NSUUID *groupID; | |
@property (strong) NSUUID *volUUID; | |
@property (strong) NSData *identifier; | |
@property (strong) NSString *roles; | |
@end | |
@implementation Volume | |
- (NSString *)description { | |
NSString *ext = @""; | |
if (self.roles) { | |
ext = [ext stringByAppendingFormat:@" <%@>", self.roles]; | |
} | |
if (self.groupMemberOf) { | |
ext = [ext stringByAppendingFormat:@" (member of %@)", self.groupMemberOf.url.path]; | |
} | |
if (self.groupMembers) { | |
ext = [ext stringByAppendingFormat:@" (members: %@)", [[self.groupMembers.allObjects valueForKeyPath:@"url.path"] componentsJoinedByString:@", "]]; | |
} | |
return [NSString stringWithFormat:@"'%@' %@%@", self.volumeName, self.url.path, ext]; | |
} | |
- (NSString *)volumeName { | |
NSString *name = nil; | |
[self.url getResourceValue:&name forKey:NSURLVolumeNameKey error:nil]; | |
return name; | |
} | |
- (NSString *)folderName { | |
NSString *name = nil; | |
[self.url getResourceValue:&name forKey:NSURLNameKey error:nil]; | |
return name; | |
} | |
@end | |
@interface AppDelegate() | |
@property NSArray<Volume*> *volumes; | |
@property NSMutableDictionary<NSUUID*,NSUUID*> *groupIDByVolUUID; | |
@property NSMutableDictionary<NSUUID*,NSString*> *rolesByVolUUID; | |
@property NSMutableDictionary<NSUUID*,NSURL*> *volURLByVolUUID; | |
@end | |
@implementation AppDelegate | |
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification { | |
[self updateVolumes]; | |
[NSApp terminate:0]; | |
} | |
- (Volume *)volumeWithURL:(NSURL *)url { | |
Volume *result = nil; | |
for (Volume *volume in self.volumes) { | |
if ([volume.url isEqual:url]) { | |
result = volume; | |
break; | |
} | |
} | |
return result; | |
} | |
- (Volume * _Nullable)volumeWithUUID:(NSUUID *)uuid { | |
Volume *result = nil; | |
for (Volume *volume in self.volumes) { | |
if ([volume.volUUID isEqual:uuid]) { | |
result = volume; | |
break; | |
} | |
} | |
return result; | |
} | |
- (Volume * _Nullable)findOrCreateVolumeWithURL:(NSURL *)url volID:(NSUUID* _Nullable)volID { | |
Volume *vol; | |
// If we already know the Volume then let's re-use the existing object | |
if (volID) { | |
vol = [self volumeWithUUID:volID]; | |
} else { | |
vol = [self volumeWithURL:url]; | |
} | |
if (vol == nil) { | |
// A newly detected volume | |
vol = Volume.new; | |
vol.url = url; | |
vol.volUUID = volID; | |
if (volID) { | |
vol.groupID = self.groupIDByVolUUID[volID]; | |
vol.roles = self.rolesByVolUUID[volID]; | |
} | |
} | |
return vol; | |
} | |
- (NSSet<NSUUID*>*) volIDsMatchingGroupID:(NSUUID*)groupID { | |
return [self.groupIDByVolUUID keysOfEntriesPassingTest:^BOOL(NSUUID * _Nonnull currVolID, NSUUID * _Nonnull currGroupID, BOOL * _Nonnull stop) { | |
return [groupID isEqual:currGroupID]; | |
}]; | |
} | |
- (void)updateVolumes { | |
@synchronized (self.volumes) { | |
self.groupIDByVolUUID = NSMutableDictionary.new; | |
self.rolesByVolUUID = NSMutableDictionary.new; | |
self.volURLByVolUUID = NSMutableDictionary.new; | |
NSMutableArray<Volume*> *newVolumes = [NSMutableArray arrayWithCapacity:self.volURLByVolUUID.count]; | |
DASessionRef daSession = DASessionCreate (NULL); | |
// Get every mounted volume's volume UUID and volume group ID from the IO Registry | |
io_iterator_t iterator; io_object_t obj; | |
IOServiceGetMatchingServices (kIOMasterPortDefault, IOServiceMatching("IOMediaBSDClient"), &iterator); | |
while ((obj = IOIteratorNext (iterator)) != 0) { | |
io_object_t obj2; | |
IORegistryEntryGetParentEntry (obj, kIOServicePlane, &obj2); | |
DADiskRef daDisk = DADiskCreateFromIOMedia (NULL, daSession, obj2); | |
CFDictionaryRef daInfo = DADiskCopyDescription (daDisk); | |
NSURL *volURL = CFDictionaryGetValue (daInfo, CFSTR("DAVolumePath")); | |
CFUUIDRef uuid = CFDictionaryGetValue (daInfo, CFSTR("DAVolumeUUID")); | |
if (uuid) { | |
NSUUID *volID = [NSUUID.alloc initWithUUIDString:CFBridgingRelease(CFUUIDCreateString(NULL, uuid))]; | |
NSArray *roles = CFBridgingRelease(IORegistryEntryCreateCFProperty(obj2, CFSTR("Role"), kCFAllocatorDefault, 0)); | |
NSString *groupID = CFBridgingRelease(IORegistryEntryCreateCFProperty(obj2, CFSTR("VolGroupUUID"), kCFAllocatorDefault, 0)); | |
if ([groupID isEqualToString:@"00000000-0000-0000-0000-000000000000"]) { | |
groupID = nil; | |
} | |
if (groupID) { | |
self.groupIDByVolUUID[volID] = [NSUUID.alloc initWithUUIDString:groupID]; | |
} | |
self.volURLByVolUUID[volID] = volURL; // may be nil, but needed to identify the boot volume's role, see below | |
if (roles == nil && [volURL.path isEqualToString:@"/"]) { | |
// Since Big Sur, the startup system volume is mounted from a Snapshot that has no Role entry in the IO Registry | |
roles = @[@"System"]; | |
} | |
self.rolesByVolUUID[volID] = [roles componentsJoinedByString:@","]; | |
} | |
CFRelease (daInfo); | |
CFRelease (daDisk); | |
} | |
NSLog (@"\nAll volumes: %@\nAll groups: %@\nAll roles: %@", self.volURLByVolUUID, self.groupIDByVolUUID, self.rolesByVolUUID); | |
// This is the block handler for checking and adding a found volume | |
void (^addVolume)(NSUUID *volID, NSURL *volURL) = ^(NSUUID *volID, NSURL *volURL) { | |
if (volURL == nil) volURL = self.volURLByVolUUID[volID]; | |
// Only collect browsable volumes | |
NSNumber *isBrowsable = @NO; | |
[volURL getResourceValue:&isBrowsable forKey:NSURLVolumeIsBrowsableKey error:nil]; | |
if (NOT isBrowsable.boolValue) return; | |
// Prevent adding the same volume multiple times | |
NSString *volPath = volURL.path.decomposedStringWithCanonicalMapping; | |
NSInteger foundIdx = [newVolumes indexOfObjectPassingTest:^BOOL(Volume * _Nonnull vol, NSUInteger idx, BOOL * _Nonnull stop) { | |
// The isEqual test fails with volumes that contain Umlauts such as an "ü": return [vol.url isEqual:volURL]; | |
return [vol.url.path.decomposedStringWithCanonicalMapping isEqualToString:volPath]; | |
}]; | |
if (foundIdx != NSNotFound) return; | |
// Create a new volume object and add it to out array | |
Volume *vol = [self findOrCreateVolumeWithURL:volURL volID:volID]; | |
if (vol == nil) return; | |
[newVolumes addObject:vol]; | |
// Identify any group-connected volumes | |
NSMutableArray<Volume*> *groupedVols = NSMutableArray.array; | |
[[self volIDsMatchingGroupID:vol.groupID] enumerateObjectsUsingBlock:^(NSUUID * _Nonnull volID, BOOL * _Nonnull stop) { | |
NSURL *url = self.volURLByVolUUID[volID]; | |
if (url && NOT [url isEqual:vol.url]) { | |
Volume *vol = [self findOrCreateVolumeWithURL:url volID:volID]; | |
if (vol) [groupedVols addObject:vol]; | |
} | |
}]; | |
for (Volume *member in groupedVols) { | |
member.groupMemberOf = vol; | |
if (vol.groupMembers == nil) vol.groupMembers = NSMutableSet.new; | |
[vol.groupMembers addObject:member]; | |
if (NOT [vol.roles isEqualToString:@"System"] && (vol.roles != nil)) { | |
DebugBreak(); | |
NSLog(@"Oops2 - unexpected volume grouping"); | |
} | |
if (NOT [newVolumes containsObject:member]) { | |
[newVolumes addObject:member]; | |
} | |
} | |
}; | |
// Process all grouped volumes first | |
for (NSUUID *groupID in self.groupIDByVolUUID.allKeys) { | |
// Sort the vols so that "System" roles come first | |
NSArray<NSUUID*> *volIDsInGroup = [self volIDsMatchingGroupID:groupID].allObjects; | |
volIDsInGroup = [volIDsInGroup sortedArrayUsingComparator:^NSComparisonResult(NSUUID* _Nonnull id1, NSUUID* _Nonnull id2) { | |
NSString *role1 = self.rolesByVolUUID[id1], *role2 = self.rolesByVolUUID[id2]; | |
if ([role1 isEqualToString:@"System"]) return -1; | |
if ([role2 isEqualToString:@"System"]) return 1; | |
return [role1 compare:role2]; | |
}]; | |
// By having the System vol added first, it'll become the root of the group, with all others | |
// in the group being added as group members of it. | |
[volIDsInGroup enumerateObjectsUsingBlock:^(NSUUID * _Nonnull volID, NSUInteger idx, BOOL * _Nonnull stop) { | |
addVolume (volID, nil); | |
}]; | |
}; | |
// Process all volumes with roles | |
[self.volURLByVolUUID enumerateKeysAndObjectsUsingBlock:^(NSUUID * _Nonnull volID, NSURL * _Nonnull volURL, BOOL * _Nonnull stop) { | |
addVolume (volID, volURL); | |
}]; | |
// Process all visible volumes | |
NSArray<NSURL*> *currentVolURLs = [NSFileManager.defaultManager mountedVolumeURLsIncludingResourceValuesForKeys:nil options:NSVolumeEnumerationSkipHiddenVolumes]; | |
[currentVolURLs enumerateObjectsUsingBlock:^(NSURL * _Nonnull volURL, NSUInteger idx, BOOL * _Nonnull stop) { | |
addVolume (nil, volURL); | |
}]; | |
self.volumes = newVolumes; | |
CFRelease (daSession); | |
} | |
NSLog (@"\nNew volumes: %@\n", self.volumes); | |
} | |
@end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment