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