Show a special message when the HID was closed during deactivation.
[enjoyable.git] / Classes / NJDeviceController.m
1 //
2 // NJDeviceController.m
3 // Enjoy
4 //
5 // Created by Sam McCall on 4/05/09.
6 //
7
8 #import "NJDeviceController.h"
9
10 #import "NJMapping.h"
11 #import "NJMappingsController.h"
12 #import "NJDevice.h"
13 #import "NJInput.h"
14 #import "NJOutput.h"
15 #import "NJOutputController.h"
16 #import "NJEvents.h"
17
18 @implementation NJDeviceController {
19 IOHIDManagerRef _hidManager;
20 NSTimer *_continuousOutputsTick;
21 NSMutableArray *_continousOutputs;
22 NSMutableArray *_devices;
23 NSMutableArray *_expanded;
24 }
25
26 #define EXPANDED_MEMORY_MAX_SIZE 100
27
28 - (id)init {
29 if ((self = [super init])) {
30 _devices = [[NSMutableArray alloc] initWithCapacity:16];
31 _continousOutputs = [[NSMutableArray alloc] initWithCapacity:32];
32
33 NSArray *expanded = [NSUserDefaults.standardUserDefaults objectForKey:@"expanded rows"];
34 if (![expanded isKindOfClass:NSArray.class])
35 expanded = @[];
36 _expanded = [[NSMutableArray alloc] initWithCapacity:MAX(16, _expanded.count)];
37 [_expanded addObjectsFromArray:expanded];
38
39 [NSNotificationCenter.defaultCenter
40 addObserver:self
41 selector:@selector(applicationDidFinishLaunching:)
42 name:NSApplicationDidFinishLaunchingNotification
43 object:nil];
44
45 // The HID manager uses 5-10ms per second doing basically
46 // nothing if a noisy device is plugged in (the % of that
47 // spent in input_callback is negligible, so it's not
48 // something we can make faster). I don't really think that's
49 // acceptable, CPU/power wise. So if translation is disabled
50 // and the window is closed, just switch off the HID manager
51 // entirely. This probably also has some marginal benefits for
52 // compatibility with other applications that want exclusive
53 // grabs.
54 [NSNotificationCenter.defaultCenter
55 addObserver:self
56 selector:@selector(closeHidIfDisabled:)
57 name:NSApplicationDidResignActiveNotification
58 object:nil];
59 [NSNotificationCenter.defaultCenter
60 addObserver:self
61 selector:@selector(openHid)
62 name:NSApplicationDidBecomeActiveNotification
63 object:nil];
64 }
65 return self;
66 }
67
68 - (void)dealloc {
69 [NSNotificationCenter.defaultCenter removeObserver:self];
70 [_continuousOutputsTick invalidate];
71 [self closeHid];
72 }
73
74 - (void)expandRecursive:(id <NJInputPathElement>)pathElement {
75 if (pathElement) {
76 [self expandRecursive:pathElement.base];
77 [outlineView expandItem:pathElement];
78 }
79 }
80
81 - (id)elementForUID:(NSString *)uid {
82 for (NJDevice *dev in _devices) {
83 id item = [dev elementForUID:uid];
84 if (item)
85 return item;
86 }
87 return nil;
88 }
89
90 - (void)expandRecursiveByUID:(NSString *)uid {
91 [self expandRecursive:[self elementForUID:uid]];
92 }
93
94 - (void)addRunningOutput:(NJOutput *)output {
95 // Axis events will trigger every small movement, don't keep
96 // re-adding them or they trigger multiple times each time.
97 if (![_continousOutputs containsObject:output])
98 [_continousOutputs addObject:output];
99 if (!_continuousOutputsTick) {
100 _continuousOutputsTick = [NSTimer scheduledTimerWithTimeInterval:1.0/60.0
101 target:self
102 selector:@selector(updateContinuousOutputs:)
103 userInfo:nil
104 repeats:YES];
105 }
106 }
107
108 - (void)runOutputForDevice:(IOHIDDeviceRef)device value:(IOHIDValueRef)value {
109 NJDevice *dev = [self findDeviceByRef:device];
110 NJInput *mainInput = [dev inputForEvent:value];
111 [mainInput notifyEvent:value];
112 NSArray *children = mainInput.children ? mainInput.children : mainInput ? @[mainInput] : @[];
113 for (NJInput *subInput in children) {
114 NJOutput *output = mappingsController.currentMapping[subInput];
115 output.magnitude = subInput.magnitude;
116 output.running = subInput.active;
117 if ((output.running || output.magnitude) && output.isContinuous)
118 [self addRunningOutput:output];
119 }
120 }
121
122 - (void)showOutputForDevice:(IOHIDDeviceRef)device value:(IOHIDValueRef)value {
123 NJDevice *dev = [self findDeviceByRef:device];
124 NJInput *handler = [dev handlerForEvent:value];
125 if (!handler)
126 return;
127
128 [self expandRecursive:handler];
129 [outlineView selectRowIndexes:[NSIndexSet indexSetWithIndex:[outlineView rowForItem:handler]]
130 byExtendingSelection: NO];
131 [outputController focusKey];
132 }
133
134 static void input_callback(void *ctx, IOReturn inResult, void *inSender, IOHIDValueRef value) {
135 NJDeviceController *controller = (__bridge NJDeviceController *)ctx;
136 IOHIDDeviceRef device = IOHIDQueueGetDevice(inSender);
137
138 if (controller.translatingEvents) {
139 [controller runOutputForDevice:device value:value];
140 } else if ([NSApplication sharedApplication].mainWindow.isVisible) {
141 [controller showOutputForDevice:device value:value];
142 }
143 }
144
145 static int findAvailableIndex(NSArray *list, NJDevice *dev) {
146 for (int index = 1; ; index++) {
147 BOOL available = YES;
148 for (NJDevice *used in list) {
149 if ([used.productName isEqualToString:dev.productName] && used.index == index) {
150 available = NO;
151 break;
152 }
153 }
154 if (available)
155 return index;
156 }
157 }
158
159 - (void)addDeviceForDevice:(IOHIDDeviceRef)device {
160 IOHIDDeviceRegisterInputValueCallback(device, input_callback, (__bridge void *)self);
161 NJDevice *dev = [[NJDevice alloc] initWithDevice:device];
162 dev.index = findAvailableIndex(_devices, dev);
163 [_devices addObject:dev];
164 [outlineView reloadData];
165 [self reexpandAll];
166 hidSleepingPrompt.hidden = YES;
167 connectDevicePrompt.hidden = !!_devices.count;
168 }
169
170 static void add_callback(void *ctx, IOReturn inResult, void *inSender, IOHIDDeviceRef device) {
171 NJDeviceController *controller = (__bridge NJDeviceController *)ctx;
172 [controller addDeviceForDevice:device];
173 }
174
175 - (NJDevice *)findDeviceByRef:(IOHIDDeviceRef)device {
176 for (NJDevice *dev in _devices)
177 if (dev.device == device)
178 return dev;
179 return nil;
180 }
181
182 static void remove_callback(void *ctx, IOReturn inResult, void *inSender, IOHIDDeviceRef device) {
183 NJDeviceController *controller = (__bridge NJDeviceController *)ctx;
184 [controller removeDeviceForDevice:device];
185 }
186
187 - (void)removeDeviceForDevice:(IOHIDDeviceRef)device {
188 NJDevice *match = [self findDeviceByRef:device];
189 IOHIDDeviceRegisterInputValueCallback(device, NULL, NULL);
190 if (match) {
191 [_devices removeObject:match];
192 [outlineView reloadData];
193 connectDevicePrompt.hidden = !!_devices.count;
194 hidSleepingPrompt.hidden = YES;
195 }
196 if (_devices.count == 1)
197 [outlineView expandItem:_devices[0]];
198 }
199
200 - (void)updateContinuousOutputs:(NSTimer *)timer {
201 self.mouseLoc = [NSEvent mouseLocation];
202 for (NJOutput *output in [_continousOutputs copy]) {
203 if (![output update:self]) {
204 [_continousOutputs removeObject:output];
205 }
206 }
207 if (!_continousOutputs.count) {
208 [_continuousOutputsTick invalidate];
209 _continuousOutputsTick = nil;
210 }
211 }
212
213 #define NSSTR(e) ((NSString *)CFSTR(e))
214
215 - (void)openHid {
216 if (_hidManager)
217 return;
218 NSLog(@"Opening HID manager.");
219 _hidManager = IOHIDManagerCreate(kCFAllocatorDefault, kIOHIDOptionsTypeNone);
220 IOHIDManagerScheduleWithRunLoop(_hidManager, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);
221 NSArray *criteria = @[ @{ NSSTR(kIOHIDDeviceUsagePageKey) : @(kHIDPage_GenericDesktop),
222 NSSTR(kIOHIDDeviceUsageKey) : @(kHIDUsage_GD_Joystick) },
223 @{ NSSTR(kIOHIDDeviceUsagePageKey) : @(kHIDPage_GenericDesktop),
224 NSSTR(kIOHIDDeviceUsageKey) : @(kHIDUsage_GD_GamePad) },
225 @{ NSSTR(kIOHIDDeviceUsagePageKey) : @(kHIDPage_GenericDesktop),
226 NSSTR(kIOHIDDeviceUsageKey) : @(kHIDUsage_GD_MultiAxisController) }
227 ];
228 IOHIDManagerSetDeviceMatchingMultiple(_hidManager, (__bridge CFArrayRef)criteria);
229 IOReturn ret = IOHIDManagerOpen(_hidManager, kIOHIDOptionsTypeNone);
230 if (ret != kIOReturnSuccess) {
231 [[NSAlert alertWithMessageText:NSLocalizedString(@"input devices unavailable",
232 @"error title when devices can't be read")
233 defaultButton:nil
234 alternateButton:nil
235 otherButton:nil
236 informativeTextWithFormat:NSLocalizedString(@"input error 0x%08x occurred",
237 @"message containing IOReturn failure code when devices can't be read"), ret]
238 beginSheetModalForWindow:outlineView.window
239 modalDelegate:nil
240 didEndSelector:nil
241 contextInfo:nil];
242 [self closeHid];
243 } else {
244 IOHIDManagerRegisterDeviceMatchingCallback(_hidManager, add_callback, (__bridge void *)self);
245 IOHIDManagerRegisterDeviceRemovalCallback(_hidManager, remove_callback, (__bridge void *)self);
246 hidSleepingPrompt.hidden = YES;
247 connectDevicePrompt.hidden = !!_devices.count;
248 }
249 }
250
251 - (void)closeHid {
252 if (_hidManager) {
253 NSLog(@"Closing HID manager.");
254 IOHIDManagerUnscheduleFromRunLoop(_hidManager, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);
255 IOHIDManagerClose(_hidManager, kIOHIDOptionsTypeNone);
256 CFRelease(_hidManager);
257 _hidManager = NULL;
258 }
259 [_devices removeAllObjects];
260 [outlineView reloadData];
261 hidSleepingPrompt.hidden = NO;
262 connectDevicePrompt.hidden = YES;
263 }
264
265 - (NJInput *)selectedInput {
266 id <NJInputPathElement> item = [outlineView itemAtRow:outlineView.selectedRow];
267 return (!item.children && item.base) ? item : nil;
268 }
269
270 - (NSInteger)outlineView:(NSOutlineView *)outlineView
271 numberOfChildrenOfItem:(id <NJInputPathElement>)item {
272 return item ? item.children.count : _devices.count;
273 }
274
275 - (BOOL)outlineView:(NSOutlineView *)outlineView
276 isItemExpandable:(id <NJInputPathElement>)item {
277 return item ? [[item children] count] > 0: YES;
278 }
279
280 - (id)outlineView:(NSOutlineView *)outlineView
281 child:(NSInteger)index
282 ofItem:(id <NJInputPathElement>)item {
283 return item ? item.children[index] : _devices[index];
284 }
285
286 - (id)outlineView:(NSOutlineView *)outlineView
287 objectValueForTableColumn:(NSTableColumn *)tableColumn
288 byItem:(id <NJInputPathElement>)item {
289 return item ? item.name : @"root";
290 }
291
292 - (void)outlineViewSelectionDidChange:(NSNotification *)notification {
293 id <NJInputPathElement> item = [outlineView itemAtRow:outlineView.selectedRow];
294 if (item)
295 [NSUserDefaults.standardUserDefaults setObject:item.uid
296 forKey:@"selected input"];
297 [outputController loadCurrent];
298 }
299
300 - (BOOL)outlineView:(NSOutlineView *)outlineView
301 isGroupItem:(id <NJInputPathElement>)item {
302 return [item isKindOfClass:NJDevice.class];
303 }
304
305 - (BOOL)outlineView:(NSOutlineView *)outlineView_
306 shouldSelectItem:(id <NJInputPathElement>)item {
307 return ![self outlineView:outlineView_ isGroupItem:item];
308 }
309
310 - (void)outlineViewItemDidExpand:(NSNotification *)notification {
311 id <NJInputPathElement> item = notification.userInfo[@"NSObject"];
312 NSString *uid = item.uid;
313 if (![_expanded containsObject:uid])
314 [_expanded addObject:uid];
315 while (_expanded.count > EXPANDED_MEMORY_MAX_SIZE)
316 [_expanded removeObjectAtIndex:0];
317 [NSUserDefaults.standardUserDefaults setObject:_expanded
318 forKey:@"expanded rows"];
319 }
320
321 - (void)outlineViewItemDidCollapse:(NSNotification *)notification {
322 id <NJInputPathElement> item = notification.userInfo[@"NSObject"];
323 [_expanded removeObject:item.uid];
324 [NSUserDefaults.standardUserDefaults setObject:_expanded
325 forKey:@"expanded rows"];
326 }
327
328 - (void)setTranslatingEvents:(BOOL)translatingEvents {
329 if (translatingEvents != _translatingEvents) {
330 _translatingEvents = translatingEvents;
331 NSInteger state = translatingEvents ? NSOnState : NSOffState;
332 translatingEventsButton.state = state;
333 NSString *name = translatingEvents
334 ? NJEventTranslationActivated
335 : NJEventTranslationDeactivated;
336 [NSNotificationCenter.defaultCenter postNotificationName:name
337 object:self];
338
339 if (!translatingEvents && !NSApplication.sharedApplication.isActive)
340 [self closeHid];
341 else if (translatingEvents || NSApplication.sharedApplication.isActive)
342 [self openHid];
343 }
344 }
345
346 - (void)reexpandAll {
347 for (NSString *uid in [_expanded copy])
348 [self expandRecursiveByUID:uid];
349 if (outlineView.selectedRow == -1) {
350 NSString *selectedUid = [NSUserDefaults.standardUserDefaults objectForKey:@"selected input"];
351 id item = [self elementForUID:selectedUid];
352 NSInteger row = [outlineView rowForItem:item];
353 if (row >= 0)
354 [outlineView selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO];
355 }
356 }
357
358 - (void)closeHidIfDisabled:(NSNotification *)application {
359 if (!self.translatingEvents)
360 [self closeHid];
361 }
362
363 - (void)applicationDidFinishLaunching:(NSNotification *)application {
364 // NSApplicationWillBecomeActiveNotification occurs just slightly
365 // too late - there's one tick where the UI is showing "No
366 // devices" even with a device plugged in.
367 [self openHid];
368 }
369
370 - (IBAction)translatingEventsChanged:(NSButton *)sender {
371 self.translatingEvents = sender.state == NSOnState;
372 }
373
374 @end