Mike Bombich provided me with this solution:
You can get the volume UUID and the volume group UUID from IOKit. Two volumes that are in the same group will have the same group UUID. Note that the group UUID is always the same as the Data volume's UUID (at least in practice).
Here's the code for getting the list of mounted volumes, including the hidden ones that are part of a volume group:
- (void)listVolumes
{
NSArray<NSURL*> *vols = [NSFileManager.defaultManager mountedVolumeURLsIncludingResourceValuesForKeys:nil options: 0 ];
vols = [vols arrayByAddingObject:[NSURL fileURLWithPath:@"/System/Volumes/Data"]]; // the root's Data vol isn't added by default
NSMutableArray<NSString*> *lines = [NSMutableArray new];
for (NSURL *vol in vols) {
NSDictionary *d = [vol resourceValuesForKeys:@[
NSURLVolumeIsBrowsableKey,
NSURLVolumeIsRootFileSystemKey,
NSURLVolumeIdentifierKey,
NSURLVolumeNameKey
] error:nil];
struct statfs fsinfo;
statfs(vol.path.UTF8String, &fsinfo);
NSString *bsdName = [NSString stringWithUTF8String:fsinfo.f_mntfromname];
bsdName = [bsdName lastPathComponent];
[lines addObject:[NSString stringWithFormat:@"%@, %@, %@, %@", bsdName, vol.path, d[NSURLVolumeIsBrowsableKey], d[NSURLVolumeNameKey]]];
}
NSLog(@"\n%@", [lines componentsJoinedByString:@"\n"]);
}
And the code for listing the volume group IDs, and their roles:
- (void)listGroupIDs
{
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);
NSString *bsdName = CFBridgingRelease(IORegistryEntryCreateCFProperty(obj2, CFSTR("BSD Name"), kCFAllocatorDefault, 0));
//NSString *volID = CFBridgingRelease(IORegistryEntryCreateCFProperty(obj2, CFSTR("UUID"), kCFAllocatorDefault, 0));
NSString *groupID = CFBridgingRelease(IORegistryEntryCreateCFProperty(obj2, CFSTR("VolGroupUUID"), kCFAllocatorDefault, 0));
NSArray *roles = CFBridgingRelease(IORegistryEntryCreateCFProperty(obj2, CFSTR("Role"), kCFAllocatorDefault, 0));
if (groupID != nil && ![groupID isEqualToString:@"00000000-0000-0000-0000-000000000000"]) {
NSLog(@"%@: %@, %@", bsdName, groupID, roles);
}
}
}
With both this information, the volumes from IOKit can be matched to the NSURLs through their BSD Names.
However, there's one more special case: On macOS Big Sur the root system's device is not the regular "diskXsY" but a snapshot device such as "diskXsYsZ". And while that gets listed as well by the IOKit code, its entry is missing the role information.
Here's an example output from the Mac with both a Big Sur and a Catalina system as shown in the question (slightly edited for readability):
disk3s1s1, /, 1, BigSur
disk3s5, /System/Volumes/VM, 0, VM
disk3s3, /System/Volumes/Preboot, 0, Preboot
disk3s6, /System/Volumes/Update, 0, Update
disk4s1, /Volumes/Catalina - Daten, 0, Catalina - Daten
disk4s2, /Volumes/Catalina, 1, Catalina
disk3s2, /System/Volumes/Data, 1, BigSur
disk4s1: 18464FE4-8321-4D36-B87A-53AC38EF6AEF, 18464FE4-8321-4D36-B87A-53AC38EF6AEF, ("Data")
disk3s1: 86812DBD-9252-4A2E-8887-752418DECE13, 058517A6-48DD-46AB-8A78-C1F115AE6E13, ("System")
disk4s2: 51DEC6AC-2D68-4B60-AE23-74BCA2C3A484, 18464FE4-8321-4D36-B87A-53AC38EF6AEF, ("System")
disk3s2: 058517A6-48DD-46AB-8A78-C1F115AE6E13, 058517A6-48DD-46AB-8A78-C1F115AE6E13, ("Data")
disk3s1s1: C26440B0-0207-4227-A4B1-EBDD62C90D24, 058517A6-48DD-46AB-8A78-C1F115AE6E13, (null)
I have published a working code sample that determines all mounted volumes, and their group relationships. The entire compilable code (which you can replace in a new Obj-C App project's AppDelegate.m
file can be found here: https://gist.github.com/tempelmann/80efc2eb84f0171a96822290dee7d8d9