SPMediaKeyTap.m 11.6 KB
Newer Older
1 2 3
/*
 Copyright (c) 2011, Joachim Bengtsson
 All rights reserved.
4

5
 Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
6

7
 * Neither the name of the organization nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
8

9 10 11 12 13
 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

// Copyright (c) 2010 Spotify AB
#import "SPMediaKeyTap.h"
14
#import "SPInvocationGrabbing.h"
15 16 17

@interface SPMediaKeyTap ()
-(BOOL)shouldInterceptMediaKeyEvents;
18
-(void)setShouldInterceptMediaKeyEvents:(BOOL)newSetting;
19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
-(void)startWatchingAppSwitching;
-(void)stopWatchingAppSwitching;
-(void)eventTapThread;
@end
static SPMediaKeyTap *singleton = nil;

static pascal OSStatus appSwitched (EventHandlerCallRef nextHandler, EventRef evt, void* userData);
static pascal OSStatus appTerminated (EventHandlerCallRef nextHandler, EventRef evt, void* userData);
static CGEventRef tapEventCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *refcon);


// Inspired by http://gist.github.com/546311

@implementation SPMediaKeyTap

#pragma mark -
#pragma mark Setup and teardown
-(id)initWithDelegate:(id)delegate;
{
38 39 40 41
    _delegate = delegate;
    [self startWatchingAppSwitching];
    singleton = self;
    _mediaKeyAppList = [NSMutableArray new];
42 43 44
    _tapThreadRL=nil;
    _eventPort=nil;
    _eventPortSource=nil;
45
    return self;
46 47 48
}
-(void)dealloc;
{
49 50
    [self stopWatchingMediaKeys];
    [self stopWatchingAppSwitching];
51 52 53 54
}

-(void)startWatchingAppSwitching;
{
55 56 57
    // Listen to "app switched" event, so that we don't intercept media keys if we
    // weren't the last "media key listening" app to be active
    EventTypeSpec eventType = { kEventClassApplication, kEventAppFrontSwitched };
58
    OSStatus err = InstallApplicationEventHandler(NewEventHandlerUPP(appSwitched), 1, &eventType, (__bridge void*)(self), &_app_switching_ref);
59 60 61
    assert(err == noErr);

    eventType.eventKind = kEventAppTerminated;
62
    err = InstallApplicationEventHandler(NewEventHandlerUPP(appTerminated), 1, &eventType, (__bridge void *)(self), &_app_terminating_ref);
63
    assert(err == noErr);
64 65 66
}
-(void)stopWatchingAppSwitching;
{
67 68 69
    if(!_app_switching_ref) return;
    RemoveEventHandler(_app_switching_ref);
    _app_switching_ref = NULL;
70 71 72
}

-(void)startWatchingMediaKeys;{
73 74 75
    // Prevent having multiple mediaKeys threads
    [self stopWatchingMediaKeys];

76 77 78 79 80 81 82 83
    [self setShouldInterceptMediaKeyEvents:YES];

    // Add an event tap to intercept the system defined media key events
    _eventPort = CGEventTapCreate(kCGSessionEventTap,
                                  kCGHeadInsertEventTap,
                                  kCGEventTapOptionDefault,
                                  CGEventMaskBit(NX_SYSDEFINED),
                                  tapEventCallback,
84
                                  (__bridge void * __nullable)(self));
85 86
    assert(_eventPort != NULL);

87
    _eventPortSource = CFMachPortCreateRunLoopSource(kCFAllocatorSystemDefault, _eventPort, 0);
88 89 90 91
    assert(_eventPortSource != NULL);

    // Let's do this in a separate thread so that a slow app doesn't lag the event tap
    [NSThread detachNewThreadSelector:@selector(eventTapThread) toTarget:self withObject:nil];
92 93 94
}
-(void)stopWatchingMediaKeys;
{
95
    // TODO<nevyn>: Shut down thread, remove event tap port and source
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111

    if(_tapThreadRL){
        CFRunLoopStop(_tapThreadRL);
        _tapThreadRL=nil;
    }

    if(_eventPort){
        CFMachPortInvalidate(_eventPort);
        CFRelease(_eventPort);
        _eventPort=nil;
    }

    if(_eventPortSource){
        CFRelease(_eventPortSource);
        _eventPortSource=nil;
    }
112 113 114 115 116 117 118 119
}

#pragma mark -
#pragma mark Accessors

+(BOOL)usesGlobalMediaKeyTap
{
#ifdef _DEBUG
120
    // breaking in gdb with a key tap inserted sometimes locks up all mouse and keyboard input forever, forcing reboot
121
    return NO;
122
#else
123
    // XXX(nevyn): MediaKey event tap doesn't work on 10.4, feel free to figure out why if you have the energy.
124 125 126
    return
        ![[NSUserDefaults standardUserDefaults] boolForKey:kIgnoreMediaKeysDefaultsKey]
        && floor(NSAppKitVersionNumber) >= 949/*NSAppKitVersionNumber10_5*/;
