App delegate now controls communication between device / mapping controllers and...
[enjoyable.git] / Classes / EnjoyableApplicationDelegate.m
1 //
2 // EnjoyableApplicationDelegate.m
3 // Enjoy
4 //
5 // Created by Sam McCall on 4/05/09.
6 //
7
8 #import <Sparkle/Sparkle.h>
9
10 #import "EnjoyableApplicationDelegate.h"
11
12 #import "NJMapping.h"
13 #import "NJMappingsController.h"
14 #import "NJEvents.h"
15
16 @implementation EnjoyableApplicationDelegate {
17 NSStatusItem *statusItem;
18 }
19
20 - (void)didSwitchApplication:(NSNotification *)note {
21 NSRunningApplication *activeApp = note.userInfo[NSWorkspaceApplicationKey];
22 if (activeApp)
23 [self.mappingsController activateMappingForProcess:activeApp];
24 }
25
26 - (void)applicationWillFinishLaunching:(NSNotification *)notification {
27 [NSNotificationCenter.defaultCenter
28 addObserver:self
29 selector:@selector(mappingDidChange:)
30 name:NJEventMappingChanged
31 object:nil];
32 [NSNotificationCenter.defaultCenter
33 addObserver:self
34 selector:@selector(eventSimulationStarted:)
35 name:NJEventSimulationStarted
36 object:nil];
37 [NSNotificationCenter.defaultCenter
38 addObserver:self
39 selector:@selector(eventSimulationStopped:)
40 name:NJEventSimulationStopped
41 object:nil];
42
43 [self.mappingsController load];
44 [self.mvc.mappingList reloadData];
45 [self.mvc changedActiveMappingToIndex:
46 [self.mappingsController indexOfMapping:
47 self.mappingsController.currentMapping]];
48
49 statusItem = [NSStatusBar.systemStatusBar statusItemWithLength:36];
50 statusItem.image = [NSImage imageNamed:@"Status Menu Icon Disabled"];
51 statusItem.highlightMode = YES;
52 statusItem.menu = statusItemMenu;
53 statusItem.target = self;
54 }
55
56 - (void)applicationDidFinishLaunching:(NSNotification *)notification {
57 if ([NSUserDefaults.standardUserDefaults boolForKey:@"hidden in status item"]
58 && NSRunningApplication.currentApplication.wasLaunchedAsLoginItemOrResume)
59 [self transformIntoElement:nil];
60 else
61 [window makeKeyAndOrderFront:nil];
62 }
63
64 - (BOOL)applicationShouldHandleReopen:(NSApplication *)theApplication
65 hasVisibleWindows:(BOOL)flag {
66 [self restoreToForeground:theApplication];
67 return NO;
68 }
69
70 - (void)restoreToForeground:(id)sender {
71 ProcessSerialNumber psn = { 0, kCurrentProcess };
72 TransformProcessType(&psn, kProcessTransformToForegroundApplication);
73 [NSApplication.sharedApplication activateIgnoringOtherApps:YES];
74 [window makeKeyAndOrderFront:sender];
75 [NSObject cancelPreviousPerformRequestsWithTarget:self
76 selector:@selector(transformIntoElement:)
77 object:self];
78 [NSUserDefaults.standardUserDefaults setBool:NO forKey:@"hidden in status item"];
79 }
80
81 - (void)applicationWillBecomeActive:(NSNotification *)notification {
82 if (window.isVisible)
83 [self restoreToForeground:notification];
84 }
85
86 - (void)transformIntoElement:(id)sender {
87 ProcessSerialNumber psn = { 0, kCurrentProcess };
88 TransformProcessType(&psn, kProcessTransformToUIElementApplication);
89 [NSUserDefaults.standardUserDefaults setBool:YES forKey:@"hidden in status item"];
90 }
91
92 - (void)flashStatusItem {
93 if ([statusItem.image.name isEqualToString:@"Status Menu Icon"]) {
94 statusItem.image = [NSImage imageNamed:@"Status Menu Icon Disabled"];
95 } else {
96 statusItem.image = [NSImage imageNamed:@"Status Menu Icon"];
97 }
98
99 }
100
101 - (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)theApplication {
102 [theApplication hide:theApplication];
103 // If we turn into a UIElement right away, the application cancels
104 // the deactivation events. The dock icon disappears, but an
105 // unresponsive menu bar remains until the user clicks somewhere.
106 // So delay just long enough to be past the end handling that.
107 [self performSelector:@selector(transformIntoElement:) withObject:self afterDelay:0.001];
108 return NO;
109 }
110
111 - (void)eventSimulationStarted:(NSNotification *)note {
112 statusItem.image = [NSImage imageNamed:@"Status Menu Icon"];
113 [NSWorkspace.sharedWorkspace.notificationCenter
114 addObserver:self
115 selector:@selector(didSwitchApplication:)
116 name:NSWorkspaceDidActivateApplicationNotification
117 object:nil];
118 }
119
120 - (void)eventSimulationStopped:(NSNotification *)note {
121 statusItem.image = [NSImage imageNamed:@"Status Menu Icon Disabled"];
122 [NSWorkspace.sharedWorkspace.notificationCenter
123 removeObserver:self
124 name:NSWorkspaceDidActivateApplicationNotification
125 object:nil];
126 }
127
128 - (void)mappingDidChange:(NSNotification *)note {
129 NSUInteger idx = [note.userInfo[NJMappingIndexKey] intValue];
130 [self.mvc changedActiveMappingToIndex:idx];
131
132 if (!window.isVisible)
133 for (int i = 0; i < 4; ++i)
134 [self performSelector:@selector(flashStatusItem)
135 withObject:self
136 afterDelay:0.2 * i];
137 }
138
139 - (NSMenu *)applicationDockMenu:(NSApplication *)sender {
140 return dockMenu;
141 }
142
143 - (BOOL)application:(NSApplication *)sender openFile:(NSString *)filename {
144 [self restoreToForeground:sender];
145 NSError *error;
146 NSURL *URL = [NSURL fileURLWithPath:filename];
147 NJMapping *mapping = [NJMapping mappingWithContentsOfURL:URL
148 error:&error];
149 if ([self.mappingsController[mapping.name] hasConflictWith:mapping]) {
150 [self promptForMapping:mapping atIndex:self.mappingsController.count];
151 } else if (self.mappingsController[mapping.name]) {
152 [self.mappingsController[mapping.name] mergeEntriesFrom:mapping];
153 } else if (mapping) {
154 [self.mvc beginUpdates];
155 [self.mappingsController addMapping:mapping];
156 [self.mvc addedMappingAtIndex:self.mappingsController.count - 1 startEditing:NO];
157 [self.mvc endUpdates];
158 [self.mappingsController activateMapping:mapping];
159 } else {
160 [window presentError:error
161 modalForWindow:window
162 delegate:nil
163 didPresentSelector:nil
164 contextInfo:nil];
165 }
166 return !!mapping;
167 }
168
169 - (void)mappingWasChosen:(NJMapping *)mapping {
170 [self.mappingsController activateMapping:mapping];
171 }
172
173 - (void)mappingListShouldOpen {
174 [self restoreToForeground:self];
175 [self.mvc mappingTriggerClicked:self];
176 }
177
178 - (void)loginItemPromptDidEnd:(NSWindow *)sheet
179 returnCode:(int)returnCode
180 contextInfo:(void *)contextInfo {
181 if (returnCode == NSAlertDefaultReturn) {
182 [NSRunningApplication.currentApplication addToLoginItems];
183 // If we're going to automatically start, don't bug the user
184 // about automatic updates next boot - they probably want it,
185 // and if they don't they probably want a prompt for it less.
186 SUUpdater.sharedUpdater.automaticallyChecksForUpdates = YES;
187 }
188 }
189
190 - (void)loginItemPromptDidDismiss:(NSWindow *)sheet
191 returnCode:(int)returnCode
192 contextInfo:(void *)contextInfo {
193 [NSUserDefaults.standardUserDefaults setBool:YES forKey:@"explained login items"];
194 [window performClose:sheet];
195 }
196
197 - (BOOL)windowShouldClose:(NSWindow *)sender {
198 if (sender != window
199 || NSRunningApplication.currentApplication.isLoginItem
200 || [NSUserDefaults.standardUserDefaults boolForKey:@"explained login items"])
201 return YES;
202 NSBeginAlertSheet(
203 NSLocalizedString(@"login items prompt", @"alert prompt for adding to login items"),
204 NSLocalizedString(@"login items add button", @"button to add to login items"),
205 NSLocalizedString(@"login items don't add button", @"button to not add to login items"),
206 nil, window, self,
207 @selector(loginItemPromptDidEnd:returnCode:contextInfo:),
208 @selector(loginItemPromptDidDismiss:returnCode:contextInfo:),
209 NULL,
210 NSLocalizedString(@"login items explanation", @"a brief explanation of login items")
211 );
212 for (int i = 0; i < 10; ++i)
213 [self performSelector:@selector(flashStatusItem)
214 withObject:self
215 afterDelay:0.5 * i];
216 return NO;
217 }
218
219 - (void)importMappingClicked:(id)sender {
220 NSOpenPanel *panel = [NSOpenPanel openPanel];
221 panel.allowedFileTypes = @[ @"enjoyable", @"json", @"txt" ];
222 [panel beginSheetModalForWindow:window
223 completionHandler:^(NSInteger result) {
224 if (result != NSFileHandlingPanelOKButton)
225 return;
226 [panel close];
227 NSError *error;
228 NJMapping *mapping = [NJMapping mappingWithContentsOfURL:panel.URL
229 error:&error];
230 if ([self.mappingsController[mapping.name] hasConflictWith:mapping]) {
231 [self promptForMapping:mapping atIndex:self.mappingsController.count];
232 } else if (self.mappingsController[mapping.name]) {
233 [self.mappingsController[mapping.name] mergeEntriesFrom:mapping];
234 } else if (mapping) {
235 [self.mappingsController addMapping:mapping];
236 } else {
237 [window presentError:error
238 modalForWindow:window
239 delegate:nil
240 didPresentSelector:nil
241 contextInfo:nil];
242 }
243 }];
244
245 }
246
247 - (void)exportMappingClicked:(id)sender {
248 NSSavePanel *panel = [NSSavePanel savePanel];
249 panel.allowedFileTypes = @[ @"enjoyable" ];
250 NJMapping *mapping = self.mappingsController.currentMapping;
251 panel.nameFieldStringValue = [mapping.name stringByFixingPathComponent];
252 [panel beginSheetModalForWindow:window
253 completionHandler:^(NSInteger result) {
254 if (result != NSFileHandlingPanelOKButton)
255 return;
256 [panel close];
257 NSError *error;
258 if (![mapping writeToURL:panel.URL error:&error]) {
259 [window presentError:error
260 modalForWindow:window
261 delegate:nil
262 didPresentSelector:nil
263 contextInfo:nil];
264 }
265 }];
266 }
267
268 - (void)mappingConflictDidResolve:(NSAlert *)alert
269 returnCode:(NSInteger)returnCode
270 contextInfo:(void *)contextInfo {
271 NSDictionary *userInfo = CFBridgingRelease(contextInfo);
272 NJMapping *oldMapping = userInfo[@"old mapping"];
273 NJMapping *newMapping = userInfo[@"new mapping"];
274 NSInteger idx = [userInfo[@"index"] intValue];
275 [alert.window orderOut:nil];
276 switch (returnCode) {
277 case NSAlertFirstButtonReturn: // Merge
278 [self.mappingsController mergeMapping:newMapping intoMapping:oldMapping];
279 [self.mappingsController activateMapping:oldMapping];
280 break;
281 case NSAlertThirdButtonReturn: // New Mapping
282 [self.mvc beginUpdates];
283 [self.mappingsController addMapping:newMapping];
284 [self.mvc addedMappingAtIndex:idx startEditing:YES];
285 [self.mvc endUpdates];
286 [self.mappingsController activateMapping:newMapping];
287 break;
288 default: // Cancel, other.
289 break;
290 }
291 }
292
293 - (void)promptForMapping:(NJMapping *)mapping atIndex:(NSInteger)idx {
294 NJMapping *mergeInto = self.mappingsController[mapping.name];
295 NSAlert *conflictAlert = [[NSAlert alloc] init];
296 conflictAlert.messageText = NSLocalizedString(@"import conflict prompt", @"Title of import conflict alert");
297 conflictAlert.informativeText =
298 [NSString stringWithFormat:NSLocalizedString(@"import conflict in %@", @"Explanation of import conflict"),
299 mapping.name];
300 [conflictAlert addButtonWithTitle:NSLocalizedString(@"import and merge", @"button to merge imported mappings")];
301 [conflictAlert addButtonWithTitle:NSLocalizedString(@"cancel import", @"button to cancel import")];
302 [conflictAlert addButtonWithTitle:NSLocalizedString(@"import new mapping", @"button to import as new mapping")];
303 [conflictAlert beginSheetModalForWindow:window
304 modalDelegate:self
305 didEndSelector:@selector(mappingConflictDidResolve:returnCode:contextInfo:)
306 contextInfo:(void *)CFBridgingRetain(@{ @"index": @(idx),
307 @"old mapping": mergeInto,
308 @"new mapping": mapping })];
309 }
310
311 - (NSInteger)numberOfMappings:(NJMappingsViewController *)mvc {
312 return self.mappingsController.count;
313 }
314
315 - (NJMapping *)mappingsViewController:(NJMappingsViewController *)mvc
316 mappingForIndex:(NSUInteger)idx {
317 return self.mappingsController[idx];
318 }
319
320 - (void)mappingsViewController:(NJMappingsViewController *)mvc
321 renameMappingAtIndex:(NSInteger)index
322 toName:(NSString *)name {
323 [self.mappingsController renameMapping:self.mappingsController[index]
324 to:name];
325 }
326
327 - (BOOL)mappingsViewController:(NJMappingsViewController *)mvc
328 canMoveMappingFromIndex:(NSInteger)fromIdx
329 toIndex:(NSInteger)toIdx {
330 return fromIdx != toIdx && fromIdx != 0 && toIdx != 0
331 && toIdx < (NSInteger)self.mappingsController.count;
332 }
333
334 - (void)mappingsViewController:(NJMappingsViewController *)mvc
335 moveMappingFromIndex:(NSInteger)fromIdx
336 toIndex:(NSInteger)toIdx {
337 [mvc beginUpdates];
338 [mvc.mappingList moveRowAtIndex:fromIdx toIndex:toIdx];
339 [self.mappingsController moveMoveMappingFromIndex:fromIdx toIndex:toIdx];
340 [mvc endUpdates];
341 }
342
343 - (BOOL)mappingsViewController:(NJMappingsViewController *)mvc
344 canRemoveMappingAtIndex:(NSInteger)idx {
345 return idx != 0;
346 }
347
348 - (void)mappingsViewController:(NJMappingsViewController *)mvc
349 removeMappingAtIndex:(NSInteger)idx {
350 [mvc beginUpdates];
351 [mvc removedMappingAtIndex:idx];
352 [self.mappingsController removeMappingAtIndex:idx];
353 [mvc endUpdates];
354 }
355
356 - (BOOL)mappingsViewController:(NJMappingsViewController *)mvc
357 importMappingFromURL:(NSURL *)url
358 atIndex:(NSInteger)index
359 error:(NSError **)error {
360 NJMapping *mapping = [NJMapping mappingWithContentsOfURL:url
361 error:error];
362 if ([self.mappingsController[mapping.name] hasConflictWith:mapping]) {
363 [self promptForMapping:mapping atIndex:index];
364 } else if (self.mappingsController[mapping.name]) {
365 [self.mappingsController[mapping.name] mergeEntriesFrom:mapping];
366 } else if (mapping) {
367 [self.mvc beginUpdates];
368 [self.mvc addedMappingAtIndex:index startEditing:NO];
369 [self.mappingsController insertMapping:mapping atIndex:index];
370 [self.mvc endUpdates];
371 }
372 return !!mapping;
373 }
374
375 - (void)mappingsViewController:(NJMappingsViewController *)mvc
376 addMapping:(NJMapping *)mapping {
377 [mvc beginUpdates];
378 [mvc addedMappingAtIndex:self.mappingsController.count startEditing:YES];
379 [self.mappingsController addMapping:mapping];
380 [mvc endUpdates];
381 [self.mappingsController activateMapping:mapping];
382 }
383
384 - (void)mappingsViewController:(NJMappingsViewController *)mvc
385 choseMappingAtIndex:(NSInteger)idx {
386 [self.mappingsController activateMapping:self.mappingsController[idx]];
387 }
388
389 - (id)deviceViewController:(NJDeviceViewController *)dvc
390 elementForUID:(NSString *)uid {
391 return self.deviceController[uid];
392 }
393
394 - (void)deviceViewControllerDidSelectNothing:(NJDeviceViewController *)dvc {
395 [self.outputController loadInput:dvc.selectedHandler];
396 }
397
398 - (void)deviceViewController:(NJDeviceViewController *)dvc
399 didSelectBranch:(NJInputPathElement *)handler {
400 [self.outputController loadInput:dvc.selectedHandler];
401 }
402
403 - (void)deviceViewController:(NJDeviceViewController *)dvc
404 didSelectHandler:(NJInputPathElement *)handler {
405 [self.outputController loadInput:dvc.selectedHandler];
406 }
407
408 - (void)deviceViewController:(NJDeviceViewController *)dvc
409 didSelectDevice:(NJInputPathElement *)device {
410 [self.outputController loadInput:dvc.selectedHandler];
411 }
412
413 - (void)deviceController:(NJDeviceController *)dc
414 didAddDevice:(NJDevice *)device {
415 [self.dvc addedDevice:device atIndex:dc.count - 1];
416 }
417
418 - (void)deviceController:(NJDeviceController *)dc
419 didRemoveDeviceAtIndex:(NSInteger)idx {
420 [self.dvc removedDeviceAtIndex:idx];
421 }
422
423 - (void)deviceControllerDidStartHID:(NJDeviceController *)dc {
424 [self.dvc hidStarted];
425 }
426
427 - (void)deviceControllerDidStopHID:(NJDeviceController *)dc {
428 [self.dvc hidStopped];
429 }
430
431 - (void)deviceController:(NJDeviceController *)dc didInput:(NJInput *)input {
432 [self.outputController loadInput:input];
433 [self.outputController focusKey];
434 }
435
436 - (void)deviceController:(NJDeviceController *)dc didError:(NSError *)error {
437 // Since the error shows the window, it can trigger another attempt
438 // to re-open the HID manager, which will also probably fail and error,
439 // so don't bother repeating ourselves.
440 if (!window.attachedSheet) {
441 [NSApplication.sharedApplication activateIgnoringOtherApps:YES];
442 [window makeKeyAndOrderFront:nil];
443 [window presentError:error
444 modalForWindow:window
445 delegate:nil
446 didPresentSelector:nil
447 contextInfo:nil];
448 }
449 }
450
451 - (NSInteger)numberOfDevicesInDeviceList:(NJDeviceViewController *)dvc {
452 return self.deviceController.count;
453 }
454
455 - (NJDevice *)deviceViewController:(NJDeviceViewController *)dvc
456 deviceForIndex:(NSUInteger)idx {
457 return self.deviceController[idx];
458 }
459
460 @end