diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c4bdd3..ae2a8b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## unreleased +* Experimental alternative method of fetching paired devices using system_profiler [#101](https://github.com/toy/blueutil/pull/101) [@arcaartem](https://github.com/arcaartem) + ## v2.12.0 (2025-02-02) * Hide debug log messages from IOBluetoothDeviceInquiry [@toy](https://github.com/toy) diff --git a/README.md b/README.md index df21925..39a6aa9 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,14 @@ Uses private API from IOBluetooth framework (i.e. `IOBluetoothPreference*()`). Opening Bluetooth preference pane always turns on discoverability if bluetooth power is on or if it is switched on when preference pane is open, this change of state is not reported by the function used by `blueutil`. +## Alternative Method + +By default, `blueutil` uses IOBluetooth framework APIs to query paired devices. As an alternative, you can set the `BLUEUTIL_USE_SYSTEM_PROFILER=1` environment variable to use the `system_profiler` command instead. + +The system_profiler method resolves an issue where some multi-point Bluetooth devices (devices that can connect to multiple devices simultaneously) may not report their connection status correctly through the IOBluetooth APIs, but do show the correct status via system_profiler. + +**Note:** The system_profiler method is experimental and may have compatibility issues with some blueutil commands. + ## Usage @@ -68,6 +76,9 @@ Favourite devices and recent access date are not stored starting with macOS 12/M Due to possible problems, blueutil will refuse to run as root user (see https://github.com/toy/blueutil/issues/41). Use environment variable BLUEUTIL_ALLOW_ROOT=1 to override (sudo BLUEUTIL_ALLOW_ROOT=1 blueutil …). +Environment variables: + BLUEUTIL_USE_SYSTEM_PROFILER=1 EXPERIMENTAL: use system_profiler instead of IOBluetoothDevice API for paired device queries + Exit codes: 0 Success 1 General failure @@ -80,6 +91,30 @@ Exit codes: ``` +### Examples + +List paired devices using IOBluetooth API (default): +```sh +blueutil --paired +``` + +Use system_profiler for a single command: +```sh +BLUEUTIL_USE_SYSTEM_PROFILER=1 blueutil --paired +``` + +Use system_profiler outside of shell (e.g., in scripts): +```sh +/usr/bin/env BLUEUTIL_USE_SYSTEM_PROFILER=1 blueutil --paired +``` + +Set environment variable to always use system_profiler: +```sh +export BLUEUTIL_USE_SYSTEM_PROFILER=1 +blueutil --paired +blueutil --connected +``` + ## Install/update/uninstall ### Homebrew diff --git a/blueutil.m b/blueutil.m index f8d537c..37178c7 100644 --- a/blueutil.m +++ b/blueutil.m @@ -21,6 +21,8 @@ #define eprintf(...) fprintf(stderr, ##__VA_ARGS__) +static const char kUnreadableRSSI = 127; // Value when RSSI cannot be read, matches IOBluetooth API + void *assert_alloc(void *pointer) { if (pointer == NULL) { eprintf("%s\n", strerror(errno)); @@ -51,6 +53,107 @@ int assert_reg(int errcode, const regex_t *restrict preg, char *reason) { void _NSSetLogCStringFunction(void (*)(const char *, unsigned, BOOL)); +// Mock IOBluetoothDevice for subprocess method +@interface MockBluetoothDevice : NSObject +@property (nonatomic, strong, readonly) NSString *address; +@property (nonatomic, strong, readonly) NSString *name; +@property (nonatomic, assign, readonly) BOOL paired; +@property (nonatomic, assign, readonly) BOOL connected; +@property (nonatomic, assign, readonly) BOOL favorite; +@property (nonatomic, strong, readonly) NSDate *recentAccessDate; +@property (nonatomic, assign, readonly) char rssi; +@property (nonatomic, strong, readonly) IOBluetoothDevice *realDevice; +- (instancetype)initWithAddress:(NSString *)address + name:(NSString *)name + paired:(BOOL)paired + connected:(BOOL)connected + favorite:(BOOL)favorite + rssi:(char)rssi; +- (instancetype)initWithAddress:(NSString *)address name:(NSString *)name connected:(BOOL)connected rssi:(char)rssi; +- (NSString *)addressString; +- (BOOL)isConnected; +- (BOOL)isPaired; +- (BOOL)isFavorite; +- (BOOL)isIncoming; +- (char)RSSI; +- (char)rawRSSI; +@end + +@implementation MockBluetoothDevice +- (instancetype)initWithAddress:(NSString *)address + name:(NSString *)name + paired:(BOOL)paired + connected:(BOOL)connected + favorite:(BOOL)favorite + rssi:(char)rssi { + if (self = [super init]) { + _address = address; + _name = name; + _paired = paired; + _connected = connected; + _favorite = favorite; + _recentAccessDate = NULL; + _rssi = rssi; + _realDevice = [IOBluetoothDevice deviceWithAddressString:address]; + } + return self; +} + +- (instancetype)initWithAddress:(NSString *)address name:(NSString *)name connected:(BOOL)connected rssi:(char)rssi { + return [self initWithAddress:address + name:name + paired:YES // Default: always paired when using system_profiler method + connected:connected + favorite:NO // Default: always NO for system_profiler method + rssi:rssi]; +} + +- (NSString *)addressString { + return self.address; +} +- (BOOL)isConnected { + return self.connected; +} +- (BOOL)isPaired { + return self.paired; +} +- (BOOL)isFavorite { + return self.favorite; +} +- (BOOL)isIncoming { + return NO; +} // Default to master mode +- (char)RSSI { + return self.rssi; +} +- (char)rawRSSI { + return self.rssi; +} + +- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector { + NSMethodSignature *signature = [super methodSignatureForSelector:aSelector]; + if (!signature && self.realDevice) { + signature = [self.realDevice methodSignatureForSelector:aSelector]; + } + return signature; +} + +- (void)forwardInvocation:(NSInvocation *)invocation { + if (self.realDevice && [self.realDevice respondsToSelector:invocation.selector]) { + [invocation invokeWithTarget:self.realDevice]; + } else { + [super forwardInvocation:invocation]; + } +} + +- (BOOL)respondsToSelector:(SEL)aSelector { + return [super respondsToSelector:aSelector] || (self.realDevice && [self.realDevice respondsToSelector:aSelector]); +} +@end + +// forward declarations +NSArray *get_paired_devices(); + // short names typedef int (*GetterFunc)(); typedef bool (*SetterFunc)(int); @@ -167,6 +270,9 @@ void usage(FILE *io) { "Due to possible problems, blueutil will refuse to run as root user (see https://github.com/toy/blueutil/issues/41).", "Use environment variable BLUEUTIL_ALLOW_ROOT=1 to override (sudo BLUEUTIL_ALLOW_ROOT=1 blueutil …).", "", + "Environment variables:", + " BLUEUTIL_USE_SYSTEM_PROFILER=1 EXPERIMENTAL: use system_profiler instead of IOBluetoothDevice API for paired device queries", + "", "Exit codes:", }; @@ -301,6 +407,83 @@ bool parse_signed_long_arg(char *arg, long *number) { } } +NSArray *get_paired_devices_subprocess() { + NSTask *task = [[NSTask alloc] init]; + task.launchPath = @"/usr/sbin/system_profiler"; + task.arguments = @[@"SPBluetoothDataType", @"-xml"]; + + NSPipe *pipe = [NSPipe pipe]; + task.standardOutput = pipe; + + [task launch]; + [task waitUntilExit]; + + if (task.terminationStatus != 0) { + eprintf("system_profiler failed with exit code %d\n", task.terminationStatus); + exit(EX_SOFTWARE); + } + + NSData *data = [[pipe fileHandleForReading] readDataToEndOfFile]; + if (data.length == 0) { + return @[]; + } + + NSError *error = nil; + NSArray *plist = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListImmutable + format:nil + error:&error]; + if (error || !plist || plist.count == 0) { + return @[]; + } + + NSMutableArray *pairedDevices = [NSMutableArray array]; + + // Navigate the system_profiler XML structure to find paired devices + for (NSDictionary *item in plist) { + NSArray *items = item[@"_items"]; + if (!items) continue; + + for (NSDictionary *bluetoothItem in items) { + // Process connected devices + NSArray *connectedDevices = bluetoothItem[@"device_connected"]; + if (connectedDevices) { + for (NSDictionary *deviceDict in connectedDevices) { + for (NSString *deviceName in deviceDict) { + NSDictionary *deviceInfo = deviceDict[deviceName]; + MockBluetoothDevice *mockDevice = [[MockBluetoothDevice alloc] + initWithAddress:deviceInfo[@"device_address"] ?: @"" + name:deviceName + connected:YES + rssi:deviceInfo[@"device_rssi"] ? [deviceInfo[@"device_rssi"] intValue] : kUnreadableRSSI]; + + [pairedDevices addObject:mockDevice]; + } + } + } + + // Process not connected devices + NSArray *notConnectedDevices = bluetoothItem[@"device_not_connected"]; + if (notConnectedDevices) { + for (NSDictionary *deviceDict in notConnectedDevices) { + for (NSString *deviceName in deviceDict) { + NSDictionary *deviceInfo = deviceDict[deviceName]; + MockBluetoothDevice *mockDevice = + [[MockBluetoothDevice alloc] initWithAddress:deviceInfo[@"device_address"] ?: @"" + name:deviceName + connected:NO + rssi:kUnreadableRSSI]; // Not connected, so no RSSI + + [pairedDevices addObject:mockDevice]; + } + } + } + } + } + + return [pairedDevices copy]; +} + IOBluetoothDevice *get_device(char *id) { NSString *nsId = [NSString stringWithCString:id encoding:[NSString defaultCStringEncoding]]; @@ -316,7 +499,7 @@ bool parse_signed_long_arg(char *arg, long *number) { } else { NSMutableArray *searchDevices = [NSMutableArray new]; - NSArray *pairedDevices = [IOBluetoothDevice pairedDevices]; + NSArray *pairedDevices = get_paired_devices(); if (pairedDevices) { [searchDevices addObjectsFromArray:pairedDevices]; } @@ -730,6 +913,11 @@ void add_cmd(void *args, cmd cmd) { } FormatterFunc list_devices = list_devices_default; +bool use_subprocess_method = false; + +NSArray *get_paired_devices() { + return use_subprocess_method ? get_paired_devices_subprocess() : [IOBluetoothDevice pairedDevices]; +} int main(int argc, char *argv[]) { signal(SIGABRT, handle_abort); @@ -742,6 +930,12 @@ int main(int argc, char *argv[]) { } } + // Check environment variable for system profiler usage + char *use_system_profiler_env = getenv("BLUEUTIL_USE_SYSTEM_PROFILER"); + if (use_system_profiler_env && 0 == strcmp(use_system_profiler_env, "1")) { + use_subprocess_method = true; + } + _NSSetLogCStringFunction(CustomNSLogOutput); if (!BTAvaliable()) { @@ -863,7 +1057,7 @@ int main(int argc, char *argv[]) { } break; case arg_paired: { add_cmd(NULL, ^int(__unused void *_args) { - list_devices([IOBluetoothDevice pairedDevices], false); + list_devices(get_paired_devices(), false); return EXIT_SUCCESS; }); } break; @@ -928,7 +1122,7 @@ int main(int argc, char *argv[]) { case arg_connected: { add_cmd(NULL, ^int(__unused void *_args) { NSPredicate *predicate = [NSPredicate predicateWithFormat:@"isConnected == YES"]; - list_devices([[IOBluetoothDevice pairedDevices] filteredArrayUsingPredicate:predicate], false); + list_devices([get_paired_devices() filteredArrayUsingPredicate:predicate], false); return EXIT_SUCCESS; }); } break;