127 128 129 130 131
#endif
}

+ (NSArray*)defaultMediaKeyUserBundleIdentifiers;
{
132 133
    return [NSArray arrayWithObjects:
            [[NSBundle mainBundle] bundleIdentifier], // your app
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149
             @"com.spotify.client",
             @"com.apple.iTunes",
             @"com.apple.QuickTimePlayerX",
             @"com.apple.quicktimeplayer",
             @"com.apple.iWork.Keynote",
             @"com.apple.iPhoto",
             @"org.videolan.vlc",
             @"com.apple.Aperture",
             @"com.plexsquared.Plex",
             @"com.soundcloud.desktop",
             @"org.niltsh.MPlayerX",
             @"com.ilabs.PandorasHelper",
             @"com.mahasoftware.pandabar",
             @"com.bitcartel.pandorajam",
             @"org.clementine-player.clementine",
             @"fm.last.Last.fm",
150
             @"fm.last.Scrobbler",
151 152 153
             @"com.beatport.BeatportPro",
             @"com.Timenut.SongKey",
             @"com.macromedia.fireworks", // the tap messes up their mouse input
154 155 156 157 158
             @"at.justp.Theremin",
             @"ru.ya.themblsha.YandexMusic",
             @"com.jriver.MediaCenter18",
             @"com.jriver.MediaCenter19",
             @"com.jriver.MediaCenter20",
159 160 161 162 163
             @"co.rackit.mate",
             @"com.ttitt.b-music",
             @"com.beardedspice.BeardedSpice",
             @"com.plug.Plug",
             @"com.netease.163music",
164
            nil
165
    ];
166 167 168 169 170
}


-(BOOL)shouldInterceptMediaKeyEvents;
{
171 172 173 174 175
    BOOL shouldIntercept = NO;
    @synchronized(self) {
        shouldIntercept = _shouldInterceptMediaKeyEvents;
    }
    return shouldIntercept;
176 177 178 179
}

-(void)pauseTapOnTapThread:(BOOL)yeahno;
{
180
    CGEventTapEnable(self->_eventPort, yeahno);
181 182 183
}
-(void)setShouldInterceptMediaKeyEvents:(BOOL)newSetting;
{
184 185 186 187 188 189 190 191 192
    BOOL oldSetting;
    @synchronized(self) {
        oldSetting = _shouldInterceptMediaKeyEvents;
        _shouldInterceptMediaKeyEvents = newSetting;
    }
    if(_tapThreadRL && oldSetting != newSetting) {
        id grab = [self grab];
        [grab pauseTapOnTapThread:newSetting];
        NSTimer *timer = [NSTimer timerWithTimeInterval:0 invocation:[grab invocation] repeats:NO];
193
        CFRunLoopAddTimer(_tapThreadRL, (CFRunLoopTimerRef)CFBridgingRetain(timer), kCFRunLoopCommonModes);
194
    }
195 196
}

197
#pragma mark
198 199 200 201 202 203 204
#pragma mark -
#pragma mark Event tap callbacks

// Note: method called on background thread

static CGEventRef tapEventCallback2(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *refcon)
{
205
    SPMediaKeyTap *self = (__bridge SPMediaKeyTap *)refcon;
206 207

    if(type == kCGEventTapDisabledByTimeout) {
208 209 210 211 212 213 214 215 216 217 218 219 220
        NSLog(@"Media key event tap was disabled by timeout");
        CGEventTapEnable(self->_eventPort, TRUE);
        return event;
    } else if(type == kCGEventTapDisabledByUserInput) {
        // Was disabled manually by -[pauseTapOnTapThread]
        return event;
    }
    NSEvent *nsEvent = nil;
    @try {
        nsEvent = [NSEvent eventWithCGEvent:event];
    }
    @catch (NSException * e) {
        NSLog(@"Strange CGEventType: %d: %@", type, e);
221
        assert(0);
222 223 224 225 226 227 228
        return event;
    }

    if (type != NX_SYSDEFINED || [nsEvent subtype] != SPSystemDefinedEventMediaKeys)
        return event;

    int keyCode = (([nsEvent data1] & 0xFFFF0000) >> 16);
229
    if (keyCode != NX_KEYTYPE_PLAY && keyCode != NX_KEYTYPE_FAST && keyCode != NX_KEYTYPE_REWIND && keyCode != NX_KEYTYPE_PREVIOUS && keyCode != NX_KEYTYPE_NEXT)
230 231 232 233 234 235 236 237
        return event;

    if (![self shouldInterceptMediaKeyEvents])
        return event;

    [self performSelectorOnMainThread:@selector(handleAndReleaseMediaKeyEvent:) withObject:nsEvent waitUntilDone:NO];

    return NULL;
238 239 240 241
}

static CGEventRef tapEventCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *refcon)
{
242 243 244 245
    @autoreleasepool {
        CGEventRef ret = tapEventCallback2(proxy, type, event, refcon);
        return ret;
    }
246 247 248
}

-(void)handleAndReleaseMediaKeyEvent:(NSEvent *)event {
249
    [_delegate mediaKeyTap:self receivedMediaKeyEvent:event];
250 251 252 253
}

-(void)eventTapThread;
{
254 255 256
    _tapThreadRL = CFRunLoopGetCurrent();
    CFRunLoopAddSource(_tapThreadRL, _eventPortSource, kCFRunLoopCommonModes);
    CFRunLoopRun();
257 258 259 260 261
}

#pragma mark Task switching callbacks

NSString *kMediaKeyUsingBundleIdentifiersDefaultsKey = @"SPApplicationsNeedingMediaKeys";
262 263
NSString *kIgnoreMediaKeysDefaultsKey = @"SPIgnoreMediaKeys";

264 265 266 267


-(void)mediaKeyAppListChanged;
{
268 269 270 271 272 273
    if([_mediaKeyAppList count] == 0) return;

    /*NSLog(@"--");
    int i = 0;
    for (NSValue *psnv in _mediaKeyAppList) {
        ProcessSerialNumber psn; [psnv getValue:&psn];
274
        NSDictionary *processInfo = (id)ProcessInformationCopyDictionary(
275 276
            &psn,
            kProcessDictionaryIncludeAllInformationMask
277
        );
278 279 280 281
        NSString *bundleIdentifier = [processInfo objectForKey:(id)kCFBundleIdentifierKey];
        NSLog(@"%d: %@", i++, bundleIdentifier);
    }*/

282
    ProcessSerialNumber mySerial, topSerial;
283
    GetCurrentProcess(&mySerial);
284
    [[_mediaKeyAppList firstObject] getValue:&topSerial];
285

286 287 288
    Boolean same;
    OSErr err = SameProcess(&mySerial, &topSerial, &same);
    [self setShouldInterceptMediaKeyEvents:(err == noErr && same)];
289 290 291
}
-(void)appIsNowFrontmost:(ProcessSerialNumber)psn;
{
292 293
    NSValue *psnv = [NSValue valueWithBytes:&psn objCType:@encode(ProcessSerialNumber)];

294
    NSDictionary *processInfo = (__bridge_transfer NSDictionary *)ProcessInformationCopyDictionary(
295 296
        &psn,
        kProcessDictionaryIncludeAllInformationMask
297
    );
298 299 300 301 302 303 304 305
    NSString *bundleIdentifier = [processInfo objectForKey:(id)kCFBundleIdentifierKey];

    NSArray *whitelistIdentifiers = [[NSUserDefaults standardUserDefaults] arrayForKey:kMediaKeyUsingBundleIdentifiersDefaultsKey];
    if(![whitelistIdentifiers containsObject:bundleIdentifier]) return;

    [_mediaKeyAppList removeObject:psnv];
    [_mediaKeyAppList insertObject:psnv atIndex:0];
    [self mediaKeyAppListChanged];
306 307 308
}
-(void)appTerminated:(ProcessSerialNumber)psn;
{
309 310 311
    NSValue *psnv = [NSValue valueWithBytes:&psn objCType:@encode(ProcessSerialNumber)];
    [_mediaKeyAppList removeObject:psnv];
    [self mediaKeyAppListChanged];
312 313 314 315
}

static pascal OSStatus appSwitched (EventHandlerCallRef nextHandler, EventRef evt, void* userData)
{
316
    SPMediaKeyTap *self = (__bridge id)userData;
317 318 319

    ProcessSerialNumber newSerial;
    GetFrontProcess(&newSerial);
320 321 322

    [self appIsNowFrontmost:newSerial];

323 324 325 326 327
    return CallNextEventHandler(nextHandler, evt);
}

static pascal OSStatus appTerminated (EventHandlerCallRef nextHandler, EventRef evt, void* userData)
{
328
    SPMediaKeyTap *self = (__bridge id)userData;
329 330 331 332 333 334 335 336 337 338 339 340 341 342

    ProcessSerialNumber deadPSN;

    GetEventParameter(
        evt,
        kEventParamProcessID,
        typeProcessSerialNumber,
        NULL,
        sizeof(deadPSN),
        NULL,
        &deadPSN
    );

    [self appTerminated:deadPSN];
343 344 345 346
    return CallNextEventHandler(nextHandler, evt);
}

@end